Skip to content

Commit 647a6ab

Browse files
committed
Add e2e testing framework
1 parent ac655ea commit 647a6ab

21 files changed

Lines changed: 1068 additions & 1 deletion

.eslintignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,3 +37,7 @@ coverage
3737
package-lock.json
3838

3939
# #endregion
40+
41+
# Playwright test output
42+
e2e-tests/playwright-report
43+
e2e-tests/test-results

.eslintrc.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,14 @@ module.exports = {
192192
'jest/globals': true,
193193
},
194194
},
195+
{
196+
// Playwright e2e test fixtures use a `use()` callback that is Playwright's fixture API,
197+
// not a React hook. react-hooks/rules-of-hooks v5 incorrectly flags these calls.
198+
files: ['e2e-tests/**/*.ts', 'e2e-tests/**/*.tsx'],
199+
rules: {
200+
'react-hooks/rules-of-hooks': 'off',
201+
},
202+
},
195203
],
196204
parser: '@typescript-eslint/parser',
197205
parserOptions: {

.github/workflows/test.yml

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,3 +47,57 @@ jobs:
4747
- name: Run tests
4848
working-directory: extension-repo
4949
run: npm run test:coverage
50+
51+
e2e-smoke:
52+
runs-on: ubuntu-latest
53+
steps:
54+
- name: Checkout git repo
55+
uses: actions/checkout@v4
56+
with:
57+
path: extension-repo
58+
59+
- name: Checkout paranext-core repo
60+
uses: actions/checkout@v4
61+
with:
62+
path: paranext-core
63+
repository: paranext/paranext-core
64+
65+
- name: Setup Node.js
66+
uses: actions/setup-node@v4
67+
with:
68+
cache: 'npm'
69+
cache-dependency-path: |
70+
extension-repo/package-lock.json
71+
paranext-core/package-lock.json
72+
node-version-file: extension-repo/package.json
73+
74+
- name: Install extension dependencies
75+
working-directory: extension-repo
76+
run: npm ci --ignore-scripts --omit=optional
77+
78+
- name: Install core dependencies
79+
working-directory: paranext-core
80+
run: npm ci --ignore-scripts --omit=optional
81+
82+
- name: Install system dependencies for Electron
83+
run: sudo apt-get update && sudo apt-get install -y xvfb libgbm-dev libnss3 libxss1 libasound2t64
84+
85+
- name: Build extension
86+
working-directory: extension-repo
87+
run: npm run build
88+
89+
- name: Build paranext-core dev bundle
90+
working-directory: paranext-core
91+
run: npm run prestart
92+
93+
- name: Run e2e smoke tests
94+
working-directory: extension-repo
95+
run: xvfb-run --auto-servernum --server-args="-screen 0 1280x960x24" npm run test:e2e:smoke
96+
97+
- name: Upload Playwright report
98+
uses: actions/upload-artifact@v4
99+
if: always()
100+
with:
101+
name: playwright-report
102+
path: extension-repo/e2e-tests/playwright-report/
103+
retention-days: 7

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,3 +32,7 @@ coverage
3232
temp-build
3333

3434
# #endregion
35+
36+
# Playwright test output
37+
e2e-tests/playwright-report
38+
e2e-tests/test-results

.prettierignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,3 +37,7 @@ coverage
3737
package-lock.json
3838

3939
# #endregion
40+
41+
# Playwright test output
42+
e2e-tests/playwright-report
43+
e2e-tests/test-results

.stylelintignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,3 +37,7 @@ coverage
3737
package-lock.json
3838

3939
# #endregion
40+
41+
# Playwright test output
42+
e2e-tests/playwright-report
43+
e2e-tests/test-results

README.md

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,48 @@ To package this extension into a zip file for distribution:
153153

154154
`npm run package`
155155

156+
## Testing
157+
158+
### Unit tests
159+
160+
Unit tests use [Jest](https://jestjs.io/) and live in `src/__tests__/`. To run:
161+
162+
```bash
163+
npm test # run all tests
164+
npm run test:coverage # run with coverage report (output to coverage/)
165+
```
166+
167+
### End-to-end tests
168+
169+
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.
170+
171+
**Prerequisites:**
172+
173+
- `npm run build` must have been run (`dist/src/main.js` must exist)
174+
- `paranext-core` must have deps installed (e.g., with `npm run core:install`)
175+
176+
**Smoke tests** (self-contained, good for CI — launches and tears down Platform.Bible automatically):
177+
178+
```bash
179+
npm run test:e2e:smoke
180+
```
181+
182+
**CDP tests** (connect to an already-running app — faster for local development):
183+
184+
1. Start Platform.Bible with remote debugging enabled:
185+
186+
```bash
187+
npm run start:cdp
188+
```
189+
190+
2. In a second terminal, run the tests:
191+
192+
```bash
193+
npm run test:e2e:cdp
194+
```
195+
196+
New feature tests should use `cdp.fixture` and navigate entirely through visible UI. See `e2e-tests/tests/_example/` for a reference template.
197+
156198
## Publishing
157199

158200
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.

e2e-tests/.eslintrc.json

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
{
2+
"rules": {
3+
// E2E tests legitimately use console for test diagnostics
4+
"no-console": "off",
5+
// E2E tests often need sequential async operations in loops
6+
"no-await-in-loop": "off",
7+
// `export default defineConfig(...)` is the standard Playwright config pattern
8+
"import/no-anonymous-default-export": "off",
9+
// TypeScript handles undefined references; ESLint no-undef doesn't know Node.js globals
10+
"no-undef": "off"
11+
}
12+
}

e2e-tests/fixtures/app.fixture.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
// Adapted from paranext-core/e2e-tests/fixtures/app.fixture.ts
2+
import {
3+
test as base,
4+
ElectronApplication,
5+
Page,
6+
TestInfo,
7+
ConsoleMessage,
8+
} from '@playwright/test';
9+
import {
10+
launchElectronWithExtension,
11+
teardownElectronApp,
12+
ElectronAppContext,
13+
PROCESS_READY_TIMEOUT,
14+
} from './helpers';
15+
16+
export { expect } from '@playwright/test';
17+
18+
/** Worker-scoped fixtures — one instance shared across all tests in a worker. */
19+
export interface WorkerAppFixtures {
20+
electronApp: ElectronApplication;
21+
}
22+
23+
/** Test-scoped fixtures — re-created for every test. */
24+
export interface TestAppFixtures {
25+
mainPage: Page;
26+
}
27+
28+
export const test = base.extend<TestAppFixtures, WorkerAppFixtures>({
29+
// Worker-scoped: the Electron process is launched once per worker and shared across all tests,
30+
// avoiding the process startup/teardown cost per test.
31+
electronApp: [
32+
// eslint-disable-next-line no-empty-pattern
33+
async ({}, use) => {
34+
const ctx: ElectronAppContext = await launchElectronWithExtension();
35+
36+
await use(ctx.electronApp);
37+
38+
console.log('[teardown] Worker-scoped app teardown starting...');
39+
await teardownElectronApp(ctx);
40+
console.log('[teardown] Worker-scoped app teardown complete — worker will exit now');
41+
},
42+
{ scope: 'worker' },
43+
],
44+
45+
mainPage: async ({ electronApp }, use, testInfo: TestInfo) => {
46+
const page = await electronApp.firstWindow({ timeout: PROCESS_READY_TIMEOUT });
47+
48+
console.log(`Window URL: ${page.url()}`);
49+
const onPageError = (err: Error) => console.error(`Page error: ${err.message}`);
50+
const onConsoleMsg = (msg: ConsoleMessage) => {
51+
if (msg.type() === 'error') console.error(`Console error: ${msg.text()}`);
52+
};
53+
page.on('pageerror', onPageError);
54+
page.on('console', onConsoleMsg);
55+
56+
await page.waitForLoadState('domcontentloaded');
57+
await page.waitForSelector('#root', { state: 'attached', timeout: PROCESS_READY_TIMEOUT });
58+
59+
await use(page);
60+
61+
page.off('pageerror', onPageError);
62+
page.off('console', onConsoleMsg);
63+
64+
if (testInfo.status !== testInfo.expectedStatus) {
65+
const screenshotPath = testInfo.outputPath('failure.png');
66+
try {
67+
await page.screenshot({ path: screenshotPath, fullPage: true });
68+
await testInfo.attach('failure-screenshot', {
69+
path: screenshotPath,
70+
contentType: 'image/png',
71+
});
72+
console.log(`Failure screenshot saved to ${screenshotPath}`);
73+
} catch {
74+
console.warn('Could not capture failure screenshot (window may already be closed)');
75+
}
76+
}
77+
},
78+
});

e2e-tests/fixtures/cdp.fixture.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
// Adapted from paranext-core/e2e-tests/fixtures/cdp.fixture.ts
2+
/**
3+
* CDP-based Playwright fixture for running E2E tests against an already-running Platform.Bible
4+
* instance with remote debugging enabled.
5+
*
6+
* Uses `connectOverCDP` (port 9223) instead of `_electron.launch()`, so:
7+
*
8+
* - No app restart needed (no port 8876 conflict)
9+
* - Tests run against the same app instance used during development
10+
* - No teardown/shutdown of the app on completion
11+
*
12+
* Prerequisite: Platform.Bible running with --remote-debugging-port=9223 and the interlinearizer
13+
* extension loaded.
14+
*/
15+
import { test as base, chromium, Page } from '@playwright/test';
16+
17+
export { expect } from '@playwright/test';
18+
19+
const CDP_URL = process.env.CDP_URL || 'http://localhost:9223';
20+
21+
export interface CdpFixtures {
22+
mainPage: Page;
23+
}
24+
25+
export const test = base.extend<CdpFixtures>({
26+
// eslint-disable-next-line no-empty-pattern
27+
mainPage: async ({}, use) => {
28+
let browser;
29+
for (let attempt = 1; attempt <= 3; attempt++) {
30+
try {
31+
// intentional retry loop
32+
// eslint-disable-next-line no-await-in-loop
33+
browser = await chromium.connectOverCDP(CDP_URL, { timeout: 30_000 });
34+
break;
35+
} catch (err) {
36+
if (attempt === 3) throw err;
37+
// intentional retry delay
38+
// eslint-disable-next-line no-await-in-loop
39+
await new Promise<void>((resolve) => {
40+
setTimeout(resolve, 2_000);
41+
});
42+
}
43+
}
44+
if (!browser) throw new Error('Failed to connect to CDP after 3 attempts');
45+
46+
// Find the renderer page (not devtools)
47+
const allPages = browser.contexts().flatMap((ctx) => ctx.pages());
48+
let page: Page | undefined = allPages.find((p) => {
49+
const url = p.url();
50+
return (
51+
(url.includes('localhost') || url.includes('index.html') || url.startsWith('file://')) &&
52+
!url.includes('devtools://')
53+
);
54+
});
55+
56+
if (!page) {
57+
page = allPages.find((p) => !p.url().includes('devtools://'));
58+
}
59+
60+
if (!page) throw new Error('No renderer page found via CDP');
61+
62+
await use(page);
63+
try {
64+
await browser.close();
65+
} catch {
66+
// Ignore disconnect errors during cleanup
67+
}
68+
},
69+
});

0 commit comments

Comments
 (0)