diff --git a/.eslintignore b/.eslintignore index 3e29184..687a559 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 627548a..d1cabf9 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/publish.yml b/.github/workflows/publish.yml index a678dda..2644870 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 aa5a978..d1f4fd7 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 @@ -47,3 +47,75 @@ 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@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 + + - name: Install core dependencies + 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 + 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 report + uses: actions/upload-artifact@v4 + if: always() + with: + name: playwright-report-${{ matrix.os }} + path: extension-repo/e2e-tests/playwright-report/ + retention-days: 7 diff --git a/.gitignore b/.gitignore index 65791e3..76900dd 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 a66f587..01f359a 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 1180000..69c75a6 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 0360e08..7d87dfe 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 0000000..a734e32 --- /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 0000000..19ab01b --- /dev/null +++ b/e2e-tests/fixtures/app.fixture.ts @@ -0,0 +1,83 @@ +// 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 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 0000000..381306a --- /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 0000000..a2cbe22 --- /dev/null +++ b/e2e-tests/fixtures/helpers.ts @@ -0,0 +1,392 @@ +// 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. + * + * @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}`, + ); + + 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 0000000..6ac663b --- /dev/null +++ b/e2e-tests/global-setup.ts @@ -0,0 +1,157 @@ +// 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. + * + * @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 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) => { + 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, 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 0000000..8739e46 --- /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 0000000..0e1b8d5 --- /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 0000000..e47a532 --- /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 0000000..0b58327 --- /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 0000000..a7ad9b5 --- /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 0000000..89157b7 --- /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 670457f..91d5c2c 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 d0ca786..9c42fb2 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 578bbd8..c4a26a0 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 62db1e0..d20496c 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"] }