| 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); |
| } |
|
|
| |
| |
| |
| |
| |
| 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, |
| |
| |
| |
| hasExistingSubmission: hasUploadForm && !hasResubmitAction && !hasViewSubmissionAction |
| ? false |
| : hasExistingSubmission, |
| hasResubmitAction, |
| hasUploadForm, |
| }; |
| } |
|
|