| import type { Page, Frame } from 'playwright'; |
| import { logger } from '../../utils/logger'; |
| import { |
| QUOTA_CONFIRM_LABELS, |
| SELECTORS, |
| readScopeDeepText, |
| readScopeVisibleDeepText, |
| } from '../selectors'; |
|
|
| |
| |
| |
|
|
| 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; |
| } |
|
|
| |
| |
| |
|
|
| 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), |
| }; |
| } |
|
|
| |
| |
| |
|
|
| 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')), |
| ]; |
| } |
|
|
| |
| |
| |
|
|
| |
| |
| |
| export async function detectQuotaLimit( |
| page: Page, |
| ): Promise<QuotaLimitResult | null> { |
| for (const scope of getScopes(page)) { |
| const text = await readScopeDeepText(scope); |
| const result = parseSubmissionQuotaLimit(text); |
| if (result) return result; |
| } |
| return null; |
| } |
|
|
| |
| |
| |
| export async function detectQuotaWarning( |
| page: Page, |
| ): Promise<QuotaWarningResult | null> { |
| for (const scope of getScopes(page)) { |
| const text = await readScopeVisibleDeepText(scope); |
| const result = parseSubmissionQuotaWarning(text); |
| if (result) return result; |
| } |
| return null; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| export async function confirmQuotaWarning( |
| page: Page, |
| ): Promise<boolean> { |
| for (const scope of getScopes(page)) { |
| const text = await readScopeVisibleDeepText(scope); |
| const warning = parseSubmissionQuotaWarning(text); |
| if (!warning) continue; |
|
|
| |
| |
| |
| const hasActionableDialog = 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 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) { |
| |
| for (const d of Array.from(root.querySelectorAll('dialog[open]'))) { |
| if (isVisible(d) && hasText(d, /more submission|file upload|continue/i)) return true; |
| } |
| |
| 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; |
| } |
| |
| 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) { |
| |
| 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); |
|
|
| |
| const stillVisible = parseSubmissionQuotaWarning( |
| await readScopeVisibleDeepText(scope), |
| ); |
| if (stillVisible) { |
| await clickQuotaWarningContinueByCoordinates(page, scope); |
| await page.waitForTimeout(1500); |
| } |
|
|
| |
| 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<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) { |
| 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; |
| } |
|
|
| |
| |
| |
| export async function runQuotaCheck( |
| page: Page, |
| ): Promise<QuotaCheckResult> { |
| 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, |
| }; |
| } |
|
|
| |
| |
| |
|
|
| async function clickQuotaWarningContinue( |
| scope: Scope, |
| ): Promise<boolean> { |
| |
| 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; |
| } |
| } |
|
|
| |
| const filledAction = scope |
| .locator(SELECTORS.quota.warningConfirmButton) |
| .last(); |
| if ( |
| await filledAction.isVisible({ timeout: 1000 }).catch(() => false) |
| ) { |
| await filledAction.click({ force: true }); |
| return true; |
| } |
|
|
| |
| |
| return scope |
| .evaluate( |
| ({ |
| labels, |
| selector, |
| }: { |
| labels: string[]; |
| 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 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<boolean> { |
| |
| const filledAction = scope |
| .locator(SELECTORS.quota.warningConfirmButton) |
| .last(); |
| if ( |
| await filledAction.isVisible({ timeout: 1000 }).catch(() => false) |
| ) { |
| await filledAction.click({ force: true }); |
| return true; |
| } |
|
|
| |
| 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; |
| } |
|
|
| |
| const point = 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 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; |
| } |
|
|
| |
| await page.mouse.click(890, 670).catch(() => {}); |
| return true; |
| } |
|
|