| 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', |
| '[role="button"]', |
| '[slot="accept-button"]', |
| '[part*="submit"]', |
| '[part*="upload"]', |
| '[data-px*="Submit"]', |
| '[data-px*="Upload"]', |
| '[with-data-px*="Submit"]', |
| '[with-data-px*="Upload"]', |
| ].join(', '); |
|
|
| |
| |
| |
|
|
| 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'; |
| } |
| } |
|
|
| |
| |
| |
|
|
| 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); |
| } |
|
|
| |
| |
| |
|
|
| 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', |
| ]; |
|
|
| |
| |
| |
| 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); |
| } |
|
|
| |
| |
| |
|
|
| 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) { |
| |
| 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; |
| } |
| } |
|
|
| |
| 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; |
| } |
|
|
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export async function uploadFile( |
| page: Page, |
| filePath: string, |
| options: { skipExistingCheck?: boolean } = {}, |
| ): Promise<void> { |
| |
| 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(); |
| } |
| } |
|
|
| |
| const quotaLimit = await detectQuotaLimit(page); |
| if (quotaLimit) { |
| throw new SubmissionQuotaLimitError(quotaLimit.message); |
| } |
|
|
| let workflowScope: Scope = page; |
| |
| 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...'); |
| } |
| } |
| } |
|
|
| |
| |
| |
| 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); |
|
|
| |
| 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'); |
| } |
|
|