relv-space5 / src /engine /steps /resubmit.ts
relv-dev's picture
Deploy latest verified Turnitin worker
2db5489 verified
Raw
History Blame Contribute Delete
13.1 kB
import type { Page, Frame } from 'playwright';
import { logger } from '../../utils/logger';
import {
SELECTORS,
deepClickByText,
findAssignmentScope,
findScopeByDeepText,
readScopeVisibleDeepText,
} from '../selectors';
import { detectQuotaLimit, confirmQuotaWarning } from './quota-detect';
import { uploadFile, SubmissionQuotaLimitError, hasSubmissionCard } from './upload';
type Scope = Page | Frame;
async function clickVisibleResubmit(scope: Scope): Promise<boolean> {
// New UI: tii-grn-button inside tii-workflow-student-summary-panel-new
// Old UI: tdl-button or button with text "Resubmit"
const locator = scope
.locator(
'tii-grn-button:has-text("Resubmit"), a:has-text("Resubmit"), button:has-text("Resubmit"), tdl-button:has-text("Resubmit"), [aria-label*="Resubmit"], [data-px*="Resubmit"], [with-data-px*="Resubmit"]',
)
.first();
if (await locator.isVisible({ timeout: 3000 }).catch(() => false)) {
await locator.click({ force: true });
return true;
}
const clickedByDom = await 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 readText = (el: Element): string =>
[
(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'),
]
.filter(Boolean)
.join(' ')
.replace(/\s+/g, ' ')
.trim()
.toLowerCase();
const candidates: Element[] = [];
for (const root of roots) {
candidates.push(
...Array.from(
root.querySelectorAll(
// Include tii-grn-button for new UI
'button, a, input, tdl-button, tii-grn-button, [role="button"]',
),
),
);
}
const target = candidates.find((el) => isVisible(el) && readText(el).includes('resubmit'));
if (!target) return false;
const shadowButton = target.shadowRoot?.querySelector(
'button:not([disabled]), [role="button"]:not([disabled])',
);
const clickTarget =
shadowButton instanceof HTMLElement ? shadowButton : (target as HTMLElement);
clickTarget.scrollIntoView({ block: 'center', inline: 'center' });
clickTarget.click();
return true;
})
.catch(() => false);
if (clickedByDom) return true;
return deepClickByText(scope, ['resubmit']);
}
/**
* Handle the new Turnitin resubmission confirmation modal.
* The dialog#tii-resubmission-last-submission-modal is rendered INSIDE a
* web component's shadow DOM, so standard document.querySelector() and even
* Playwright's page.locator() may not find it.
*
* This function uses a full recursive shadow-DOM traversal to locate the
* dialog and click its "Continue to File Upload" (or any accept-like) button.
*/
async function confirmResubmissionModal(page: Page): Promise<boolean> {
// Helper: find dialog anywhere in the full shadow DOM tree
const findDialog = () => page.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 as any).shadowRoot) roots.push((el as any).shadowRoot);
}
}
for (const root of roots) {
if (root.querySelector('dialog#tii-resubmission-last-submission-modal,dialog[open]')) return true;
}
return false;
}).catch(() => false);
// Wait up to 6s for the modal to appear (shadow DOM traversal)
let found = false;
const deadline = Date.now() + 6000;
while (!found && Date.now() < deadline) {
found = await findDialog();
if (!found) await page.waitForTimeout(400);
}
if (!found) {
// Also try the standard Playwright locator as a fallback (works when dialog is in light DOM)
found = await page
.locator('dialog#tii-resubmission-last-submission-modal, dialog[open]')
.first()
.isVisible({ timeout: 2000 })
.catch(() => false);
}
if (!found) {
logger.info('No resubmission confirmation modal detected');
return false;
}
logger.info('Resubmission confirmation modal detected; clicking Continue to File Upload...');
// Click the accept button via full shadow-DOM traversal
const clicked = await page.evaluate(() => {
// Build complete shadow-DOM root list
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 as any).shadowRoot) roots.push((el as any).shadowRoot);
}
}
// Find the dialog first
let dialog: Element | null = null;
for (const root of roots) {
dialog = root.querySelector('dialog#tii-resubmission-last-submission-modal') ||
root.querySelector('dialog[open]');
if (dialog) break;
}
if (!dialog) return false;
// Traverse dialog's own shadow DOM
const dialogRoots: (Element | ShadowRoot)[] = [dialog];
const dSeen = new Set<Element>();
for (let i = 0; i < dialogRoots.length; i++) {
const root = dialogRoots[i];
for (const el of Array.from(root.querySelectorAll('*'))) {
if (dSeen.has(el as Element)) continue;
dSeen.add(el as Element);
if ((el as any).shadowRoot) dialogRoots.push((el as any).shadowRoot);
}
}
// Priority: "Continue to File Upload" > any accept/submit/confirm button
const priority = (text: string): number => {
const t = text.toLowerCase();
if (t.includes('continue to file upload')) return 100;
if (t.includes('file upload')) return 90;
if (t.includes('continue')) return 80;
if (t.includes('accept')) return 70;
if (t.includes('submit')) return 60;
if (t.includes('confirm')) return 50;
return 0;
};
const candidates: Array<{ el: Element; score: number }> = [];
for (const root of dialogRoots) {
for (const el of Array.from(
root.querySelectorAll('tii-grn-button, button, tdl-button, [role="button"], [slot="accept-button"]'),
)) {
const text = [
(el as HTMLElement).innerText,
el.textContent,
el.getAttribute('aria-label'),
el.getAttribute('part'),
el.getAttribute('slot'),
el.getAttribute('data-px'),
el.getAttribute('with-data-px'),
].filter(Boolean).join(' ').replace(/\s+/g, ' ').trim();
const score = priority(text);
if (score > 0) candidates.push({ el, score });
}
}
candidates.sort((a, b) => b.score - a.score);
const best = candidates[0];
if (!best) {
// Last resort: close the dialog directly
if (dialog instanceof HTMLDialogElement) {
dialog.close();
return true;
}
return false;
}
// Click: prefer inner shadow <button>, else click host element
const inner = (best.el as any).shadowRoot?.querySelector('button:not([disabled])');
const target = inner instanceof HTMLElement ? inner : (best.el as HTMLElement);
target.scrollIntoView({ block: 'center', inline: 'center' });
target.click();
return true;
}).catch(() => false);
if (clicked) {
await page.waitForTimeout(2000);
// Verify dialog actually closed (wait up to 3s)
const stillOpen = await findDialog();
if (stillOpen) {
logger.warn('Dialog still open after click; trying dialog.close() fallback');
await page.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 as any).shadowRoot) roots.push((el as any).shadowRoot);
}
}
for (const root of roots) {
const d = root.querySelector('dialog#tii-resubmission-last-submission-modal') ||
root.querySelector('dialog[open]');
if (d instanceof HTMLDialogElement) { d.close(); return; }
}
}).catch(() => {});
await page.waitForTimeout(1500);
}
}
return clicked;
}
async function hasFirstSubmissionUploadForm(page: Page): Promise<boolean> {
const scope =
(await findAssignmentScope(
page,
`${SELECTORS.upload.browseButton}, ${SELECTORS.upload.fileInput}, ${SELECTORS.upload.uploadStepContainer}`,
2000,
)) || (await findScopeByDeepText(
page,
['browse files', 'drag and drop file', 'drag and drop your file', 'your device'],
2000,
));
if (!scope) return false;
const visibleText = (await readScopeVisibleDeepText(scope)).toLowerCase();
return (
(await scope.locator(SELECTORS.upload.fileInput).first().isVisible({ timeout: 500 }).catch(() => false)) ||
(await scope.locator(SELECTORS.upload.browseButton).first().isVisible({ timeout: 500 }).catch(() => false)) ||
(await scope.locator(SELECTORS.upload.uploadStepContainer).first().isVisible({ timeout: 500 }).catch(() => false)) ||
visibleText.includes('browse files') ||
visibleText.includes('drag and drop file') ||
visibleText.includes('drag and drop your file') ||
visibleText.includes('your device')
);
}
/**
* Resubmit a file to an assignment that already has a prior submission.
*
* Steps:
* 1. Assert quota is available
* 2. Click Resubmit button
* 3. Handle quota warnings (confirm and continue)
* 4. Use same upload popup flow (Browse Files -> Upload and Preview -> Submit)
* 5. If Resubmit button not found, fallback to initial upload
*/
export async function resubmitFile(
page: Page,
filePath: string,
): Promise<void> {
logger.info('Resubmit requested; opening resubmission upload modal');
// Check quota before attempting resubmit
const quotaLimit = await detectQuotaLimit(page);
if (quotaLimit) {
throw new SubmissionQuotaLimitError(quotaLimit.message);
}
// Find the Resubmit button
const scope =
(await findAssignmentScope(
page,
'tii-grn-button:has-text("Resubmit"), a:has-text("Resubmit"), button:has-text("Resubmit"), tdl-button:has-text("Resubmit"), [aria-label*="Resubmit"], [data-px*="Resubmit"], [with-data-px*="Resubmit"]',
5000,
)) ||
(await findScopeByDeepText(page, ['resubmit'], 8000));
if (!scope) {
if (await hasFirstSubmissionUploadForm(page)) {
logger.warn(
'Resubmit button was not found and first-submission upload form is visible; using initial upload flow',
);
await uploadFile(page, filePath);
return;
}
throw new Error('Resubmit was requested, but no visible Resubmit button or first-upload form was found');
}
let clicked = await clickVisibleResubmit(scope);
if (!clicked) {
const submissionCardVisible = await hasSubmissionCard(scope);
if (!submissionCardVisible && await hasFirstSubmissionUploadForm(page)) {
logger.warn(
'Resubmit button click failed, but no submission card is visible; using initial upload flow',
);
await uploadFile(page, filePath);
return;
}
throw new Error('Resubmit button was found but could not be clicked');
}
await page.waitForTimeout(2000);
// Handle NEW UI resubmission confirmation modal
// (dialog#tii-resubmission-last-submission-modal with accept-button slot)
const modalConfirmed = await confirmResubmissionModal(page);
if (modalConfirmed) {
logger.info('Resubmission confirmation modal accepted');
await page.waitForTimeout(2000);
}
// Check quota again after clicking resubmit
const quotaLimitAfter = await detectQuotaLimit(page);
if (quotaLimitAfter) {
throw new SubmissionQuotaLimitError(quotaLimitAfter.message);
}
// Handle quota warnings
await confirmQuotaWarning(page);
// Now proceed with the standard upload flow (the resubmit popup is the same)
await uploadFile(page, filePath, { skipExistingCheck: true });
}