From 647a6aba5a8ff0c9cab21d6921d2829a56707012 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Thu, 21 May 2026 17:20:37 -0400 Subject: [PATCH 01/43] Add e2e testing framework --- .eslintignore | 4 + .eslintrc.js | 8 + .github/workflows/test.yml | 54 +++ .gitignore | 4 + .prettierignore | 4 + .stylelintignore | 4 + README.md | 42 +++ e2e-tests/.eslintrc.json | 12 + e2e-tests/fixtures/app.fixture.ts | 78 ++++ e2e-tests/fixtures/cdp.fixture.ts | 69 ++++ e2e-tests/fixtures/helpers.ts | 344 ++++++++++++++++++ e2e-tests/global-setup.ts | 129 +++++++ e2e-tests/global-teardown.ts | 43 +++ e2e-tests/playwright-cdp.config.ts | 28 ++ e2e-tests/playwright.config.ts | 46 +++ .../example-interlinearizer-feature.spec.ts | 72 ++++ .../tests/smoke/extension-launch.spec.ts | 27 ++ e2e-tests/tsconfig.json | 16 + package-lock.json | 76 ++++ package.json | 7 + tsconfig.lint.json | 2 +- 21 files changed, 1068 insertions(+), 1 deletion(-) create mode 100644 e2e-tests/.eslintrc.json create mode 100644 e2e-tests/fixtures/app.fixture.ts create mode 100644 e2e-tests/fixtures/cdp.fixture.ts create mode 100644 e2e-tests/fixtures/helpers.ts create mode 100644 e2e-tests/global-setup.ts create mode 100644 e2e-tests/global-teardown.ts create mode 100644 e2e-tests/playwright-cdp.config.ts create mode 100644 e2e-tests/playwright.config.ts create mode 100644 e2e-tests/tests/_example/example-interlinearizer-feature.spec.ts create mode 100644 e2e-tests/tests/smoke/extension-launch.spec.ts create mode 100644 e2e-tests/tsconfig.json diff --git a/.eslintignore b/.eslintignore index 3e29184d..687a559a 100644 --- a/.eslintignore +++ b/.eslintignore @@ -37,3 +37,7 @@ coverage package-lock.json # #endregion + +# Playwright test output +e2e-tests/playwright-report +e2e-tests/test-results diff --git a/.eslintrc.js b/.eslintrc.js index 627548a9..d1cabf9a 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -192,6 +192,14 @@ module.exports = { 'jest/globals': true, }, }, + { + // Playwright e2e test fixtures use a `use()` callback that is Playwright's fixture API, + // not a React hook. react-hooks/rules-of-hooks v5 incorrectly flags these calls. + files: ['e2e-tests/**/*.ts', 'e2e-tests/**/*.tsx'], + rules: { + 'react-hooks/rules-of-hooks': 'off', + }, + }, ], parser: '@typescript-eslint/parser', parserOptions: { diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index aa5a9783..3bfafc52 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -47,3 +47,57 @@ jobs: - name: Run tests working-directory: extension-repo run: npm run test:coverage + + e2e-smoke: + runs-on: ubuntu-latest + steps: + - name: Checkout git repo + uses: actions/checkout@v4 + with: + path: extension-repo + + - name: Checkout paranext-core repo + uses: actions/checkout@v4 + with: + path: paranext-core + repository: paranext/paranext-core + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + cache: 'npm' + cache-dependency-path: | + extension-repo/package-lock.json + paranext-core/package-lock.json + node-version-file: extension-repo/package.json + + - name: Install extension dependencies + working-directory: extension-repo + run: npm ci --ignore-scripts --omit=optional + + - name: Install core dependencies + working-directory: paranext-core + run: npm ci --ignore-scripts --omit=optional + + - name: Install system dependencies for Electron + run: sudo apt-get update && sudo apt-get install -y xvfb libgbm-dev libnss3 libxss1 libasound2t64 + + - name: Build extension + working-directory: extension-repo + run: npm run build + + - name: Build paranext-core dev bundle + working-directory: paranext-core + run: npm run prestart + + - name: Run e2e smoke tests + working-directory: extension-repo + run: xvfb-run --auto-servernum --server-args="-screen 0 1280x960x24" npm run test:e2e:smoke + + - name: Upload Playwright report + uses: actions/upload-artifact@v4 + if: always() + with: + name: playwright-report + path: extension-repo/e2e-tests/playwright-report/ + retention-days: 7 diff --git a/.gitignore b/.gitignore index 65791e37..76900ddd 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,7 @@ coverage temp-build # #endregion + +# Playwright test output +e2e-tests/playwright-report +e2e-tests/test-results diff --git a/.prettierignore b/.prettierignore index a66f5879..01f359ac 100644 --- a/.prettierignore +++ b/.prettierignore @@ -37,3 +37,7 @@ coverage package-lock.json # #endregion + +# Playwright test output +e2e-tests/playwright-report +e2e-tests/test-results diff --git a/.stylelintignore b/.stylelintignore index 11800009..69c75a68 100644 --- a/.stylelintignore +++ b/.stylelintignore @@ -37,3 +37,7 @@ coverage package-lock.json # #endregion + +# Playwright test output +e2e-tests/playwright-report +e2e-tests/test-results diff --git a/README.md b/README.md index 0360e082..7d87dfe5 100644 --- a/README.md +++ b/README.md @@ -153,6 +153,48 @@ To package this extension into a zip file for distribution: `npm run package` +## Testing + +### Unit tests + +Unit tests use [Jest](https://jestjs.io/) and live in `src/__tests__/`. To run: + +```bash +npm test # run all tests +npm run test:coverage # run with coverage report (output to coverage/) +``` + +### End-to-end tests + +E2E tests use [Playwright](https://playwright.dev/) and live in `e2e-tests/`. They launch Platform.Bible with this extension loaded and verify behavior through the real UI. + +**Prerequisites:** + +- `npm run build` must have been run (`dist/src/main.js` must exist) +- `paranext-core` must have deps installed (e.g., with `npm run core:install`) + +**Smoke tests** (self-contained, good for CI — launches and tears down Platform.Bible automatically): + +```bash +npm run test:e2e:smoke +``` + +**CDP tests** (connect to an already-running app — faster for local development): + +1. Start Platform.Bible with remote debugging enabled: + + ```bash + npm run start:cdp + ``` + +2. In a second terminal, run the tests: + + ```bash + npm run test:e2e:cdp + ``` + +New feature tests should use `cdp.fixture` and navigate entirely through visible UI. See `e2e-tests/tests/_example/` for a reference template. + ## Publishing These steps will walk you through releasing a version on GitHub and bumping the version to a new version so future changes apply to the new in-progress version. diff --git a/e2e-tests/.eslintrc.json b/e2e-tests/.eslintrc.json new file mode 100644 index 00000000..a734e32f --- /dev/null +++ b/e2e-tests/.eslintrc.json @@ -0,0 +1,12 @@ +{ + "rules": { + // E2E tests legitimately use console for test diagnostics + "no-console": "off", + // E2E tests often need sequential async operations in loops + "no-await-in-loop": "off", + // `export default defineConfig(...)` is the standard Playwright config pattern + "import/no-anonymous-default-export": "off", + // TypeScript handles undefined references; ESLint no-undef doesn't know Node.js globals + "no-undef": "off" + } +} diff --git a/e2e-tests/fixtures/app.fixture.ts b/e2e-tests/fixtures/app.fixture.ts new file mode 100644 index 00000000..e794b994 --- /dev/null +++ b/e2e-tests/fixtures/app.fixture.ts @@ -0,0 +1,78 @@ +// Adapted from paranext-core/e2e-tests/fixtures/app.fixture.ts +import { + test as base, + ElectronApplication, + Page, + TestInfo, + ConsoleMessage, +} from '@playwright/test'; +import { + launchElectronWithExtension, + teardownElectronApp, + ElectronAppContext, + PROCESS_READY_TIMEOUT, +} from './helpers'; + +export { expect } from '@playwright/test'; + +/** Worker-scoped fixtures — one instance shared across all tests in a worker. */ +export interface WorkerAppFixtures { + electronApp: ElectronApplication; +} + +/** Test-scoped fixtures — re-created for every test. */ +export interface TestAppFixtures { + mainPage: Page; +} + +export const test = base.extend({ + // Worker-scoped: the Electron process is launched once per worker and shared across all tests, + // avoiding the process startup/teardown cost per test. + electronApp: [ + // eslint-disable-next-line no-empty-pattern + async ({}, use) => { + const ctx: ElectronAppContext = await launchElectronWithExtension(); + + await use(ctx.electronApp); + + console.log('[teardown] Worker-scoped app teardown starting...'); + await teardownElectronApp(ctx); + console.log('[teardown] Worker-scoped app teardown complete — worker will exit now'); + }, + { scope: 'worker' }, + ], + + mainPage: async ({ electronApp }, use, testInfo: TestInfo) => { + const page = await electronApp.firstWindow({ timeout: PROCESS_READY_TIMEOUT }); + + console.log(`Window URL: ${page.url()}`); + const onPageError = (err: Error) => console.error(`Page error: ${err.message}`); + const onConsoleMsg = (msg: ConsoleMessage) => { + if (msg.type() === 'error') console.error(`Console error: ${msg.text()}`); + }; + page.on('pageerror', onPageError); + page.on('console', onConsoleMsg); + + await page.waitForLoadState('domcontentloaded'); + await page.waitForSelector('#root', { state: 'attached', timeout: PROCESS_READY_TIMEOUT }); + + await use(page); + + page.off('pageerror', onPageError); + page.off('console', onConsoleMsg); + + if (testInfo.status !== testInfo.expectedStatus) { + const screenshotPath = testInfo.outputPath('failure.png'); + try { + await page.screenshot({ path: screenshotPath, fullPage: true }); + await testInfo.attach('failure-screenshot', { + path: screenshotPath, + contentType: 'image/png', + }); + console.log(`Failure screenshot saved to ${screenshotPath}`); + } catch { + console.warn('Could not capture failure screenshot (window may already be closed)'); + } + } + }, +}); diff --git a/e2e-tests/fixtures/cdp.fixture.ts b/e2e-tests/fixtures/cdp.fixture.ts new file mode 100644 index 00000000..171916a0 --- /dev/null +++ b/e2e-tests/fixtures/cdp.fixture.ts @@ -0,0 +1,69 @@ +// Adapted from paranext-core/e2e-tests/fixtures/cdp.fixture.ts +/** + * CDP-based Playwright fixture for running E2E tests against an already-running Platform.Bible + * instance with remote debugging enabled. + * + * Uses `connectOverCDP` (port 9223) instead of `_electron.launch()`, so: + * + * - No app restart needed (no port 8876 conflict) + * - Tests run against the same app instance used during development + * - No teardown/shutdown of the app on completion + * + * Prerequisite: Platform.Bible running with --remote-debugging-port=9223 and the interlinearizer + * extension loaded. + */ +import { test as base, chromium, Page } from '@playwright/test'; + +export { expect } from '@playwright/test'; + +const CDP_URL = process.env.CDP_URL || 'http://localhost:9223'; + +export interface CdpFixtures { + mainPage: Page; +} + +export const test = base.extend({ + // eslint-disable-next-line no-empty-pattern + mainPage: async ({}, use) => { + let browser; + for (let attempt = 1; attempt <= 3; attempt++) { + try { + // intentional retry loop + // eslint-disable-next-line no-await-in-loop + browser = await chromium.connectOverCDP(CDP_URL, { timeout: 30_000 }); + break; + } catch (err) { + if (attempt === 3) throw err; + // intentional retry delay + // eslint-disable-next-line no-await-in-loop + await new Promise((resolve) => { + setTimeout(resolve, 2_000); + }); + } + } + if (!browser) throw new Error('Failed to connect to CDP after 3 attempts'); + + // Find the renderer page (not devtools) + const allPages = browser.contexts().flatMap((ctx) => ctx.pages()); + let page: Page | undefined = allPages.find((p) => { + const url = p.url(); + return ( + (url.includes('localhost') || url.includes('index.html') || url.startsWith('file://')) && + !url.includes('devtools://') + ); + }); + + if (!page) { + page = allPages.find((p) => !p.url().includes('devtools://')); + } + + if (!page) throw new Error('No renderer page found via CDP'); + + await use(page); + try { + await browser.close(); + } catch { + // Ignore disconnect errors during cleanup + } + }, +}); diff --git a/e2e-tests/fixtures/helpers.ts b/e2e-tests/fixtures/helpers.ts new file mode 100644 index 00000000..b86435d9 --- /dev/null +++ b/e2e-tests/fixtures/helpers.ts @@ -0,0 +1,344 @@ +// Adapted from paranext-core/e2e-tests/fixtures/helpers.ts +import { _electron as electron, ElectronApplication, Page } from '@playwright/test'; +import fs from 'fs'; +import { createRequire } from 'module'; +import os from 'os'; +import path from 'path'; +import WebSocket from 'ws'; + +const DEFAULT_WEBSOCKET_PORT = 8876; +const RPC_DISCOVER_POLL_INTERVAL_MS = 250; +export const PROCESS_READY_TIMEOUT = 120_000; + +/** + * Same serialized request type as `registerCommand('platform.about', ...)` in command.service + * (`command` + `:` + `platform.about`). + */ +const PLATFORM_ABOUT_COMMAND = 'command:platform.about'; + +/** + * Keep in sync with GET_METHODS from @shared/data/rpc.model. Required to be 'rpc.discover' by the + * OpenRPC specification. + */ +const GET_METHODS = 'rpc.discover'; + +type RpcDiscoverResult = { + methods?: Array<{ name: string }>; +}; + +/** Return value from {@link launchElectronWithExtension}. */ +export interface ElectronAppContext { + electronApp: ElectronApplication; + userDataDir: string; + /** Resolves when the Electron process closes (registered before yielding to tests). */ + appClosed: Promise; +} + +/** Options accepted by {@link launchElectronWithExtension}. */ +export interface LaunchElectronAppOptions { + /** + * Additional environment variables to merge into the child process environment, applied after the + * defaults. Keys present here override the defaults (e.g. `{ DEV_NOISY: 'false' }`). + */ + envOverrides?: Record; +} + +/** Wait for the WebSocket server to be ready on the specified port. */ +async function waitForWebSocketReady(port: number, timeout: number): Promise { + const startTime = Date.now(); + + while (Date.now() - startTime < timeout) { + try { + await new Promise((resolve, reject) => { + const ws = new WebSocket(`ws://localhost:${port}`); + const timer = setTimeout(() => { + ws.close(); + reject(new Error('Connection timeout')); + }, 2000); + + ws.on('open', () => { + clearTimeout(timer); + ws.close(); + resolve(); + }); + ws.on('error', (err) => { + clearTimeout(timer); + ws.close(); + reject(err); + }); + }); + return; + } catch { + await new Promise((resolve) => { + setTimeout(resolve, 500); + }); + } + } + throw new Error(`WebSocket server not ready on port ${port} after ${timeout}ms`); +} + +/** + * Launch a fresh Electron instance (paranext-core) with the interlinearizer extension loaded via + * `--extensionDirs`. Returns the app handle, the temp directory path, and a promise that resolves + * when the app closes. + */ +export async function launchElectronWithExtension( + opts: LaunchElectronAppOptions = {}, +): Promise { + const coreDir = path.resolve(__dirname, '../../../paranext-core'); + const extensionDist = path.resolve(__dirname, '../../dist'); + + // Resolve the Electron binary from paranext-core's node_modules — the electron package exports + // the path to the platform binary as its default export. + const coreRequire = createRequire(path.resolve(coreDir, 'package.json')); + // eslint-disable-next-line no-type-assertion/no-type-assertion + const electronExecutable = coreRequire('electron') as string; + + console.log(`Launching Platform.Bible from: ${coreDir}`); + console.log(`Loading extension from: ${extensionDist}`); + + // VSCode/Claude Code set ELECTRON_RUN_AS_NODE=1 which forces the Electron binary to run as plain + // Node.js. Omit it so the Electron child does not inherit it. + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { ELECTRON_RUN_AS_NODE, ...restEnv } = process.env; + const env = { + ...restEnv, + NODE_ENV: 'development', + DEV_NOISY: process.env.DEV_NOISY ?? 'false', + ...opts.envOverrides, + }; + + // Use an isolated user-data directory so the singleton instance lock does not + // conflict with any already-running Platform.Bible instance. + const userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'paranext-e2e-')); + + let electronApp: ElectronApplication; + try { + electronApp = await electron.launch({ + executablePath: electronExecutable, + args: [`--user-data-dir=${userDataDir}`, coreDir, '--extensions', extensionDist], + cwd: coreDir, + env, + timeout: PROCESS_READY_TIMEOUT, + }); + } catch (error) { + console.error('Failed to launch Electron:', error); + fs.rmSync(userDataDir, { recursive: true, force: true }); + throw error; + } + + console.log('Waiting for WebSocket server on port 8876...'); + try { + await waitForWebSocketReady(DEFAULT_WEBSOCKET_PORT, PROCESS_READY_TIMEOUT); + } catch (error) { + console.error('WebSocket readiness check failed after Electron launch:', error); + const proc = electronApp.process(); + if (proc?.pid) { + try { + process.kill(-proc.pid, 'SIGKILL'); + } catch { + try { + proc.kill('SIGKILL'); + } catch { + /* already dead */ + } + } + } + fs.rmSync(userDataDir, { recursive: true, force: true }); + throw error; + } + console.log('WebSocket server is ready'); + + const appClosed = new Promise((resolve) => { + electronApp.once('close', () => { + resolve(); + }); + }); + + return { electronApp, userDataDir, appClosed }; +} + +/** + * Tear down an Electron instance: kill the process group, wait for close, and clean up the isolated + * user-data directory. + */ +export async function teardownElectronApp(ctx: ElectronAppContext): Promise { + const { electronApp, userDataDir, appClosed } = ctx; + + const electronProcess = electronApp.process(); + console.log( + `[teardown] Closing Electron app... pid=${electronProcess?.pid} exitCode=${electronProcess?.exitCode} signalCode=${electronProcess?.signalCode}`, + ); + + const killGroup = (sig: NodeJS.Signals) => { + if (!electronProcess?.pid) return; + try { + process.kill(-electronProcess.pid, sig); + } catch { + try { + electronProcess.kill(sig); + } catch { + /* already dead */ + } + } + }; + + // Node.js ChildProcess.exitCode/signalCode are null until the process exits + // eslint-disable-next-line no-null/no-null + if (electronProcess && electronProcess.exitCode === null && electronProcess.signalCode === null) { + console.log('[teardown] Sending SIGKILL to process group...'); + killGroup('SIGKILL'); + console.log('[teardown] Waiting for appClosed after SIGKILL (up to 3s)...'); + await Promise.race([ + appClosed, + new Promise((resolve) => { + setTimeout(resolve, 3_000); + }), + ]); + console.log('[teardown] Done waiting after SIGKILL'); + } + + console.log('[teardown] Cleaning up user data dir...'); + try { + fs.rmSync(userDataDir, { recursive: true, force: true }); + } catch { + console.warn('[teardown] First rmSync attempt failed — retrying in 3s...'); + await new Promise((resolve) => { + setTimeout(resolve, 3_000); + }); + try { + fs.rmSync(userDataDir, { recursive: true, force: true }); + } catch (e) { + console.warn(`[teardown] Could not remove ${userDataDir}: ${e}`); + } + } + console.log('[teardown] Complete'); +} + +/** + * One JSON-RPC 2.0 request over WebSocket: open, send, wait for response id `1`, close. Ignores + * unrelated messages until the matching response arrives. + */ +async function sendPapiJsonRpcOnce( + method: string, + timeoutErrorMessage?: string, + params: unknown[] = [], + port: number = DEFAULT_WEBSOCKET_PORT, + perRequestTimeoutMs = 10_000, +): Promise { + const timeoutMessage = + timeoutErrorMessage ?? `PAPI request "${method}" timed out after ${perRequestTimeoutMs}ms`; + + return new Promise((resolve, reject) => { + const ws = new WebSocket(`ws://localhost:${port}`); + const timeout = setTimeout(() => { + ws.close(); + reject(new Error(timeoutMessage)); + }, perRequestTimeoutMs); + + ws.on('open', () => { + ws.send( + JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method, + params, + }), + ); + }); + + ws.on('message', (data) => { + let parsed: { id?: number; error?: unknown; result?: unknown }; + try { + parsed = JSON.parse(data.toString()); + } catch (err) { + clearTimeout(timeout); + ws.close(); + reject(err); + return; + } + if (parsed.id !== 1) return; + clearTimeout(timeout); + ws.close(); + if (parsed.error) { + reject(new Error(`PAPI error: ${JSON.stringify(parsed.error)}`)); + } else { + // eslint-disable-next-line no-type-assertion/no-type-assertion + resolve(parsed.result as T); + } + }); + + ws.on('error', (err) => { + clearTimeout(timeout); + reject(err); + }); + }); +} + +/** + * Send a single JSON-RPC request where `method` is a PAPI request type (e.g. `rpc.discover`). Opens + * a connection, sends one request, waits for the matching response id, then closes. + */ +export async function sendPapiRequestOnce( + method: string, + params: unknown[] = [], + port: number = DEFAULT_WEBSOCKET_PORT, + perRequestTimeoutMs = 10_000, +): Promise { + return sendPapiJsonRpcOnce(method, undefined, params, port, perRequestTimeoutMs); +} + +/** Poll `rpc.discover` until `methodName` appears in `result.methods` or `timeoutMs` elapses. */ +export async function waitForPapiMethodRegistered( + methodName: string, + port: number = DEFAULT_WEBSOCKET_PORT, + timeoutMs = 60_000, +): Promise { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + const remaining = timeoutMs - (Date.now() - start); + try { + const result = await sendPapiRequestOnce( + GET_METHODS, + [], + port, + Math.min(10_000, Math.max(1000, remaining)), + ); + if (result.methods?.some((m) => m.name === methodName)) return; + } catch { + /* next poll */ + } + const sleepMs = Math.min(RPC_DISCOVER_POLL_INTERVAL_MS, timeoutMs - (Date.now() - start)); + if (sleepMs <= 0) break; + await new Promise((resolve) => { + setTimeout(resolve, sleepMs); + }); + } + throw new Error(`PAPI method "${methodName}" not listed in rpc.discover within ${timeoutMs}ms`); +} + +/** + * Wait for the Platform.Bible UI to be fully ready: dock layout appears and `platform.about` + * command is registered (dialog service has finished initializing). + */ +export async function waitForAppReady(page: Page, timeout = 60_000): Promise { + const start = Date.now(); + await page.waitForSelector('div[class*="dock-layout"]', { + state: 'attached', + timeout, + }); + const remaining = Math.max(0, timeout - (Date.now() - start)); + await waitForPapiMethodRegistered(PLATFORM_ABOUT_COMMAND, DEFAULT_WEBSOCKET_PORT, remaining); +} + +/** + * Wait for the interlinearizer extension to finish activating by polling `rpc.discover` until + * `interlinearizer.openForWebView` is listed. + */ +export async function waitForInterlinearizerReady(timeoutMs = 90_000): Promise { + await waitForPapiMethodRegistered( + 'command:interlinearizer.openForWebView', + DEFAULT_WEBSOCKET_PORT, + timeoutMs, + ); +} diff --git a/e2e-tests/global-setup.ts b/e2e-tests/global-setup.ts new file mode 100644 index 00000000..7c9d9893 --- /dev/null +++ b/e2e-tests/global-setup.ts @@ -0,0 +1,129 @@ +// Adapted from paranext-core/e2e-tests/global-setup.ts +import type { FullConfig } from '@playwright/test'; +import { execSync, spawn } from 'child_process'; +import net from 'net'; +import path from 'path'; +import fs from 'fs'; + +const WEBSOCKET_PORT = 8876; +const RENDERER_PORT = 1212; + +/** Check if a port is already in use */ +function isPortInUse(port: number): Promise { + return new Promise((resolve) => { + const server = net.createServer(); + server.once('error', () => { + server.close(); + resolve(true); + }); + server.once('listening', () => { + server.close(); + resolve(false); + }); + server.listen(port); + }); +} + +/** Wait until a port is accepting connections */ +function waitForPort(port: number, timeout: number): Promise { + const startTime = Date.now(); + return new Promise((resolve, reject) => { + const tryConnect = () => { + if (Date.now() - startTime > timeout) { + reject(new Error(`Port ${port} did not become available within ${timeout}ms`)); + return; + } + const socket = net.createConnection(port, '127.0.0.1'); + socket.on('connect', () => { + socket.destroy(); + resolve(); + }); + socket.on('error', () => { + socket.destroy(); + setTimeout(tryConnect, 500); + }); + }; + tryConnect(); + }); +} + +// Playwright global setup requires this signature even though config is unused +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export default async function globalSetup(_config: FullConfig): Promise { + const extensionRoot = path.resolve(__dirname, '..'); + const coreDir = path.resolve(__dirname, '../../paranext-core'); + + // Fail fast if Platform.Bible is already running (single-instance lock will + // cause Playwright's Electron instance to exit immediately) + if (await isPortInUse(WEBSOCKET_PORT)) { + throw new Error( + `Port ${WEBSOCKET_PORT} is already in use. ` + + 'Stop the running Platform.Bible instance (npm run core:stop) before running E2E tests.', + ); + } + + // Remove stale Electron singleton lock files (left behind after crashes). + const os = await import('os'); + let appSupportDir: string; + if (process.platform === 'darwin') { + appSupportDir = path.join(os.homedir(), 'Library/Application Support'); + } else if (process.platform === 'linux') { + appSupportDir = path.join(os.homedir(), '.config'); + } else { + appSupportDir = process.env.APPDATA || ''; + } + + ['Electron', 'paratext-10-studio', 'platform-bible', 'Paranext', 'Platform.Bible'].forEach( + (dir) => { + const lockPath = path.join(appSupportDir, dir, 'SingletonLock'); + if (fs.existsSync(lockPath)) { + console.log(`Removing stale singleton lock: ${lockPath}`); + fs.unlinkSync(lockPath); + } + }, + ); + + // Fail fast if the extension dist is missing — tests cannot run without a built extension + const extensionMain = path.join(extensionRoot, 'dist/src/main.js'); + if (!fs.existsSync(extensionMain)) { + throw new Error( + `Extension dist not found at ${extensionMain}. ` + + 'Run "npm run build" in interlinearizer-extension before running E2E tests.', + ); + } + console.log('Extension dist found.'); + + // Ensure the paranext-core dev main bundle exists + const devMainPath = path.join(coreDir, '.erb/dll/main.bundle.dev.js'); + if (!fs.existsSync(devMainPath)) { + console.log('Development main bundle not found. Building...'); + execSync('npm run prestart', { cwd: coreDir, stdio: 'inherit' }); + } else { + console.log('Development main bundle found.'); + } + + // Start the webpack dev server for the renderer if not already running + if (await isPortInUse(RENDERER_PORT)) { + console.log(`Renderer dev server already running on port ${RENDERER_PORT}.`); + } else { + console.log('Starting paranext-core renderer dev server...'); + const devServer = spawn('npm', ['run', 'start:renderer'], { + cwd: coreDir, + stdio: 'ignore', + shell: true, + detached: true, + env: { ...process.env, ELECTRON_RUN_AS_NODE: undefined, SKIP_START_MAIN: '1' }, + }); + + devServer.unref(); + + const pidFile = path.join(extensionRoot, 'e2e-tests/.dev-server.pid'); + if (devServer.pid) { + fs.writeFileSync(pidFile, String(devServer.pid)); + } + + console.log(`Waiting for renderer dev server on port ${RENDERER_PORT}...`); + await waitForPort(RENDERER_PORT, 120_000); + console.log('Renderer dev server is ready.'); + } +} diff --git a/e2e-tests/global-teardown.ts b/e2e-tests/global-teardown.ts new file mode 100644 index 00000000..82132ee5 --- /dev/null +++ b/e2e-tests/global-teardown.ts @@ -0,0 +1,43 @@ +// Adapted from paranext-core/e2e-tests/global-teardown.ts +import type { FullConfig } from '@playwright/test'; +import { execSync } from 'child_process'; +import path from 'path'; +import fs from 'fs'; + +// Playwright global teardown requires this signature even though config is unused +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export default async function globalTeardown(_config: FullConfig): Promise { + const extensionRoot = path.resolve(__dirname, '..'); + const coreDir = path.resolve(__dirname, '../../paranext-core'); + + // Kill the renderer dev server if we started it + const pidFile = path.join(extensionRoot, 'e2e-tests', '.dev-server.pid'); + if (fs.existsSync(pidFile)) { + const pid = parseInt(fs.readFileSync(pidFile, 'utf-8').trim(), 10); + if (Number.isNaN(pid)) { + console.warn(`Invalid PID in ${pidFile}, skipping process kill`); + fs.unlinkSync(pidFile); + } else { + console.log(`Stopping renderer dev server (PID: ${pid})...`); + try { + process.kill(-pid, 'SIGTERM'); + } catch { + try { + process.kill(pid, 'SIGTERM'); + } catch { + // Already stopped + } + } + fs.unlinkSync(pidFile); + } + } + + // Run the core stop script to ensure all Electron processes are terminated + console.log('Running cleanup: npm run stop (in paranext-core)'); + try { + execSync('npm run stop', { cwd: coreDir, stdio: 'pipe', timeout: 10_000 }); + console.log('Cleanup completed.'); + } catch { + console.log('Cleanup: No processes to stop or already stopped.'); + } +} diff --git a/e2e-tests/playwright-cdp.config.ts b/e2e-tests/playwright-cdp.config.ts new file mode 100644 index 00000000..0e1b8d52 --- /dev/null +++ b/e2e-tests/playwright-cdp.config.ts @@ -0,0 +1,28 @@ +// Adapted from paranext-core/e2e-tests/playwright-cdp.config.ts +import { defineConfig } from '@playwright/test'; + +/** + * Playwright configuration for running E2E tests against an already-running Platform.Bible instance + * with CDP enabled (port 9223). + * + * Prerequisites: Platform.Bible running with --remote-debugging-port=9223 and the interlinearizer + * extension loaded. + * + * Use: npx playwright test --config=e2e-tests/playwright-cdp.config.ts + */ +export default defineConfig({ + testDir: './tests', + testIgnore: ['**/smoke/**', '**/_example/**'], + fullyParallel: false, + workers: 1, + reporter: [['html', { outputFolder: 'playwright-report' }], ['list']], + timeout: 120_000, + expect: { timeout: 10_000 }, + use: { + trace: 'on-first-retry', + screenshot: 'only-on-failure', + video: 'retain-on-failure', + }, + outputDir: './test-results', + // NO globalSetup/globalTeardown — app is already running +}); diff --git a/e2e-tests/playwright.config.ts b/e2e-tests/playwright.config.ts new file mode 100644 index 00000000..5d9712bd --- /dev/null +++ b/e2e-tests/playwright.config.ts @@ -0,0 +1,46 @@ +// Adapted from paranext-core/e2e-tests/playwright.config.ts +import { defineConfig } from '@playwright/test'; + +/** + * Playwright configuration for interlinearizer extension E2E tests. + * + * Launches Platform.Bible with the interlinearizer extension loaded via `--extensionDirs`. + * + * Prerequisites: + * + * - `npm run build` must have been run (dist/src/main.js must exist) + * - Paranext-core must be cloned at `../../paranext-core` with deps installed + * - `smoke`: tests share a single Electron instance per worker — fast, for CI. + * - `isolated`: each test gets a fresh Electron restart — for state-mutating tests. + */ +export default defineConfig({ + testDir: './tests', + testIgnore: ['**/_example/**'], + fullyParallel: false, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 1, + workers: 1, + reporter: [['html', { outputFolder: 'playwright-report' }], ['list']], + timeout: 120_000, + expect: { + timeout: 10_000, + }, + use: { + trace: 'on-first-retry', + screenshot: 'only-on-failure', + video: 'retain-on-failure', + }, + globalSetup: './global-setup.ts', + globalTeardown: './global-teardown.ts', + outputDir: './test-results', + projects: [ + { + name: 'smoke', + testDir: './tests/smoke', + }, + { + name: 'isolated', + testDir: './tests/isolated', + }, + ], +}); diff --git a/e2e-tests/tests/_example/example-interlinearizer-feature.spec.ts b/e2e-tests/tests/_example/example-interlinearizer-feature.spec.ts new file mode 100644 index 00000000..4d6c3e31 --- /dev/null +++ b/e2e-tests/tests/_example/example-interlinearizer-feature.spec.ts @@ -0,0 +1,72 @@ +/** + * === REFERENCE EXAMPLE === + * + * Template for per-feature E2E tests using cdp.fixture. Copy this file when writing tests for + * specific interlinearizer UI features. + * + * Key rules: + * + * - ALWAYS import from '../../fixtures/cdp.fixture' (NOT app.fixture) + * - ALWAYS navigate via visible UI (menu clicks, button presses) + * - NEVER use direct JSON-RPC/WebSocket calls to drive the test + * - Cdp.fixture only provides { mainPage } — no electronApp + * + * This file is excluded from test runs — it's documentation only. + */ +import { test, expect } from '../../fixtures/cdp.fixture'; +import { waitForAppReady, waitForInterlinearizerReady } from '../../fixtures/helpers'; + +function filterConsoleErrors(errors: string[]): string[] { + return errors.filter( + (e) => + !e.includes('DevTools') && + !e.includes('favicon') && + !e.includes('source map') && + !e.includes('net::ERR_'), + ); +} + +test.describe('Example: Open Interlinearizer via menu', () => { + test('should open the interlinearizer WebView via menu', async ({ mainPage }) => { + await waitForAppReady(mainPage); + await waitForInterlinearizerReady(); + + // Step 1: Click the top-level menu that contains the interlinearizer entry + const menuTrigger = mainPage.getByRole('menuitem', { name: /Tools/i }); + await menuTrigger.click(); + + // Step 2: Click the interlinearizer entry in the dropdown + const featureItem = mainPage.getByRole('menuitem', { name: /Interlinearizer/i }); + await featureItem.click(); + + // Step 3: Verify the WebView tab opened in the dock + const tab = mainPage.locator('.dock-tab', { hasText: /Interlinearizer/i }); + await expect(tab).toBeVisible({ timeout: 15_000 }); + }); + + test('should render without critical console errors', async ({ mainPage }) => { + await waitForAppReady(mainPage); + await waitForInterlinearizerReady(); + + const consoleErrors: string[] = []; + mainPage.on('console', (msg) => { + if (msg.type() === 'error') consoleErrors.push(msg.text()); + }); + + // Navigate to the feature + const menuTrigger = mainPage.getByRole('menuitem', { name: /Tools/i }); + await menuTrigger.click(); + const featureItem = mainPage.getByRole('menuitem', { name: /Interlinearizer/i }); + await featureItem.click(); + + const tab = mainPage.locator('.dock-tab', { hasText: /Interlinearizer/i }); + await expect(tab).toBeVisible({ timeout: 15_000 }); + + // For WebView content inside iframes, switch frame context: + // const webViewFrame = mainPage.frameLocator('iframe[title="Interlinearizer WebView Title"]'); + // await expect(webViewFrame.locator('[data-testid="my-component"]')).toBeVisible(); + + const criticalErrors = filterConsoleErrors(consoleErrors); + expect(criticalErrors).toHaveLength(0); + }); +}); diff --git a/e2e-tests/tests/smoke/extension-launch.spec.ts b/e2e-tests/tests/smoke/extension-launch.spec.ts new file mode 100644 index 00000000..a7ad9b5c --- /dev/null +++ b/e2e-tests/tests/smoke/extension-launch.spec.ts @@ -0,0 +1,27 @@ +import { test, expect } from '../../fixtures/app.fixture'; +import { waitForAppReady, waitForInterlinearizerReady } from '../../fixtures/helpers'; + +test.describe('Interlinearizer Extension Smoke Tests', () => { + test('should launch Platform.Bible and create at least one window', async ({ electronApp }) => { + expect(electronApp.windows().length).toBeGreaterThanOrEqual(1); + }); + + test('should render the React root', async ({ mainPage }) => { + await mainPage.waitForSelector('#root', { state: 'attached', timeout: 30_000 }); + const root = mainPage.locator('#root'); + await expect(root).toBeAttached(); + }); + + test('should load the dock layout', async ({ mainPage }) => { + await waitForAppReady(mainPage); + const dock = mainPage.locator('div[class*="dock-layout"]'); + await expect(dock).toBeAttached({ timeout: 60_000 }); + }); + + test('should register interlinearizer PAPI commands', async ({ mainPage }) => { + await waitForAppReady(mainPage); + // Waits for interlinearizer.openForWebView to appear in rpc.discover, confirming the + // extension activated successfully. + await waitForInterlinearizerReady(); + }); +}); diff --git a/e2e-tests/tsconfig.json b/e2e-tests/tsconfig.json new file mode 100644 index 00000000..89157b73 --- /dev/null +++ b/e2e-tests/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022", "DOM"], + "types": ["node"], + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true, + "resolveJsonModule": true + }, + "include": ["**/*.ts"], + "exclude": ["node_modules", "playwright-report", "test-results"] +} diff --git a/package-lock.json b/package-lock.json index d0ca7869..9c42fb26 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "devDependencies": { "@dreamsicle.io/stylelint-config-tailwindcss": "^1.2.2", "@fontsource-variable/ibm-plex-sans": "^5.2.8", + "@playwright/test": "^1.49.0", "@stylistic/eslint-plugin-ts": "^2.13.0", "@swc/core": "1.13.3", "@tailwindcss/postcss": "^4.3.0", @@ -28,6 +29,7 @@ "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "@types/webpack": "^5.28.5", + "@types/ws": "^8.5.14", "@typescript-eslint/eslint-plugin": "^8.0.0", "@typescript-eslint/parser": "^8.0.0", "concurrently": "^9.1.2", @@ -76,6 +78,7 @@ "webpack": "^5.105.2", "webpack-cli": "^5.1.4", "webpack-merge": "^6.0.1", + "ws": "^8.18.0", "zip-build": "^1.8.0" }, "peerDependencies": { @@ -2900,6 +2903,22 @@ "url": "https://opencollective.com/pkgr" } }, + "node_modules/@playwright/test": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz", + "integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@rtsao/scc": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", @@ -3979,6 +3998,16 @@ "webpack": "^5" } }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/yargs": { "version": "17.0.35", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", @@ -13766,6 +13795,53 @@ "resolved": "../paranext-core/lib/platform-bible-utils", "link": true }, + "node_modules/playwright": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", + "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", + "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", diff --git a/package.json b/package.json index 578bbd8f..c4a26a09 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "package": "npm run build:production && npm run zip", "package:debug": "cross-env DEBUG_PROD=true npm run package", "start": "cross-env MAIN_ARGS=\"--extensions $INIT_CWD/dist\" concurrently \"npm:watch\" \"npm:core:start\"", + "start:cdp": "cross-env MAIN_ARGS=\"--extensions $INIT_CWD/dist --remote-debugging-port=9223\" concurrently \"npm:watch\" \"npm:core:start\"", "start:production": "cross-env MAIN_ARGS=\"--extensions $INIT_CWD/dist\" concurrently \"npm:watch:production\" \"npm:core:start\"", "lint": "npm run lint:scripts && npm run lint:styles && npm run lint:typecheck", "lint:scripts": "cross-env NODE_ENV=development eslint --ext .cjs,.js,.jsx,.ts,.tsx --cache .", @@ -29,6 +30,9 @@ "bump-versions": "ts-node ./lib/bump-versions.ts", "test": "jest", "test:coverage": "jest --coverage", + "test:e2e": "playwright test --config e2e-tests/playwright.config.ts", + "test:e2e:cdp": "playwright test --config e2e-tests/playwright-cdp.config.ts", + "test:e2e:smoke": "playwright test --config e2e-tests/playwright.config.ts --project=smoke", "core:start": "npm --prefix ../paranext-core start", "core:stop": "npm --prefix ../paranext-core stop", "core:reset": "npm run core:stop && node ./scripts/delete-temp-files.cjs --core --ext", @@ -50,6 +54,7 @@ "devDependencies": { "@dreamsicle.io/stylelint-config-tailwindcss": "^1.2.2", "@fontsource-variable/ibm-plex-sans": "^5.2.8", + "@playwright/test": "^1.49.0", "@stylistic/eslint-plugin-ts": "^2.13.0", "@swc/core": "1.13.3", "@tailwindcss/postcss": "^4.3.0", @@ -62,6 +67,7 @@ "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "@types/webpack": "^5.28.5", + "@types/ws": "^8.5.14", "@typescript-eslint/eslint-plugin": "^8.0.0", "@typescript-eslint/parser": "^8.0.0", "concurrently": "^9.1.2", @@ -110,6 +116,7 @@ "webpack": "^5.105.2", "webpack-cli": "^5.1.4", "webpack-merge": "^6.0.1", + "ws": "^8.18.0", "zip-build": "^1.8.0" }, "overrides": { diff --git a/tsconfig.lint.json b/tsconfig.lint.json index 62db1e05..d20496cb 100644 --- a/tsconfig.lint.json +++ b/tsconfig.lint.json @@ -3,5 +3,5 @@ "compilerOptions": { "allowJs": true }, - "include": [".*.js", "*.js", "*.ts", "lib", "scripts", "src", "webpack"] + "include": [".*.js", "*.js", "*.ts", "e2e-tests", "lib", "scripts", "src", "webpack"] } From 7946657eee3ea26a1395f315a0c22504538a89c3 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Thu, 21 May 2026 17:29:00 -0400 Subject: [PATCH 02/43] Fix test workflows --- .github/workflows/publish.yml | 2 +- .github/workflows/test.yml | 2 +- jest.config.ts | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index a678ddaa..2644870c 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -80,7 +80,7 @@ jobs: - name: Install core packages working-directory: paranext-core run: | - npm ci --ignore-scripts + npm ci --ignore-scripts --omit=optional - name: Package for distribution run: | diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3bfafc52..78f0abcd 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -73,7 +73,7 @@ jobs: - name: Install extension dependencies working-directory: extension-repo - run: npm ci --ignore-scripts --omit=optional + run: npm ci --ignore-scripts - name: Install core dependencies working-directory: paranext-core diff --git a/jest.config.ts b/jest.config.ts index 670457f5..91d5c2cb 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -120,8 +120,8 @@ const config: Config = { */ testMatch: ['**/__tests__/**/*.(test|spec).[jt]s?(x)', '**/?(*.)+(spec|test).[jt]s?(x)'], - /** Do not run tests from build output or dependencies. */ - testPathIgnorePatterns: ['/node_modules/', '/dist/'], + /** Do not run tests from build output, e2e tests, or dependencies. */ + testPathIgnorePatterns: ['/dist/', '/e2e-tests/', '/node_modules/'], /** * Transform TS/TSX with ts-jest (webpack uses SWC; Jest does not run webpack). Explicitly list From 61721a193e68a8a9977a8948986b56c738d43857 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Thu, 21 May 2026 17:47:19 -0400 Subject: [PATCH 03/43] En-doc-inate --- .github/workflows/test.yml | 2 +- e2e-tests/fixtures/app.fixture.ts | 5 ++ e2e-tests/fixtures/cdp.fixture.ts | 5 ++ e2e-tests/fixtures/helpers.ts | 56 +++++++++++++++++-- e2e-tests/global-setup.ts | 34 ++++++++++- e2e-tests/global-teardown.ts | 11 +++- e2e-tests/playwright.config.ts | 6 +- .../example-interlinearizer-feature.spec.ts | 6 ++ 8 files changed, 111 insertions(+), 14 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 78f0abcd..8fb7d677 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -80,7 +80,7 @@ jobs: run: npm ci --ignore-scripts --omit=optional - name: Install system dependencies for Electron - run: sudo apt-get update && sudo apt-get install -y xvfb libgbm-dev libnss3 libxss1 libasound2t64 + run: sudo apt-get update && sudo apt-get install -y --no-install-recommends xvfb libgbm-dev libnss3 libxss1 libasound2t64 - name: Build extension working-directory: extension-repo diff --git a/e2e-tests/fixtures/app.fixture.ts b/e2e-tests/fixtures/app.fixture.ts index e794b994..19ab01b5 100644 --- a/e2e-tests/fixtures/app.fixture.ts +++ b/e2e-tests/fixtures/app.fixture.ts @@ -25,6 +25,11 @@ export interface TestAppFixtures { mainPage: Page; } +/** + * Playwright test fixture for smoke tests. Launches one Electron instance per worker (shared across + * all tests in that worker) and provides `electronApp` and `mainPage`. Attaches a failure + * screenshot to the report when a test does not meet its expected status. + */ export const test = base.extend({ // Worker-scoped: the Electron process is launched once per worker and shared across all tests, // avoiding the process startup/teardown cost per test. diff --git a/e2e-tests/fixtures/cdp.fixture.ts b/e2e-tests/fixtures/cdp.fixture.ts index 171916a0..381306a9 100644 --- a/e2e-tests/fixtures/cdp.fixture.ts +++ b/e2e-tests/fixtures/cdp.fixture.ts @@ -18,10 +18,15 @@ export { expect } from '@playwright/test'; const CDP_URL = process.env.CDP_URL || 'http://localhost:9223'; +/** Fixtures provided by the CDP test fixture. */ export interface CdpFixtures { mainPage: Page; } +/** + * Playwright test fixture for feature tests. Connects to an already-running Platform.Bible instance + * via CDP and provides `mainPage`. Does not launch or shut down the app. + */ export const test = base.extend({ // eslint-disable-next-line no-empty-pattern mainPage: async ({}, use) => { diff --git a/e2e-tests/fixtures/helpers.ts b/e2e-tests/fixtures/helpers.ts index b86435d9..a2cbe22c 100644 --- a/e2e-tests/fixtures/helpers.ts +++ b/e2e-tests/fixtures/helpers.ts @@ -43,7 +43,14 @@ export interface LaunchElectronAppOptions { envOverrides?: Record; } -/** Wait for the WebSocket server to be ready on the specified port. */ +/** + * Wait for the WebSocket server to be ready on the specified port. + * + * @param port Port number to connect to. + * @param timeout Maximum time in milliseconds to wait before throwing. + * @returns Resolves when a WebSocket connection to the port succeeds. + * @throws {Error} If the WebSocket server is not ready within `timeout` milliseconds. + */ async function waitForWebSocketReady(port: number, timeout: number): Promise { const startTime = Date.now(); @@ -79,8 +86,12 @@ async function waitForWebSocketReady(port: number, timeout: number): Promise { const { electronApp, userDataDir, appClosed } = ctx; @@ -218,6 +232,15 @@ export async function teardownElectronApp(ctx: ElectronAppContext): Promise( method: string, @@ -278,6 +301,13 @@ async function sendPapiJsonRpcOnce( /** * Send a single JSON-RPC request where `method` is a PAPI request type (e.g. `rpc.discover`). Opens * a connection, sends one request, waits for the matching response id, then closes. + * + * @param method PAPI request type to invoke (e.g. `rpc.discover`). + * @param params Positional parameters to send with the request. + * @param port WebSocket port to connect to. + * @param perRequestTimeoutMs Milliseconds before the request times out. + * @returns The `result` field of the JSON-RPC response, typed as `T`. + * @throws {Error} If the request times out or the server returns a JSON-RPC error. */ export async function sendPapiRequestOnce( method: string, @@ -288,7 +318,15 @@ export async function sendPapiRequestOnce( return sendPapiJsonRpcOnce(method, undefined, params, port, perRequestTimeoutMs); } -/** Poll `rpc.discover` until `methodName` appears in `result.methods` or `timeoutMs` elapses. */ +/** + * Poll `rpc.discover` until `methodName` appears in `result.methods` or `timeoutMs` elapses. + * + * @param methodName The fully-qualified PAPI method name to wait for (e.g. `command:foo.bar`). + * @param port WebSocket port to connect to. + * @param timeoutMs Maximum time in milliseconds to poll before throwing. + * @returns Resolves when the method appears in `rpc.discover`. + * @throws {Error} If the method is not registered within `timeoutMs` milliseconds. + */ export async function waitForPapiMethodRegistered( methodName: string, port: number = DEFAULT_WEBSOCKET_PORT, @@ -320,6 +358,12 @@ export async function waitForPapiMethodRegistered( /** * Wait for the Platform.Bible UI to be fully ready: dock layout appears and `platform.about` * command is registered (dialog service has finished initializing). + * + * @param page The Playwright `Page` for the Platform.Bible renderer window. + * @param timeout Maximum time in milliseconds to wait before throwing. + * @returns Resolves when the dock layout is visible and `platform.about` is registered. + * @throws If the dock layout or `platform.about` command does not appear within `timeout` + * milliseconds. */ export async function waitForAppReady(page: Page, timeout = 60_000): Promise { const start = Date.now(); @@ -334,6 +378,10 @@ export async function waitForAppReady(page: Page, timeout = 60_000): Promise { await waitForPapiMethodRegistered( diff --git a/e2e-tests/global-setup.ts b/e2e-tests/global-setup.ts index 7c9d9893..6ac663b8 100644 --- a/e2e-tests/global-setup.ts +++ b/e2e-tests/global-setup.ts @@ -8,7 +8,12 @@ import fs from 'fs'; const WEBSOCKET_PORT = 8876; const RENDERER_PORT = 1212; -/** Check if a port is already in use */ +/** + * Check if a port is already in use. + * + * @param port Port number to probe. + * @returns Resolves to `true` if the port is occupied, `false` if it is free. + */ function isPortInUse(port: number): Promise { return new Promise((resolve) => { const server = net.createServer(); @@ -24,7 +29,14 @@ function isPortInUse(port: number): Promise { }); } -/** Wait until a port is accepting connections */ +/** + * Wait until a port is accepting connections. + * + * @param port Port number to poll. + * @param timeout Maximum time in milliseconds to wait before rejecting. + * @returns Resolves when a TCP connection to the port succeeds. + * @throws {Error} If the port does not become available within `timeout` milliseconds. + */ function waitForPort(port: number, timeout: number): Promise { const startTime = Date.now(); return new Promise((resolve, reject) => { @@ -47,7 +59,23 @@ function waitForPort(port: number, timeout: number): Promise { }); } -// Playwright global setup requires this signature even though config is unused +/** + * Playwright global setup. Runs once before any test worker starts. + * + * 1. Fails fast if port 8876 is already in use (a running Platform.Bible would conflict with the + * Electron instance launched by fixtures). + * 2. Removes stale Electron singleton lock files left behind by crashes. + * 3. Fails fast if the extension dist is missing (directs the developer to run `npm run build`). + * 4. Ensures the paranext-core dev main bundle exists, building it via `npm run prestart` if not. + * 5. Starts the paranext-core webpack renderer dev server on port 1212 if not already running, and + * stores its PID for {@link globalTeardown} to stop it. + * + * @param _config Playwright config object — unused; required by Playwright's global-setup + * interface. + * @returns Resolves when the renderer dev server is ready. + * @throws {Error} If port 8876 is already in use. + * @throws {Error} If the extension dist is missing. + */ // eslint-disable-next-line @typescript-eslint/no-unused-vars export default async function globalSetup(_config: FullConfig): Promise { const extensionRoot = path.resolve(__dirname, '..'); diff --git a/e2e-tests/global-teardown.ts b/e2e-tests/global-teardown.ts index 82132ee5..8739e466 100644 --- a/e2e-tests/global-teardown.ts +++ b/e2e-tests/global-teardown.ts @@ -4,7 +4,16 @@ import { execSync } from 'child_process'; import path from 'path'; import fs from 'fs'; -// Playwright global teardown requires this signature even though config is unused +/** + * Playwright global teardown. Runs once after all test workers have finished. + * + * Stops the renderer dev server started by {@link globalSetup} (if any), then runs `npm run stop` in + * paranext-core to terminate any lingering Electron processes. + * + * @param _config Playwright config object — unused; required by Playwright's global-teardown + * interface. + * @returns Resolves when all cleanup steps have completed. + */ // eslint-disable-next-line @typescript-eslint/no-unused-vars export default async function globalTeardown(_config: FullConfig): Promise { const extensionRoot = path.resolve(__dirname, '..'); diff --git a/e2e-tests/playwright.config.ts b/e2e-tests/playwright.config.ts index 5d9712bd..e47a532f 100644 --- a/e2e-tests/playwright.config.ts +++ b/e2e-tests/playwright.config.ts @@ -4,7 +4,7 @@ import { defineConfig } from '@playwright/test'; /** * Playwright configuration for interlinearizer extension E2E tests. * - * Launches Platform.Bible with the interlinearizer extension loaded via `--extensionDirs`. + * Launches Platform.Bible with the interlinearizer extension loaded via `--extensions`. * * Prerequisites: * @@ -38,9 +38,5 @@ export default defineConfig({ name: 'smoke', testDir: './tests/smoke', }, - { - name: 'isolated', - testDir: './tests/isolated', - }, ], }); diff --git a/e2e-tests/tests/_example/example-interlinearizer-feature.spec.ts b/e2e-tests/tests/_example/example-interlinearizer-feature.spec.ts index 4d6c3e31..0b583275 100644 --- a/e2e-tests/tests/_example/example-interlinearizer-feature.spec.ts +++ b/e2e-tests/tests/_example/example-interlinearizer-feature.spec.ts @@ -16,6 +16,12 @@ import { test, expect } from '../../fixtures/cdp.fixture'; import { waitForAppReady, waitForInterlinearizerReady } from '../../fixtures/helpers'; +/** + * Filter out expected/benign console errors from a list of captured error messages. + * + * @param errors Array of console error message strings to filter. + * @returns The subset of `errors` that are not considered benign. + */ function filterConsoleErrors(errors: string[]): string[] { return errors.filter( (e) => From 205cdaae90202790886bbe49779026573146164d Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Thu, 21 May 2026 17:55:23 -0400 Subject: [PATCH 04/43] Build core dll --- .github/workflows/test.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8fb7d677..6f0d6a58 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -90,6 +90,10 @@ jobs: working-directory: paranext-core run: npm run prestart + - name: Build paranext-core renderer DLL + working-directory: paranext-core + run: npm run build:dll + - name: Run e2e smoke tests working-directory: extension-repo run: xvfb-run --auto-servernum --server-args="-screen 0 1280x960x24" npm run test:e2e:smoke From b6efd54cb748ce7c2b3d01e72deab34bdbc6ae5c Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Thu, 21 May 2026 18:02:46 -0400 Subject: [PATCH 05/43] Fix test; Expand OSes --- .github/workflows/test.yml | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6f0d6a58..d1f4fd7c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -14,7 +14,7 @@ jobs: runs-on: ${{ matrix.os }} strategy: matrix: - os: [ubuntu-latest] + os: [ubuntu-latest, windows-latest] steps: - name: Checkout git repo uses: actions/checkout@v4 @@ -49,7 +49,10 @@ jobs: run: npm run test:coverage e2e-smoke: - runs-on: ubuntu-latest + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, windows-latest] steps: - name: Checkout git repo uses: actions/checkout@v4 @@ -79,7 +82,12 @@ jobs: working-directory: paranext-core run: npm ci --ignore-scripts --omit=optional + - name: Install Electron binary + working-directory: paranext-core + run: node node_modules/electron/install.js + - name: Install system dependencies for Electron + if: matrix.os == 'ubuntu-latest' run: sudo apt-get update && sudo apt-get install -y --no-install-recommends xvfb libgbm-dev libnss3 libxss1 libasound2t64 - name: Build extension @@ -94,14 +102,20 @@ jobs: working-directory: paranext-core run: npm run build:dll - - name: Run e2e smoke tests + - name: Run e2e smoke tests (Linux) + if: matrix.os == 'ubuntu-latest' working-directory: extension-repo run: xvfb-run --auto-servernum --server-args="-screen 0 1280x960x24" npm run test:e2e:smoke + - name: Run e2e smoke tests (Windows) + if: matrix.os == 'windows-latest' + working-directory: extension-repo + run: npm run test:e2e:smoke + - name: Upload Playwright report uses: actions/upload-artifact@v4 if: always() with: - name: playwright-report + name: playwright-report-${{ matrix.os }} path: extension-repo/e2e-tests/playwright-report/ retention-days: 7 From 075fbd17e5618c8875a175f9aa2bf7ef684aa29f Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Fri, 22 May 2026 09:00:19 -0400 Subject: [PATCH 06/43] Fix Windows CI/CD --- jest.config.ts | 2 +- jest.setup.js | 5 +++++ jest.setup.ts | 5 ----- 3 files changed, 6 insertions(+), 6 deletions(-) create mode 100644 jest.setup.js delete mode 100644 jest.setup.ts diff --git a/jest.config.ts b/jest.config.ts index 91d5c2cb..8ff09ddd 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -109,7 +109,7 @@ const config: Config = { modulePathIgnorePatterns: ['/dist'], /** Load @testing-library/jest-dom matchers for React component tests. */ - setupFilesAfterEnv: ['/jest.setup.ts'], + setupFilesAfterEnv: ['/jest.setup.js'], /** Use jsdom for React component tests; parser tests run fine in jsdom (no DOM use). */ testEnvironment: 'jsdom', diff --git a/jest.setup.js b/jest.setup.js new file mode 100644 index 00000000..f5b949ff --- /dev/null +++ b/jest.setup.js @@ -0,0 +1,5 @@ +// Jest setup file. Runs before each test file. Extends expect with @testing-library/jest-dom +// matchers for React component tests. +// .js rather than .ts: Jest validates setupFilesAfterEnv paths via require.resolve before +// ts-jest's transform hooks are registered, so .ts paths can fail to resolve on Windows. +require('@testing-library/jest-dom'); diff --git a/jest.setup.ts b/jest.setup.ts deleted file mode 100644 index cf32fbe1..00000000 --- a/jest.setup.ts +++ /dev/null @@ -1,5 +0,0 @@ -/** - * Jest setup file. Runs before each test file. Extends expect with @testing-library/jest-dom - * matchers for React component tests. - */ -import '@testing-library/jest-dom'; From 69a5529cd31bf0d82002ab1df572a5572d6fa7dd Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Fri, 22 May 2026 09:15:06 -0400 Subject: [PATCH 07/43] Wait for webpack compilation before returning from global-setup After the renderer dev server binds port 1212, webpack-dev-middleware still holds HTTP requests open until the initial bundle compiles. Electron launched during that window loads a blank page, then receives an HMR full-reload once webpack finishes, which closes the Playwright page reference and causes waitForSelector('#root') to fail. Add waitForHttpOk, which makes a real HTTP GET to localhost:1212/ and resolves only when webpack-dev-middleware unblocks the response (i.e. compilation is done). This replaces the previous TCP-only waitForPort call, ensuring Electron never loads an uncompiled renderer. Co-Authored-By: Claude Sonnet 4.6 --- e2e-tests/global-setup.ts | 45 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 44 insertions(+), 1 deletion(-) diff --git a/e2e-tests/global-setup.ts b/e2e-tests/global-setup.ts index 6ac663b8..aff4cbf8 100644 --- a/e2e-tests/global-setup.ts +++ b/e2e-tests/global-setup.ts @@ -1,6 +1,7 @@ // Adapted from paranext-core/e2e-tests/global-setup.ts import type { FullConfig } from '@playwright/test'; import { execSync, spawn } from 'child_process'; +import http from 'http'; import net from 'net'; import path from 'path'; import fs from 'fs'; @@ -29,6 +30,43 @@ function isPortInUse(port: number): Promise { }); } +/** + * Wait until an HTTP GET to `url` returns a non-5xx response. + * + * webpack-dev-middleware holds every request open until the current compilation finishes, so a + * successful response guarantees the initial bundle is ready. Using this after `waitForPort` + * ensures Electron is not launched while webpack is still compiling (which would cause a + * mid-load HMR reload that closes the Playwright page reference). + * + * @param url URL to probe. + * @param timeout Maximum time in milliseconds to wait before rejecting. + * @returns Resolves when the server returns a non-5xx response. + * @throws {Error} If the server does not respond within `timeout` milliseconds. + */ +function waitForHttpOk(url: string, timeout: number): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + req.destroy(); + reject(new Error(`${url} did not respond within ${timeout}ms`)); + }, timeout); + + const req = http.get(url, (res) => { + clearTimeout(timer); + res.resume(); + if (res.statusCode !== undefined && res.statusCode < 500) { + resolve(); + } else { + reject(new Error(`HTTP ${res.statusCode} from ${url}`)); + } + }); + + req.on('error', (err) => { + clearTimeout(timer); + reject(err); + }); + }); +} + /** * Wait until a port is accepting connections. * @@ -151,7 +189,12 @@ export default async function globalSetup(_config: FullConfig): Promise { } console.log(`Waiting for renderer dev server on port ${RENDERER_PORT}...`); - await waitForPort(RENDERER_PORT, 120_000); + await waitForPort(RENDERER_PORT, 60_000); + console.log(`Port ${RENDERER_PORT} is accepting connections. Waiting for webpack compilation...`); + // webpack-dev-middleware blocks HTTP responses until the initial bundle is compiled. + // Waiting here ensures Electron loads a fully-compiled renderer and avoids a mid-load + // HMR full-reload that would close the Playwright page reference during tests. + await waitForHttpOk(`http://localhost:${RENDERER_PORT}/`, 300_000); console.log('Renderer dev server is ready.'); } } From 46bee55f4a7b40cd4cc3ab2f463c4681fe52a51a Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Fri, 22 May 2026 09:24:59 -0400 Subject: [PATCH 08/43] Retry on socket hang up in waitForHttpOk webpack-dev-server briefly closes connections before its middleware is fully attached, causing a socket hang up on the first HTTP probe. Retry on error (matching the pattern used by waitForPort) instead of rejecting immediately. The overallTimer still enforces the 5-minute budget and cancels any hanging request if webpack never finishes compiling. Co-Authored-By: Claude Sonnet 4.6 --- e2e-tests/global-setup.ts | 63 +++++++++++++++++++++++++++------------ 1 file changed, 44 insertions(+), 19 deletions(-) diff --git a/e2e-tests/global-setup.ts b/e2e-tests/global-setup.ts index aff4cbf8..1ed71d62 100644 --- a/e2e-tests/global-setup.ts +++ b/e2e-tests/global-setup.ts @@ -33,10 +33,10 @@ function isPortInUse(port: number): Promise { /** * Wait until an HTTP GET to `url` returns a non-5xx response. * - * webpack-dev-middleware holds every request open until the current compilation finishes, so a + * Webpack-dev-middleware holds every request open until the current compilation finishes, so a * successful response guarantees the initial bundle is ready. Using this after `waitForPort` - * ensures Electron is not launched while webpack is still compiling (which would cause a - * mid-load HMR reload that closes the Playwright page reference). + * ensures Electron is not launched while webpack is still compiling (which would cause a mid-load + * HMR reload that closes the Playwright page reference). * * @param url URL to probe. * @param timeout Maximum time in milliseconds to wait before rejecting. @@ -44,26 +44,49 @@ function isPortInUse(port: number): Promise { * @throws {Error} If the server does not respond within `timeout` milliseconds. */ function waitForHttpOk(url: string, timeout: number): Promise { + const startTime = Date.now(); return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - req.destroy(); - reject(new Error(`${url} did not respond within ${timeout}ms`)); + let done = false; + let currentReq: http.ClientRequest | undefined; + + const overallTimer = setTimeout(() => { + if (!done) { + done = true; + currentReq?.destroy(); + reject(new Error(`${url} did not respond within ${timeout}ms`)); + } }, timeout); - const req = http.get(url, (res) => { - clearTimeout(timer); - res.resume(); - if (res.statusCode !== undefined && res.statusCode < 500) { - resolve(); - } else { - reject(new Error(`HTTP ${res.statusCode} from ${url}`)); + const attempt = () => { + if (done) return; + if (Date.now() - startTime >= timeout) { + clearTimeout(overallTimer); + done = true; + reject(new Error(`${url} did not respond within ${timeout}ms`)); + return; } - }); - req.on('error', (err) => { - clearTimeout(timer); - reject(err); - }); + currentReq = http.get(url, (res) => { + if (done) { + res.resume(); + return; + } + clearTimeout(overallTimer); + done = true; + res.resume(); + if (res.statusCode !== undefined && res.statusCode < 500) { + resolve(); + } else { + reject(new Error(`HTTP ${res.statusCode} from ${url}`)); + } + }); + + currentReq.on('error', () => { + if (!done) setTimeout(attempt, 1_000); + }); + }; + + attempt(); }); } @@ -190,7 +213,9 @@ export default async function globalSetup(_config: FullConfig): Promise { console.log(`Waiting for renderer dev server on port ${RENDERER_PORT}...`); await waitForPort(RENDERER_PORT, 60_000); - console.log(`Port ${RENDERER_PORT} is accepting connections. Waiting for webpack compilation...`); + console.log( + `Port ${RENDERER_PORT} is accepting connections. Waiting for webpack compilation...`, + ); // webpack-dev-middleware blocks HTTP responses until the initial bundle is compiled. // Waiting here ensures Electron loads a fully-compiled renderer and avoids a mid-load // HMR full-reload that would close the Playwright page reference during tests. From 497a166eac05ef3d4dcf8d6f9a3f2c2b0c32f1d6 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Fri, 22 May 2026 09:43:52 -0400 Subject: [PATCH 09/43] Fix Jest setup resolution in CI and remove setup file --- jest.config.ts | 2 +- jest.setup.js | 5 ----- 2 files changed, 1 insertion(+), 6 deletions(-) delete mode 100644 jest.setup.js diff --git a/jest.config.ts b/jest.config.ts index 8ff09ddd..7b9624dd 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -109,7 +109,7 @@ const config: Config = { modulePathIgnorePatterns: ['/dist'], /** Load @testing-library/jest-dom matchers for React component tests. */ - setupFilesAfterEnv: ['/jest.setup.js'], + setupFilesAfterEnv: ['@testing-library/jest-dom'], /** Use jsdom for React component tests; parser tests run fine in jsdom (no DOM use). */ testEnvironment: 'jsdom', diff --git a/jest.setup.js b/jest.setup.js deleted file mode 100644 index f5b949ff..00000000 --- a/jest.setup.js +++ /dev/null @@ -1,5 +0,0 @@ -// Jest setup file. Runs before each test file. Extends expect with @testing-library/jest-dom -// matchers for React component tests. -// .js rather than .ts: Jest validates setupFilesAfterEnv paths via require.resolve before -// ts-jest's transform hooks are registered, so .ts paths can fail to resolve on Windows. -require('@testing-library/jest-dom'); From c9d9d3509729b4035a394feab7f0f70f23d4a06e Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Fri, 22 May 2026 09:52:33 -0400 Subject: [PATCH 10/43] Harden e2e readiness checks and CI artifact capture --- .github/workflows/test.yml | 10 +++++--- e2e-tests/global-setup.ts | 47 ++++++++++++++++++++++---------------- 2 files changed, 34 insertions(+), 23 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d1f4fd7c..ae05076c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -13,6 +13,7 @@ jobs: test: runs-on: ${{ matrix.os }} strategy: + fail-fast: false matrix: os: [ubuntu-latest, windows-latest] steps: @@ -51,6 +52,7 @@ jobs: e2e-smoke: runs-on: ${{ matrix.os }} strategy: + fail-fast: false matrix: os: [ubuntu-latest, windows-latest] steps: @@ -112,10 +114,12 @@ jobs: working-directory: extension-repo run: npm run test:e2e:smoke - - name: Upload Playwright report + - name: Upload Playwright artifacts uses: actions/upload-artifact@v4 if: always() with: - name: playwright-report-${{ matrix.os }} - path: extension-repo/e2e-tests/playwright-report/ + name: playwright-artifacts-${{ matrix.os }} + path: | + extension-repo/e2e-tests/playwright-report/ + extension-repo/e2e-tests/test-results/ retention-days: 7 diff --git a/e2e-tests/global-setup.ts b/e2e-tests/global-setup.ts index 1ed71d62..04062bad 100644 --- a/e2e-tests/global-setup.ts +++ b/e2e-tests/global-setup.ts @@ -33,10 +33,8 @@ function isPortInUse(port: number): Promise { /** * Wait until an HTTP GET to `url` returns a non-5xx response. * - * Webpack-dev-middleware holds every request open until the current compilation finishes, so a - * successful response guarantees the initial bundle is ready. Using this after `waitForPort` - * ensures Electron is not launched while webpack is still compiling (which would cause a mid-load - * HMR reload that closes the Playwright page reference). + * Webpack-dev-middleware holds requests open until the initial compilation finishes, so a + * successful response guarantees the initial renderer bundle is ready. * * @param url URL to probe. * @param timeout Maximum time in milliseconds to wait before rejecting. @@ -49,36 +47,46 @@ function waitForHttpOk(url: string, timeout: number): Promise { let done = false; let currentReq: http.ClientRequest | undefined; + const fail = (message: string) => { + if (done) return; + done = true; + currentReq?.destroy(); + reject(new Error(message)); + }; + const overallTimer = setTimeout(() => { - if (!done) { - done = true; - currentReq?.destroy(); - reject(new Error(`${url} did not respond within ${timeout}ms`)); - } + fail(`${url} did not respond within ${timeout}ms`); }, timeout); const attempt = () => { if (done) return; if (Date.now() - startTime >= timeout) { clearTimeout(overallTimer); - done = true; - reject(new Error(`${url} did not respond within ${timeout}ms`)); + fail(`${url} did not respond within ${timeout}ms`); return; } - currentReq = http.get(url, (res) => { + currentReq = http.get(url, { headers: { Connection: 'close' } }, (res) => { if (done) { res.resume(); return; } - clearTimeout(overallTimer); - done = true; + res.resume(); + if (res.statusCode !== undefined && res.statusCode < 500) { + clearTimeout(overallTimer); + done = true; resolve(); - } else { - reject(new Error(`HTTP ${res.statusCode} from ${url}`)); + return; } + + // Retry transient dev-server responses while staying within overall timeout budget. + setTimeout(attempt, 1_000); + }); + + currentReq.setTimeout(15_000, () => { + currentReq?.destroy(new Error('HTTP readiness probe timed out')); }); currentReq.on('error', () => { @@ -216,10 +224,9 @@ export default async function globalSetup(_config: FullConfig): Promise { console.log( `Port ${RENDERER_PORT} is accepting connections. Waiting for webpack compilation...`, ); - // webpack-dev-middleware blocks HTTP responses until the initial bundle is compiled. - // Waiting here ensures Electron loads a fully-compiled renderer and avoids a mid-load - // HMR full-reload that would close the Playwright page reference during tests. - await waitForHttpOk(`http://localhost:${RENDERER_PORT}/`, 300_000); + // Wait for the renderer index to serve successfully so Electron does not attach during + // an in-progress compilation window. + await waitForHttpOk(`http://127.0.0.1:${RENDERER_PORT}/index.html`, 300_000); console.log('Renderer dev server is ready.'); } } From 3d4dcedb903789c7bf008d74b390ef0105121fae Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Fri, 22 May 2026 10:04:38 -0400 Subject: [PATCH 11/43] Fix Windows Jest setup resolution in CI --- .github/workflows/test.yml | 4 ++-- jest.config.ts | 9 +++++++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ae05076c..4c0e2894 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -39,7 +39,7 @@ jobs: - name: Install extension dependencies working-directory: extension-repo - run: npm ci --ignore-scripts --omit=optional + run: npm ci --ignore-scripts --omit=optional --include=dev - name: Install core dependencies working-directory: paranext-core @@ -78,7 +78,7 @@ jobs: - name: Install extension dependencies working-directory: extension-repo - run: npm ci --ignore-scripts + run: npm ci --ignore-scripts --include=dev - name: Install core dependencies working-directory: paranext-core diff --git a/jest.config.ts b/jest.config.ts index 7b9624dd..38af9c55 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -108,8 +108,13 @@ const config: Config = { /** Exclude dist from module resolution to avoid Haste naming collision with root package.json. */ modulePathIgnorePatterns: ['/dist'], - /** Load @testing-library/jest-dom matchers for React component tests. */ - setupFilesAfterEnv: ['@testing-library/jest-dom'], + /** + * Load jest-dom matchers for React tests. + * + * Using the concrete file path avoids occasional package-name resolution failures during Jest's + * config validation on Windows CI. + */ + setupFilesAfterEnv: ['/node_modules/@testing-library/jest-dom/dist/index.js'], /** Use jsdom for React component tests; parser tests run fine in jsdom (no DOM use). */ testEnvironment: 'jsdom', From ee8243b5605e1d5b659454850a1b269c9b679004 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Fri, 22 May 2026 10:08:55 -0400 Subject: [PATCH 12/43] Relax CI e2e readiness gating and capture renderer logs --- .github/workflows/test.yml | 1 + e2e-tests/global-setup.ts | 23 +++++++++++++++++++---- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4c0e2894..89e5e2f6 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -122,4 +122,5 @@ jobs: path: | extension-repo/e2e-tests/playwright-report/ extension-repo/e2e-tests/test-results/ + extension-repo/e2e-tests/test-results/renderer-dev-server.log retention-days: 7 diff --git a/e2e-tests/global-setup.ts b/e2e-tests/global-setup.ts index 04062bad..8ec18583 100644 --- a/e2e-tests/global-setup.ts +++ b/e2e-tests/global-setup.ts @@ -149,6 +149,9 @@ function waitForPort(port: number, timeout: number): Promise { export default async function globalSetup(_config: FullConfig): Promise { const extensionRoot = path.resolve(__dirname, '..'); const coreDir = path.resolve(__dirname, '../../paranext-core'); + const testResultsDir = path.join(extensionRoot, 'e2e-tests/test-results'); + fs.mkdirSync(testResultsDir, { recursive: true }); + const rendererLogPath = path.join(testResultsDir, 'renderer-dev-server.log'); // Fail fast if Platform.Bible is already running (single-instance lock will // cause Playwright's Electron instance to exit immediately) @@ -204,9 +207,10 @@ export default async function globalSetup(_config: FullConfig): Promise { console.log(`Renderer dev server already running on port ${RENDERER_PORT}.`); } else { console.log('Starting paranext-core renderer dev server...'); + const rendererLogFd = fs.openSync(rendererLogPath, 'w'); const devServer = spawn('npm', ['run', 'start:renderer'], { cwd: coreDir, - stdio: 'ignore', + stdio: ['ignore', rendererLogFd, rendererLogFd], shell: true, detached: true, env: { ...process.env, ELECTRON_RUN_AS_NODE: undefined, SKIP_START_MAIN: '1' }, @@ -224,9 +228,20 @@ export default async function globalSetup(_config: FullConfig): Promise { console.log( `Port ${RENDERER_PORT} is accepting connections. Waiting for webpack compilation...`, ); - // Wait for the renderer index to serve successfully so Electron does not attach during - // an in-progress compilation window. - await waitForHttpOk(`http://127.0.0.1:${RENDERER_PORT}/index.html`, 300_000); + // Wait for webpack-dev-middleware to begin serving renderer assets before launching Electron. + // In CI we degrade to a warning if this probe times out, since the dev server can still finish + // compiling shortly after Electron starts. + try { + await waitForHttpOk(`http://127.0.0.1:${RENDERER_PORT}/dist/renderer.dev.js`, 120_000); + } catch (error) { + if (!process.env.CI) throw error; + const message = + error instanceof Error ? error.message : 'Unknown renderer readiness probe failure'; + console.warn( + `Renderer HTTP readiness probe timed out in CI: ${message}. Continuing with port-only readiness.`, + ); + console.warn(`Renderer dev server logs: ${rendererLogPath}`); + } console.log('Renderer dev server is ready.'); } } From 1e0dab9467d3477367698763dd97ea802e3b7b5d Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Fri, 22 May 2026 10:16:41 -0400 Subject: [PATCH 13/43] Stabilize CI install and verify jest-dom presence --- .github/workflows/test.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 89e5e2f6..8cdf8fdc 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -39,12 +39,16 @@ jobs: - name: Install extension dependencies working-directory: extension-repo - run: npm ci --ignore-scripts --omit=optional --include=dev + run: npm ci --ignore-scripts - name: Install core dependencies working-directory: paranext-core run: npm ci --ignore-scripts --omit=optional + - name: Verify jest-dom dependency + working-directory: extension-repo + run: npm ls @testing-library/jest-dom --depth=0 + - name: Run tests working-directory: extension-repo run: npm run test:coverage From 115bdb35d7ec32d46baf3fbee54100d905a8e5a5 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Fri, 22 May 2026 10:37:50 -0400 Subject: [PATCH 14/43] Retry main page load when Electron opens chrome-error --- e2e-tests/fixtures/app.fixture.ts | 41 ++++++++++++++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/e2e-tests/fixtures/app.fixture.ts b/e2e-tests/fixtures/app.fixture.ts index 19ab01b5..80c6070c 100644 --- a/e2e-tests/fixtures/app.fixture.ts +++ b/e2e-tests/fixtures/app.fixture.ts @@ -59,7 +59,46 @@ export const test = base.extend({ page.on('console', onConsoleMsg); await page.waitForLoadState('domcontentloaded'); - await page.waitForSelector('#root', { state: 'attached', timeout: PROCESS_READY_TIMEOUT }); + + const readyDeadline = Date.now() + PROCESS_READY_TIMEOUT; + let rootAttached = false; + + while (Date.now() < readyDeadline) { + const currentUrl = page.url(); + + // CI can intermittently land on chrome-error://chromewebdata/ if the renderer dev server + // is still finishing compilation. Re-navigate and retry until React mounts or timeout. + if (currentUrl.startsWith('chrome-error://') || currentUrl === 'about:blank') { + try { + await page.goto('http://127.0.0.1:1212/', { + waitUntil: 'domcontentloaded', + timeout: 10_000, + }); + } catch { + // Retry loop handles transient dev-server unavailability. + } + } + + const remaining = Math.max(0, readyDeadline - Date.now()); + if (remaining <= 0) break; + + try { + await page.waitForSelector('#root', { + state: 'attached', + timeout: Math.min(5_000, remaining), + }); + rootAttached = true; + break; + } catch { + // Keep retrying until deadline. + } + } + + if (!rootAttached) { + throw new Error( + `Main renderer did not mount #root within ${PROCESS_READY_TIMEOUT}ms (current URL: ${page.url()})`, + ); + } await use(page); From 796360ec41548a081440da396db8b9b3e6a343e4 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Fri, 22 May 2026 10:51:53 -0400 Subject: [PATCH 15/43] Try without explicit path --- jest.config.ts | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/jest.config.ts b/jest.config.ts index 38af9c55..75dd396f 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -108,13 +108,8 @@ const config: Config = { /** Exclude dist from module resolution to avoid Haste naming collision with root package.json. */ modulePathIgnorePatterns: ['/dist'], - /** - * Load jest-dom matchers for React tests. - * - * Using the concrete file path avoids occasional package-name resolution failures during Jest's - * config validation on Windows CI. - */ - setupFilesAfterEnv: ['/node_modules/@testing-library/jest-dom/dist/index.js'], + /** Load jest-dom matchers for React tests. */ + setupFilesAfterEnv: ['@testing-library/jest-dom'], /** Use jsdom for React component tests; parser tests run fine in jsdom (no DOM use). */ testEnvironment: 'jsdom', From aad2b2567bd9985928f39174c703d2686ff2c30a Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Fri, 22 May 2026 10:56:00 -0400 Subject: [PATCH 16/43] Make smoke tests wait for the real renderer URL --- e2e-tests/fixtures/app.fixture.ts | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/e2e-tests/fixtures/app.fixture.ts b/e2e-tests/fixtures/app.fixture.ts index 80c6070c..a942e318 100644 --- a/e2e-tests/fixtures/app.fixture.ts +++ b/e2e-tests/fixtures/app.fixture.ts @@ -49,6 +49,7 @@ export const test = base.extend({ mainPage: async ({ electronApp }, use, testInfo: TestInfo) => { const page = await electronApp.firstWindow({ timeout: PROCESS_READY_TIMEOUT }); + const rendererUrl = 'http://localhost:1212/index.html?logLevel=debug'; console.log(`Window URL: ${page.url()}`); const onPageError = (err: Error) => console.error(`Page error: ${err.message}`); @@ -58,21 +59,24 @@ export const test = base.extend({ page.on('pageerror', onPageError); page.on('console', onConsoleMsg); - await page.waitForLoadState('domcontentloaded'); - const readyDeadline = Date.now() + PROCESS_READY_TIMEOUT; let rootAttached = false; while (Date.now() < readyDeadline) { const currentUrl = page.url(); - // CI can intermittently land on chrome-error://chromewebdata/ if the renderer dev server - // is still finishing compilation. Re-navigate and retry until React mounts or timeout. - if (currentUrl.startsWith('chrome-error://') || currentUrl === 'about:blank') { + // CI can intermittently land on chrome-error://chromewebdata/ if Electron opens before the + // renderer navigation has fully settled. Drive the page back to the actual renderer URL and + // retry until React mounts or timeout. + if ( + currentUrl.startsWith('chrome-error://') || + currentUrl === 'about:blank' || + !currentUrl.startsWith('http://localhost:1212/') + ) { try { - await page.goto('http://127.0.0.1:1212/', { + await page.goto(rendererUrl, { waitUntil: 'domcontentloaded', - timeout: 10_000, + timeout: 15_000, }); } catch { // Retry loop handles transient dev-server unavailability. @@ -83,6 +87,7 @@ export const test = base.extend({ if (remaining <= 0) break; try { + await page.waitForLoadState('domcontentloaded'); await page.waitForSelector('#root', { state: 'attached', timeout: Math.min(5_000, remaining), @@ -100,6 +105,8 @@ export const test = base.extend({ ); } + console.log(`Window URL: ${page.url()}`); + await use(page); page.off('pageerror', onPageError); From 8f2eff743e7060e206e4177944b72cddac2759e3 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Fri, 22 May 2026 11:22:13 -0400 Subject: [PATCH 17/43] Wait for renderer window by URL in smoke tests --- e2e-tests/fixtures/app.fixture.ts | 135 +++++++++++++++++------------- 1 file changed, 75 insertions(+), 60 deletions(-) diff --git a/e2e-tests/fixtures/app.fixture.ts b/e2e-tests/fixtures/app.fixture.ts index a942e318..68a03031 100644 --- a/e2e-tests/fixtures/app.fixture.ts +++ b/e2e-tests/fixtures/app.fixture.ts @@ -48,82 +48,97 @@ export const test = base.extend({ ], mainPage: async ({ electronApp }, use, testInfo: TestInfo) => { - const page = await electronApp.firstWindow({ timeout: PROCESS_READY_TIMEOUT }); const rendererUrl = 'http://localhost:1212/index.html?logLevel=debug'; + const readyDeadline = Date.now() + PROCESS_READY_TIMEOUT; - console.log(`Window URL: ${page.url()}`); const onPageError = (err: Error) => console.error(`Page error: ${err.message}`); const onConsoleMsg = (msg: ConsoleMessage) => { if (msg.type() === 'error') console.error(`Console error: ${msg.text()}`); }; - page.on('pageerror', onPageError); - page.on('console', onConsoleMsg); - const readyDeadline = Date.now() + PROCESS_READY_TIMEOUT; - let rootAttached = false; + const attachListeners = (page: Page) => { + page.on('pageerror', onPageError); + page.on('console', onConsoleMsg); + }; + + const detachListeners = (page: Page) => { + page.off('pageerror', onPageError); + page.off('console', onConsoleMsg); + }; + let page: Page | undefined; while (Date.now() < readyDeadline) { - const currentUrl = page.url(); - - // CI can intermittently land on chrome-error://chromewebdata/ if Electron opens before the - // renderer navigation has fully settled. Drive the page back to the actual renderer URL and - // retry until React mounts or timeout. - if ( - currentUrl.startsWith('chrome-error://') || - currentUrl === 'about:blank' || - !currentUrl.startsWith('http://localhost:1212/') - ) { + const pages = electronApp.windows(); + page = + pages.find((candidate) => { + const candidateUrl = candidate.url(); + return ( + candidateUrl.startsWith('http://localhost:1212/') && + !candidateUrl.includes('devtools://') + ); + }) ?? pages.find((candidate) => !candidate.url().includes('devtools://')); + + if (page) { + attachListeners(page); + try { - await page.goto(rendererUrl, { - waitUntil: 'domcontentloaded', - timeout: 15_000, + const currentUrl = page.url(); + console.log(`Window URL: ${currentUrl}`); + + // CI can intermittently land on chrome-error://chromewebdata/ if Electron opens before the + // renderer navigation has fully settled. Drive the page back to the actual renderer URL and + // retry until React mounts or timeout. + if ( + currentUrl.startsWith('chrome-error://') || + currentUrl === 'about:blank' || + !currentUrl.startsWith('http://localhost:1212/') + ) { + await page.goto(rendererUrl, { + waitUntil: 'domcontentloaded', + timeout: 15_000, + }); + } + + const remaining = Math.max(0, readyDeadline - Date.now()); + if (remaining <= 0) break; + + await page.waitForLoadState('domcontentloaded'); + await page.waitForSelector('#root', { + state: 'attached', + timeout: Math.min(5_000, remaining), }); + + console.log(`Window URL: ${page.url()}`); + await use(page); + detachListeners(page); + + if (testInfo.status !== testInfo.expectedStatus) { + const screenshotPath = testInfo.outputPath('failure.png'); + try { + await page.screenshot({ path: screenshotPath, fullPage: true }); + await testInfo.attach('failure-screenshot', { + path: screenshotPath, + contentType: 'image/png', + }); + console.log(`Failure screenshot saved to ${screenshotPath}`); + } catch { + console.warn('Could not capture failure screenshot (window may already be closed)'); + } + } + return; } catch { - // Retry loop handles transient dev-server unavailability. + detachListeners(page); + page = undefined; } } - const remaining = Math.max(0, readyDeadline - Date.now()); - if (remaining <= 0) break; - - try { - await page.waitForLoadState('domcontentloaded'); - await page.waitForSelector('#root', { - state: 'attached', - timeout: Math.min(5_000, remaining), - }); - rootAttached = true; - break; - } catch { - // Keep retrying until deadline. - } + await new Promise((resolve) => { + setTimeout(resolve, 500); + }); } - if (!rootAttached) { - throw new Error( - `Main renderer did not mount #root within ${PROCESS_READY_TIMEOUT}ms (current URL: ${page.url()})`, - ); - } - - console.log(`Window URL: ${page.url()}`); - - await use(page); - - page.off('pageerror', onPageError); - page.off('console', onConsoleMsg); - - if (testInfo.status !== testInfo.expectedStatus) { - const screenshotPath = testInfo.outputPath('failure.png'); - try { - await page.screenshot({ path: screenshotPath, fullPage: true }); - await testInfo.attach('failure-screenshot', { - path: screenshotPath, - contentType: 'image/png', - }); - console.log(`Failure screenshot saved to ${screenshotPath}`); - } catch { - console.warn('Could not capture failure screenshot (window may already be closed)'); - } - } + throw new Error( + `Main renderer did not mount #root within ${PROCESS_READY_TIMEOUT}ms (last URL: ${page?.url() ?? 'no window'})`, + ); }, }); From 6818273c4475715706611e06bf71ea1323c222eb Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Fri, 22 May 2026 11:42:24 -0400 Subject: [PATCH 18/43] Use BrowserWindow.loadURL to escape chrome-error page in CI --- e2e-tests/fixtures/app.fixture.ts | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/e2e-tests/fixtures/app.fixture.ts b/e2e-tests/fixtures/app.fixture.ts index 68a03031..92ddadb6 100644 --- a/e2e-tests/fixtures/app.fixture.ts +++ b/e2e-tests/fixtures/app.fixture.ts @@ -85,17 +85,22 @@ export const test = base.extend({ const currentUrl = page.url(); console.log(`Window URL: ${currentUrl}`); - // CI can intermittently land on chrome-error://chromewebdata/ if Electron opens before the - // renderer navigation has fully settled. Drive the page back to the actual renderer URL and - // retry until React mounts or timeout. + // CI can intermittently land on chrome-error://chromewebdata/ when Electron opens before the + // renderer is fully ready. Playwright's page.goto() cannot navigate away from chrome-error:// + // pages in some Electron configurations — it throws immediately. Use Electron's main-process + // BrowserWindow.loadURL() instead, which can force-reload the window from any URL state. if ( currentUrl.startsWith('chrome-error://') || currentUrl === 'about:blank' || !currentUrl.startsWith('http://localhost:1212/') ) { - await page.goto(rendererUrl, { - waitUntil: 'domcontentloaded', - timeout: 15_000, + await electronApp.evaluate(({ BrowserWindow }, url) => { + const win = BrowserWindow.getAllWindows().find((w) => !w.isDestroyed()); + if (win) win.loadURL(url).catch(() => {}); + }, rendererUrl); + // Allow the navigation to start before we poll loadState. + await new Promise((resolve) => { + setTimeout(resolve, 1_000); }); } From ce2bc7b1811c308352a23396a5b004da3860cc05 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Fri, 22 May 2026 11:53:44 -0400 Subject: [PATCH 19/43] Fix renderer readiness probe URL and remove CI degradation --- e2e-tests/global-setup.ts | 19 +++++-------------- 1 file changed, 5 insertions(+), 14 deletions(-) diff --git a/e2e-tests/global-setup.ts b/e2e-tests/global-setup.ts index 8ec18583..f8546954 100644 --- a/e2e-tests/global-setup.ts +++ b/e2e-tests/global-setup.ts @@ -228,20 +228,11 @@ export default async function globalSetup(_config: FullConfig): Promise { console.log( `Port ${RENDERER_PORT} is accepting connections. Waiting for webpack compilation...`, ); - // Wait for webpack-dev-middleware to begin serving renderer assets before launching Electron. - // In CI we degrade to a warning if this probe times out, since the dev server can still finish - // compiling shortly after Electron starts. - try { - await waitForHttpOk(`http://127.0.0.1:${RENDERER_PORT}/dist/renderer.dev.js`, 120_000); - } catch (error) { - if (!process.env.CI) throw error; - const message = - error instanceof Error ? error.message : 'Unknown renderer readiness probe failure'; - console.warn( - `Renderer HTTP readiness probe timed out in CI: ${message}. Continuing with port-only readiness.`, - ); - console.warn(`Renderer dev server logs: ${rendererLogPath}`); - } + // webpack-dev-middleware holds every request open until the initial compilation finishes, so + // a successful HTTP response guarantees the renderer bundle is ready. The probe URL must match + // the webpack output.publicPath ('/') — the bundle is served at /renderer.dev.js, not under + // /dist/. CI compilation can take several minutes, so allow up to 10 minutes. + await waitForHttpOk(`http://127.0.0.1:${RENDERER_PORT}/renderer.dev.js`, 600_000); console.log('Renderer dev server is ready.'); } } From 6189dc4fce9239734403dc5d615ba6288ee0c314 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Fri, 22 May 2026 12:17:04 -0400 Subject: [PATCH 20/43] Fix CI smoke setup and upload sanitized renderer logs --- .github/workflows/test.yml | 69 +++++++++++++++++++++++++++++++++-- e2e-tests/fixtures/helpers.ts | 2 +- e2e-tests/global-setup.ts | 20 +++++++--- 3 files changed, 82 insertions(+), 9 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8cdf8fdc..a63dddb4 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -43,7 +43,8 @@ jobs: - name: Install core dependencies working-directory: paranext-core - run: npm ci --ignore-scripts --omit=optional + # Do not omit optional deps here: extensions/watch needs lightningcss native binaries. + run: npm ci --ignore-scripts - name: Verify jest-dom dependency working-directory: extension-repo @@ -118,13 +119,75 @@ jobs: working-directory: extension-repo run: npm run test:e2e:smoke + - name: Redact renderer dev server log + if: always() + shell: pwsh + run: | + $rawLog = "extension-repo/e2e-tests/test-results/renderer-dev-server.log" + $sanitizedLog = "extension-repo/e2e-tests/test-results/renderer-dev-server.sanitized.log" + if (-not (Test-Path $rawLog)) { + Write-Host "Renderer log file not found: $rawLog" + exit 0 + } + + # Redact common secret patterns before printing/uploading logs. + $content = Get-Content -Path $rawLog -Raw + $patterns = @( + @{ Regex = '(?i)(authorization\s*[:=]\s*)(?:bearer\s+)?[^\s"'']+'; Replacement = '$1[REDACTED]' }, + @{ Regex = '(?i)(x-api-key\s*[:=]\s*)[^\s"'']+'; Replacement = '$1[REDACTED]' }, + @{ Regex = '(?i)(token\s*[:=]\s*)[^\s"'']+'; Replacement = '$1[REDACTED]' }, + @{ Regex = '(?i)(secret\s*[:=]\s*)[^\s"'']+'; Replacement = '$1[REDACTED]' }, + @{ Regex = '(?i)(password\s*[:=]\s*)[^\s"'']+'; Replacement = '$1[REDACTED]' }, + @{ Regex = '(?i)(client_secret\s*[:=]\s*)[^\s"'']+'; Replacement = '$1[REDACTED]' }, + @{ Regex = '(?i)(access[_-]?token\s*[:=]\s*)[^\s"'']+'; Replacement = '$1[REDACTED]' }, + @{ Regex = '(?i)(refresh[_-]?token\s*[:=]\s*)[^\s"'']+'; Replacement = '$1[REDACTED]' }, + @{ Regex = '(?i)(api[_-]?key\s*[:=]\s*)[^\s"'']+'; Replacement = '$1[REDACTED]' }, + @{ Regex = '(?i)(-----BEGIN [A-Z ]+PRIVATE KEY-----)(.|\n|\r)*?(-----END [A-Z ]+PRIVATE KEY-----)'; Replacement = '$1[REDACTED]$3' } + ) + + foreach ($pattern in $patterns) { + $content = [System.Text.RegularExpressions.Regex]::Replace( + $content, + $pattern.Regex, + $pattern.Replacement, + [System.Text.RegularExpressions.RegexOptions]::IgnoreCase + ) + } + + Set-Content -Path $sanitizedLog -Value $content -NoNewline + Write-Host "Sanitized renderer log written to: $sanitizedLog" + + - name: Show renderer dev server log tail (Linux) + if: failure() && matrix.os == 'ubuntu-latest' + run: | + LOG_PATH="extension-repo/e2e-tests/test-results/renderer-dev-server.sanitized.log" + if [ -f "$LOG_PATH" ]; then + echo "=== Renderer log tail: $LOG_PATH ===" + tail -n 200 "$LOG_PATH" + else + echo "Renderer log file not found: $LOG_PATH" + fi + + - name: Show renderer dev server log tail (Windows) + if: failure() && matrix.os == 'windows-latest' + shell: pwsh + run: | + $logPath = "extension-repo/e2e-tests/test-results/renderer-dev-server.sanitized.log" + if (Test-Path $logPath) { + Write-Host "=== Renderer log tail: $logPath ===" + Get-Content -Path $logPath -Tail 200 + } else { + Write-Host "Renderer log file not found: $logPath" + } + - name: Upload Playwright artifacts uses: actions/upload-artifact@v4 if: always() with: name: playwright-artifacts-${{ matrix.os }} path: | - extension-repo/e2e-tests/playwright-report/ + extension-repo/playwright-report/ extension-repo/e2e-tests/test-results/ - extension-repo/e2e-tests/test-results/renderer-dev-server.log + extension-repo/e2e-tests/test-results/renderer-dev-server.sanitized.log + if-no-files-found: warn retention-days: 7 diff --git a/e2e-tests/fixtures/helpers.ts b/e2e-tests/fixtures/helpers.ts index a2cbe22c..a577928a 100644 --- a/e2e-tests/fixtures/helpers.ts +++ b/e2e-tests/fixtures/helpers.ts @@ -8,7 +8,7 @@ import WebSocket from 'ws'; const DEFAULT_WEBSOCKET_PORT = 8876; const RPC_DISCOVER_POLL_INTERVAL_MS = 250; -export const PROCESS_READY_TIMEOUT = 120_000; +export const PROCESS_READY_TIMEOUT = process.env.CI ? 600_000 : 120_000; /** * Same serialized request type as `registerCommand('platform.about', ...)` in command.service diff --git a/e2e-tests/global-setup.ts b/e2e-tests/global-setup.ts index f8546954..6e34211c 100644 --- a/e2e-tests/global-setup.ts +++ b/e2e-tests/global-setup.ts @@ -228,11 +228,21 @@ export default async function globalSetup(_config: FullConfig): Promise { console.log( `Port ${RENDERER_PORT} is accepting connections. Waiting for webpack compilation...`, ); - // webpack-dev-middleware holds every request open until the initial compilation finishes, so - // a successful HTTP response guarantees the renderer bundle is ready. The probe URL must match - // the webpack output.publicPath ('/') — the bundle is served at /renderer.dev.js, not under - // /dist/. CI compilation can take several minutes, so allow up to 10 minutes. - await waitForHttpOk(`http://127.0.0.1:${RENDERER_PORT}/renderer.dev.js`, 600_000); + // webpack-dev-middleware holds requests open until initial compilation finishes. Probe a + // renderer URL to opportunistically wait for compilation, but do not hard-fail CI on this + // probe because CI runners can be noisy and late-compiling; the fixture has a longer CI-ready + // timeout and will keep waiting for the renderer window to recover. + try { + await waitForHttpOk(`http://127.0.0.1:${RENDERER_PORT}/`, 120_000); + } catch (error) { + if (!process.env.CI) throw error; + const message = + error instanceof Error ? error.message : 'Unknown renderer readiness probe failure'; + console.warn( + `Renderer HTTP readiness probe timed out in CI: ${message}. Continuing with port-only readiness.`, + ); + console.warn(`Renderer dev server logs: ${rendererLogPath}`); + } console.log('Renderer dev server is ready.'); } } From 0000f738efa49c286067198d4eecdfb5c695a937 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Fri, 22 May 2026 13:05:37 -0400 Subject: [PATCH 21/43] Install lightningcss binary for e2e smoke CI --- .github/workflows/test.yml | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a63dddb4..6b41c23a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -46,6 +46,11 @@ jobs: # Do not omit optional deps here: extensions/watch needs lightningcss native binaries. run: npm ci --ignore-scripts + - name: Install Linux lightningcss native binary + if: matrix.os == 'ubuntu-latest' + working-directory: paranext-core + run: npm install --ignore-scripts --no-save lightningcss-linux-x64-gnu@1.32.0 + - name: Verify jest-dom dependency working-directory: extension-repo run: npm ls @testing-library/jest-dom --depth=0 @@ -87,7 +92,13 @@ jobs: - name: Install core dependencies working-directory: paranext-core - run: npm ci --ignore-scripts --omit=optional + # Do not omit optional deps here: extensions/watch needs lightningcss native binaries. + run: npm ci --ignore-scripts + + - name: Install Linux lightningcss native binary + if: matrix.os == 'ubuntu-latest' + working-directory: paranext-core + run: npm install --ignore-scripts --no-save lightningcss-linux-x64-gnu@1.32.0 - name: Install Electron binary working-directory: paranext-core From 9ed39e2a8010e0827457570f2ee47563112c0a0e Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Fri, 22 May 2026 13:13:54 -0400 Subject: [PATCH 22/43] Remove renderer dev server log workflow steps --- .github/workflows/test.yml | 62 -------------------------------------- 1 file changed, 62 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6b41c23a..0e3bd389 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -130,67 +130,6 @@ jobs: working-directory: extension-repo run: npm run test:e2e:smoke - - name: Redact renderer dev server log - if: always() - shell: pwsh - run: | - $rawLog = "extension-repo/e2e-tests/test-results/renderer-dev-server.log" - $sanitizedLog = "extension-repo/e2e-tests/test-results/renderer-dev-server.sanitized.log" - if (-not (Test-Path $rawLog)) { - Write-Host "Renderer log file not found: $rawLog" - exit 0 - } - - # Redact common secret patterns before printing/uploading logs. - $content = Get-Content -Path $rawLog -Raw - $patterns = @( - @{ Regex = '(?i)(authorization\s*[:=]\s*)(?:bearer\s+)?[^\s"'']+'; Replacement = '$1[REDACTED]' }, - @{ Regex = '(?i)(x-api-key\s*[:=]\s*)[^\s"'']+'; Replacement = '$1[REDACTED]' }, - @{ Regex = '(?i)(token\s*[:=]\s*)[^\s"'']+'; Replacement = '$1[REDACTED]' }, - @{ Regex = '(?i)(secret\s*[:=]\s*)[^\s"'']+'; Replacement = '$1[REDACTED]' }, - @{ Regex = '(?i)(password\s*[:=]\s*)[^\s"'']+'; Replacement = '$1[REDACTED]' }, - @{ Regex = '(?i)(client_secret\s*[:=]\s*)[^\s"'']+'; Replacement = '$1[REDACTED]' }, - @{ Regex = '(?i)(access[_-]?token\s*[:=]\s*)[^\s"'']+'; Replacement = '$1[REDACTED]' }, - @{ Regex = '(?i)(refresh[_-]?token\s*[:=]\s*)[^\s"'']+'; Replacement = '$1[REDACTED]' }, - @{ Regex = '(?i)(api[_-]?key\s*[:=]\s*)[^\s"'']+'; Replacement = '$1[REDACTED]' }, - @{ Regex = '(?i)(-----BEGIN [A-Z ]+PRIVATE KEY-----)(.|\n|\r)*?(-----END [A-Z ]+PRIVATE KEY-----)'; Replacement = '$1[REDACTED]$3' } - ) - - foreach ($pattern in $patterns) { - $content = [System.Text.RegularExpressions.Regex]::Replace( - $content, - $pattern.Regex, - $pattern.Replacement, - [System.Text.RegularExpressions.RegexOptions]::IgnoreCase - ) - } - - Set-Content -Path $sanitizedLog -Value $content -NoNewline - Write-Host "Sanitized renderer log written to: $sanitizedLog" - - - name: Show renderer dev server log tail (Linux) - if: failure() && matrix.os == 'ubuntu-latest' - run: | - LOG_PATH="extension-repo/e2e-tests/test-results/renderer-dev-server.sanitized.log" - if [ -f "$LOG_PATH" ]; then - echo "=== Renderer log tail: $LOG_PATH ===" - tail -n 200 "$LOG_PATH" - else - echo "Renderer log file not found: $LOG_PATH" - fi - - - name: Show renderer dev server log tail (Windows) - if: failure() && matrix.os == 'windows-latest' - shell: pwsh - run: | - $logPath = "extension-repo/e2e-tests/test-results/renderer-dev-server.sanitized.log" - if (Test-Path $logPath) { - Write-Host "=== Renderer log tail: $logPath ===" - Get-Content -Path $logPath -Tail 200 - } else { - Write-Host "Renderer log file not found: $logPath" - } - - name: Upload Playwright artifacts uses: actions/upload-artifact@v4 if: always() @@ -199,6 +138,5 @@ jobs: path: | extension-repo/playwright-report/ extension-repo/e2e-tests/test-results/ - extension-repo/e2e-tests/test-results/renderer-dev-server.sanitized.log if-no-files-found: warn retention-days: 7 From c960d3f74b49df85d4ce583c14ab4a740315d395 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Fri, 22 May 2026 13:21:02 -0400 Subject: [PATCH 23/43] Nix possibly redundant stuff --- .github/workflows/test.yml | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 0e3bd389..215110f8 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -43,13 +43,7 @@ jobs: - name: Install core dependencies working-directory: paranext-core - # Do not omit optional deps here: extensions/watch needs lightningcss native binaries. - run: npm ci --ignore-scripts - - - name: Install Linux lightningcss native binary - if: matrix.os == 'ubuntu-latest' - working-directory: paranext-core - run: npm install --ignore-scripts --no-save lightningcss-linux-x64-gnu@1.32.0 + run: npm ci --ignore-scripts --include=optional - name: Verify jest-dom dependency working-directory: extension-repo @@ -92,12 +86,15 @@ jobs: - name: Install core dependencies working-directory: paranext-core - # Do not omit optional deps here: extensions/watch needs lightningcss native binaries. + # Keep optional deps enabled; the lockfile includes the native lightningcss package that + # extensions/watch needs during the renderer/extensions build. run: npm ci --ignore-scripts - name: Install Linux lightningcss native binary if: matrix.os == 'ubuntu-latest' working-directory: paranext-core + # Belt-and-suspenders install for GitHub Actions Ubuntu runners, where the native binary + # has been observed missing even after npm ci. run: npm install --ignore-scripts --no-save lightningcss-linux-x64-gnu@1.32.0 - name: Install Electron binary From d054d541b4a36a36e881f383eba2a49a316f87ad Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Fri, 22 May 2026 13:26:50 -0400 Subject: [PATCH 24/43] Nix more possibly extraneous things --- .github/workflows/test.yml | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 215110f8..2c314c99 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -43,7 +43,7 @@ jobs: - name: Install core dependencies working-directory: paranext-core - run: npm ci --ignore-scripts --include=optional + run: npm ci --ignore-scripts --omit=optional - name: Verify jest-dom dependency working-directory: extension-repo @@ -86,16 +86,7 @@ jobs: - name: Install core dependencies working-directory: paranext-core - # Keep optional deps enabled; the lockfile includes the native lightningcss package that - # extensions/watch needs during the renderer/extensions build. - run: npm ci --ignore-scripts - - - name: Install Linux lightningcss native binary - if: matrix.os == 'ubuntu-latest' - working-directory: paranext-core - # Belt-and-suspenders install for GitHub Actions Ubuntu runners, where the native binary - # has been observed missing even after npm ci. - run: npm install --ignore-scripts --no-save lightningcss-linux-x64-gnu@1.32.0 + run: npm ci --ignore-scripts --include=optional - name: Install Electron binary working-directory: paranext-core From d12df9f21da664d9d01e465875f191c2036dff83 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Fri, 22 May 2026 13:59:18 -0400 Subject: [PATCH 25/43] Remove diagnostic jest-dom verification step --- .github/workflows/test.yml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2c314c99..37b50158 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -45,10 +45,6 @@ jobs: working-directory: paranext-core run: npm ci --ignore-scripts --omit=optional - - name: Verify jest-dom dependency - working-directory: extension-repo - run: npm ls @testing-library/jest-dom --depth=0 - - name: Run tests working-directory: extension-repo run: npm run test:coverage From 0b61b8dfd4aff7c781d195a5bb8685a6ff5cacf5 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Fri, 22 May 2026 14:00:06 -0400 Subject: [PATCH 26/43] Remove fail-fast: false from unit test job --- .github/workflows/test.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 37b50158..ff1c8aed 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -13,7 +13,6 @@ jobs: test: runs-on: ${{ matrix.os }} strategy: - fail-fast: false matrix: os: [ubuntu-latest, windows-latest] steps: From d32ea606086723d162c0bae41e81378729603bb1 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Fri, 22 May 2026 14:02:58 -0400 Subject: [PATCH 27/43] Don't explicitly include optional --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ff1c8aed..6008d7d8 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -81,7 +81,7 @@ jobs: - name: Install core dependencies working-directory: paranext-core - run: npm ci --ignore-scripts --include=optional + run: npm ci --ignore-scripts - name: Install Electron binary working-directory: paranext-core From 059e415b05ecc9ad696223920dff20ac967385ce Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Fri, 22 May 2026 14:04:09 -0400 Subject: [PATCH 28/43] Try to omit optional again --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6008d7d8..923a7693 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -38,7 +38,7 @@ jobs: - name: Install extension dependencies working-directory: extension-repo - run: npm ci --ignore-scripts + run: npm ci --ignore-scripts --omit=optional - name: Install core dependencies working-directory: paranext-core From a5fc3755306dcfccfb0dc6dea5a58b8e096a2bf1 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Fri, 22 May 2026 14:15:26 -0400 Subject: [PATCH 29/43] Try to fix failing Windows test run --- .github/workflows/test.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 923a7693..468f5ee4 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -38,7 +38,7 @@ jobs: - name: Install extension dependencies working-directory: extension-repo - run: npm ci --ignore-scripts --omit=optional + run: npm ci --ignore-scripts --include=dev --omit=optional - name: Install core dependencies working-directory: paranext-core @@ -119,7 +119,7 @@ jobs: with: name: playwright-artifacts-${{ matrix.os }} path: | - extension-repo/playwright-report/ extension-repo/e2e-tests/test-results/ + extension-repo/playwright-report/ if-no-files-found: warn retention-days: 7 From 083d20512d5de67743b4ec4bbf2466dff870bb44 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Fri, 22 May 2026 14:21:25 -0400 Subject: [PATCH 30/43] Try to fix failing Windows test run --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 468f5ee4..d6f77168 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -38,7 +38,7 @@ jobs: - name: Install extension dependencies working-directory: extension-repo - run: npm ci --ignore-scripts --include=dev --omit=optional + run: npm ci --ignore-scripts - name: Install core dependencies working-directory: paranext-core From b432abc4a55278ded1252a63801ab15c361c8989 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Fri, 22 May 2026 14:27:30 -0400 Subject: [PATCH 31/43] Document why --omit=optional is excluded from extension install --- .github/workflows/test.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d6f77168..2ab062c6 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -38,6 +38,8 @@ jobs: - name: Install extension dependencies working-directory: extension-repo + # Cannot --omit=optional: @swc/core-win32-x64-msvc is an optional dep and SWC needs it to + # transpile TypeScript in Jest on Windows. run: npm ci --ignore-scripts - name: Install core dependencies From 95776fe9a5436765f8e920d3f62d11054f95977a Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Fri, 22 May 2026 14:30:19 -0400 Subject: [PATCH 32/43] Restore jest.setup.ts (was a red herring, real fix was --omit=optional) --- jest.config.ts | 4 ++-- jest.setup.ts | 5 +++++ 2 files changed, 7 insertions(+), 2 deletions(-) create mode 100644 jest.setup.ts diff --git a/jest.config.ts b/jest.config.ts index 75dd396f..91d5c2cb 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -108,8 +108,8 @@ const config: Config = { /** Exclude dist from module resolution to avoid Haste naming collision with root package.json. */ modulePathIgnorePatterns: ['/dist'], - /** Load jest-dom matchers for React tests. */ - setupFilesAfterEnv: ['@testing-library/jest-dom'], + /** Load @testing-library/jest-dom matchers for React component tests. */ + setupFilesAfterEnv: ['/jest.setup.ts'], /** Use jsdom for React component tests; parser tests run fine in jsdom (no DOM use). */ testEnvironment: 'jsdom', diff --git a/jest.setup.ts b/jest.setup.ts new file mode 100644 index 00000000..cf32fbe1 --- /dev/null +++ b/jest.setup.ts @@ -0,0 +1,5 @@ +/** + * Jest setup file. Runs before each test file. Extends expect with @testing-library/jest-dom + * matchers for React component tests. + */ +import '@testing-library/jest-dom'; From ca10849c3c4c8918bd86826c998b13c10fc55947 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Fri, 22 May 2026 14:35:57 -0400 Subject: [PATCH 33/43] Try removing --include=dev --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2ab062c6..6551af71 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -79,7 +79,7 @@ jobs: - name: Install extension dependencies working-directory: extension-repo - run: npm ci --ignore-scripts --include=dev + run: npm ci --ignore-scripts - name: Install core dependencies working-directory: paranext-core From 8e6bfd9daa32a226429a86ccdd013475876d4812 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Fri, 22 May 2026 14:44:08 -0400 Subject: [PATCH 34/43] Document why --omit=optional is excluded from key install steps --- .github/workflows/publish.yml | 3 +++ .github/workflows/test.yml | 4 ++++ cspell.json | 1 + 3 files changed, 8 insertions(+) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 2644870c..92b81587 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -75,11 +75,14 @@ jobs: - name: Install packages run: | + # Cannot --omit=optional: lightningcss-linux-x64-gnu is an optional dep; Tailwind needs it + # for the CSS build. npm ci --ignore-scripts - name: Install core packages working-directory: paranext-core run: | + # Core is only checked out for its local npm packages; no core webpack build runs here. npm ci --ignore-scripts --omit=optional - name: Package for distribution diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6551af71..1feb7395 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -79,10 +79,14 @@ jobs: - name: Install extension dependencies working-directory: extension-repo + # Cannot --omit=optional: @swc/core-win32-x64-msvc is an optional dep; webpack needs it to + # transpile TypeScript on Windows. run: npm ci --ignore-scripts - name: Install core dependencies working-directory: paranext-core + # Cannot --omit=optional: lightningcss native binaries are optional deps required by the + # webpack build. run: npm ci --ignore-scripts - name: Install Electron binary diff --git a/cspell.json b/cspell.json index 4e534cb1..49de79ec 100644 --- a/cspell.json +++ b/cspell.json @@ -33,6 +33,7 @@ "interlinearize", "interlinearizer", "interlinearizing", + "lightningcss", "localstorage", "maximizable", "morphosyntactic", From 8ca7cdfc0cafd4a3fb45de8cc41c8104e73a586a Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Fri, 22 May 2026 14:54:57 -0400 Subject: [PATCH 35/43] Add README --- e2e-tests/README.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 e2e-tests/README.md diff --git a/e2e-tests/README.md b/e2e-tests/README.md new file mode 100644 index 00000000..523f7984 --- /dev/null +++ b/e2e-tests/README.md @@ -0,0 +1,17 @@ +# e2e-tests + +End-to-end tests for the interlinearizer extension using Playwright + Electron. The suite launches a real Platform.Bible instance with the extension loaded via `--extensions` and verifies the extension starts up correctly. Currently contains one smoke test confirming the extension activates and registers its PAPI command. + +**Contents:** + +- `global-*.ts` — start/stop the paranext-core renderer dev server around the test run +- `fixtures/` — test fixtures and helpers +- `playwright*.config.ts` — fixture configs +- `tests/` — tests, including a smoke test and a test template + +## Key differences from `paranext-core/e2e-tests/` + +These tests are adapted from `paranext-core`'s e2e suite with changes to support testing a side-loaded extension rather than the core platform itself: + +- **Extension launch helper** — `fixtures/helpers.ts` uses `launchElectronWithExtension()` instead of `launchElectronApp()`. It passes `--extensions ` to the Electron process, resolves the Electron binary from paranext-core's `node_modules`, and polls `rpc.discover` for the extension's PAPI method to confirm activation. +- **Window finding** — `fixtures/app.fixture.ts` manually polls `electronApp.windows()` by URL instead of calling `electronApp.firstWindow()`, because the extension injects content into an existing window rather than being the sole owner of the renderer. From bf53dad244c27adca14b7faf0cc11da15690c67a Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Fri, 22 May 2026 14:57:34 -0400 Subject: [PATCH 36/43] Discard renderer dev server logs to match paranext-core --- e2e-tests/global-setup.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/e2e-tests/global-setup.ts b/e2e-tests/global-setup.ts index 6e34211c..3b2a1477 100644 --- a/e2e-tests/global-setup.ts +++ b/e2e-tests/global-setup.ts @@ -149,9 +149,6 @@ function waitForPort(port: number, timeout: number): Promise { export default async function globalSetup(_config: FullConfig): Promise { const extensionRoot = path.resolve(__dirname, '..'); const coreDir = path.resolve(__dirname, '../../paranext-core'); - const testResultsDir = path.join(extensionRoot, 'e2e-tests/test-results'); - fs.mkdirSync(testResultsDir, { recursive: true }); - const rendererLogPath = path.join(testResultsDir, 'renderer-dev-server.log'); // Fail fast if Platform.Bible is already running (single-instance lock will // cause Playwright's Electron instance to exit immediately) @@ -207,10 +204,9 @@ export default async function globalSetup(_config: FullConfig): Promise { console.log(`Renderer dev server already running on port ${RENDERER_PORT}.`); } else { console.log('Starting paranext-core renderer dev server...'); - const rendererLogFd = fs.openSync(rendererLogPath, 'w'); const devServer = spawn('npm', ['run', 'start:renderer'], { cwd: coreDir, - stdio: ['ignore', rendererLogFd, rendererLogFd], + stdio: 'ignore', shell: true, detached: true, env: { ...process.env, ELECTRON_RUN_AS_NODE: undefined, SKIP_START_MAIN: '1' }, @@ -241,7 +237,6 @@ export default async function globalSetup(_config: FullConfig): Promise { console.warn( `Renderer HTTP readiness probe timed out in CI: ${message}. Continuing with port-only readiness.`, ); - console.warn(`Renderer dev server logs: ${rendererLogPath}`); } console.log('Renderer dev server is ready.'); } From d8735ee0dd7758c3933e78abeeb7c0638731b83a Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Fri, 22 May 2026 14:58:33 -0400 Subject: [PATCH 37/43] Add e2e-tests README --- e2e-tests/README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/e2e-tests/README.md b/e2e-tests/README.md index 523f7984..122808f9 100644 --- a/e2e-tests/README.md +++ b/e2e-tests/README.md @@ -15,3 +15,4 @@ These tests are adapted from `paranext-core`'s e2e suite with changes to support - **Extension launch helper** — `fixtures/helpers.ts` uses `launchElectronWithExtension()` instead of `launchElectronApp()`. It passes `--extensions ` to the Electron process, resolves the Electron binary from paranext-core's `node_modules`, and polls `rpc.discover` for the extension's PAPI method to confirm activation. - **Window finding** — `fixtures/app.fixture.ts` manually polls `electronApp.windows()` by URL instead of calling `electronApp.firstWindow()`, because the extension injects content into an existing window rather than being the sole owner of the renderer. +- **Renderer readiness** — `global-setup.ts` adds an HTTP GET probe after the TCP port check to wait for webpack compilation to finish, rather than assuming the port being open means the bundle is ready. From c6b4dd53724dda5538ea199000099871a9266449 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Fri, 22 May 2026 15:11:52 -0400 Subject: [PATCH 38/43] Add missing content --- e2e-tests/README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/e2e-tests/README.md b/e2e-tests/README.md index 122808f9..db053ae1 100644 --- a/e2e-tests/README.md +++ b/e2e-tests/README.md @@ -4,6 +4,7 @@ End-to-end tests for the interlinearizer extension using Playwright + Electron. **Contents:** +- `*.json` — lint configs identical to those in `paranext-core/e2e-tests/` - `global-*.ts` — start/stop the paranext-core renderer dev server around the test run - `fixtures/` — test fixtures and helpers - `playwright*.config.ts` — fixture configs From a348eb0f54bf753d3edac2f238bdd1f11990034c Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Fri, 22 May 2026 15:22:34 -0400 Subject: [PATCH 39/43] Fix Devin bugs --- .github/workflows/test.yml | 9 ++++----- e2e-tests/fixtures/helpers.ts | 1 + 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1feb7395..e7d33816 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -53,7 +53,6 @@ jobs: e2e-smoke: runs-on: ${{ matrix.os }} strategy: - fail-fast: false matrix: os: [ubuntu-latest, windows-latest] steps: @@ -119,13 +118,13 @@ jobs: working-directory: extension-repo run: npm run test:e2e:smoke - - name: Upload Playwright artifacts + - name: Upload Playwright test results uses: actions/upload-artifact@v4 if: always() with: - name: playwright-artifacts-${{ matrix.os }} + name: playwright-results-${{ matrix.os }} + retention-days: 7 path: | + extension-repo/e2e-tests/playwright-report/ extension-repo/e2e-tests/test-results/ - extension-repo/playwright-report/ if-no-files-found: warn - retention-days: 7 diff --git a/e2e-tests/fixtures/helpers.ts b/e2e-tests/fixtures/helpers.ts index a577928a..2b19b91d 100644 --- a/e2e-tests/fixtures/helpers.ts +++ b/e2e-tests/fixtures/helpers.ts @@ -22,6 +22,7 @@ const PLATFORM_ABOUT_COMMAND = 'command:platform.about'; */ const GET_METHODS = 'rpc.discover'; +/** Subset of the `rpc.discover` response we actually inspect. */ type RpcDiscoverResult = { methods?: Array<{ name: string }>; }; From d8d075fba06eabb4789065b7dc09b681b348d4b4 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Fri, 22 May 2026 15:28:19 -0400 Subject: [PATCH 40/43] Pin all workflow action versions to SHA and set persist-credentials: false --- .github/workflows/bump-versions.yml | 6 +++--- .github/workflows/codeql.yml | 8 +++++--- .github/workflows/lint.yml | 8 +++++--- .github/workflows/publish.yml | 15 ++++++++------- .github/workflows/test.yml | 16 +++++++++------- 5 files changed, 30 insertions(+), 23 deletions(-) diff --git a/.github/workflows/bump-versions.yml b/.github/workflows/bump-versions.yml index f0ce3740..ea598950 100644 --- a/.github/workflows/bump-versions.yml +++ b/.github/workflows/bump-versions.yml @@ -26,16 +26,16 @@ jobs: run: echo "${{ toJSON(github.event.inputs) }}" - name: Checkout git repo - uses: actions/checkout@v4 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Read package.json id: package_json - uses: zoexx/github-action-json-file-properties@1.0.6 + uses: zoexx/github-action-json-file-properties@d02f28167f05bf70cd75352b11c25a4e8c39bf38 # 1.0.6 with: file_path: 'package.json' - name: Install Node and NPM - uses: actions/setup-node@v4 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: cache: npm node-version: ${{ fromJson(steps.package_json.outputs.volta).node }} diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 5a941a5c..92aa326f 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -45,7 +45,9 @@ jobs: # your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false # Add any setup steps before running the `github/codeql-action/init` action. # This includes steps like installing compilers or runtimes (`actions/setup-node` @@ -55,7 +57,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@v3 + uses: github/codeql-action/init@03e4368ac7daa2bd82b3e85262f3bf87ee112f57 # v3.36.0 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} @@ -83,6 +85,6 @@ jobs: exit 1 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3 + uses: github/codeql-action/analyze@03e4368ac7daa2bd82b3e85262f3bf87ee112f57 # v3.36.0 with: category: '/language:${{matrix.language}}' diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index b4f04b30..b9853ed9 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -17,18 +17,20 @@ jobs: os: [ubuntu-latest] steps: - name: Checkout git repo - uses: actions/checkout@v4 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: path: extension-repo + persist-credentials: false - name: Checkout paranext-core repo to use its sub-packages - uses: actions/checkout@v4 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: path: paranext-core repository: paranext/paranext-core + persist-credentials: false - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: cache: 'npm' cache-dependency-path: | diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 92b81587..a2b9476f 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -42,19 +42,20 @@ jobs: run: echo "${{ toJSON(github.event.inputs) }}" - name: Checkout git repo - uses: actions/checkout@v4 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: path: extension-repo - name: Checkout paranext-core repo to use its sub-packages - uses: actions/checkout@v4 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: path: paranext-core repository: paranext/paranext-core + persist-credentials: false - name: Read package.json id: package_json - uses: zoexx/github-action-json-file-properties@1.0.6 + uses: zoexx/github-action-json-file-properties@d02f28167f05bf70cd75352b11c25a4e8c39bf38 # 1.0.6 with: file_path: 'extension-repo/package.json' @@ -65,7 +66,7 @@ jobs: exit 1 - name: Install Node and NPM - uses: actions/setup-node@v4 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: cache: npm cache-dependency-path: | @@ -90,7 +91,7 @@ jobs: npm run package - name: Publish draft release - uses: ncipollo/release-action@v1 + uses: ncipollo/release-action@339a81892b84b4eeb0f6e744e4574d79d0d9b8dd # v1.21.0 with: artifactErrorsFailBuild: true artifacts: | @@ -114,7 +115,7 @@ jobs: - name: Checkout bump ref if: ${{ inputs.newVersionAfterPublishing != '' && inputs.bumpRef != '' && inputs.bumpRef != (github.head_ref || github.ref_name) }} - uses: actions/checkout@v4 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: clean: false path: extension-repo @@ -129,6 +130,6 @@ jobs: # Enable tmate debugging of manually-triggered workflows if the input option was provided - name: Setup tmate session if: ${{ github.event_name == 'workflow_dispatch' && inputs.debug_enabled }} - uses: mxschmitt/action-tmate@v3 + uses: mxschmitt/action-tmate@c0afd6f790e3a5564914980036ebf83216678101 # v3.23 with: limit-access-to-actor: true diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e7d33816..b417d99e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -17,18 +17,20 @@ jobs: os: [ubuntu-latest, windows-latest] steps: - name: Checkout git repo - uses: actions/checkout@v4 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: path: extension-repo + persist-credentials: false - name: Checkout paranext-core repo to use its sub-packages - uses: actions/checkout@v4 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: path: paranext-core repository: paranext/paranext-core + persist-credentials: false - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: cache: 'npm' cache-dependency-path: | @@ -57,18 +59,18 @@ jobs: os: [ubuntu-latest, windows-latest] steps: - name: Checkout git repo - uses: actions/checkout@v4 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: path: extension-repo - name: Checkout paranext-core repo - uses: actions/checkout@v4 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: path: paranext-core repository: paranext/paranext-core - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: cache: 'npm' cache-dependency-path: | @@ -119,7 +121,7 @@ jobs: run: npm run test:e2e:smoke - name: Upload Playwright test results - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 if: always() with: name: playwright-results-${{ matrix.os }} From df59cd3ae00c510c4c2a85a16034c051e1b7a9d1 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Fri, 22 May 2026 15:37:30 -0400 Subject: [PATCH 41/43] Add JSDoc to all internal closures in e2e-tests --- e2e-tests/fixtures/app.fixture.ts | 21 +++++++++++++++++++++ e2e-tests/fixtures/helpers.ts | 6 ++++++ e2e-tests/global-setup.ts | 7 +++++++ 3 files changed, 34 insertions(+) diff --git a/e2e-tests/fixtures/app.fixture.ts b/e2e-tests/fixtures/app.fixture.ts index 92ddadb6..80e46004 100644 --- a/e2e-tests/fixtures/app.fixture.ts +++ b/e2e-tests/fixtures/app.fixture.ts @@ -51,16 +51,37 @@ export const test = base.extend({ const rendererUrl = 'http://localhost:1212/index.html?logLevel=debug'; const readyDeadline = Date.now() + PROCESS_READY_TIMEOUT; + /** + * Log an uncaught page error to the console. + * + * @param err The error thrown in the page context. + */ const onPageError = (err: Error) => console.error(`Page error: ${err.message}`); + + /** + * Log console error messages from the page to the process console. + * + * @param msg The console message emitted by the page. + */ const onConsoleMsg = (msg: ConsoleMessage) => { if (msg.type() === 'error') console.error(`Console error: ${msg.text()}`); }; + /** + * Attach error and console listeners to a page for test observability. + * + * @param page The Playwright page to listen on. + */ const attachListeners = (page: Page) => { page.on('pageerror', onPageError); page.on('console', onConsoleMsg); }; + /** + * Remove error and console listeners previously attached by {@link attachListeners}. + * + * @param page The Playwright page to stop listening on. + */ const detachListeners = (page: Page) => { page.off('pageerror', onPageError); page.off('console', onConsoleMsg); diff --git a/e2e-tests/fixtures/helpers.ts b/e2e-tests/fixtures/helpers.ts index 2b19b91d..40598ff0 100644 --- a/e2e-tests/fixtures/helpers.ts +++ b/e2e-tests/fixtures/helpers.ts @@ -185,6 +185,12 @@ export async function teardownElectronApp(ctx: ElectronAppContext): Promise { if (!electronProcess?.pid) return; try { diff --git a/e2e-tests/global-setup.ts b/e2e-tests/global-setup.ts index 3b2a1477..327a1bc7 100644 --- a/e2e-tests/global-setup.ts +++ b/e2e-tests/global-setup.ts @@ -47,6 +47,11 @@ function waitForHttpOk(url: string, timeout: number): Promise { let done = false; let currentReq: http.ClientRequest | undefined; + /** + * Mark the probe as failed, destroy the in-flight request, and reject the outer promise. + * + * @param message Human-readable failure reason passed to the rejected Error. + */ const fail = (message: string) => { if (done) return; done = true; @@ -58,6 +63,7 @@ function waitForHttpOk(url: string, timeout: number): Promise { fail(`${url} did not respond within ${timeout}ms`); }, timeout); + /** Fire one HTTP GET attempt; retries on transient failure within the overall timeout budget. */ const attempt = () => { if (done) return; if (Date.now() - startTime >= timeout) { @@ -109,6 +115,7 @@ function waitForHttpOk(url: string, timeout: number): Promise { function waitForPort(port: number, timeout: number): Promise { const startTime = Date.now(); return new Promise((resolve, reject) => { + /** Attempt one TCP connection; retries after 500 ms on failure within the overall timeout. */ const tryConnect = () => { if (Date.now() - startTime > timeout) { reject(new Error(`Port ${port} did not become available within ${timeout}ms`)); From b9ab327a45a4f679fb9183cd8d7d653a90534792 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Fri, 22 May 2026 15:40:42 -0400 Subject: [PATCH 42/43] Remove template codeowners file --- .github/CODEOWNERS | 1 - 1 file changed, 1 deletion(-) delete mode 100644 .github/CODEOWNERS diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS deleted file mode 100644 index d239b66f..00000000 --- a/.github/CODEOWNERS +++ /dev/null @@ -1 +0,0 @@ -* @tjcouch-sil @lyonsil @irahopkinson From 127fd327a8a727da9bb1a6a07a16096407efc9bf Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Fri, 22 May 2026 15:54:47 -0400 Subject: [PATCH 43/43] Add persist-credentials: false to e2e-smoke checkout steps --- .github/workflows/test.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b417d99e..435d9058 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -62,12 +62,14 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: path: extension-repo + persist-credentials: false - name: Checkout paranext-core repo uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: path: paranext-core repository: paranext/paranext-core + persist-credentials: false - name: Setup Node.js uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0