Spaces:
Sleeping
Sleeping
| 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<boolean> { | |
| 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<Download> { | |
| return new Promise((resolve, reject) => { | |
| const timer = setTimeout( | |
| () => cleanup(new Error(`Timed out waiting ${timeoutMs}ms for download`)), | |
| timeoutMs, | |
| ); | |
| const pageListeners = new Map<Page, (d: Download) => 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<string> { | |
| 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; | |
| } | |