| import type { Page, Frame } from 'playwright'; |
| import { logger } from '../../utils/logger'; |
| import { |
| SELECTORS, |
| deepClickByText, |
| findAssignmentScope, |
| findScopeByDeepText, |
| readScopeVisibleDeepText, |
| } from '../selectors'; |
| import { detectQuotaLimit, confirmQuotaWarning } from './quota-detect'; |
| import { uploadFile, SubmissionQuotaLimitError, hasSubmissionCard } from './upload'; |
|
|
| type Scope = Page | Frame; |
|
|
| async function clickVisibleResubmit(scope: Scope): Promise<boolean> { |
| |
| |
| const locator = scope |
| .locator( |
| 'tii-grn-button:has-text("Resubmit"), a:has-text("Resubmit"), button:has-text("Resubmit"), tdl-button:has-text("Resubmit"), [aria-label*="Resubmit"], [data-px*="Resubmit"], [with-data-px*="Resubmit"]', |
| ) |
| .first(); |
|
|
| if (await locator.isVisible({ timeout: 3000 }).catch(() => false)) { |
| await locator.click({ force: true }); |
| return true; |
| } |
|
|
| const clickedByDom = await 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 readText = (el: Element): string => |
| [ |
| (el as HTMLElement).innerText, |
| el.textContent, |
| el.getAttribute('aria-label'), |
| el.getAttribute('value'), |
| el.getAttribute('part'), |
| el.getAttribute('data-px'), |
| el.getAttribute('with-data-px'), |
| ] |
| .filter(Boolean) |
| .join(' ') |
| .replace(/\s+/g, ' ') |
| .trim() |
| .toLowerCase(); |
|
|
| const candidates: Element[] = []; |
| for (const root of roots) { |
| candidates.push( |
| ...Array.from( |
| root.querySelectorAll( |
| |
| 'button, a, input, tdl-button, tii-grn-button, [role="button"]', |
| ), |
| ), |
| ); |
| } |
|
|
| const target = candidates.find((el) => isVisible(el) && readText(el).includes('resubmit')); |
| if (!target) return false; |
|
|
| const shadowButton = target.shadowRoot?.querySelector( |
| 'button:not([disabled]), [role="button"]:not([disabled])', |
| ); |
| const clickTarget = |
| shadowButton instanceof HTMLElement ? shadowButton : (target as HTMLElement); |
| clickTarget.scrollIntoView({ block: 'center', inline: 'center' }); |
| clickTarget.click(); |
| return true; |
| }) |
| .catch(() => false); |
|
|
| if (clickedByDom) return true; |
| return deepClickByText(scope, ['resubmit']); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| async function confirmResubmissionModal(page: Page): Promise<boolean> { |
| |
| const findDialog = () => page.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 as any).shadowRoot) roots.push((el as any).shadowRoot); |
| } |
| } |
| for (const root of roots) { |
| if (root.querySelector('dialog#tii-resubmission-last-submission-modal,dialog[open]')) return true; |
| } |
| return false; |
| }).catch(() => false); |
|
|
| |
| let found = false; |
| const deadline = Date.now() + 6000; |
| while (!found && Date.now() < deadline) { |
| found = await findDialog(); |
| if (!found) await page.waitForTimeout(400); |
| } |
|
|
| if (!found) { |
| |
| found = await page |
| .locator('dialog#tii-resubmission-last-submission-modal, dialog[open]') |
| .first() |
| .isVisible({ timeout: 2000 }) |
| .catch(() => false); |
| } |
|
|
| if (!found) { |
| logger.info('No resubmission confirmation modal detected'); |
| return false; |
| } |
|
|
| logger.info('Resubmission confirmation modal detected; clicking Continue to File Upload...'); |
|
|
| |
| const clicked = await page.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 as any).shadowRoot) roots.push((el as any).shadowRoot); |
| } |
| } |
|
|
| |
| let dialog: Element | null = null; |
| for (const root of roots) { |
| dialog = root.querySelector('dialog#tii-resubmission-last-submission-modal') || |
| root.querySelector('dialog[open]'); |
| if (dialog) break; |
| } |
| if (!dialog) return false; |
|
|
| |
| const dialogRoots: (Element | ShadowRoot)[] = [dialog]; |
| const dSeen = new Set<Element>(); |
| for (let i = 0; i < dialogRoots.length; i++) { |
| const root = dialogRoots[i]; |
| for (const el of Array.from(root.querySelectorAll('*'))) { |
| if (dSeen.has(el as Element)) continue; |
| dSeen.add(el as Element); |
| if ((el as any).shadowRoot) dialogRoots.push((el as any).shadowRoot); |
| } |
| } |
|
|
| |
| const priority = (text: string): number => { |
| const t = text.toLowerCase(); |
| if (t.includes('continue to file upload')) return 100; |
| if (t.includes('file upload')) return 90; |
| if (t.includes('continue')) return 80; |
| if (t.includes('accept')) return 70; |
| if (t.includes('submit')) return 60; |
| if (t.includes('confirm')) return 50; |
| return 0; |
| }; |
|
|
| const candidates: Array<{ el: Element; score: number }> = []; |
| for (const root of dialogRoots) { |
| for (const el of Array.from( |
| root.querySelectorAll('tii-grn-button, button, tdl-button, [role="button"], [slot="accept-button"]'), |
| )) { |
| const text = [ |
| (el as HTMLElement).innerText, |
| el.textContent, |
| el.getAttribute('aria-label'), |
| el.getAttribute('part'), |
| el.getAttribute('slot'), |
| el.getAttribute('data-px'), |
| el.getAttribute('with-data-px'), |
| ].filter(Boolean).join(' ').replace(/\s+/g, ' ').trim(); |
|
|
| const score = priority(text); |
| if (score > 0) candidates.push({ el, score }); |
| } |
| } |
|
|
| candidates.sort((a, b) => b.score - a.score); |
| const best = candidates[0]; |
| if (!best) { |
| |
| if (dialog instanceof HTMLDialogElement) { |
| dialog.close(); |
| return true; |
| } |
| return false; |
| } |
|
|
| |
| const inner = (best.el as any).shadowRoot?.querySelector('button:not([disabled])'); |
| const target = inner instanceof HTMLElement ? inner : (best.el as HTMLElement); |
| target.scrollIntoView({ block: 'center', inline: 'center' }); |
| target.click(); |
| return true; |
| }).catch(() => false); |
|
|
| if (clicked) { |
| await page.waitForTimeout(2000); |
|
|
| |
| const stillOpen = await findDialog(); |
| if (stillOpen) { |
| logger.warn('Dialog still open after click; trying dialog.close() fallback'); |
| await page.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 as any).shadowRoot) roots.push((el as any).shadowRoot); |
| } |
| } |
| for (const root of roots) { |
| const d = root.querySelector('dialog#tii-resubmission-last-submission-modal') || |
| root.querySelector('dialog[open]'); |
| if (d instanceof HTMLDialogElement) { d.close(); return; } |
| } |
| }).catch(() => {}); |
| await page.waitForTimeout(1500); |
| } |
| } |
|
|
| return clicked; |
| } |
|
|
| async function hasFirstSubmissionUploadForm(page: Page): Promise<boolean> { |
| const scope = |
| (await findAssignmentScope( |
| page, |
| `${SELECTORS.upload.browseButton}, ${SELECTORS.upload.fileInput}, ${SELECTORS.upload.uploadStepContainer}`, |
| 2000, |
| )) || (await findScopeByDeepText( |
| page, |
| ['browse files', 'drag and drop file', 'drag and drop your file', 'your device'], |
| 2000, |
| )); |
|
|
| if (!scope) return false; |
| const visibleText = (await readScopeVisibleDeepText(scope)).toLowerCase(); |
| return ( |
| (await scope.locator(SELECTORS.upload.fileInput).first().isVisible({ timeout: 500 }).catch(() => false)) || |
| (await scope.locator(SELECTORS.upload.browseButton).first().isVisible({ timeout: 500 }).catch(() => false)) || |
| (await scope.locator(SELECTORS.upload.uploadStepContainer).first().isVisible({ timeout: 500 }).catch(() => false)) || |
| visibleText.includes('browse files') || |
| visibleText.includes('drag and drop file') || |
| visibleText.includes('drag and drop your file') || |
| visibleText.includes('your device') |
| ); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export async function resubmitFile( |
| page: Page, |
| filePath: string, |
| ): Promise<void> { |
| logger.info('Resubmit requested; opening resubmission upload modal'); |
|
|
| |
| const quotaLimit = await detectQuotaLimit(page); |
| if (quotaLimit) { |
| throw new SubmissionQuotaLimitError(quotaLimit.message); |
| } |
|
|
| |
| const scope = |
| (await findAssignmentScope( |
| page, |
| 'tii-grn-button:has-text("Resubmit"), a:has-text("Resubmit"), button:has-text("Resubmit"), tdl-button:has-text("Resubmit"), [aria-label*="Resubmit"], [data-px*="Resubmit"], [with-data-px*="Resubmit"]', |
| 5000, |
| )) || |
| (await findScopeByDeepText(page, ['resubmit'], 8000)); |
| if (!scope) { |
| if (await hasFirstSubmissionUploadForm(page)) { |
| logger.warn( |
| 'Resubmit button was not found and first-submission upload form is visible; using initial upload flow', |
| ); |
| await uploadFile(page, filePath); |
| return; |
| } |
| throw new Error('Resubmit was requested, but no visible Resubmit button or first-upload form was found'); |
| } |
|
|
| let clicked = await clickVisibleResubmit(scope); |
|
|
| if (!clicked) { |
| const submissionCardVisible = await hasSubmissionCard(scope); |
| if (!submissionCardVisible && await hasFirstSubmissionUploadForm(page)) { |
| logger.warn( |
| 'Resubmit button click failed, but no submission card is visible; using initial upload flow', |
| ); |
| await uploadFile(page, filePath); |
| return; |
| } |
| throw new Error('Resubmit button was found but could not be clicked'); |
| } |
|
|
| await page.waitForTimeout(2000); |
|
|
| |
| |
| const modalConfirmed = await confirmResubmissionModal(page); |
| if (modalConfirmed) { |
| logger.info('Resubmission confirmation modal accepted'); |
| await page.waitForTimeout(2000); |
| } |
|
|
| |
| const quotaLimitAfter = await detectQuotaLimit(page); |
| if (quotaLimitAfter) { |
| throw new SubmissionQuotaLimitError(quotaLimitAfter.message); |
| } |
|
|
| |
| await confirmQuotaWarning(page); |
|
|
| |
| await uploadFile(page, filePath, { skipExistingCheck: true }); |
| } |
|
|