import type { Page, Frame, Locator } from 'playwright'; import { logger } from '../../utils/logger'; import { readScopeDeepText, resolveAssignmentScope, SELECTORS, } from '../selectors'; import { acceptEulaEverywhere } from './login'; // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- export interface SimilarityResult { similarityPercent: number | null; viewerUrl: string | null; scope?: Page | Frame; locator?: Locator; } export interface WaitForSimilarityOptions { timeoutMs: number; pollMs: number; refreshAfterMs: number; inputFileName?: string; } // --------------------------------------------------------------------------- // Public API // --------------------------------------------------------------------------- function getSubmissionButtonLocator(scope: Page | Frame, inputTitle: string): Locator { const viewButtons = scope.locator(SELECTORS.similarity.viewSubmissionButton); if (inputTitle) { return viewButtons.filter({ hasText: inputTitle }).first(); } return viewButtons.first(); } /** * Poll for the similarity score to appear on the submission card. * * After `refreshAfterMs` with no result, refreshes the assignment launch URL * once. Returns the similarity percent (if readable) and viewer URL. */ export async function waitForSimilarity( page: Page, assignmentLaunchUrl: string, options: WaitForSimilarityOptions, ): Promise { const { timeoutMs, pollMs, refreshAfterMs } = options; const inputTitle = options.inputFileName ? options.inputFileName.replace(/\.[^.]+$/, '').trim() : ''; const started = Date.now(); let lastText = ''; // BUG-11 FIX: Allow multiple refreshes (every refreshAfterMs from first refresh) let refreshCount = 0; const MAX_REFRESHES = 2; let lastRefreshAt = 0; while (Date.now() - started < timeoutMs) { // ---- Deep text scan across all scopes ---- const scopes: (Page | Frame)[] = [ page, ...page.frames().filter((f) => !f.url().includes('cookie-shim')), ]; for (const candidateScope of scopes) { const deepText = await readScopeDeepText(candidateScope); const percentMatch = deepText.match( /\b(?:Similarity:\s*)?(\d{1,3})%\b/i, ); // BUG-4 FIX: Validate bounds — regex now matches 0-999 so we clamp to 0-100 const percentRaw = percentMatch ? parseInt(percentMatch[1], 10) : NaN; const percent = Number.isFinite(percentRaw) && percentRaw >= 0 && percentRaw <= 100 ? percentRaw : NaN; const percentOk = !isNaN(percent); const titleVisible = Boolean(inputTitle) && deepText.toLowerCase().includes(inputTitle.toLowerCase()); if ( percentOk && (titleVisible || /Submitted|Similarity|Your work/i.test(deepText)) ) { const text = percentMatch![0].includes('Similarity') ? percentMatch![0] : `Similarity: ${percent}%`; logger.info('Similarity detected from deep text', { text, percent, }); return { similarityPercent: percent, viewerUrl: null, scope: candidateScope, locator: getSubmissionButtonLocator(candidateScope, inputTitle), }; } // After refresh, if submission row is visible even without percent, proceed if ( refreshCount > 0 && titleVisible && /Your work/i.test(deepText) ) { logger.info( 'Submission row visible after refresh; proceeding without similarity value', ); return { similarityPercent: null, viewerUrl: null, scope: candidateScope, locator: getSubmissionButtonLocator(candidateScope, inputTitle), }; } } // ---- Locator-based scan ---- const scope = await resolveAssignmentScope(page); const candidates = [ { kind: 'similarity', locator: scope .locator( 'span[part="tii-grn-badge-label"]:has-text("Similarity:")', ) .first(), }, { kind: 'similarity', locator: scope.locator(SELECTORS.similarity.similaritySpan).first(), }, { kind: 'similarity', locator: scope.locator(SELECTORS.similarity.similarityDisplay).first(), }, { kind: 'title', locator: scope.locator(SELECTORS.similarity.viewSubmissionButton).first(), }, ]; for (const candidateEntry of candidates) { const candidate = candidateEntry.locator; if ( await candidate.isVisible({ timeout: 1500 }).catch(() => false) ) { const text = ( await candidate.innerText().catch(() => '') ).trim(); const aria = ( (await candidate .getAttribute('aria-label') .catch(() => '')) || '' ).trim(); const visibleText = text || aria; if (visibleText && visibleText !== lastText) { lastText = visibleText; logger.info('Submission state', { visibleText }); } if ( candidateEntry.kind === 'title' && visibleText && refreshCount > 0 ) { return { similarityPercent: null, viewerUrl: null, scope, locator: candidate, }; } const simMatch = visibleText.match( /Similarity:\s*(\d+)%/i, ); if (simMatch) { const percent = parseInt(simMatch[1], 10); logger.info('Similarity detected from locator', { visibleText, percent, }); return { similarityPercent: percent, viewerUrl: null, scope, locator: getSubmissionButtonLocator(scope, inputTitle), }; } } } // ---- Log body text for diagnostics ---- const bodyText = await scope .locator('body') .innerText({ timeout: 3000 }) .catch(() => ''); const shortText = bodyText.replace(/\s+/g, ' ').trim().slice(0, 240); if (shortText && shortText !== lastText) { lastText = shortText; logger.debug('Waiting for submission/similarity', { bodySnippet: shortText, }); } // ---- Refresh assignment launch URL periodically ---- // BUG-11 FIX: Allow up to MAX_REFRESHES refreshes, each triggered after // refreshAfterMs has passed since the previous refresh (or since start). const elapsedSinceLastRefresh = lastRefreshAt === 0 ? Date.now() - started : Date.now() - lastRefreshAt; const currentUrl = page.url(); const urlSeemsFine = currentUrl.includes('turnitin.com') && (currentUrl.includes('/assignment/') || currentUrl.includes('/class/')); const shouldRefresh = refreshCount < MAX_REFRESHES && elapsedSinceLastRefresh >= refreshAfterMs && (!urlSeemsFine || currentUrl.includes('/assignment/type/tool/launch')); if (shouldRefresh) { refreshCount++; lastRefreshAt = Date.now(); logger.info( `Similarity not visible; refreshing assignment launch URL (refresh ${refreshCount}/${MAX_REFRESHES})`, { currentUrl }, ); await page .reload({ waitUntil: 'domcontentloaded', timeout: 60000 }) .catch(() => {}); await page.waitForTimeout(5000); // ── Handle 502/503/504 error pages after refresh ── for (let refreshRetry = 0; refreshRetry < 2; refreshRetry++) { const bodyAfterRefresh = await page .locator('body') .innerText({ timeout: 3000 }) .catch(() => ''); if (/502 Bad Gateway|503 Service|504 Gateway/i.test(bodyAfterRefresh)) { logger.warn( `Server error detected after refresh ${refreshCount} (attempt ${refreshRetry + 1}/2); retrying reload`, { bodySnippet: bodyAfterRefresh.slice(0, 200) }, ); await page.waitForTimeout(5000); await page .reload({ waitUntil: 'domcontentloaded', timeout: 60000 }) .catch(() => {}); await page.waitForTimeout(5000); } else { break; } } continue; } await page.waitForTimeout(pollMs); } throw new Error( `Submission card/similarity did not become ready within ${timeoutMs}ms`, ); }