relv-turnitin-backup / src /engine /steps /submission-state.ts
RelVDev2's picture
Deploy pre-receipt backup worker
d5c9d40
Raw
History Blame Contribute Delete
8.81 kB
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,
};
}