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 { 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 { 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 { return scope .evaluate((textMatches: string[]) => { const normalizedMatches = textMatches.map((value) => value.toLowerCase()); const roots: (Document | ShadowRoot)[] = [document]; const seen = new Set(); 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 { 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 { 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(); 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(); 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>> { return scope .evaluate((selector: string) => { const roots: (Document | ShadowRoot)[] = [document]; const seen = new Set(); 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> = []; 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 { 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 { 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(); 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 { // 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'); }