Spaces:
Sleeping
Sleeping
| 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<boolean> { | |
| return scope.locator(selector).first().isVisible({ timeout: timeoutMs }).catch(() => false); | |
| } | |
| async function hasVisibleActionText(scope: Scope, textMatches: string[]): Promise<boolean> { | |
| return scope | |
| .evaluate((matches: string[]) => { | |
| const normalizedMatches = matches.map((value) => value.toLowerCase()); | |
| 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 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<boolean> { | |
| return 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 hasAncestorSignal = (el: Element): boolean => { | |
| let current: Element | null = el; | |
| const visited = new Set<Element>(); | |
| 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<SubmissionState> { | |
| 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, | |
| }; | |
| } | |