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 { // 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(); 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 { // Helper: find dialog anywhere in the full shadow DOM tree const findDialog = () => page.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 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(); 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(); 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