Spaces:
Sleeping
Sleeping
| 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> { | |
| // New UI: tii-grn-button inside tii-workflow-student-summary-panel-new | |
| // Old UI: tdl-button or button with text "Resubmit" | |
| 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( | |
| // Include tii-grn-button for new UI | |
| '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']); | |
| } | |
| /** | |
| * Handle the new Turnitin resubmission confirmation modal. | |
| * The dialog#tii-resubmission-last-submission-modal is rendered INSIDE a | |
| * web component's shadow DOM, so standard document.querySelector() and even | |
| * Playwright's page.locator() may not find it. | |
| * | |
| * This function uses a full recursive shadow-DOM traversal to locate the | |
| * dialog and click its "Continue to File Upload" (or any accept-like) button. | |
| */ | |
| async function confirmResubmissionModal(page: Page): Promise<boolean> { | |
| // Helper: find dialog anywhere in the full shadow DOM tree | |
| 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); | |
| // Wait up to 6s for the modal to appear (shadow DOM traversal) | |
| let found = false; | |
| const deadline = Date.now() + 6000; | |
| while (!found && Date.now() < deadline) { | |
| found = await findDialog(); | |
| if (!found) await page.waitForTimeout(400); | |
| } | |
| if (!found) { | |
| // Also try the standard Playwright locator as a fallback (works when dialog is in light DOM) | |
| 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...'); | |
| // Click the accept button via full shadow-DOM traversal | |
| const clicked = await page.evaluate(() => { | |
| // Build complete shadow-DOM root list | |
| 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); | |
| } | |
| } | |
| // Find the dialog first | |
| 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; | |
| // Traverse dialog's own shadow DOM | |
| 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); | |
| } | |
| } | |
| // Priority: "Continue to File Upload" > any accept/submit/confirm button | |
| 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) { | |
| // Last resort: close the dialog directly | |
| if (dialog instanceof HTMLDialogElement) { | |
| dialog.close(); | |
| return true; | |
| } | |
| return false; | |
| } | |
| // Click: prefer inner shadow <button>, else click host element | |
| 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); | |
| // Verify dialog actually closed (wait up to 3s) | |
| 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') | |
| ); | |
| } | |
| /** | |
| * Resubmit a file to an assignment that already has a prior submission. | |
| * | |
| * Steps: | |
| * 1. Assert quota is available | |
| * 2. Click Resubmit button | |
| * 3. Handle quota warnings (confirm and continue) | |
| * 4. Use same upload popup flow (Browse Files -> Upload and Preview -> Submit) | |
| * 5. If Resubmit button not found, fallback to initial upload | |
| */ | |
| export async function resubmitFile( | |
| page: Page, | |
| filePath: string, | |
| ): Promise<void> { | |
| logger.info('Resubmit requested; opening resubmission upload modal'); | |
| // Check quota before attempting resubmit | |
| const quotaLimit = await detectQuotaLimit(page); | |
| if (quotaLimit) { | |
| throw new SubmissionQuotaLimitError(quotaLimit.message); | |
| } | |
| // Find the Resubmit button | |
| 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); | |
| // Handle NEW UI resubmission confirmation modal | |
| // (dialog#tii-resubmission-last-submission-modal with accept-button slot) | |
| const modalConfirmed = await confirmResubmissionModal(page); | |
| if (modalConfirmed) { | |
| logger.info('Resubmission confirmation modal accepted'); | |
| await page.waitForTimeout(2000); | |
| } | |
| // Check quota again after clicking resubmit | |
| const quotaLimitAfter = await detectQuotaLimit(page); | |
| if (quotaLimitAfter) { | |
| throw new SubmissionQuotaLimitError(quotaLimitAfter.message); | |
| } | |
| // Handle quota warnings | |
| await confirmQuotaWarning(page); | |
| // Now proceed with the standard upload flow (the resubmit popup is the same) | |
| await uploadFile(page, filePath, { skipExistingCheck: true }); | |
| } | |