Spaces:
Sleeping
Sleeping
| 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'); | |
| } | |