relv-space6 / src /engine /selectors.ts
relv-dev's picture
Deploy latest verified Turnitin worker
9179e11 verified
Raw
History Blame Contribute Delete
17.3 kB
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
);
}