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/CODEOWNERS b/.github/CODEOWNERS deleted file mode 100644 index d239b66f..00000000 --- a/.github/CODEOWNERS +++ /dev/null @@ -1 +0,0 @@ -* @tjcouch-sil @lyonsil @irahopkinson 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 a678ddaa..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: | @@ -75,19 +76,22 @@ 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: | - npm ci --ignore-scripts + # 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 run: | npm run package - name: Publish draft release - uses: ncipollo/release-action@v1 + uses: ncipollo/release-action@339a81892b84b4eeb0f6e744e4574d79d0d9b8dd # v1.21.0 with: artifactErrorsFailBuild: true artifacts: | @@ -111,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 @@ -126,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 aa5a9783..435d9058 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -14,21 +14,23 @@ jobs: runs-on: ${{ matrix.os }} strategy: matrix: - os: [ubuntu-latest] + 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: | @@ -38,7 +40,9 @@ jobs: - name: Install extension dependencies working-directory: extension-repo - run: npm ci --ignore-scripts --omit=optional + # 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 working-directory: paranext-core @@ -47,3 +51,84 @@ jobs: - name: Run tests working-directory: extension-repo run: npm run test:coverage + + e2e-smoke: + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, windows-latest] + steps: + - name: Checkout git repo + 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 + 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 + # 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 + 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 + working-directory: extension-repo + run: npm run build + + - name: Build paranext-core dev bundle + 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 (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 test results + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + if: always() + with: + name: playwright-results-${{ matrix.os }} + retention-days: 7 + path: | + extension-repo/e2e-tests/playwright-report/ + extension-repo/e2e-tests/test-results/ + if-no-files-found: warn 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/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", 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/README.md b/e2e-tests/README.md new file mode 100644 index 00000000..db053ae1 --- /dev/null +++ b/e2e-tests/README.md @@ -0,0 +1,19 @@ +# 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:** + +- `*.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 +- `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. +- **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. diff --git a/e2e-tests/fixtures/app.fixture.ts b/e2e-tests/fixtures/app.fixture.ts new file mode 100644 index 00000000..80e46004 --- /dev/null +++ b/e2e-tests/fixtures/app.fixture.ts @@ -0,0 +1,170 @@ +// 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; +} + +/** + * 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. + 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 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); + }; + + let page: Page | undefined; + while (Date.now() < readyDeadline) { + 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 { + const currentUrl = page.url(); + console.log(`Window URL: ${currentUrl}`); + + // 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 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); + }); + } + + 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 { + detachListeners(page); + page = undefined; + } + } + + await new Promise((resolve) => { + setTimeout(resolve, 500); + }); + } + + throw new Error( + `Main renderer did not mount #root within ${PROCESS_READY_TIMEOUT}ms (last URL: ${page?.url() ?? 'no window'})`, + ); + }, +}); diff --git a/e2e-tests/fixtures/cdp.fixture.ts b/e2e-tests/fixtures/cdp.fixture.ts new file mode 100644 index 00000000..381306a9 --- /dev/null +++ b/e2e-tests/fixtures/cdp.fixture.ts @@ -0,0 +1,74 @@ +// 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'; + +/** 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) => { + 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..40598ff0 --- /dev/null +++ b/e2e-tests/fixtures/helpers.ts @@ -0,0 +1,399 @@ +// 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 = process.env.CI ? 600_000 : 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'; + +/** Subset of the `rpc.discover` response we actually inspect. */ +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. + * + * @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(); + + 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 + * `--extensions`. + * + * @param opts Optional launch options (e.g. environment variable overrides). + * @returns The app handle, the isolated user-data directory path, and a promise that resolves when + * the app closes. + * @throws If Electron fails to launch or the WebSocket server does not become ready. + */ +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. + * + * @param ctx The app context returned by {@link launchElectronWithExtension}. + * @returns Resolves when the Electron process has been killed and user-data cleaned up. + */ +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}`, + ); + + /** + * Send `sig` to the Electron process group, falling back to the process itself if group kill + * fails. + * + * @param sig Signal to send (e.g. `'SIGKILL'`). + */ + 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. + * + * @param method JSON-RPC method name to invoke. + * @param timeoutErrorMessage Custom error message on timeout; defaults to a standard timeout + * message. + * @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. + */ +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. + * + * @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, + 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. + * + * @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, + 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). + * + * @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(); + 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. + * + * @param timeoutMs Maximum time in milliseconds to poll before throwing. + * @returns Resolves when `interlinearizer.openForWebView` is listed in `rpc.discover`. + * @throws {Error} If the extension does not register within `timeoutMs` milliseconds. + */ +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..327a1bc7 --- /dev/null +++ b/e2e-tests/global-setup.ts @@ -0,0 +1,250 @@ +// 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'; + +const WEBSOCKET_PORT = 8876; +const RENDERER_PORT = 1212; + +/** + * 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(); + server.once('error', () => { + server.close(); + resolve(true); + }); + server.once('listening', () => { + server.close(); + resolve(false); + }); + server.listen(port); + }); +} + +/** + * Wait until an HTTP GET to `url` returns a non-5xx response. + * + * 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. + * @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 { + const startTime = Date.now(); + return new Promise((resolve, reject) => { + 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; + currentReq?.destroy(); + reject(new Error(message)); + }; + + const overallTimer = setTimeout(() => { + 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) { + clearTimeout(overallTimer); + fail(`${url} did not respond within ${timeout}ms`); + return; + } + + currentReq = http.get(url, { headers: { Connection: 'close' } }, (res) => { + if (done) { + res.resume(); + return; + } + + res.resume(); + + if (res.statusCode !== undefined && res.statusCode < 500) { + clearTimeout(overallTimer); + done = true; + resolve(); + 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', () => { + if (!done) setTimeout(attempt, 1_000); + }); + }; + + attempt(); + }); +} + +/** + * 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) => { + /** 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`)); + 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. 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, '..'); + 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, 60_000); + console.log( + `Port ${RENDERER_PORT} is accepting connections. Waiting for webpack compilation...`, + ); + // 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.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..8739e466 --- /dev/null +++ b/e2e-tests/global-teardown.ts @@ -0,0 +1,52 @@ +// 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. 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, '..'); + 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..e47a532f --- /dev/null +++ b/e2e-tests/playwright.config.ts @@ -0,0 +1,42 @@ +// 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 `--extensions`. + * + * 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', + }, + ], +}); 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..0b583275 --- /dev/null +++ b/e2e-tests/tests/_example/example-interlinearizer-feature.spec.ts @@ -0,0 +1,78 @@ +/** + * === 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'; + +/** + * 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) => + !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/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 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"] }