Add PDF_PARSER_VERSION constant and propagate parser versioning throughout the PDF parsing, job, and API layers. Implement normalization of parse state to ensure compatibility with the current parser version, and enable reuse of parsed PDF results when possible. Add isPlaybackReady state to document hooks and TTS player, improving playback UX by disabling controls until content is ready. Update tests to reflect new playback readiness logic. BREAKING CHANGE: PDF parse state and job logic now require explicit parserVersion; older parse states may be treated as pending until reprocessed.
580 lines
22 KiB
TypeScript
580 lines
22 KiB
TypeScript
import { Page, expect, type TestInfo, type Locator } from '@playwright/test';
|
|
import fs from 'fs';
|
|
import path from 'path';
|
|
import { createHash } from 'crypto';
|
|
|
|
const DIR = './tests/files/';
|
|
|
|
// Small util to safely use filenames inside regex patterns
|
|
function escapeRegExp(input: string) {
|
|
return input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
}
|
|
|
|
function fixturePath(fileName: string) {
|
|
return path.join(__dirname, 'files', fileName);
|
|
}
|
|
|
|
function sha256HexOfFile(filePath: string) {
|
|
return createHash('sha256').update(fs.readFileSync(filePath)).digest('hex');
|
|
}
|
|
|
|
async function waitForPdfViewerReady(page: Page, timeout = 60000) {
|
|
await expect(page).toHaveURL(/\/pdf\/[A-Za-z0-9._%-]+$/, { timeout: Math.min(timeout, 20000) });
|
|
const loader = page.getByTestId('pdf-status-loader').first();
|
|
const parseFailedBanner = page.getByText('PDF parsing failed. Retry to continue.').first();
|
|
await expect
|
|
.poll(async () => {
|
|
const parseFailed = await parseFailedBanner.isVisible().catch(() => false);
|
|
if (parseFailed) return 'failed';
|
|
|
|
const documentVisible = await page.locator('.react-pdf__Document').first().isVisible().catch(() => false);
|
|
const pageVisible = await page.locator('.react-pdf__Page').first().isVisible().catch(() => false);
|
|
const canvasVisible = await page.locator('.react-pdf__Page canvas').first().isVisible().catch(() => false);
|
|
|
|
const hasRenderedPdf = documentVisible || pageVisible || canvasVisible;
|
|
const loaderVisible = await loader.isVisible().catch(() => false);
|
|
if (hasRenderedPdf && !loaderVisible) return 'ready';
|
|
return 'loading';
|
|
}, { timeout })
|
|
.toBe('ready');
|
|
}
|
|
|
|
/**
|
|
* Upload a sample epub or pdf
|
|
*/
|
|
export async function uploadFile(page: Page, filePath: string) {
|
|
const input = page.locator('input[type=file]').first();
|
|
await expect(input).toBeVisible({ timeout: 10000 });
|
|
await expect(input).toBeEnabled({ timeout: 10000 });
|
|
|
|
await input.setInputFiles(`${DIR}${filePath}`);
|
|
|
|
// Wait for the uploader to finish processing. The input is disabled while
|
|
// uploading/converting via react-dropzone's `disabled` prop.
|
|
// Tolerate extremely fast operations where the disabled state may be missed.
|
|
try {
|
|
await expect(input).toBeDisabled({ timeout: 2000 });
|
|
} catch {
|
|
// ignore
|
|
}
|
|
await expect(input).toBeEnabled({ timeout: 15000 });
|
|
}
|
|
|
|
/**
|
|
* Upload and display a document
|
|
*/
|
|
export async function uploadAndDisplay(page: Page, fileName: string) {
|
|
await uploadFile(page, fileName);
|
|
|
|
const lower = fileName.toLowerCase();
|
|
|
|
if (lower.endsWith('.docx')) {
|
|
// Best-effort: conversion can complete before we observe this UI state.
|
|
try {
|
|
await expect(page.getByText('Converting DOCX to PDF...')).toBeVisible({ timeout: 5000 });
|
|
} catch {
|
|
// ignore
|
|
}
|
|
const expectedId = sha256HexOfFile(fixturePath(fileName));
|
|
const targetLink = page.locator(`a[href$="/pdf/${expectedId}"]`).first();
|
|
await expect(targetLink).toBeVisible({ timeout: 15000 });
|
|
await dismissOnboardingModals(page);
|
|
await targetLink.click();
|
|
await waitForPdfViewerReady(page, 60000);
|
|
return;
|
|
}
|
|
|
|
const targetLink = page.getByRole('link', { name: new RegExp(escapeRegExp(fileName), 'i') }).first();
|
|
for (let attempt = 0; attempt < 3; attempt++) {
|
|
await dismissOnboardingModals(page);
|
|
try {
|
|
await targetLink.click({ timeout: 10000 });
|
|
break;
|
|
} catch (error) {
|
|
if (attempt === 2) throw error;
|
|
await page.waitForTimeout(200);
|
|
}
|
|
}
|
|
|
|
if (lower.endsWith('.pdf')) {
|
|
await waitForPdfViewerReady(page, 60000);
|
|
} else if (lower.endsWith('.epub')) {
|
|
await page.waitForSelector('.epub-container', { timeout: 10000 });
|
|
} else if (lower.endsWith('.txt') || lower.endsWith('.md')) {
|
|
await page.waitForSelector('.html-container', { timeout: 10000 });
|
|
}
|
|
}
|
|
|
|
async function dismissOnboardingModals(page: Page): Promise<void> {
|
|
const privacyDialog = page.getByTestId('privacy-modal');
|
|
const claimDialog = page.getByTestId('claim-modal');
|
|
const migrationDialog = page.getByTestId('migration-modal');
|
|
const settingsDialog = page.getByTestId('settings-modal');
|
|
|
|
const maxSteps = 12;
|
|
const settleChecks = 3;
|
|
let settledWithoutDialog = 0;
|
|
|
|
for (let step = 0; step < maxSteps; step += 1) {
|
|
if (await privacyDialog.isVisible().catch(() => false)) {
|
|
const privacyAgree = page.getByTestId('privacy-agree-checkbox');
|
|
if (await privacyAgree.isVisible().catch(() => false)) {
|
|
if (!(await privacyAgree.isChecked())) {
|
|
await privacyAgree.check();
|
|
}
|
|
}
|
|
const continueBtn = page.getByTestId('privacy-continue-button');
|
|
await expect(continueBtn).toBeEnabled({ timeout: 10000 });
|
|
await continueBtn.click();
|
|
await privacyDialog.waitFor({ state: 'hidden', timeout: 15000 });
|
|
await page.waitForTimeout(100);
|
|
settledWithoutDialog = 0;
|
|
continue;
|
|
}
|
|
|
|
if (await claimDialog.isVisible().catch(() => false)) {
|
|
const dismissBtn = page.getByTestId('claim-dismiss-button');
|
|
await expect(dismissBtn).toBeEnabled({ timeout: 10000 });
|
|
await dismissBtn.click();
|
|
await claimDialog.waitFor({ state: 'hidden', timeout: 15000 });
|
|
await page.waitForTimeout(100);
|
|
settledWithoutDialog = 0;
|
|
continue;
|
|
}
|
|
|
|
if (await settingsDialog.isVisible().catch(() => false)) {
|
|
const backToSettingsBtn = settingsDialog.getByRole('button', { name: /back to settings/i });
|
|
if (await backToSettingsBtn.isVisible().catch(() => false)) {
|
|
await expect(backToSettingsBtn).toBeEnabled({ timeout: 10000 });
|
|
await backToSettingsBtn.click();
|
|
await page.waitForTimeout(100);
|
|
}
|
|
|
|
// For test setup teardown, we only need to dismiss overlays.
|
|
// Avoid saving because Save can race with state changes and detach.
|
|
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
await page.keyboard.press('Escape');
|
|
const hidden = await settingsDialog.isHidden().catch(() => false);
|
|
if (hidden) {
|
|
break;
|
|
}
|
|
await page.waitForTimeout(100);
|
|
}
|
|
await settingsDialog.waitFor({ state: 'hidden', timeout: 15000 });
|
|
await page.waitForTimeout(100);
|
|
settledWithoutDialog = 0;
|
|
continue;
|
|
}
|
|
|
|
if (await migrationDialog.isVisible().catch(() => false)) {
|
|
const skipBtn = page.getByTestId('migration-skip-button');
|
|
await expect(skipBtn).toBeEnabled({ timeout: 10000 });
|
|
await skipBtn.click();
|
|
await migrationDialog.waitFor({ state: 'hidden', timeout: 15000 });
|
|
await page.waitForTimeout(100);
|
|
settledWithoutDialog = 0;
|
|
continue;
|
|
}
|
|
|
|
settledWithoutDialog += 1;
|
|
if (settledWithoutDialog >= settleChecks) {
|
|
return;
|
|
}
|
|
await page.waitForTimeout(150);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Wait for the play button to be clickable and click it
|
|
*/
|
|
export async function waitAndClickPlay(page: Page) {
|
|
const playButton = page.getByRole('button', { name: 'Play' });
|
|
await expect(playButton).toBeVisible();
|
|
await expect(playButton).toBeEnabled({ timeout: 15000 });
|
|
// Play the TTS by clicking the button
|
|
await playButton.click();
|
|
// Use resilient processing transition helper (tolerates fast completion)
|
|
await expectProcessingTransition(page);
|
|
}
|
|
|
|
/**
|
|
* Setup function for TTS playback tests
|
|
*/
|
|
export async function playTTSAndWaitForASecond(page: Page, fileName: string) {
|
|
// Upload and display the document
|
|
await uploadAndDisplay(page, fileName);
|
|
// Wait for play button selector without disabled attribute
|
|
await waitAndClickPlay(page);
|
|
// play for 1s
|
|
await page.waitForTimeout(1000);
|
|
}
|
|
/**
|
|
* Pause TTS playback and verify paused state
|
|
*/
|
|
export async function pauseTTSAndVerify(page: Page) {
|
|
const ttsbar = page.locator('[data-app-ttsbar]');
|
|
const pauseButton = ttsbar.getByRole('button', { name: 'Pause' }).first();
|
|
await expect(pauseButton).toBeVisible({ timeout: 15000 });
|
|
await expect(pauseButton).toBeEnabled({ timeout: 15000 });
|
|
|
|
// Retry pause because Firefox can race with transient processing transitions.
|
|
for (let attempt = 0; attempt < 3; attempt++) {
|
|
await pauseButton.click();
|
|
try {
|
|
await expect(ttsbar.getByRole('button', { name: 'Play' }).first()).toBeVisible({ timeout: 5000 });
|
|
await expectMediaState(page, 'paused');
|
|
return;
|
|
} catch {
|
|
if (attempt === 2) throw new Error('Failed to pause TTS playback after retries');
|
|
await page.waitForTimeout(250);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Common test setup function
|
|
*/
|
|
export async function setupTest(page: Page, testInfo?: TestInfo) {
|
|
const namespace = testInfo
|
|
? `${testInfo.project.name}-w${testInfo.workerIndex}-r${testInfo.retry}-${createHash('sha1')
|
|
.update(`${testInfo.file}|${testInfo.title}|${testInfo.repeatEachIndex}`)
|
|
.digest('hex')
|
|
.slice(0, 12)}`
|
|
: null;
|
|
if (namespace) {
|
|
// Isolate server-side storage per test run (scoped by project/worker/retry/test)
|
|
// to avoid cross-test flake from in-flight server-side writes.
|
|
await page.context().setExtraHTTPHeaders({ 'x-openreader-test-namespace': namespace });
|
|
}
|
|
|
|
// Pre-seed consent to prevent the cookie banner from blocking interactions.
|
|
await page.addInitScript(() => {
|
|
try {
|
|
window.localStorage.setItem('cookie-consent', 'accepted');
|
|
} catch {
|
|
// ignore storage errors in restricted contexts
|
|
}
|
|
});
|
|
|
|
// If we explicitly choose to bootstrap anonymous sessions for a test run,
|
|
// do it before navigation so protected startup routes do not intermittently 401.
|
|
// await ensureAnonymousSession(page);
|
|
|
|
// Navigate to the protected app home before each test
|
|
await page.goto('/app');
|
|
await page.waitForLoadState('networkidle');
|
|
|
|
// AuthLoader may show a full-screen overlay while session is loading.
|
|
// Wait for it to be gone before interacting with underlying UI.
|
|
await page
|
|
.waitForSelector('.fixed.inset-0.bg-base.z-50', { state: 'detached', timeout: 15_000 })
|
|
.catch(() => { });
|
|
|
|
// Fallback: if the banner still appears, dismiss it before continuing.
|
|
const cookieAcceptBtn = page.getByRole('button', { name: 'Accept All' });
|
|
if (await cookieAcceptBtn.isVisible().catch(() => false)) {
|
|
await cookieAcceptBtn.click();
|
|
}
|
|
|
|
// Close first-run dialogs (when present). These do not always appear.
|
|
await dismissOnboardingModals(page);
|
|
}
|
|
|
|
/**
|
|
* More reliable than Playwright's `locator.dragTo` when a drop immediately opens a modal
|
|
* (which can intercept pointer events mid-gesture and cause flakiness).
|
|
*
|
|
* This uses DOM drag events directly; our app's doc list DnD logic only needs the events,
|
|
* not a real OS-level drag interaction.
|
|
*/
|
|
export async function dispatchHtml5DragAndDrop(page: Page, source: Locator, target: Locator): Promise<void> {
|
|
const sourceHandle = await source.elementHandle();
|
|
const targetHandle = await target.elementHandle();
|
|
if (!sourceHandle) throw new Error('drag source element not found');
|
|
if (!targetHandle) throw new Error('drag target element not found');
|
|
|
|
await page.evaluate(
|
|
async ([src, dst]) => {
|
|
const dt = typeof DataTransfer !== 'undefined' ? new DataTransfer() : ({} as DataTransfer);
|
|
const fire = (el: Element, type: string) => {
|
|
const event = new DragEvent(type, { bubbles: true, cancelable: true, dataTransfer: dt });
|
|
el.dispatchEvent(event);
|
|
};
|
|
|
|
fire(src, 'dragstart');
|
|
// Let React flush state updates (draggedDoc) before dispatching drop events.
|
|
await Promise.resolve();
|
|
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
|
fire(dst, 'dragenter');
|
|
fire(dst, 'dragover');
|
|
fire(dst, 'drop');
|
|
fire(src, 'dragend');
|
|
},
|
|
[sourceHandle, targetHandle],
|
|
);
|
|
}
|
|
|
|
|
|
// Assert a document link containing the given filename appears in the list
|
|
export async function expectDocumentListed(page: Page, fileName: string) {
|
|
const link = page.getByRole('link', { name: new RegExp(escapeRegExp(fileName), 'i') }).first();
|
|
await expect
|
|
.poll(async () => link.count(), { timeout: 20000 })
|
|
.toBeGreaterThan(0);
|
|
await expect(link).toBeVisible({ timeout: 15000 });
|
|
}
|
|
|
|
// Assert a document link containing the given filename does NOT exist
|
|
export async function expectNoDocumentLink(page: Page, fileName: string) {
|
|
await expect(
|
|
page.getByRole('link', { name: new RegExp(escapeRegExp(fileName), 'i') })
|
|
).toHaveCount(0);
|
|
}
|
|
|
|
// Upload multiple files in sequence
|
|
export async function uploadFiles(page: Page, ...fileNames: string[]) {
|
|
for (const name of fileNames) {
|
|
await uploadFile(page, name);
|
|
await expectDocumentListed(page, name);
|
|
}
|
|
}
|
|
|
|
// Ensure a set of documents are visible in the list
|
|
export async function ensureDocumentsListed(page: Page, fileNames: string[]) {
|
|
for (const name of fileNames) {
|
|
await expectDocumentListed(page, name);
|
|
}
|
|
}
|
|
|
|
// Click the document link row by filename
|
|
export async function clickDocumentLink(page: Page, fileName: string) {
|
|
const link = page
|
|
.getByRole('link', { name: new RegExp(escapeRegExp(fileName), 'i') })
|
|
.first();
|
|
await expect(link).toBeVisible({ timeout: 15_000 });
|
|
|
|
const href = await link.getAttribute('href');
|
|
if (!href) {
|
|
await link.click();
|
|
return;
|
|
}
|
|
|
|
const navigatedByClick = await Promise.all([
|
|
page
|
|
.waitForURL((url) => url.pathname === href, { timeout: 8_000 })
|
|
.then(() => true)
|
|
.catch(() => false),
|
|
link.click(),
|
|
]).then(([ok]) => ok);
|
|
|
|
if (!navigatedByClick) {
|
|
await page.goto(href);
|
|
}
|
|
}
|
|
|
|
// Expect correct URL and viewer to be visible for a given file by extension
|
|
export async function expectViewerForFile(page: Page, fileName: string) {
|
|
const lower = fileName.toLowerCase();
|
|
if (lower.endsWith('.pdf') || lower.endsWith('.docx')) {
|
|
// DOCX converts to PDF, so viewer expectations are PDF
|
|
await expect(page).toHaveURL(/\/pdf\/[A-Za-z0-9._%-]+$/);
|
|
await waitForPdfViewerReady(page, 60000);
|
|
return;
|
|
}
|
|
if (lower.endsWith('.epub')) {
|
|
await expect(page).toHaveURL(/\/epub\/[A-Za-z0-9._%-]+$/);
|
|
await expect(page.locator('.epub-container')).toBeVisible({ timeout: 15000 });
|
|
return;
|
|
}
|
|
if (lower.endsWith('.txt') || lower.endsWith('.md')) {
|
|
await expect(page).toHaveURL(/\/html\/[A-Za-z0-9._%-]+$/);
|
|
await expect(page.locator('.html-container')).toBeVisible({ timeout: 15000 });
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Delete a single document by filename via row action and confirm dialog
|
|
export async function deleteDocumentByName(page: Page, fileName: string) {
|
|
await page.getByRole('button', { name: new RegExp(`^Delete\\s+${escapeRegExp(fileName)}$`, 'i') }).first().click();
|
|
|
|
const heading = page.getByRole('heading', { name: 'Delete Document' });
|
|
await expect(heading).toBeVisible({ timeout: 10000 });
|
|
|
|
const confirmBtn = heading.locator('xpath=ancestor::*[@role="dialog"][1]//button[normalize-space()="Delete"]');
|
|
await confirmBtn.click();
|
|
}
|
|
|
|
// Open Settings modal and navigate to Documents section
|
|
export async function openSettingsDocumentsTab(page: Page) {
|
|
await page.getByRole('button', { name: 'Settings' }).click();
|
|
const settingsDialog = page.locator('[data-testid="settings-modal"]');
|
|
await expect(settingsDialog).toBeVisible({ timeout: 10000 });
|
|
await settingsDialog.getByRole('button', { name: /^Documents$/ }).click();
|
|
}
|
|
|
|
// Open the Voices dropdown from the TTS bar and return the button locator
|
|
export async function openVoicesMenu(page: Page) {
|
|
const ttsbar = page.locator('[data-app-ttsbar]');
|
|
await expect(ttsbar).toBeVisible({ timeout: 10000 });
|
|
|
|
// If the listbox/options already exist, assume it's open and return (idempotent)
|
|
const alreadyOpen = await page.locator('[role="listbox"], [role="option"]').count();
|
|
if (alreadyOpen > 0) {
|
|
return;
|
|
}
|
|
|
|
// Prefer a stable selector using accessible name if present, otherwise fall back to a
|
|
// button whose label matches any known default voice (including "af_" prefixed ones),
|
|
// and finally the last button heuristic.
|
|
const candidateByName = ttsbar.getByRole('button', { name: /Voices|(af_)?(alloy|ash|coral|echo|fable|onyx|nova|sage|shimmer)/i });
|
|
|
|
const hasNamed = await candidateByName.count();
|
|
const voicesButton = hasNamed > 0 ? candidateByName.first() : ttsbar.getByRole('button').last();
|
|
|
|
await expect(voicesButton).toBeVisible();
|
|
await voicesButton.click();
|
|
|
|
// Wait for the options panel to appear; tolerate different render strategies by
|
|
// waiting for either the listbox container or at least one option.
|
|
await Promise.race([
|
|
page.waitForSelector('[role="listbox"]', { timeout: 10000 }),
|
|
page.waitForSelector('[role="option"]', { timeout: 10000 }),
|
|
]);
|
|
}
|
|
|
|
// Select a voice from the Voices dropdown and assert processing -> playing
|
|
export async function selectVoiceAndAssertPlayback(page: Page, voiceName: string | RegExp) {
|
|
// Ensure the menu is open without toggling it closed if already open
|
|
const optionCount = await page.locator('[role="option"]').count();
|
|
if (optionCount === 0) {
|
|
await openVoicesMenu(page);
|
|
}
|
|
|
|
await page.getByRole('option', { name: voiceName }).first().click();
|
|
await expectProcessingTransition(page);
|
|
}
|
|
|
|
// Assert skip buttons disabled during processing, then enabled, and playbackState=playing
|
|
export async function expectProcessingTransition(page: Page) {
|
|
// Try to detect a brief processing phase where skip buttons are disabled,
|
|
// but tolerate cases where processing completes too quickly to observe.
|
|
const disabledForward = page.locator('button[aria-label="Skip forward"][disabled]');
|
|
const disabledBackward = page.locator('button[aria-label="Skip backward"][disabled]');
|
|
try {
|
|
await Promise.all([
|
|
expect(disabledForward).toBeVisible({ timeout: 3000 }),
|
|
expect(disabledBackward).toBeVisible({ timeout: 3000 }),
|
|
]);
|
|
} catch {
|
|
// Processing may have completed before we observed disabled state; cause warning but continue
|
|
}
|
|
|
|
// Wait for the TTS to stop processing and buttons to be enabled
|
|
await Promise.all([
|
|
page.waitForSelector('button[aria-label="Skip forward"]:not([disabled])', { timeout: 45000 }),
|
|
page.waitForSelector('button[aria-label="Skip backward"]:not([disabled])', { timeout: 45000 }),
|
|
]);
|
|
|
|
// Ensure media session is playing
|
|
await expectMediaState(page, 'playing');
|
|
}
|
|
|
|
// Expect navigator.mediaSession.playbackState to equal given state
|
|
export async function expectMediaState(page: Page, state: 'playing' | 'paused') {
|
|
// Engines can intermittently miss mediaSession updates. Accept either:
|
|
// - expected UI control state (Pause button for playing, Play button for paused), or
|
|
// - underlying media state from mediaSession/audio element signals.
|
|
const desiredButtonName = state === 'playing' ? 'Pause' : 'Play';
|
|
await expect
|
|
.poll(async () => {
|
|
const uiMatches = await page
|
|
.getByRole('button', { name: desiredButtonName })
|
|
.first()
|
|
.isVisible()
|
|
.catch(() => false);
|
|
if (uiMatches) return true;
|
|
|
|
return page.evaluate((desired) => {
|
|
try {
|
|
const msState = (navigator.mediaSession && navigator.mediaSession.playbackState) || '';
|
|
if (msState === desired) return true;
|
|
|
|
const audio: HTMLAudioElement | null = document.querySelector('audio');
|
|
if (audio) {
|
|
const w = window as Window & { __openreaderLastAudioTime?: number };
|
|
const last = w.__openreaderLastAudioTime ?? -1;
|
|
const curr = audio.currentTime;
|
|
w.__openreaderLastAudioTime = curr;
|
|
|
|
if (desired === 'playing') {
|
|
if (!audio.paused && curr > 0 && curr > last) return true;
|
|
} else if (audio.paused) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}, state);
|
|
}, { timeout: 45000 })
|
|
.toBe(true);
|
|
}
|
|
|
|
// Use Navigator to go to a specific page number (PDF)
|
|
export async function navigateToPdfPageViaNavigator(page: Page, targetPage: number) {
|
|
// Navigator popover shows "X / Y"
|
|
const navTrigger = page.getByRole('button', { name: /\d+\s*\/\s*\d+/ });
|
|
await expect(navTrigger).toBeVisible({ timeout: 10000 });
|
|
await navTrigger.click();
|
|
|
|
const input = page.getByLabel('Page number');
|
|
await expect(input).toBeVisible({ timeout: 10000 });
|
|
await input.fill(String(targetPage));
|
|
await input.press('Enter');
|
|
}
|
|
|
|
// Count currently rendered react-pdf Page components
|
|
export async function countRenderedPdfPages(page: Page): Promise<number> {
|
|
return await page.locator('.react-pdf__Page').count();
|
|
}
|
|
|
|
// Count currently rendered text layers (active page(s))
|
|
export async function countRenderedTextLayers(page: Page): Promise<number> {
|
|
return await page.locator('.react-pdf__Page__textContent').count();
|
|
}
|
|
|
|
// Force viewport resize to trigger resize hooks (e.g., EPUB)
|
|
export async function triggerViewportResize(page: Page, width: number, height: number) {
|
|
await page.setViewportSize({ width, height });
|
|
}
|
|
|
|
// Wait for DocumentListState.showHint to persist in IndexedDB 'app-config' store
|
|
export async function waitForDocumentListHintPersist(page: Page, expected: boolean) {
|
|
await page.waitForFunction(async (exp) => {
|
|
try {
|
|
const openDb = () => new Promise<IDBDatabase>((resolve, reject) => {
|
|
const req = indexedDB.open('openreader-db');
|
|
req.onsuccess = () => resolve(req.result);
|
|
req.onerror = () => reject(req.error);
|
|
});
|
|
const db = await openDb();
|
|
const readConfig = () => new Promise<unknown>((resolve, reject) => {
|
|
const tx = db.transaction(['app-config'], 'readonly');
|
|
const store = tx.objectStore('app-config');
|
|
const getReq = store.get('singleton');
|
|
getReq.onsuccess = () => resolve(getReq.result);
|
|
getReq.onerror = () => reject(getReq.error);
|
|
});
|
|
const item = await readConfig();
|
|
db.close();
|
|
if (!item || typeof item !== 'object') return false;
|
|
const state = (item as { documentListState?: unknown }).documentListState;
|
|
if (!state || typeof state !== 'object') return false;
|
|
const showHint = (state as { showHint?: unknown }).showHint;
|
|
return typeof showHint === 'boolean' && showHint === exp;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}, expected, { timeout: 5000 });
|
|
}
|