import { type Page, type BrowserContext, type Frame } from 'playwright'; import * as path from 'path'; import * as fs from 'fs'; import { config } from '../config'; import { logger } from '../utils/logger'; import { getBrowser } from '../worker/browser-pool'; import { loginToTurnitin } from './steps/login'; import { navigateToAssignment } from './steps/navigate'; import { dropClassByTitle } from './steps/class-management'; import { uploadFile, SubmissionQuotaLimitError } from './steps/upload'; import { resubmitFile } from './steps/resubmit'; import { detectQuotaLimit, runQuotaCheck, } from './steps/quota-detect'; import { waitForSimilarity, type SimilarityResult } from './steps/similarity'; import { openReportViewerPage } from './steps/viewer'; import { readViewerSimilarityPercent } from './steps/viewer-similarity'; import { readSubmissionDetails, type SubmissionDetails } from './steps/submission-details'; import { detectSubmissionState } from './steps/submission-state'; import { applyFilters, validateFilters, hasActiveFilters, type FilterOptions } from './steps/filters'; import { downloadPdf } from './steps/download'; import { runLegacyTurnitinJob } from './legacy'; // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- export type { FilterOptions } from './steps/filters'; export type TurnitinUiVariant = 'modern_lti' | 'legacy_carta'; export const MODERN_ONE_POOL_KEY = 'modern_one'; export interface RunTurnitinJobInput { account: { id: string; email: string; password: string; quotaLimit?: number | null; quotaRemaining?: number | null; }; assignmentTarget: { targetUrl: string; classTitle: string; assignmentTitle?: string | null; assignmentLaunchUrl?: string | null; uiVariant?: TurnitinUiVariant; accountPoolKey?: string | null; }; inputFilePath: string; inputFileName?: string; inputFileSize?: number; outputDir: string; mode: 'upload' | 'resubmit' | 'quota_check'; filters: FilterOptions; storageStatePath?: string; /** * When set, the engine will skip steps that have already been completed and * resume from the step after this one. For example, if `resumeAfterStep` is * `'submitted'`, the engine will skip upload/resubmit and jump straight to * waiting for the similarity score. */ resumeAfterStep?: string; /** * Existing report viewer URL from a previous attempt. When the previous * attempt already reached the viewer, retries reopen this URL directly * instead of uploading/resubmitting the same file again. */ resumeViewerUrl?: string | null; attemptCount?: number; onEvent?: (event: { level: 'info' | 'warning' | 'error'; step: string; message: string; metadata?: Record; }) => Promise; } export interface RunTurnitinJobResult { viewerUrl?: string; similarityPercent?: number; outputPdfPath?: string; receiptPdfPath?: string; submissionDetails?: SubmissionDetails; quotaWarning?: string; quotaLimit?: { limit: number; message: string; retryText?: string }; quotaCooldown?: { message: string; nextRetryAt: string; submissionCount?: number }; permanentLimit?: { message: string; submissionCount?: number; dropClassResult?: { attempted: boolean; dropped: boolean; reason?: string; }; }; submittedAt?: string; submissionCount?: number; /** The last step that completed successfully – persisted so retries can skip. */ lastCompletedStep?: string; } // --------------------------------------------------------------------------- // EULA-blocked error (for retry logic) // --------------------------------------------------------------------------- class EulaBlockedError extends Error { constructor(message = 'Assignment launch is blocked by EULA') { super(message); this.name = 'EulaBlockedError'; } } // --------------------------------------------------------------------------- // Event emitter helper // --------------------------------------------------------------------------- async function emit( onEvent: RunTurnitinJobInput['onEvent'], level: 'info' | 'warning' | 'error', step: string, message: string, metadata?: Record, ): Promise { if (onEvent) { await onEvent({ level, step, message, metadata }).catch(() => {}); } if (level === 'error') { logger.error(message, { step, ...metadata }); } else if (level === 'warning') { logger.warn(message, { step, ...metadata }); } else { logger.info(message, { step, ...metadata }); } } function compactEngineErrorMessage(message: string): string { const firstLine = message.split('\n').map((line) => line.trim()).find(Boolean) || message; if (/locator\.waitFor: Timeout/i.test(firstLine)) { const selector = firstLine.match(/locator\('([^']+)'/i)?.[1]; return selector ? `Turnitin page element did not appear in time: ${selector}` : 'Turnitin page element did not appear in time.'; } if (/Call log:/i.test(message)) return firstLine; return firstLine.length > 260 ? `${firstLine.slice(0, 257)}...` : firstLine; } // --------------------------------------------------------------------------- // Step ordering — used to determine whether to skip already-completed steps // --------------------------------------------------------------------------- const STEP_ORDER = [ 'login', 'navigate', 'quota_check', 'upload', // covers both upload and resubmit 'submitted', 'similarity', 'viewer', 'filters', 'download', ] as const; /** * Returns true when `completedStep` is at or after `targetStep` in the * pipeline, meaning `targetStep` can safely be skipped. */ function isStepCompleted(completedStep: string | undefined, targetStep: string): boolean { if (!completedStep) return false; const completedIdx = STEP_ORDER.indexOf(completedStep as any); const targetIdx = STEP_ORDER.indexOf(targetStep as any); if (completedIdx < 0 || targetIdx < 0) return false; return completedIdx >= targetIdx; } function decodeFileNameForCompare(fileName: string): string { try { return decodeURIComponent(fileName); } catch { return fileName; } } function normalizeFileNameForCompare(fileName: string | undefined): string { return decodeFileNameForCompare(path.basename(String(fileName || ''))) .normalize('NFKC') .replace(/[\u200B-\u200D\uFEFF]/g, '') .replace(/\s+/g, ' ') .trim() .toLowerCase(); } function compactFileNameForCompare(fileName: string | undefined): string { return normalizeFileNameForCompare(fileName).replace(/\s+/g, ''); } function submissionDetailsMatchInput( details: SubmissionDetails | null, inputFileName: string, inputFileSize?: number, ): boolean { if (!details?.fileName || typeof inputFileSize !== 'number') return false; const detailName = normalizeFileNameForCompare(details.fileName); const expectedName = normalizeFileNameForCompare(inputFileName); const detailCompactName = compactFileNameForCompare(details.fileName); const expectedCompactName = compactFileNameForCompare(inputFileName); return Boolean( details.fileSize === inputFileSize && detailName && expectedName && (detailName === expectedName || detailCompactName === expectedCompactName), ); } function getScopeOwnerPage(scope: Page | Frame): Page { return typeof (scope as any).page === 'function' ? (scope as Frame).page() : (scope as Page); } const MODERN_VIEWER_READY_SELECTOR = [ 'tii-sws-tab-button#tab-similarity', '#tab-similarity', '.tii-SimilarityReportPanel[aria-hidden="false"]', '.tii-OverviewScore__Value', 'tdl-button[with-data-px="SettingsClicked"]', '.tii-SimilarityReportPanelHeader__SettingsButton', 'tii-sws-download-btn-mfe', 'tii-sws-submission-details-btn', ].join(', '); async function waitForModernViewerReady( viewerPage: Page, viewerUrl: string, timeoutMs = 75_000, ): Promise { const deadline = Date.now() + timeoutMs; let reloads = 0; while (Date.now() < deadline) { if ( await viewerPage .locator(MODERN_VIEWER_READY_SELECTOR) .first() .isVisible({ timeout: 2500 }) .catch(() => false) ) { return true; } const bodyText = await viewerPage .locator('body') .innerText({ timeout: 1500 }) .catch(() => ''); if (bodyText.trim().length > 20) { await viewerPage.waitForTimeout(2500).catch(() => {}); continue; } if (reloads < 2) { reloads++; logger.warn('Report viewer body was empty; reloading direct viewer URL', { reloads, viewerUrl, }); await viewerPage .goto(viewerUrl, { waitUntil: 'domcontentloaded', timeout: 60000 }) .catch(() => {}); await viewerPage .waitForLoadState('networkidle', { timeout: 20000 }) .catch(() => {}); await viewerPage.waitForTimeout(5000).catch(() => {}); continue; } await viewerPage.waitForTimeout(2500).catch(() => {}); } return false; } // --------------------------------------------------------------------------- // Main orchestrator // --------------------------------------------------------------------------- /** * Run a complete Turnitin automation job. * * Flow: * 1. Launch browser context (headless per config) * 2. Restore storageState if available * 3. Login * 4. Navigate to assignment * 5. If mode=quota_check: run quota check and return * 6. Detect quota limit before upload * 7. If already submitted (resume or live detection): skip to step 8 * Otherwise, if mode=resubmit: call resubmit, else call upload * 8. Wait for similarity * 9. Open viewer * 10. Apply filters * 11. Download PDF * 12. Save storageState * 13. Close context * 14. Return result */ export async function runTurnitinJob( input: RunTurnitinJobInput, ): Promise { if (input.assignmentTarget.uiVariant === 'legacy_carta') { return runLegacyTurnitinJob(input); } const { account, assignmentTarget, inputFilePath, inputFileName, inputFileSize, outputDir, mode: requestedMode, filters, storageStatePath, resumeAfterStep, resumeViewerUrl, attemptCount, onEvent, } = input; const result: RunTurnitinJobResult = {}; let similarityResult: SimilarityResult | null = null; let quotaClassDropAttempted = false; let mode = requestedMode; let viewerPage: Page | Frame | null = null; // Track whether we should skip the upload step entirely. const skipUpload = isStepCompleted(resumeAfterStep, 'submitted'); const resumeFromViewer = isStepCompleted(resumeAfterStep, 'viewer') && Boolean(resumeViewerUrl); const expectedInputFileName = inputFileName || path.basename(inputFilePath); const isModernOnePool = assignmentTarget.accountPoolKey === MODERN_ONE_POOL_KEY; if (skipUpload) { logger.info('Resuming after previous upload — skipping upload/resubmit step', { resumeAfterStep, }); } // --- 1. Launch browser context --- // NEW-BUG-1 FIX: Use the shared browser from browser-pool.ts instead of // launching a new Chromium process for every job. This reduces memory from // N × ~200MB to ~200MB + N × ~30MB (one context per job). await emit(onEvent, 'info', 'browser', 'Creating browser context'); const browser = await getBrowser(); // --- 2. Create context (restore storageState if available) --- const contextOptions: Record = { userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36', viewport: { width: 1366, height: 768 }, acceptDownloads: true, extraHTTPHeaders: { 'Accept-Language': 'en-US,en;q=0.9' }, }; if (storageStatePath && fs.existsSync(storageStatePath)) { contextOptions.storageState = storageStatePath; logger.info('Restoring storage state', { path: storageStatePath }); } const context = await browser.newContext(contextOptions as any); let page: Page | null = null; const dropClassAfterQuotaLimit = async ( quotaMessage: string, ): Promise => { if (quotaClassDropAttempted || !page) return; quotaClassDropAttempted = true; await emit( onEvent, 'warning', 'class_cleanup', 'Quota limit detected; dropping class from account', { classTitle: assignmentTarget.classTitle, quotaMessage, }, ); const dropResult = await dropClassByTitle( page, assignmentTarget.classTitle, ); await emit( onEvent, dropResult.dropped ? 'info' : 'warning', 'class_cleanup', dropResult.dropped ? 'Quota-limited class dropped or already absent' : 'Quota-limited class could not be dropped automatically', { ...dropResult }, ); }; try { page = await context.newPage(); // --- 3. Login --- await emit(onEvent, 'info', 'login', 'Logging in to Turnitin', { email: account.email, }); await loginToTurnitin( page, account.email, account.password, storageStatePath, assignmentTarget.targetUrl, ); result.lastCompletedStep = 'login'; let assignmentLaunchUrl = ''; // initialized to avoid undefined crash if (resumeFromViewer && resumeViewerUrl) { await emit( onEvent, 'info', 'viewer', 'Reopening report viewer from previous attempt', { viewerUrl: resumeViewerUrl, resumeAfterStep }, ); const resumedViewerPage = await context.newPage(); await resumedViewerPage.goto(resumeViewerUrl, { waitUntil: 'domcontentloaded', timeout: 60000, }); await resumedViewerPage .waitForLoadState('networkidle', { timeout: 30000 }) .catch(() => {}); const viewerReady = await waitForModernViewerReady( resumedViewerPage, resumeViewerUrl, ); if (viewerReady) { viewerPage = resumedViewerPage; result.viewerUrl = resumedViewerPage.url(); result.lastCompletedStep = 'viewer'; await emit( onEvent, 'info', 'viewer', 'Report viewer opened', { viewerUrl: result.viewerUrl, resumed: true }, ); } else { await emit( onEvent, 'warning', 'viewer', 'Direct report viewer did not hydrate; reopening through assignment page', { viewerUrl: resumeViewerUrl, resumed: true }, ); await resumedViewerPage.close().catch(() => {}); } } if (!viewerPage) { // --- 4. Navigate to assignment --- await emit( onEvent, 'info', 'navigate', 'Navigating to assignment', { classTitle: assignmentTarget.classTitle, assignmentTitle: assignmentTarget.assignmentTitle, }, ); // Retry loop for EULA-blocked assignment access (up to 3 attempts) for (let attempt = 1; attempt <= 3; attempt++) { try { assignmentLaunchUrl = await navigateToAssignment( page, assignmentTarget.classTitle, assignmentTarget.assignmentTitle, ); result.lastCompletedStep = 'navigate'; // --- 5. Quota check mode --- if (mode === 'quota_check') { await emit( onEvent, 'info', 'quota_check', 'Running quota check', ); const quotaResult = await runQuotaCheck(page); if (quotaResult.quotaLimited) { result.quotaLimit = { limit: quotaResult.limit!, message: quotaResult.message!, retryText: quotaResult.retryText || undefined, }; await dropClassAfterQuotaLimit(result.quotaLimit.message); } if (quotaResult.warning) { result.quotaWarning = quotaResult.warning; } await emit( onEvent, 'info', 'quota_check', 'Quota check complete', { quotaResult }, ); return result; } // --- 6. Detect quota limit before upload --- // A quota-limit banner can still be visible after the third successful // submission. If this job is resuming after upload/submission, do not // abort here: no additional submission is needed, and the worker only // has to open/download the existing report. const preUploadQuota = skipUpload ? null : await detectQuotaLimit(page); if (preUploadQuota) { result.quotaLimit = { limit: preUploadQuota.limit, message: preUploadQuota.message, retryText: preUploadQuota.retryText, }; await dropClassAfterQuotaLimit(preUploadQuota.message); throw new SubmissionQuotaLimitError(preUploadQuota.message); } // --- 7. Upload / Resubmit / Skip --- // Detect live page state to decide what action is needed. const submissionState = await detectSubmissionState(page); if (!skipUpload && isModernOnePool && submissionState.hasExistingSubmission) { const message = 'Modern one-use account already has a submission for this assignment. Marking account as permanently limited and trying another account.'; result.quotaLimit = { limit: 1, message, }; await dropClassAfterQuotaLimit(message); throw new SubmissionQuotaLimitError(message); } if ( !skipUpload && !viewerPage && (attemptCount || 0) > 1 && submissionState.hasExistingSubmission && typeof inputFileSize === 'number' ) { await emit( onEvent, 'info', 'recovery_check', 'Existing submission found on retry; verifying file details before deciding whether to skip upload', { inputFileName: expectedInputFileName, inputFileSize, attemptCount, }, ); const candidateViewer = await openReportViewerPage( page, context, submissionState.scope || undefined, ).catch(async (error: unknown) => { await emit( onEvent, 'warning', 'recovery_check', 'Existing submission could not be opened for recovery verification', { error: error instanceof Error ? error.message : String(error), }, ); return null; }); if (candidateViewer) { const recoveredDetails = await readSubmissionDetails(candidateViewer); if (recoveredDetails) { result.submissionDetails = recoveredDetails; await emit( onEvent, 'info', 'submission_details', 'Submission details captured', recoveredDetails as Record, ); } if ( submissionDetailsMatchInput( recoveredDetails, expectedInputFileName, inputFileSize, ) ) { viewerPage = candidateViewer; result.viewerUrl = candidateViewer.url(); result.lastCompletedStep = 'viewer'; await emit( onEvent, 'info', 'viewer', 'Existing submission matches current file; resuming from report viewer', { viewerUrl: result.viewerUrl, recovered: true, matchBasis: 'fileName+fileSize', }, ); break; } await emit( onEvent, 'warning', 'recovery_check', 'Existing submission does not match current input file; continuing with requested upload flow', { inputFileName: expectedInputFileName, inputFileSize, turnitinFileName: recoveredDetails?.fileName || null, turnitinFileSize: recoveredDetails?.fileSize || null, }, ); const candidateOwner = getScopeOwnerPage(candidateViewer); if (candidateOwner !== page) { await candidateOwner.close().catch(() => {}); } else { await page.goto(assignmentLaunchUrl, { waitUntil: 'domcontentloaded', timeout: 60000, }).catch(() => {}); await page.waitForTimeout(1500).catch(() => {}); } } } if (viewerPage) { break; } // ── A: File was already submitted (resume scenario) ── // If the caller told us to skip upload, ALWAYS skip — do not // re-check the live page because the submission card may not be // visible yet (Turnitin is still processing it). if (skipUpload) { await emit( onEvent, 'info', 'resume', 'Resuming after previous upload — skipping upload step', { resumeAfterStep, hasExistingSubmission: submissionState.hasExistingSubmission }, ); result.lastCompletedStep = 'submitted'; result.submittedAt = new Date().toISOString(); // approximate } else { // ── B: Normal upload / resubmit ── if (mode === 'upload' && submissionState.hasExistingSubmission) { mode = 'resubmit'; await emit( onEvent, 'warning', 'mode_switch', 'Existing submission detected; switching to resubmit mode', { requestedMode, effectiveMode: mode, hasResubmitAction: submissionState.hasResubmitAction, }, ); } else if ( mode === 'resubmit' && !submissionState.hasExistingSubmission && submissionState.hasUploadForm ) { mode = 'upload'; await emit( onEvent, 'warning', 'mode_switch', 'No existing submission detected; switching to first submission mode', { requestedMode, effectiveMode: mode, }, ); } if (mode === 'resubmit') { await emit( onEvent, 'info', 'resubmit', 'Resubmitting file', { filePath: inputFilePath }, ); await resubmitFile(page, inputFilePath); } else { await emit(onEvent, 'info', 'upload', 'Uploading file', { filePath: inputFilePath, }); await uploadFile(page, inputFilePath); } result.submittedAt = new Date().toISOString(); result.lastCompletedStep = 'submitted'; await emit( onEvent, 'info', 'submitted', 'File submitted successfully', { submittedAt: result.submittedAt }, ); } // --- 8. Wait for similarity --- await emit( onEvent, 'info', 'similarity', 'Waiting for similarity score', ); similarityResult = await waitForSimilarity( page, assignmentLaunchUrl!, { timeoutMs: config.similarityTimeoutMs, pollMs: config.similarityPollMs, refreshAfterMs: config.similarityRefreshAfterMs, inputFileName: path.basename(inputFilePath), }, ); if (similarityResult.similarityPercent !== null) { result.similarityPercent = similarityResult.similarityPercent; } if (similarityResult.viewerUrl) { result.viewerUrl = similarityResult.viewerUrl; } result.lastCompletedStep = 'similarity'; await emit( onEvent, 'info', 'similarity', `Similarity: ${result.similarityPercent ?? 'pending'}%`, { similarityPercent: result.similarityPercent }, ); break; // Success – exit retry loop } catch (error) { if ( error instanceof EulaBlockedError && attempt < 3 ) { await emit( onEvent, 'warning', 'navigate', 'Assignment launch returned EULA block; retrying', { attempt }, ); // Navigate back to class page and retry await page.goto( `https://www.turnitin.com/s_home.asp?lang=en_us`, { waitUntil: 'domcontentloaded', timeout: 60000 }, ); continue; } throw error; } } } if (!viewerPage && !assignmentLaunchUrl) { throw new Error('Assignment launch URL was not captured — cannot proceed to similarity wait'); } // --- 9. Open viewer --- if (!viewerPage) { await emit( onEvent, 'info', 'viewer', 'Opening report viewer', ); if (!similarityResult) { throw new Error('Similarity/submission card result was not captured before opening viewer'); } viewerPage = await openReportViewerPage( page, context, similarityResult.scope, similarityResult.locator, ); result.viewerUrl = viewerPage.url(); result.lastCompletedStep = 'viewer'; await emit( onEvent, 'info', 'viewer', 'Report viewer opened', { viewerUrl: result.viewerUrl, resumed: false }, ); } if (!viewerPage) { throw new Error('Report viewer page was not available after open/resume'); } const submissionDetails = await readSubmissionDetails(viewerPage); if (submissionDetails) { result.submissionDetails = submissionDetails; await emit( onEvent, 'info', 'submission_details', 'Submission details captured', submissionDetails as Record, ); } // --- 10. Apply filters --- await emit(onEvent, 'info', 'filters', 'Applying filters', { filters, }); try { await applyFilters(viewerPage, filters); } catch (filterError) { const filterMessage = filterError instanceof Error ? filterError.message : String(filterError); // When the user has enabled at least one filter, filter application // is MANDATORY — do NOT proceed to download with an unfiltered report. if (hasActiveFilters(filters)) { await emit( onEvent, 'error', 'filters', 'Filters are enabled but could not be applied; aborting download to prevent unfiltered report', { error: filterMessage, filters }, ); throw new Error( `Filter application failed with active filters: ${filterMessage}`, ); } // No filters are active — the panel might simply be unavailable; // it is safe to continue with the download. await emit( onEvent, 'warning', 'filters', 'Filter panel was not available (no filters were active); continuing with report download', { error: filterMessage }, ); } result.lastCompletedStep = 'filters'; // --- 10b. Validate that all active filters were applied before downloading --- if (hasActiveFilters(filters)) { await emit( onEvent, 'info', 'filters', 'Validating applied filters before download', { filters }, ); await validateFilters(viewerPage, filters, true /* retryApply */); await emit( onEvent, 'info', 'filters', 'Filter validation passed', { filters }, ); } // Read the similarity score from the viewer after filters are applied. // The score may have changed due to filter exclusions; give the UI a // moment to re-render before reading. await emit( onEvent, 'info', 'similarity', 'Reading post-filter similarity score from viewer', ); const viewerSimilarityPercent = await readViewerSimilarityPercent(viewerPage); if (viewerSimilarityPercent !== null) { result.similarityPercent = viewerSimilarityPercent; await emit( onEvent, 'info', 'similarity', `Viewer similarity (post-filter): ${viewerSimilarityPercent}%`, { similarityPercent: viewerSimilarityPercent, filtered: hasActiveFilters(filters) }, ); } else { await emit( onEvent, 'warning', 'similarity', 'Could not read similarity score from viewer; using pre-filter value if available', { preFilteSimilarity: result.similarityPercent ?? null }, ); } // --- 11. Download PDF --- await emit( onEvent, 'info', 'download', 'Downloading PDF report', ); fs.mkdirSync(outputDir, { recursive: true }); const outputPdfPath = path.join( outputDir, `turnitin_report_${Date.now()}.pdf`, ); const actualPath = await downloadPdf( viewerPage, context, outputPdfPath, ); result.outputPdfPath = actualPath; result.lastCompletedStep = 'download'; await emit( onEvent, 'info', 'download', 'PDF downloaded successfully', { outputPdfPath: actualPath }, ); if (isModernOnePool) { const message = 'Modern one-use account consumed its single allowed submission. Class will be dropped and account will be permanently limited.'; let dropClassResult: NonNullable['dropClassResult'] | undefined; if (page) { await emit(onEvent, 'warning', 'class_cleanup', 'Modern one-use submission completed; dropping class from account', { classTitle: assignmentTarget.classTitle, }); dropClassResult = await dropClassByTitle(page, assignmentTarget.classTitle).catch((dropError: unknown) => ({ attempted: true, dropped: false, reason: dropError instanceof Error ? dropError.message : String(dropError), })); await emit( onEvent, dropClassResult.dropped ? 'info' : 'warning', 'class_cleanup', dropClassResult.dropped ? 'Modern one-use class dropped or already absent' : 'Modern one-use class could not be dropped automatically', { ...dropClassResult }, ); } result.permanentLimit = { message, submissionCount: 1, dropClassResult, }; } // --- 12. Save storageState --- if (storageStatePath) { await context .storageState({ path: storageStatePath }) .catch(() => {}); logger.info('Storage state saved after job', { path: storageStatePath, }); } } catch (error) { const message = error instanceof Error ? error.message : String(error); const publicMessage = compactEngineErrorMessage(message); const isQuotaLimit = error instanceof Error && (error.name === 'SubmissionQuotaLimitError' || /reached your limit|submission quota limit/i.test(message)); if (isQuotaLimit && page) { await dropClassAfterQuotaLimit(message).catch((dropError: unknown) => { logger.warn('Failed to drop class after quota limit', { classTitle: assignmentTarget.classTitle, error: dropError instanceof Error ? dropError.message : String(dropError), }); }); } await emit(onEvent, 'error', 'error', publicMessage, { errorName: error instanceof Error ? error.name : 'UnknownError', }); if (error && typeof error === 'object') { (error as any).lastCompletedStep = result.lastCompletedStep; (error as any).viewerUrl = result.viewerUrl; (error as any).similarityPercent = result.similarityPercent; } throw error; } finally { // --- 13. Close context (NOT the browser — it's shared across jobs) --- await context.close().catch(() => {}); } // --- 14. Return result --- return result; }