import type { Page, Frame } from 'playwright'; import { findAssignmentScope, readScopeVisibleDeepText, scopeHasVisibleDeepText, } from '../selectors'; import { SELECTORS } from '../selectors'; type Scope = Page | Frame; export interface SubmissionState { scope: Scope | null; hasExistingSubmission: boolean; hasResubmitAction: boolean; hasUploadForm: boolean; } async function hasVisible(scope: Scope, selector: string, timeoutMs = 1000): Promise { return scope.locator(selector).first().isVisible({ timeout: timeoutMs }).catch(() => false); } async function hasVisibleActionText(scope: Scope, textMatches: string[]): Promise { return scope .evaluate((matches: string[]) => { const normalizedMatches = matches.map((value) => value.toLowerCase()); 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 candidates: Element[] = []; for (const root of roots) { candidates.push( ...Array.from( root.querySelectorAll( 'button, a, input, tdl-button, tii-grn-button, tdl-labeled-button, [role="button"]', ), ), ); } return candidates.some((el) => { if (!isVisible(el)) return false; const text = [ (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'), el.getAttribute('with-px-label'), ] .filter(Boolean) .join(' ') .replace(/\s+/g, ' ') .trim() .toLowerCase(); if ( text.includes('setting info') || text.includes('resubmissions are allowed') || text.includes('late submissions are allowed') || text.includes('collapse details') || text.includes('assignment details') || text.includes('rubric') || text.includes('template') ) { return false; } return normalizedMatches.some((match) => text.includes(match)); }); }, textMatches) .catch(() => false); } async function hasInitialSubmitAction(scope: Scope): Promise { return 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 hasAncestorSignal = (el: Element): boolean => { let current: Element | null = el; const visited = new Set(); while (current && !visited.has(current)) { visited.add(current); const className = current.getAttribute('class') || ''; const slot = current.getAttribute('slot') || ''; const tag = current.tagName.toLowerCase(); if ( slot === 'submission-action' || className.includes('submission-action') || className.includes('submission-slot-container') || tag === 'tii-workflow-student-summary-panel-new' || tag === 'tii-workflow-lfw-student-show-assignment' ) { return true; } const parent: HTMLElement | null = current.parentElement; if (parent) { current = parent; continue; } const root = current.getRootNode(); current = root instanceof ShadowRoot ? root.host : null; } return false; }; for (const root of roots) { for (const el of Array.from( root.querySelectorAll('tii-grn-button, button, tdl-button, [role="button"], input[type="submit"]'), )) { if (!isVisible(el)) continue; const text = [ (el as HTMLElement).innerText, el.textContent, el.getAttribute('aria-label'), el.getAttribute('value'), ] .filter(Boolean) .join(' ') .replace(/\s+/g, ' ') .trim(); const normalized = text.toLowerCase(); if (!/(^|\s)submit(\s|$)/i.test(text)) continue; if ( normalized.includes('resubmit') || normalized.includes('submit file') || normalized.includes('upload and preview') || normalized.includes('collapse details') || normalized.includes('setting info') ) { continue; } if (hasAncestorSignal(el)) return true; } } return false; }) .catch(() => false); } /** * Detect what Turnitin is currently showing after opening an assignment. * This is intentionally based on the live page, because account history can * differ from the requested job mode. */ export async function detectSubmissionState(page: Page): Promise { const scope = (await findAssignmentScope( page, [ SELECTORS.resubmit.resubmitButton, SELECTORS.similarity.viewSubmissionButton, SELECTORS.upload.browseButton, SELECTORS.upload.fileInput, SELECTORS.resubmit.submissionActionPanel, 'tii-workflow-lfw-student-show-assignment', 'div[slot="submission-action"].submission-action', 'tii-grn-button:has-text("Submit")', ].join(', '), 3000, )); if (!scope) { return { scope: null, hasExistingSubmission: false, hasResubmitAction: false, hasUploadForm: false, }; } const visibleText = (await readScopeVisibleDeepText(scope)).toLowerCase(); const hasSubmitAction = await hasInitialSubmitAction(scope); const hasResubmitAction = (await hasVisible(scope, SELECTORS.resubmit.resubmitButton, 1500)) || (await hasVisible( scope, 'a:has-text("Resubmit"), button:has-text("Resubmit"), tdl-button:has-text("Resubmit"), [aria-label*="Resubmit"], [data-px*="Resubmit"]', 1500, )) || (await hasVisibleActionText(scope, ['resubmit'])); const hasUploadForm = (await hasVisible(scope, SELECTORS.upload.fileInput, 1500)) || (await hasVisible(scope, SELECTORS.upload.browseButton, 1500)) || hasSubmitAction || visibleText.includes('browse files') || visibleText.includes('drag and drop file') || visibleText.includes('your device'); const hasViewSubmissionAction = (await hasVisible( scope, 'button.link-button[aria-label*="View submission"], a[aria-label*="View submission"], a.view-mark, .similarity-button', 1500, )) || (await hasVisibleActionText(scope, ['view submission'])); const hasExistingSubmission = hasResubmitAction || hasViewSubmissionAction || (await hasVisible(scope, SELECTORS.similarity.similarityDisplay, 1500)) || (await hasVisible(scope, SELECTORS.similarity.similarityBadge, 1500)) || (visibleText.includes('similarity:') || visibleText.includes('view submission')) || ((await scopeHasVisibleDeepText(scope, ['submitted'])) && !hasUploadForm); return { scope, // If the first-submission upload form is visible and there is no visible // submission action, prefer upload. Turnitin web components often keep // hidden "Resubmit" text in the DOM before any file exists. hasExistingSubmission: hasUploadForm && !hasResubmitAction && !hasViewSubmissionAction ? false : hasExistingSubmission, hasResubmitAction, hasUploadForm, }; }