import type { Page, Frame } from 'playwright'; // --------------------------------------------------------------------------- // CSS Selectors – grouped by workflow area // --------------------------------------------------------------------------- export const SELECTORS = { // ---- Login ---- login: { emailInput: '#email', passwordInput: '#password', submitButton: 'input.submit[type="submit"]', }, // ---- EULA ---- eula: { agreementModalSaveBtn: '#eulaAgreementModalSaveBtn', iAgreeButton: 'button:has-text("I Agree")', acceptButton: 'button:has-text("Accept")', tdlIAgreeButton: 'tdl-button:has-text("I Agree")', tdlAcceptButton: 'tdl-button:has-text("Accept")', iAgreeContinueLink: 'a:has-text("I Agree -- Continue")', }, // ---- Class ---- class: { classNameLink: 'td.class_name a, a.ib-class-link, a', }, // ---- Assignment ---- assignment: { assignmentRow: 'tr.assignment-row', openButton: 'a.btn-open, a.btn-primary, button:has-text("Open"), a:has-text("Open")', allOpenButtons: 'tr.assignment-row a.btn-open, a.btn-open, a.btn-primary, button:has-text("Open"), a:has-text("Open")', }, // ---- Upload ---- upload: { fileInput: 'input[type="file"]', browseButton: // New UI: tii-ing-upload-button > tdl-button[part="upload-form-container-button"] // Old UI: tdl-button with "Browse Files" text 'tdl-button[part="upload-form-container-button"], tii-ing-upload-button tdl-button, button:has-text("Browse Files"), button:has-text("Browse"), tdl-button:has-text("Browse")', uploadFromDevice: 'tdl-button[part="upload-form-container-menu-browse-button"], .actions-menu tdl-button[part="upload-form-container-menu-browse-button"], button:has-text("Your device"), tdl-button:has-text("Your device"), button:has-text("Upload from this device"), tdl-button:has-text("Upload from this device")', submitButton: // New UI: tdl-button with slot="form-submit-button-label" containing "Upload and Preview" // Also handles plain Submit/Confirm after preview, plus accept-button/tii-grn-button 'tdl-button[part="submit-button"], tdl-button:has-text("Upload and Preview"), tdl-button.accept-button, tii-grn-button, button:has-text("Upload"), tdl-button:has-text("Upload"), button:has-text("Confirm"), tdl-button:has-text("Confirm"), button:has-text("Submit"), tdl-button:has-text("Submit")', // New UI container for upload step uploadStepContainer: 'div.upload-step, tii-ing-dropzone', }, // ---- Resubmit ---- resubmit: { // New UI: tii-grn-button with text "Resubmit" (inside tii-workflow-student-summary-panel-new) // Old UI: tdl-button or button with text "Resubmit" resubmitButton: 'tii-grn-button:has-text("Resubmit"), button:has-text("Resubmit"), tdl-button:has-text("Resubmit"), [aria-label*="Resubmit"]', // New submission action wrapper (contains Submit/Resubmit button) submissionActionPanel: 'tii-workflow-student-summary-panel-new, div.submission-action', // New resubmission confirmation modal (dialog element) resubmissionConfirmModal: 'dialog#tii-resubmission-last-submission-modal', // Accept button inside the new modal (slot-based) resubmissionConfirmAccept: 'dialog#tii-resubmission-last-submission-modal [slot="accept-button"] tdl-button, dialog#tii-resubmission-last-submission-modal .accept-button, dialog#tii-resubmission-last-submission-modal tdl-button.accept-button', }, // ---- Submission Card / Similarity ---- similarity: { viewSubmissionButton: 'button.link-button[aria-label*="View submission"], button.link-button, a[aria-label*="View submission"], a.view-mark, .similarity-button', similarityDisplay: 'tii-mpdd-similarity-display', similarityBadge: 'span[part="tii-grn-badge-label"]', similaritySpan: 'tii-mpdd-similarity-display span:has-text("Similarity:")', bodyContainer: '.body-container', linkButtons: 'button.link-button', }, // ---- Viewer ---- viewer: { titleButton: 'button.link-button[aria-label*="View submission"], button.link-button, a[aria-label*="View submission"], a.view-mark, .similarity-button, [part="tii-grn-badge-label"]', }, // ---- Filters ---- filters: { similarityTab: [ 'tii-sws-tab-button#tab-similarity', '#tab-similarity', 'tii-sws-tab-button[aria-controls="tii-sws-main"]:has-text("Similarity")', 'tii-sws-tab-button:has-text("Similarity")', 'button.tab-button[data-px="SimilarityTabClicked"]', '[data-px="SimilarityTabClicked"]', '[with-data-px="SimilarityTabClicked"]', '[withdatapx="SimilarityTabClicked"]', '[data-px*="SimilarityTab"]', '[with-data-px*="SimilarityTab"]', '[withdatapx*="SimilarityTab"]', '[role="tab"]:has-text("Similarity")', 'button:has-text("Similarity")', 'tdl-button:has-text("Similarity")', 'tdl-labeled-button:has-text("Similarity")', '[aria-label*="Similarity"]', ].join(', '), filterButton: [ '.tii-SimilarityReportPanel[aria-hidden="false"] .tii-SimilarityReportPanelHeader__SettingsButton', '.tii-SimilarityReportPanel[aria-hidden="false"] tdl-button[with-data-px="SettingsClicked"]', '.tii-filters-and-exclusions-container tdl-button[with-data-px="SettingsClicked"]', 'tdl-button[with-data-px="SettingsClicked"]', 'tdl-button[withdatapx="SettingsClicked"]', '[with-data-px="SettingsClicked"]', '[withdatapx="SettingsClicked"]', '[data-px="SettingsClicked"]', '[data-px*="Settings"]', '[with-data-px*="Settings"]', '[withdatapx*="Settings"]', '.tii-SimilarityReportPanelHeader__SettingsButton', 'tdl-button:has-text("Filters")', 'tdl-labeled-button:has-text("Filters")', 'tii-grn-button:has-text("Filters")', 'button:has-text("Filters")', '[role="button"]:has-text("Filters")', '[aria-label*="Filter"]', '[title*="Filter"]', '[part*="filter"]', ].join(', '), excludeBibliography: '#excludeBibliography, [with-id="excludeBibliography"]', excludeQuotes: '#excludeQuotes, [with-id="excludeQuotes"]', excludeCitations: '#excludeCitations, [with-id="excludeCitations"]', excludeSmallMatches: 'tdl-checkbox[with-px-label="ExcludeSmallMatches"], tdl-checkbox:has-text("Exclude small matches")', smallMatchesInput: 'tdl-number-input.small-matches-input, tdl-number-input[label="Set match exclusion threshold"]', applyFilters: [ 'tdl-button[with-data-px="ApplyFiltersClicked"]', 'tdl-button[withdatapx="ApplyFiltersClicked"]', '[with-data-px="ApplyFiltersClicked"]', '[withdatapx="ApplyFiltersClicked"]', '[data-px="ApplyFiltersClicked"]', 'tdl-button[slot="accept-button"]:has-text("Apply Filters")', 'tdl-button:has-text("Apply Filters")', 'button:has-text("Apply Filters")', ].join(', '), backToReport: [ '[id="supplement-container.viewSettings"] button.tii-similarity-supplement--back-button', '.tii-panel-overlay--is-open button.tii-similarity-supplement--back-button', 'button.tii-similarity-supplement--back-button', '[data-px="FiltersBackToReportClicked"]', '[with-data-px="FiltersBackToReportClicked"]', '[withdatapx="FiltersBackToReportClicked"]', 'button:has-text("Back to Similarity Report")', 'tdl-button:has-text("Back to Similarity Report")', ].join(', '), }, // ---- Download ---- download: { downloadMenu: [ 'tii-sws-download-btn-mfe tdl-labeled-button', 'tii-sws-download-btn-mfe', 'tii-sws-header [slot="download-btn"]', 'tdl-labeled-button[withdatapx="DownloadMenuClicked"]', '[withdatapx="DownloadMenuClicked"]', 'tdl-labeled-button:has-text("Download")', 'button:has-text("Download")', ].join(', '), currentViewOption: [ '[with-data-px="DownloadOptionCurrentViewClicked"]', '[withdatapx="DownloadOptionCurrentViewClicked"]', '[data-px="DownloadOptionCurrentViewClicked"]', ].join(', '), }, // ---- Quota ---- quota: { warningConfirmButton: 'tii-grn-button:has-text("Continue"), tii-grn-button.accept-button, tdl-button[slot="accept-button"], tdl-button[variety="filled"], tii-grn-button', }, }; // --------------------------------------------------------------------------- // EULA selector list (iterated during acceptance) // --------------------------------------------------------------------------- export const EULA_SELECTORS = [ SELECTORS.eula.agreementModalSaveBtn, SELECTORS.eula.iAgreeButton, SELECTORS.eula.acceptButton, SELECTORS.eula.tdlIAgreeButton, SELECTORS.eula.tdlAcceptButton, SELECTORS.eula.iAgreeContinueLink, ]; export const EULA_BODY_REGEX = /End User License Agreement|Terms of Use|I Agree|I Accept/i; export const EULA_DEEP_CLICK_TEXTS = [ 'i agree', 'i accept', 'accept', 'continue', 'save', ]; export const EULA_BLOCK_REGEX = /Could not access the assignment|End User License Agreement needs to be accepted|EULA needs to be accepted/i; // --------------------------------------------------------------------------- // Quota warning confirmation labels (in priority order) // --------------------------------------------------------------------------- export const QUOTA_CONFIRM_LABELS = [ 'Continue to File Upload', 'Continue', 'Confirm', 'I understand', 'OK', 'Yes', ]; // --------------------------------------------------------------------------- // Shadow DOM helpers // --------------------------------------------------------------------------- type Scope = Page | Frame; /** * Read all text from a scope, traversing into shadow DOMs. */ export async function readScopeDeepText(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); } } return roots .map((r) => (r as any).body?.innerText || (r as any).host?.innerText || r.textContent || '') .join(' ') .replace(/\s+/g, ' ') .trim(); }).catch(() => ''); } /** * Read only visible text from a scope, traversing into shadow DOMs. */ export async function readScopeVisibleDeepText(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 chunks: string[] = []; for (const root of roots) { for (const el of Array.from(root.querySelectorAll('*'))) { if (!isVisible(el)) continue; const text = (el as HTMLElement).innerText || ''; if (text) chunks.push(text); } } return chunks.join(' ').replace(/\s+/g, ' ').trim(); }).catch(() => ''); } /** * Check if any of textMatches appear in deep text of the scope. */ export async function scopeHasDeepText( scope: Scope, textMatches: string[], ): Promise { const text = await readScopeDeepText(scope); const normalized = text.toLowerCase(); return textMatches.some((m) => normalized.includes(m.toLowerCase())); } /** * Check if any of textMatches appear in visible deep text of the scope. * This avoids matching hidden web-component templates that Turnitin keeps in * the DOM for states that are not currently active. */ export async function scopeHasVisibleDeepText( scope: Scope, textMatches: string[], ): Promise { const text = await readScopeVisibleDeepText(scope); const normalized = text.toLowerCase(); return textMatches.some((m) => normalized.includes(m.toLowerCase())); } /** * Click a visible checkbox in the given scope. */ export async function clickVisibleCheckbox(scope: Scope): Promise { return scope .evaluate(() => { const isVisible = (el: Element): boolean => { const style = window.getComputedStyle(el); const rect = el.getBoundingClientRect(); return ( style.visibility !== 'hidden' && style.display !== 'none' && rect.width > 0 && rect.height > 0 ); }; const cb = Array.from( document.querySelectorAll( 'input[type="checkbox"], tdl-checkbox, tii-grn-checkbox', ), ).find(isVisible); if (!cb) return false; (cb as HTMLElement).click(); return true; }) .catch(() => false); } /** * Traverse shadow DOM looking for clickable elements whose text matches one of * the given strings, then click the first match. */ export async function deepClickByText( scope: Scope, textMatches: string[], ): Promise { return scope .evaluate((matches: string[]) => { const normalizedMatches = matches.map((v) => v.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', ), ), ); } const target = candidates.find((el) => { if (!isVisible(el)) return false; const text = [ (el as HTMLElement).innerText, el.textContent, el.getAttribute('aria-label'), el.getAttribute('value'), ] .filter(Boolean) .join(' ') .replace(/\s+/g, ' ') .trim() .toLowerCase(); return normalizedMatches.some((m) => text.includes(m)); }); if (!target) return false; (target as HTMLElement).click(); return true; }, textMatches) .catch(() => false); } /** * Find the scope (page or frame) that contains a visible element matching the * given selector. */ export async function findAssignmentScope( page: Page, selector: string, timeoutMs = 1000, ): Promise { if ( await page .locator(selector) .first() .isVisible({ timeout: timeoutMs }) .catch(() => false) ) return page; for (const frame of page.frames()) { if (frame.url().includes('cookie-shim')) continue; if ( await frame .locator(selector) .first() .isVisible({ timeout: timeoutMs }) .catch(() => false) ) return frame; } return null; } /** * Find the scope (page or frame) that contains deep text matching any of the * given strings, polling until timeoutMs. */ export async function findScopeByDeepText( page: Page, textMatches: string[], timeoutMs = 30000, ): Promise { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { if (await scopeHasDeepText(page, textMatches)) return page; for (const frame of page.frames()) { if (frame.url().includes('cookie-shim')) continue; if (await scopeHasDeepText(frame, textMatches)) return frame; } await page.waitForTimeout(1000); } return null; } /** * Resolve the best scope for the assignment page. */ export async function resolveAssignmentScope( page: Page, ): Promise { return ( (await findAssignmentScope( page, 'button.link-button[aria-label*="View submission"], tii-mpdd-similarity-display', 1000, )) || (await findAssignmentScope( page, 'tdl-button[part="upload-form-container-button"], button:has-text("Browse Files"), input[type="file"]', 1000, )) || page ); }