Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .eslintignore
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,7 @@ coverage
package-lock.json

# #endregion

# Playwright test output
e2e-tests/playwright-report
e2e-tests/test-results
8 changes: 8 additions & 0 deletions .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand Down
74 changes: 73 additions & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,7 @@ coverage
temp-build

# #endregion

# Playwright test output
e2e-tests/playwright-report
e2e-tests/test-results
4 changes: 4 additions & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,7 @@ coverage
package-lock.json

# #endregion

# Playwright test output
e2e-tests/playwright-report
e2e-tests/test-results
4 changes: 4 additions & 0 deletions .stylelintignore
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,7 @@ coverage
package-lock.json

# #endregion

# Playwright test output
e2e-tests/playwright-report
e2e-tests/test-results
42 changes: 42 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
12 changes: 12 additions & 0 deletions e2e-tests/.eslintrc.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
83 changes: 83 additions & 0 deletions e2e-tests/fixtures/app.fixture.ts
Original file line number Diff line number Diff line change
@@ -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<TestAppFixtures, WorkerAppFixtures>({
// 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)');
}
}
},
});
74 changes: 74 additions & 0 deletions e2e-tests/fixtures/cdp.fixture.ts
Original file line number Diff line number Diff line change
@@ -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<CdpFixtures>({
// 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<void>((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
}
},
});
Loading
Loading