import type { Page, Frame, BrowserContext, Download } from 'playwright'; import * as fs from 'fs'; import { logger } from '../../utils/logger'; import { SELECTORS, deepClickByText } from '../selectors'; type Scope = Page | Frame; async function clickFirstVisible( page: Scope, selector: string, limit = 20, ): Promise { const locator = page.locator(selector); const count = Math.min(await locator.count().catch(() => 0), limit); for (let index = 0; index < count; index++) { const candidate = locator.nth(index); if (!(await candidate.isVisible({ timeout: 800 }).catch(() => false))) { continue; } await candidate.click({ force: true }); return true; } return false; } // --------------------------------------------------------------------------- // Context-level download listener // --------------------------------------------------------------------------- /** * Wait for a download event across all pages in the context. * Turnitin may open/close temporary pages during export, so we listen at the * context level rather than on a single page. */ function waitForAnyDownload( context: BrowserContext, timeoutMs: number, ): Promise { return new Promise((resolve, reject) => { const timer = setTimeout( () => cleanup(new Error(`Timed out waiting ${timeoutMs}ms for download`)), timeoutMs, ); const pageListeners = new Map void>(); const onDownload = (download: Download) => cleanup(null, download); const onPage = (p: Page) => attach(p); function attach(p: Page) { p.on('download', onDownload); pageListeners.set(p, onDownload); } function cleanup(error: Error | null, download?: Download) { clearTimeout(timer); context.off('page', onPage); for (const [p, listener] of pageListeners) { p.off('download', listener); } if (error) reject(error); else resolve(download!); } for (const p of context.pages()) attach(p); context.on('page', onPage); }); } // --------------------------------------------------------------------------- // Public API // --------------------------------------------------------------------------- /** * Download the Turnitin report as a PDF from the viewer page. * * Steps: * 1. Set up context-level download listener (not page-level) * 2. Click download menu * 3. Select Current View / Similarity Report * 4. Wait for download to complete * 5. Save to outputPath * * @returns The actual file path where the PDF was saved. */ export async function downloadPdf( page: Scope, context: BrowserContext, outputPath: string, ): Promise { const ownerPage = typeof (page as any).page === 'function' ? (page as Frame).page() : (page as Page); // Validate that we are on the correct viewer URL. // Check both the scope URL (Frame or Page) and the owner page URL — // the report domain may only appear on one of them. const scopeUrl = page.url(); const ownerUrl = ownerPage.url(); const isValidViewerUrl = (url: string) => url.includes('reports.integrity.turnitin.com') && /submission-viewer|\/submission\//i.test(url); if (!isValidViewerUrl(scopeUrl) && !isValidViewerUrl(ownerUrl)) { throw new Error( `Cannot download report because viewer page is not active. ` + `scope URL: ${scopeUrl}, owner URL: ${ownerUrl}`, ); } // ── BUG-5 FIX: Attach download listener BEFORE clicking any menu ── // Previously the listener was set up after a 1-second wait post-click. // If the browser starts the download faster than 1s the event was missed // and the job timed out after 120 seconds. const downloadPromise = waitForAnyDownload(context, 120000); // Click download menu let clickedDownloadMenu = false; const downloadMenuDeadline = Date.now() + 30_000; while (Date.now() < downloadMenuDeadline && !clickedDownloadMenu) { clickedDownloadMenu = await clickFirstVisible( page, SELECTORS.download.downloadMenu, 20, ); if (!clickedDownloadMenu) { await ownerPage.waitForTimeout(1000); } } if (!clickedDownloadMenu) { const clicked = await deepClickByText(page, ['download']); if (!clicked) { throw new Error('Download menu button was not found in report viewer'); } } await ownerPage.waitForTimeout(1000); const optionSelectors = [ SELECTORS.download.currentViewOption, '[with-data-px="DownloadOptionCurrentViewClicked"]', '[data-px="DownloadOptionCurrentViewClicked"]', 'button:has-text("Current View")', 'tdl-button:has-text("Current View")', '[role="menuitem"]:has-text("Current View")', 'button:has-text("Similarity Report")', 'tdl-button:has-text("Similarity Report")', '[role="menuitem"]:has-text("Similarity Report")', ]; let clickedOption = false; for (const selector of optionSelectors) { if (await clickFirstVisible(page, selector, 10)) { clickedOption = true; break; } } if (!clickedOption) { clickedOption = await deepClickByText(page, [ 'current view', 'download current view', 'similarity report', 'download similarity report', ]); } if (!clickedOption) { throw new Error('Download option was not found after opening download menu'); } const download = await downloadPromise; await download.saveAs(outputPath); const stat = fs.statSync(outputPath); if (stat.size <= 0) { // BUG-12 FIX: Remove the empty file so it doesn't accumulate in tmpDir try { fs.unlinkSync(outputPath); } catch { /* ignore */ } throw new Error(`Downloaded PDF is empty: ${outputPath}`); } logger.info('PDF downloaded', { outputPath, suggestedFilename: download.suggestedFilename(), size: stat.size, }); return outputPath; }