Spaces:
Sleeping
Sleeping
| 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<string> { | |
| 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); | |
| } | |
| } | |
| 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<string> { | |
| 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 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<boolean> { | |
| 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<boolean> { | |
| 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<boolean> { | |
| 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<boolean> { | |
| return scope | |
| .evaluate((matches: string[]) => { | |
| const normalizedMatches = matches.map((v) => v.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', | |
| ), | |
| ), | |
| ); | |
| } | |
| 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<Page | Frame | null> { | |
| 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<Page | Frame | null> { | |
| 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<Page | Frame> { | |
| 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 | |
| ); | |
| } | |