import type { Page, Frame } from 'playwright'; import { logger } from '../../utils/logger'; import { QUOTA_CONFIRM_LABELS, SELECTORS, readScopeDeepText, readScopeVisibleDeepText, } from '../selectors'; // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- export interface QuotaLimitResult { limited: true; limit: number; retryText: string; message: string; } export interface QuotaWarningResult { warning: true; message: string; } export interface QuotaCheckResult { quotaLimited: boolean; limit: number | null; retryText: string | null; message: string | null; warning: string | null; } // --------------------------------------------------------------------------- // Parsers // --------------------------------------------------------------------------- function parseSubmissionQuotaLimit(text: string): QuotaLimitResult | null { const normalized = String(text || '') .replace(/\s+/g, ' ') .trim(); const match = normalized.match( /You have reached your limit of\s+(\d+)\s+submissions\.\s+You can submit again\s+(.+?)(?:\.|$)/i, ); if (!match) return null; return { limited: true, limit: Number(match[1]), retryText: match[2], message: match[0], }; } function parseSubmissionQuotaWarning( text: string, ): QuotaWarningResult | null { const normalized = String(text || '') .replace(/\s+/g, ' ') .trim(); const patterns = [ /(?:only\s+)?(?:1|one)\s+submissions?\s+remaining/i, /(?:only\s+)?(?:1|one)\s+remaining\s+submissions?/i, /(?:only\s+)?(?:1|one)\s+more\s+submissions?\s+left/i, /final\s+submission/i, /last\s+submission/i, /remaining\s+submissions?/i, /limited\s+number\s+of\s+submissions/i, ]; if (!patterns.some((p) => p.test(normalized))) return null; return { warning: true, message: normalized.slice(0, 500), }; } // --------------------------------------------------------------------------- // Scope helpers // --------------------------------------------------------------------------- type Scope = Page | Frame; const QUOTA_ACTION_SELECTOR = [ 'button', 'input[type="button"]', 'input[type="submit"]', 'tdl-button', 'tii-grn-button', '[role="button"]', '[slot="accept-button"]', '[part*="button"]', '[data-px*="Continue"]', '[with-data-px*="Continue"]', ].join(', '); function getScopes(page: Page): Scope[] { return [ page, ...page.frames().filter((f) => !f.url().includes('cookie-shim')), ]; } // --------------------------------------------------------------------------- // Public API // --------------------------------------------------------------------------- /** * Detect whether the page shows a hard quota limit (all submissions used). */ export async function detectQuotaLimit( page: Page, ): Promise { for (const scope of getScopes(page)) { const text = await readScopeDeepText(scope); const result = parseSubmissionQuotaLimit(text); if (result) return result; } return null; } /** * Detect whether the page shows a quota warning (e.g. 1 submission remaining). */ export async function detectQuotaWarning( page: Page, ): Promise { for (const scope of getScopes(page)) { const text = await readScopeVisibleDeepText(scope); const result = parseSubmissionQuotaWarning(text); if (result) return result; } return null; } /** * Attempt to confirm/dismiss a quota warning popup by clicking a continue * button. Returns true if a warning was found and confirmed. * * Improvements: * - Skips if the warning text is just background info (no active dialog/modal) * - Uses dialog.close() as a nuclear fallback to guarantee modal closure */ export async function confirmQuotaWarning( page: Page, ): Promise { for (const scope of getScopes(page)) { const text = await readScopeVisibleDeepText(scope); const warning = parseSubmissionQuotaWarning(text); if (!warning) continue; // Check if there is ACTUALLY an interactive modal/dialog open. // Without this check, background informational text ("You have 1 more // submission left") keeps triggering the handler even when no dialog exists. const hasActionableDialog = 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 as any).shadowRoot) roots.push((el as any).shadowRoot); } } const isVisible = (el: Element): boolean => { if (!(el instanceof HTMLElement)) return false; const s = window.getComputedStyle(el); const r = el.getBoundingClientRect(); return s.visibility !== 'hidden' && s.display !== 'none' && r.width > 0 && r.height > 0; }; const hasText = (el: Element, pattern: RegExp): boolean => pattern.test((el as HTMLElement).innerText || el.textContent || ''); for (const root of roots) { // Native for (const d of Array.from(root.querySelectorAll('dialog[open]'))) { if (isVisible(d) && hasText(d, /more submission|file upload|continue/i)) return true; } // Custom modal/overlay elements for (const el of Array.from( root.querySelectorAll('[role="dialog"], [aria-modal="true"], tii-modal, .tii-modal, [id*="modal"]'), )) { if (isVisible(el) && hasText(el, /more submission|file upload|continue/i)) return true; } // tii-grn-button with "Continue to File Upload" visible => quota dialog must be open for (const el of Array.from(root.querySelectorAll('tii-grn-button'))) { if (!isVisible(el)) continue; const t = ((el as HTMLElement).innerText || el.textContent || '').toLowerCase(); if (t.includes('continue to file upload')) return true; } } return false; }) .catch(() => false); if (!hasActionableDialog) { // Only background informational text — no interactive dialog to dismiss. logger.info('Quota warning text is background-only (no active dialog); skipping dismissal'); continue; } logger.info('Submission quota warning dialog detected; attempting to dismiss', { message: warning.message, }); const clicked = await clickQuotaWarningContinue(scope); if (!clicked) { logger.warn('Submission quota warning continue button was not found'); } await page.waitForTimeout(1500); // If warning is still visible, try coordinate-based click const stillVisible = parseSubmissionQuotaWarning( await readScopeVisibleDeepText(scope), ); if (stillVisible) { await clickQuotaWarningContinueByCoordinates(page, scope); await page.waitForTimeout(1500); } // Nuclear fallback: forcibly close any open dialog with quota/upload text const stillVisibleAfter = parseSubmissionQuotaWarning( await readScopeVisibleDeepText(scope), ); if (stillVisibleAfter) { logger.warn('Quota warning still visible; using dialog.close() nuclear fallback'); 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 as any).shadowRoot) roots.push((el as any).shadowRoot); } } for (const root of roots) { for (const d of Array.from(root.querySelectorAll('dialog[open], dialog'))) { if (d instanceof HTMLDialogElement) { const t = (d as HTMLElement).innerText || d.textContent || ''; if (/more submission|file upload|continue/i.test(t)) { d.close(); } } } } }) .catch(() => {}); await page.waitForTimeout(1000); } return true; } return false; } /** * Run a complete quota check: detect limit and/or warning. */ export async function runQuotaCheck( page: Page, ): Promise { const limit = await detectQuotaLimit(page); if (limit) { return { quotaLimited: true, limit: limit.limit, retryText: limit.retryText, message: limit.message, warning: null, }; } const warning = await detectQuotaWarning(page); return { quotaLimited: false, limit: null, retryText: null, message: null, warning: warning?.message || null, }; } // --------------------------------------------------------------------------- // Internal: click quota warning continue buttons // --------------------------------------------------------------------------- async function clickQuotaWarningContinue( scope: Scope, ): Promise { // Try each preferred label in order for (const label of QUOTA_CONFIRM_LABELS) { const escaped = label.replace(/"/g, '\\"'); const button = scope .locator( `tii-grn-button:has-text("${escaped}"), button:has-text("${escaped}"), tdl-button:has-text("${escaped}"), [role="button"]:has-text("${escaped}")`, ) .last(); if (await button.isVisible({ timeout: 1000 }).catch(() => false)) { await button.click({ force: true }); return true; } const textTarget = scope.getByText(label, { exact: false }).last(); if ( await textTarget.isVisible({ timeout: 1000 }).catch(() => false) ) { await textTarget.click({ force: true }); return true; } } // Try filled action slot button const filledAction = scope .locator(SELECTORS.quota.warningConfirmButton) .last(); if ( await filledAction.isVisible({ timeout: 1000 }).catch(() => false) ) { await filledAction.click({ force: true }); return true; } // Deep shadow DOM scan fallback. Turnitin often renders the visible label in // a shadow/slot child, while the actual clickable target is the host element. return scope .evaluate( ({ labels, selector, }: { labels: string[]; selector: string; }) => { 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 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 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() .toLowerCase(); const resolveClickable = (el: Element): HTMLElement | null => { const direct = (el as HTMLElement).closest?.( 'button, a, [role="button"], tdl-button, tii-grn-button, [slot="accept-button"]', ) as HTMLElement | null; const root = el.getRootNode(); const host = root instanceof ShadowRoot ? (root.host as HTMLElement) : null; const clickable = direct || host || (el as HTMLElement); const innerButton = clickable.shadowRoot?.querySelector( 'button:not([disabled]), input[type="submit"]:not([disabled]), input[type="button"]:not([disabled])', ); return innerButton instanceof HTMLElement ? innerButton : clickable; }; const candidates: Element[] = []; for (const root of roots) { candidates.push(...Array.from(root.querySelectorAll(selector))); candidates.push(...Array.from(root.querySelectorAll('*'))); } const normalizedLabels = labels.map((l) => l.toLowerCase()); const target = candidates .filter(isVisible) .map((el) => { const rect = el.getBoundingClientRect(); const text = readText(el); const labelIndex = normalizedLabels.findIndex((l) => text.includes(l), ); const clickable = resolveClickable(el); return { element: el, clickable, labelIndex, area: rect.width * rect.height, disabled: clickable ? isDisabled(clickable) : true, }; }) .filter((c) => c.labelIndex >= 0 && !c.disabled && c.clickable) .sort( (a, b) => a.labelIndex - b.labelIndex || a.area - b.area, )[0]?.clickable; if (!target) return false; target.scrollIntoView({ block: 'center', inline: 'center' }); target.click(); return true; }, { labels: QUOTA_CONFIRM_LABELS, selector: QUOTA_ACTION_SELECTOR }, ) .catch(() => false); } async function clickQuotaWarningContinueByCoordinates( page: Page, scope: Scope, ): Promise { // Try filled action slot button first const filledAction = scope .locator(SELECTORS.quota.warningConfirmButton) .last(); if ( await filledAction.isVisible({ timeout: 1000 }).catch(() => false) ) { await filledAction.click({ force: true }); return true; } // Try coordinate-based click on "Continue to File Upload" const label = 'Continue to File Upload'; const textTarget = scope.getByText(label, { exact: false }).last(); const box = await textTarget .boundingBox({ timeout: 1000 }) .catch(() => null); if (box) { await page.mouse .click(box.x + box.width / 2, box.y + box.height / 2) .catch(() => {}); return true; } // Deep shadow DOM coordinate search const point = 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 target = roots .flatMap((r) => Array.from(r.querySelectorAll('*'))) .filter(isVisible) .map((el) => { const rect = el.getBoundingClientRect(); const text = [ (el as HTMLElement).innerText, el.textContent, ] .filter(Boolean) .join(' ') .replace(/\s+/g, ' ') .trim() .toLowerCase(); return { rect, text, area: rect.width * rect.height }; }) .filter((c) => c.text.includes('continue to file upload')) .sort((a, b) => a.area - b.area)[0]; if (!target) return null; return { x: target.rect.x + target.rect.width / 2, y: target.rect.y + target.rect.height / 2, }; }) .catch(() => null); if (point) { await page.mouse.click(point.x, point.y).catch(() => {}); return true; } // Final fallback: click approximate center of common dialog area await page.mouse.click(890, 670).catch(() => {}); return true; }