relv-space5 / src /engine /steps /upload.ts
relv-dev's picture
Deploy latest verified Turnitin worker
2db5489 verified
Raw
History Blame Contribute Delete
32 kB
import type { Page, Frame } from 'playwright';
import { logger } from '../../utils/logger';
import {
SELECTORS,
findAssignmentScope,
findScopeByDeepText,
scopeHasVisibleDeepText,
} from '../selectors';
import {
detectQuotaLimit,
confirmQuotaWarning,
} from './quota-detect';
type Scope = Page | Frame;
const UPLOAD_ACTION_SELECTOR = [
'button',
'input[type="submit"]',
'input[type="button"]',
'tdl-button',
'tii-grn-button', // New UI: Submit/Resubmit button
'[role="button"]',
'[slot="accept-button"]',
'[part*="submit"]',
'[part*="upload"]',
'[data-px*="Submit"]',
'[data-px*="Upload"]',
'[with-data-px*="Submit"]',
'[with-data-px*="Upload"]',
].join(', ');
// ---------------------------------------------------------------------------
// Error types
// ---------------------------------------------------------------------------
export class SubmissionQuotaLimitError extends Error {
constructor(message = 'Submission quota limit reached') {
super(message);
this.name = 'SubmissionQuotaLimitError';
}
}
export class ExistingSubmissionError extends Error {
constructor(message = 'Existing submission is already visible; resubmit mode is required') {
super(message);
this.name = 'ExistingSubmissionError';
}
}
// ---------------------------------------------------------------------------
// Submission-card check
// ---------------------------------------------------------------------------
export async function hasSubmissionCard(scope: Scope): Promise<boolean> {
const cardSelector = [
SELECTORS.similarity.viewSubmissionButton,
SELECTORS.similarity.similarityDisplay,
SELECTORS.resubmit.resubmitButton,
'a:has-text("Resubmit"), [aria-label*="Resubmit"], [data-px*="Resubmit"]',
'span[part="tii-grn-badge-label"]:has-text("Similarity:")',
].join(', ');
return scope
.locator(cardSelector)
.first()
.isVisible({ timeout: 2000 })
.catch(() => false);
}
// ---------------------------------------------------------------------------
// File chooser control
// ---------------------------------------------------------------------------
async function setDirectFileInput(scope: Scope, filePath: string): Promise<boolean> {
const input = scope.locator(SELECTORS.upload.fileInput).first();
if ((await input.count().catch(() => 0)) === 0) return false;
await input.setInputFiles(filePath);
return true;
}
async function clickUploadControlByText(
scope: Scope,
labels: string[],
): Promise<boolean> {
return scope
.evaluate((textMatches: string[]) => {
const normalizedMatches = textMatches.map((value) => value.toLowerCase());
const roots: (Document | ShadowRoot)[] = [document];
const seen = new Set<Element>();
for (let i = 0; i < roots.length; i++) {
const root = roots[i];
for (const el of Array.from(root.querySelectorAll('*'))) {
if (seen.has(el)) continue;
seen.add(el);
if (el.shadowRoot) roots.push(el.shadowRoot);
}
}
const isVisible = (el: Element): boolean => {
if (!(el instanceof HTMLElement)) return false;
const style = window.getComputedStyle(el);
const rect = el.getBoundingClientRect();
return (
style.visibility !== 'hidden' &&
style.display !== 'none' &&
rect.width > 0 &&
rect.height > 0
);
};
const isDisabled = (el: Element): boolean =>
el instanceof HTMLElement &&
(
(el as HTMLButtonElement).disabled === true ||
el.getAttribute('disabled') !== null ||
el.getAttribute('aria-disabled') === 'true' ||
el.getAttribute('with-disabled') === 'true' ||
el.closest('[disabled], [aria-disabled="true"], [with-disabled="true"]') !== null
);
const readText = (el: Element): string =>
[
(el as HTMLElement).innerText,
el.textContent,
el.getAttribute('aria-label'),
el.getAttribute('value'),
el.getAttribute('part'),
el.getAttribute('slot'),
]
.filter(Boolean)
.join(' ')
.replace(/\s+/g, ' ')
.trim();
const clickElement = (el: Element): void => {
const shadowButton = el.shadowRoot?.querySelector(
'button:not([disabled]), [role="button"]:not([disabled])',
);
const target =
shadowButton instanceof HTMLElement
? shadowButton
: (el as HTMLElement);
target.scrollIntoView({ block: 'center', inline: 'center' });
target.click();
};
const candidates: Array<{ el: Element; score: number; top: number }> = [];
for (const root of roots) {
for (const el of Array.from(
root.querySelectorAll(
'tii-ing-upload-button, tdl-button, button, [role="button"], input[type="button"]',
),
)) {
if (!isVisible(el) || isDisabled(el)) continue;
const text = readText(el).toLowerCase();
const score = normalizedMatches.findIndex((match) => text.includes(match));
if (score < 0) continue;
candidates.push({
el,
score: 100 - score,
top: (el as HTMLElement).getBoundingClientRect().top,
});
}
}
candidates.sort((a, b) => b.score - a.score || b.top - a.top);
const target = candidates[0];
if (!target) return false;
clickElement(target.el);
return true;
}, labels)
.catch(() => false);
}
async function clickUploadControl(
page: Page,
scope: Scope,
filePath: string,
): Promise<void> {
const browseLabels = [
'browse files',
'browse from your computer',
'browse',
];
const deviceLabels = [
'your device',
'upload from this device',
'local drive',
];
// New Turnitin UI: "Browse Files" opens a source menu, then "Your device"
// opens the native file chooser. Setting the hidden input before that menu
// is opened is unreliable because the upload component wires state on click.
const directChooserPromise = page
.waitForEvent('filechooser', { timeout: 7000 })
.catch(() => null);
const browse = scope.locator(SELECTORS.upload.browseButton).first();
let clickedBrowse = false;
if (await browse.isVisible({ timeout: 5000 }).catch(() => false)) {
await browse.click({ force: true });
clickedBrowse = true;
} else {
clickedBrowse = await clickUploadControlByText(scope, browseLabels);
}
if (clickedBrowse) {
const directChooser = await directChooserPromise;
if (directChooser) {
await directChooser.setFiles(filePath);
return;
}
await scope.waitForTimeout(1000).catch(() => {});
const menuChooserPromise = page
.waitForEvent('filechooser', { timeout: 10000 })
.catch(() => null);
const uploadFromDevice = scope
.locator(SELECTORS.upload.uploadFromDevice)
.first();
let clickedDevice = false;
if (await uploadFromDevice.isVisible({ timeout: 5000 }).catch(() => false)) {
await uploadFromDevice.click({ force: true });
clickedDevice = true;
} else {
clickedDevice = await clickUploadControlByText(scope, deviceLabels);
}
if (!clickedDevice) {
throw new Error('Upload source menu opened, but the Your device option was not found');
}
const menuChooser = await menuChooserPromise;
if (!menuChooser) {
throw new Error('File chooser did not open from Browse Files control');
}
await menuChooser.setFiles(filePath);
return;
}
if (await setDirectFileInput(scope, filePath)) return;
throw new Error('Browse Files control was not found in the upload form');
}
async function clickInitialSubmitButton(scope: Scope): Promise<boolean> {
const candidates = scope.locator(
'tii-grn-button, button, tdl-button, [role="button"], input[type="submit"]',
);
const count = Math.min(await candidates.count().catch(() => 0), 40);
for (let i = 0; i < count; i++) {
const candidate = candidates.nth(i);
if (!(await candidate.isVisible({ timeout: 500 }).catch(() => false))) {
continue;
}
const meta = await candidate
.evaluate((el: Element) => {
const text = [
(el as HTMLElement).innerText,
el.textContent,
el.getAttribute('aria-label'),
el.getAttribute('value'),
el.getAttribute('part'),
el.getAttribute('slot'),
]
.filter(Boolean)
.join(' ')
.replace(/\s+/g, ' ')
.trim();
const disabled =
(el as HTMLButtonElement).disabled === true ||
el.getAttribute('disabled') !== null ||
el.getAttribute('aria-disabled') === 'true' ||
el.getAttribute('with-disabled') === 'true' ||
el.closest('[disabled], [aria-disabled="true"], [with-disabled="true"]') !== null;
return { text, disabled };
})
.catch(() => null);
if (!meta || meta.disabled) continue;
const normalized = meta.text.toLowerCase();
if (!/(^|\s)submit(\s|$)/i.test(meta.text)) continue;
if (
normalized.includes('resubmit') ||
normalized.includes('submit file') ||
normalized.includes('upload and preview') ||
normalized.includes('setting info') ||
normalized.includes('collapse details')
) {
continue;
}
await candidate.click({ force: true });
return true;
}
return scope
.evaluate(() => {
const roots: (Document | ShadowRoot)[] = [document];
const seen = new Set<Element>();
for (let i = 0; i < roots.length; i++) {
const root = roots[i];
for (const el of Array.from(root.querySelectorAll('*'))) {
if (seen.has(el)) continue;
seen.add(el);
if (el.shadowRoot) roots.push(el.shadowRoot);
}
}
const isVisible = (el: Element): boolean => {
if (!(el instanceof HTMLElement)) return false;
const style = window.getComputedStyle(el);
const rect = el.getBoundingClientRect();
return (
style.visibility !== 'hidden' &&
style.display !== 'none' &&
rect.width > 0 &&
rect.height > 0
);
};
const isDisabled = (el: Element): boolean => {
if (!(el instanceof HTMLElement)) return true;
return (
(el as HTMLButtonElement).disabled === true ||
el.getAttribute('disabled') !== null ||
el.getAttribute('aria-disabled') === 'true' ||
el.getAttribute('with-disabled') === 'true' ||
el.closest('[disabled], [aria-disabled="true"], [with-disabled="true"]') !== null
);
};
const readText = (el: Element): string =>
[
(el as HTMLElement).innerText,
el.textContent,
el.getAttribute('aria-label'),
el.getAttribute('value'),
el.getAttribute('part'),
el.getAttribute('slot'),
]
.filter(Boolean)
.join(' ')
.replace(/\s+/g, ' ')
.trim();
const hasAncestorSignal = (el: Element): boolean => {
let current: Element | null = el;
const visited = new Set<Element>();
while (current && !visited.has(current)) {
visited.add(current);
const className = current.getAttribute('class') || '';
const slot = current.getAttribute('slot') || '';
const tag = current.tagName.toLowerCase();
if (
slot === 'submission-action' ||
className.includes('submission-action') ||
className.includes('submission-slot-container') ||
tag === 'tii-workflow-student-summary-panel-new' ||
tag === 'tii-workflow-lfw-student-show-assignment'
) {
return true;
}
const parent: HTMLElement | null = current.parentElement;
if (parent) {
current = parent;
continue;
}
const root = current.getRootNode();
current = root instanceof ShadowRoot ? root.host : null;
}
return false;
};
const clickElement = (el: Element): void => {
const shadowButton = el.shadowRoot?.querySelector(
'button:not([disabled]), [role="button"]:not([disabled])',
);
const target =
shadowButton instanceof HTMLElement
? shadowButton
: (el as HTMLElement);
target.scrollIntoView({ block: 'center', inline: 'center' });
target.click();
};
const candidates: Array<{ el: Element; score: number; top: number; text: string }> = [];
for (const root of roots) {
for (const el of Array.from(
root.querySelectorAll('tii-grn-button, button, tdl-button, [role="button"], input[type="submit"]'),
)) {
if (!isVisible(el) || isDisabled(el)) continue;
const text = readText(el);
const normalized = text.toLowerCase();
if (!/(^|\s)submit(\s|$)/i.test(text)) continue;
if (
normalized.includes('resubmit') ||
normalized.includes('collapse details') ||
normalized.includes('setting info') ||
normalized.includes('submit file') ||
normalized.includes('upload and preview')
) {
continue;
}
candidates.push({
el,
text,
score: hasAncestorSignal(el) ? 100 : 50,
top: (el as HTMLElement).getBoundingClientRect().top,
});
}
}
candidates.sort((a, b) => b.score - a.score || b.top - a.top);
const target = candidates[0];
if (!target) return false;
clickElement(target.el);
return true;
})
.catch(() => false);
}
// ---------------------------------------------------------------------------
// Submit button – tries labelled buttons then deep shadow DOM scan
// ---------------------------------------------------------------------------
async function collectUploadActionDiagnostics(
scope: Scope,
): Promise<Array<Record<string, string | boolean>>> {
return scope
.evaluate((selector: string) => {
const roots: (Document | ShadowRoot)[] = [document];
const seen = new Set<Element>();
for (let i = 0; i < roots.length; i++) {
const root = roots[i];
for (const el of Array.from(root.querySelectorAll('*'))) {
if (seen.has(el)) continue;
seen.add(el);
if (el.shadowRoot) roots.push(el.shadowRoot);
}
}
const isVisible = (el: Element): boolean => {
if (!(el instanceof HTMLElement)) return false;
const style = window.getComputedStyle(el);
const rect = el.getBoundingClientRect();
return (
style.visibility !== 'hidden' &&
style.display !== 'none' &&
rect.width > 0 &&
rect.height > 0
);
};
const isDisabled = (el: Element): boolean => {
if (!(el instanceof HTMLElement)) return true;
return (
(el as HTMLButtonElement).disabled === true ||
el.getAttribute('disabled') !== null ||
el.getAttribute('aria-disabled') === 'true' ||
el.getAttribute('with-disabled') === 'true' ||
el.closest(
'[disabled], [aria-disabled="true"], [with-disabled="true"], .disabled, .is-disabled',
) !== null
);
};
const rows: Array<Record<string, string | boolean>> = [];
for (const root of roots) {
for (const el of Array.from(root.querySelectorAll(selector))) {
if (!isVisible(el)) continue;
rows.push({
tag: el.tagName.toLowerCase(),
text: [
(el as HTMLElement).innerText,
el.textContent,
el.getAttribute('aria-label'),
el.getAttribute('value'),
el.getAttribute('part'),
el.getAttribute('slot'),
el.getAttribute('data-px'),
el.getAttribute('with-data-px'),
el.getAttribute('with-px-label'),
]
.filter(Boolean)
.join(' ')
.replace(/\s+/g, ' ')
.trim()
.slice(0, 180),
disabled: isDisabled(el),
});
}
}
return rows.slice(0, 20);
}, UPLOAD_ACTION_SELECTOR)
.catch(() => []);
}
async function clickVisibleUploadActionByLocator(scope: Scope): Promise<string | null> {
const selectors = [
{ selector: 'tdl-button:has-text("Upload and Preview")', phase: 'preview' },
{ selector: 'button:has-text("Upload and Preview")', phase: 'preview' },
{ selector: 'tdl-button[part="submit-button"]', phase: 'preview' },
{ selector: 'tdl-button:has-text("Confirm and Submit")', phase: 'final' },
{ selector: 'button:has-text("Confirm and Submit")', phase: 'final' },
{ selector: 'tdl-button:has-text("Submit File")', phase: 'final' },
{ selector: 'button:has-text("Submit File")', phase: 'final' },
{ selector: 'tdl-button:has-text("Submit")', phase: 'final' },
{ selector: 'button:has-text("Submit")', phase: 'final' },
{ selector: 'tii-grn-button:has-text("Submit")', phase: 'final' },
{ selector: '[slot="accept-button"]:has-text("Submit")', phase: 'final' },
];
for (const { selector, phase } of selectors) {
const locator = scope.locator(selector);
const count = Math.min(await locator.count().catch(() => 0), 8);
for (let index = 0; index < count; index++) {
const target = locator.nth(index);
if (!(await target.isVisible({ timeout: 500 }).catch(() => false))) {
continue;
}
const meta = await target
.evaluate((el: Element) => {
const text = [
(el as HTMLElement).innerText,
el.textContent,
el.getAttribute('aria-label'),
el.getAttribute('part'),
el.getAttribute('slot'),
]
.filter(Boolean)
.join(' ')
.replace(/\s+/g, ' ')
.trim();
const disabled =
(el as HTMLButtonElement).disabled === true ||
el.getAttribute('disabled') !== null ||
el.getAttribute('aria-disabled') === 'true' ||
el.getAttribute('with-disabled') === 'true' ||
el.closest(
'[disabled], [aria-disabled="true"], [with-disabled="true"], .disabled, .is-disabled',
) !== null;
return { text, disabled };
})
.catch(() => null);
if (!meta || meta.disabled) continue;
const normalized = meta.text.toLowerCase();
if (
normalized.includes('cancel') ||
normalized.includes('close') ||
normalized.includes('help') ||
normalized.includes('collapse') ||
normalized.includes('setting info') ||
normalized.includes('assignment details') ||
normalized.includes('view submission')
) {
continue;
}
await target.scrollIntoViewIfNeeded().catch(() => {});
await target.click({ force: true, timeout: 5000 }).catch(async () => {
const box = await target.boundingBox().catch(() => null);
if (!box) throw new Error(`Upload action click failed for ${selector}`);
const ownerPage =
typeof (scope as any).page === 'function'
? (scope as Frame).page()
: (scope as Page);
await ownerPage.mouse.click(box.x + box.width / 2, box.y + box.height / 2);
});
return `${phase}:${meta.text || selector}`;
}
}
return null;
}
async function clickVisibleSubmit(
scope: Scope,
page: Page,
): Promise<boolean> {
const deadline = Date.now() + 180000;
while (Date.now() < deadline) {
// Accept quota warnings that may appear at each iteration
await confirmQuotaWarning(page);
const visibleText = await scopeHasVisibleDeepText(scope, [
'we are creating a preview',
'please wait for us to process a preview',
]);
if (visibleText) {
await scope.waitForTimeout(2000).catch(() => {});
continue;
}
const locatorClicked = await clickVisibleUploadActionByLocator(scope).catch(() => null);
if (locatorClicked) {
await scope.waitForTimeout(2500).catch(() => {});
if (/^(preview|continue):/i.test(locatorClicked)) {
await scope.waitForTimeout(1000).catch(() => {});
} else if (!(await scopeHasVisibleDeepText(scope, ['submit file', 'upload and preview']))) {
return true;
}
}
const clicked = await scope
.evaluate((selector: string) => {
const roots: (Document | ShadowRoot)[] = [document];
const seen = new Set<Element>();
for (let i = 0; i < roots.length; i++) {
const root = roots[i];
for (const el of Array.from(root.querySelectorAll('*'))) {
if (seen.has(el)) continue;
seen.add(el);
if (el.shadowRoot) roots.push(el.shadowRoot);
}
}
const isVisible = (el: Element): boolean => {
if (!(el instanceof HTMLElement)) return false;
const style = window.getComputedStyle(el);
const rect = el.getBoundingClientRect();
return (
style.visibility !== 'hidden' &&
style.display !== 'none' &&
rect.width > 0 &&
rect.height > 0
);
};
const isDisabled = (el: Element): boolean => {
if (!(el instanceof HTMLElement)) return true;
return (
(el as HTMLButtonElement).disabled === true ||
el.getAttribute('disabled') !== null ||
el.getAttribute('aria-disabled') === 'true' ||
el.getAttribute('with-disabled') === 'true' ||
el.closest(
'[disabled], [aria-disabled="true"], [with-disabled="true"], .disabled, .is-disabled',
) !== null
);
};
const clickElement = (el: Element): void => {
const shadowButton = el.shadowRoot?.querySelector(
'button:not([disabled]), input[type="submit"]:not([disabled])',
);
const target =
shadowButton instanceof HTMLElement
? shadowButton
: (el as HTMLElement);
target.scrollIntoView({ block: 'center', inline: 'center' });
target.click();
};
const readText = (el: Element): string =>
[
(el as HTMLElement).innerText,
el.textContent,
el.getAttribute('aria-label'),
el.getAttribute('value'),
el.getAttribute('part'),
el.getAttribute('slot'),
el.getAttribute('data-px'),
el.getAttribute('with-data-px'),
el.getAttribute('with-px-label'),
]
.filter(Boolean)
.join(' ')
.replace(/\s+/g, ' ')
.trim();
const priority = (text: string): { score: number; phase: string } => {
const normalized = text.toLowerCase();
if (
normalized.includes('resubmit') ||
normalized.includes('cancel') ||
normalized.includes('close') ||
normalized.includes('expand') ||
normalized.includes('help') ||
normalized.includes('setting info') ||
normalized.includes('view submission') ||
normalized.includes('assignment details')
) {
return { score: 0, phase: '' };
}
if (normalized.includes('continue to file upload')) {
return { score: 120, phase: 'continue' };
}
if (normalized.includes('upload and preview')) {
return { score: 110, phase: 'preview' };
}
if (normalized.includes('confirm and submit')) {
return { score: 100, phase: 'final' };
}
if (/(^|\s)submit file(\s|$)/i.test(normalized)) {
return { score: 95, phase: 'final' };
}
if (/(^|\s)submit(\s|$)/i.test(normalized)) {
return { score: 90, phase: 'final' };
}
if (/(^|\s)confirm(\s|$)/i.test(normalized)) {
return { score: 80, phase: 'final' };
}
if (/(^|\s)upload(\s|$)/i.test(normalized)) {
return { score: 70, phase: 'preview' };
}
if (/(^|\s)continue(\s|$)/i.test(normalized)) {
return { score: 60, phase: 'continue' };
}
if (normalized.includes('accept-button')) {
return { score: 50, phase: 'continue' };
}
return { score: 0, phase: '' };
};
const candidates: Array<{
el: Element;
text: string;
score: number;
phase: string;
top: number;
}> = [];
for (const root of roots) {
for (const el of Array.from(root.querySelectorAll(selector))) {
if (!isVisible(el) || isDisabled(el)) continue;
const text = readText(el);
const { score, phase } = priority(text);
if (score > 0) {
candidates.push({
el,
text,
score,
phase,
top: (el as HTMLElement).getBoundingClientRect().top,
});
}
}
}
candidates.sort((a, b) => b.score - a.score || b.top - a.top);
const target = candidates[0];
if (!target) return '';
clickElement(target.el);
return `${target.phase}:${target.text}`;
}, UPLOAD_ACTION_SELECTOR)
.catch(() => '');
if (clicked) {
await scope.waitForTimeout(2500).catch(() => {});
if (/^(preview|continue):/i.test(clicked)) {
await scope.waitForTimeout(1000).catch(() => {});
} else if (!(await scopeHasVisibleDeepText(scope, ['submit file', 'upload and preview']))) {
return true;
}
}
// Handle "Preview Unavailable" state
if (await scopeHasVisibleDeepText(scope, ['preview unavailable'])) {
const ownerPage =
typeof (scope as any).page === 'function'
? (scope as Frame).page()
: (scope as Page);
await ownerPage.mouse.click(890, 670).catch(() => {});
await scope.waitForTimeout(3500).catch(() => {});
if (!(await scopeHasVisibleDeepText(scope, ['submit file', 'upload and preview']))) return true;
}
await scope.waitForTimeout(1000).catch(() => {});
}
return false;
}
// ---------------------------------------------------------------------------
// Public: uploadFile
// ---------------------------------------------------------------------------
/**
* Upload a file to an assignment that has no prior submission.
*
* Steps:
* 1. Assert quota is available
* 2. Find browse/upload form scope
* 3. Click Browse Files / set file via file chooser
* 4. Click Upload and Preview
* 5. Handle Preview Unavailable
* 6. Click Submit
* 7. Wait for success toast and submission card
*/
export async function uploadFile(
page: Page,
filePath: string,
options: { skipExistingCheck?: boolean } = {},
): Promise<void> {
// Check for existing submission
if (!options.skipExistingCheck) {
const existingScope = await findAssignmentScope(
page,
[
'button.link-button[aria-label*="View submission"]',
'a[aria-label*="View submission"]',
'button:has-text("Resubmit")',
'tdl-button:has-text("Resubmit")',
'span[part="tii-grn-badge-label"]:has-text("Similarity:")',
].join(', '),
1000,
);
if (existingScope && (await hasSubmissionCard(existingScope))) {
throw new ExistingSubmissionError();
}
}
// Assert quota is available
const quotaLimit = await detectQuotaLimit(page);
if (quotaLimit) {
throw new SubmissionQuotaLimitError(quotaLimit.message);
}
let workflowScope: Scope = page;
// Check if we need to click the initial "Submit" button to open the upload form
const isUploadFormVisible = await page
.locator(SELECTORS.upload.browseButton + ', ' + SELECTORS.upload.fileInput + ', ' + SELECTORS.upload.uploadStepContainer)
.first()
.isVisible({ timeout: 2000 })
.catch(() => false);
if (!isUploadFormVisible) {
logger.info('Upload form not visible. Searching for initial Submit button...');
const initialSubmitSelectors = [
'tii-workflow-lfw-student-show-assignment tii-workflow-student-summary-panel-new [slot="submission-action"] tii-grn-button:has-text("Submit")',
'tii-workflow-lfw-student-show-assignment .submission-action tii-grn-button:has-text("Submit")',
'tii-workflow-student-summary-panel-new tii-grn-button:has-text("Submit")',
'div[slot="submission-action"].submission-action tii-grn-button:has-text("Submit")',
'.submission-slot-container tii-grn-button:has-text("Submit")',
'tii-workflow-student-summary-panel-new tii-grn-button',
'tii-grn-button:has-text("Submit")',
'button:has-text("Submit")',
'tdl-button:has-text("Submit")',
];
let clickedInitialSubmit = false;
const initialSubmitScope =
(await findAssignmentScope(page, initialSubmitSelectors.join(', '), 3000)) ||
(await findScopeByDeepText(
page,
['submission settings', 'resubmissions are allowed', 'submit'],
3000,
)) ||
page;
workflowScope = initialSubmitScope;
for (const selector of initialSubmitSelectors) {
const btn = initialSubmitScope.locator(selector).first();
if (await btn.isVisible({ timeout: 1500 }).catch(() => false)) {
logger.info(`Clicking initial Submit button: ${selector}`);
await btn.click({ force: true });
clickedInitialSubmit = true;
await page.waitForTimeout(3000);
break;
}
}
if (!clickedInitialSubmit) {
const clickedByDom = await clickInitialSubmitButton(initialSubmitScope);
if (clickedByDom) {
logger.info('Initial Submit button clicked via deep shadow DOM fallback');
await page.waitForTimeout(3000);
} else {
logger.warn('Initial Submit button not found or could not be clicked. Proceeding to find upload form anyway...');
}
}
}
// Find upload form
// New UI: div.upload-step or tii-ing-dropzone
// Old UI: tdl-button[part="upload-form-container-button"] or input[type="file"]
let scope: Scope | null =
(await findAssignmentScope(
page,
SELECTORS.upload.browseButton + ', ' + SELECTORS.upload.fileInput + ', ' + SELECTORS.upload.uploadStepContainer,
5000,
)) ||
(await findScopeByDeepText(
page,
['browse files', 'drag and drop file', 'drag and drop your file', 'your work'],
30000,
));
if (!scope) {
logger.warn('Upload form scope was not resolved; trying workflow scope upload controls');
scope = workflowScope;
}
logger.info('Uploading file', { filePath });
await clickUploadControl(page, scope, filePath);
await confirmQuotaWarning(page);
await page.waitForTimeout(4000);
// Re-resolve scope for submit button
scope =
(await findAssignmentScope(page, SELECTORS.upload.submitButton, 5000)) ||
(await findScopeByDeepText(
page,
['upload and preview', 'submit file', 'preview unavailable'],
5000,
)) ||
scope;
const clickedSubmit = await clickVisibleSubmit(scope, page);
if (!clickedSubmit) {
logger.warn('Upload action candidates after file selection', {
candidates: await collectUploadActionDiagnostics(scope),
});
throw new Error(
'Upload file was selected, but no Upload/Confirm/Submit button was found',
);
}
await page.waitForTimeout(2500);
logger.info('File upload completed');
}