Skip to content

Commit 673a77d

Browse files
committed
test: add Playwright e2e suite and wire it into CI
Run the existing Playwright `_electron` suite in a new e2e.yml (Node 24 — the version Electron bundles — under xvfb, with the Electron binary cached and materialized for the cached-binary case). Add a launch/cleanup harness: `launchApp` isolates each test with Electron's native `--user-data-dir` (the app honors it via app.getPath('userData')) and can seed `app-data.json`; `pageByTitle` selects a window by title since the app opens several views, most title-less, so `firstWindow()` is ambiguous. Smoke tests: launch, the Welcome view renders a Welcome-specific element, the multi-view composition is present (guards the Phase 3 BrowserView->WebContentsView migration), and the app shuts down without hanging (close in finally; job timeout bounds it). Note: AI-assisted (Claude Code). Manually verified: 4 smoke tests pass locally (yarn build && yarn test:e2e); tsc, eslint, prettier clean.
1 parent c980ac4 commit 673a77d

5 files changed

Lines changed: 192 additions & 69 deletions

File tree

.github/workflows/e2e.yml

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
name: E2E
2+
3+
on:
4+
push:
5+
branches: [master]
6+
pull_request:
7+
branches: [master]
8+
9+
permissions:
10+
contents: read
11+
12+
concurrency:
13+
group: ${{ github.workflow }}-${{ github.head_ref || github.ref }}
14+
cancel-in-progress: true
15+
16+
jobs:
17+
e2e:
18+
name: 'Playwright (Linux)'
19+
runs-on: ubuntu-latest
20+
# Bound the job so a stuck Electron launch/close fails fast instead of
21+
# tying up a runner; this suite specifically guards shutdown hangs.
22+
timeout-minutes: 15
23+
env:
24+
# Only Electron is launched here, never Playwright's bundled browsers, so
25+
# skip their download during yarn install (faster, fewer network flakes).
26+
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: '1'
27+
steps:
28+
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
29+
- uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
30+
with:
31+
# Match the Node that the target Electron bundles (Electron 42 -> Node
32+
# 24); do not run ahead of it. Node 26 broke Electron's install tooling.
33+
node-version: '24.x'
34+
cache: 'yarn'
35+
# Cache the @electron/get binary download so launch does not depend on a
36+
# fresh GitHub-releases fetch every run.
37+
- uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
38+
with:
39+
path: ~/.cache/electron
40+
key: ${{ runner.os }}-electron-${{ hashFiles('yarn.lock') }}
41+
restore-keys: ${{ runner.os }}-electron-
42+
- run: yarn install --frozen-lockfile
43+
# Electron's postinstall only extracts the binary into
44+
# node_modules/electron/dist on a fresh download. On a ~/.cache/electron
45+
# cache hit it skips that, leaving dist empty and launch failing with
46+
# "Electron failed to install correctly", so materialize it explicitly.
47+
- run: node node_modules/electron/install.js
48+
- run: yarn build
49+
# Electron needs system libraries; xvfb provides a display on the headless
50+
# runner. The app launches up to four windows, so a real display beats
51+
# --headless here.
52+
- run: npx playwright install-deps
53+
# A minimal Python env with jupyterlab so the env-backed tests run (a venv
54+
# + pip is lighter than a full conda env for a smoke). The seeded
55+
# app-data.json points the app at this interpreter.
56+
- name: Provision a Python env for the env-backed e2e
57+
run: |
58+
python3 -m venv "$RUNNER_TEMP/jlab-venv"
59+
"$RUNNER_TEMP/jlab-venv/bin/pip" install --quiet --upgrade pip jupyterlab
60+
echo "JLAB_TEST_PYTHON_PATH=$RUNNER_TEMP/jlab-venv/bin/python" >> "$GITHUB_ENV"
61+
- run: xvfb-run -a yarn test:e2e
62+
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
63+
if: ${{ !cancelled() }}
64+
with:
65+
name: playwright-report
66+
path: |
67+
playwright-report/
68+
test-results/
69+
retention-days: 14

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ node_modules
88
.cache
99
.eslintcache
1010
coverage
11+
test-results
12+
playwright-report
1113
.vscode/*
1214
!.vscode/launch.json
1315
*.py[co]

playwright.config.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@ export default defineConfig({
44
testDir: './test/e2e',
55
timeout: 60000,
66
retries: process.env.CI ? 2 : 0,
7-
reporter: process.env.CI ? 'github' : 'list',
7+
workers: process.env.CI ? 1 : undefined,
8+
reporter: process.env.CI ? [['github'], ['html', { open: 'never' }]] : 'list',
89
use: {
910
trace: 'on-first-retry',
1011
video: 'on-first-retry'

test/e2e/helpers.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { _electron as electron, ElectronApplication } from '@playwright/test';
2+
import { stubAllDialogs } from 'electron-playwright-helpers';
3+
import { mkdtempSync, rmSync, writeFileSync } from 'fs';
4+
import { join } from 'path';
5+
import { tmpdir } from 'os';
6+
7+
// A fresh userData dir per launch keeps tests independent. Electron's native
8+
// --user-data-dir flag is honored because the app reads app.getPath('userData')
9+
// (see getUserDataDir in utils) and only overrides it on Snap. Returns the app
10+
// plus the temp userData dir so the caller can clean it up in a finally block.
11+
// If launch fails, the temp dir is removed here so repeated runs don't
12+
// accumulate dirs.
13+
//
14+
// Pass `pythonPath` to seed `app-data.json` before launch (the app reads
15+
// `appData.pythonPath`), which makes the app treat that env as configured and
16+
// enables the local-server welcome actions.
17+
export async function launchApp(opts?: {
18+
pythonPath?: string;
19+
}): Promise<{ app: ElectronApplication; userDataDir: string }> {
20+
const userDataDir = mkdtempSync(join(tmpdir(), 'jlab-e2e-'));
21+
if (opts?.pythonPath) {
22+
writeFileSync(
23+
join(userDataDir, 'app-data.json'),
24+
JSON.stringify({ pythonPath: opts.pythonPath })
25+
);
26+
}
27+
try {
28+
const app = await electron.launch({
29+
args: ['.', `--user-data-dir=${userDataDir}`]
30+
});
31+
await stubAllDialogs(app);
32+
return { app, userDataDir };
33+
} catch (error) {
34+
cleanup(userDataDir);
35+
throw error;
36+
}
37+
}
38+
39+
export function cleanup(userDataDir: string): void {
40+
rmSync(userDataDir, { recursive: true, force: true });
41+
}
42+
43+
// The app opens several windows (titlebar, welcome, session, manager) and most
44+
// have an empty title, so firstWindow() is ambiguous. Poll windows() and match
45+
// on the page title instead. Post Electron 30 a WebContentsView also surfaces
46+
// as its own page here, so title matching stays the reliable selector.
47+
export async function pageByTitle(
48+
app: ElectronApplication,
49+
pattern: RegExp,
50+
timeout = 15000
51+
) {
52+
const deadline = Date.now() + timeout;
53+
while (Date.now() < deadline) {
54+
for (const page of app.windows()) {
55+
try {
56+
if (pattern.test(await page.title())) {
57+
return page;
58+
}
59+
} catch {
60+
// page may be mid-navigation; retry on the next pass
61+
}
62+
}
63+
await new Promise(resolve => setTimeout(resolve, 100));
64+
}
65+
throw new Error(`no window title matched ${pattern}`);
66+
}

test/e2e/smoke.test.ts

Lines changed: 53 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -1,75 +1,60 @@
1-
import { _electron as electron, expect, test } from '@playwright/test';
2-
import { stubAllDialogs } from 'electron-playwright-helpers';
3-
import { mkdtempSync, rmSync } from 'fs';
4-
import { join } from 'path';
5-
import { tmpdir } from 'os';
6-
7-
let tempUserData: string;
8-
9-
test.beforeEach(() => {
10-
tempUserData = mkdtempSync(join(tmpdir(), 'jlab-e2e-'));
1+
import { expect, test } from '@playwright/test';
2+
import { cleanup, launchApp, pageByTitle } from './helpers';
3+
4+
test('app launches and opens at least one window', async () => {
5+
const { app, userDataDir } = await launchApp();
6+
try {
7+
await app.firstWindow();
8+
expect(app.windows().length).toBeGreaterThan(0);
9+
} finally {
10+
await app.close();
11+
cleanup(userDataDir);
12+
}
1113
});
1214

13-
test.afterEach(async () => {
14-
rmSync(tempUserData, { recursive: true, force: true });
15+
test('on first run the Welcome window renders', async () => {
16+
const { app, userDataDir } = await launchApp();
17+
try {
18+
// Selecting by title rather than firstWindow(): the app opens several
19+
// windows and only this one is the welcome view.
20+
const welcome = await pageByTitle(app, /welcome/i);
21+
await welcome.waitForLoadState('domcontentloaded');
22+
// Assert a Welcome-specific element, not just body, so a blank or error
23+
// page would fail rather than pass.
24+
await expect(welcome.locator('#new-notebook-link')).toBeVisible();
25+
} finally {
26+
await app.close();
27+
cleanup(userDataDir);
28+
}
1529
});
1630

17-
test('app launches and shows a window', async () => {
18-
const app = await electron.launch({
19-
args: ['.'],
20-
env: {
21-
...process.env,
22-
JLAB_DESKTOP_HOME: tempUserData,
23-
ELECTRON_IS_TEST: '1'
24-
}
25-
});
26-
27-
await stubAllDialogs(app);
28-
const window = await app.firstWindow();
29-
await window.waitForLoadState('domcontentloaded');
30-
31-
expect(app.windows().length).toBeGreaterThan(0);
32-
await app.close();
31+
test('the session window composes multiple views (titlebar + welcome + content)', async () => {
32+
const { app, userDataDir } = await launchApp();
33+
try {
34+
// Once the welcome view has settled, the BrowserView/WebContentsView
35+
// composition should expose several views (titlebar, welcome, content area)
36+
// as separate pages. A dropped view after the Phase 3 WebContentsView
37+
// migration would lower this count.
38+
await pageByTitle(app, /welcome/i);
39+
await expect
40+
.poll(() => app.windows().length, { timeout: 10000 })
41+
.toBeGreaterThanOrEqual(3);
42+
} finally {
43+
await app.close();
44+
cleanup(userDataDir);
45+
}
3346
});
3447

35-
test('app exits with code 0', async () => {
36-
const app = await electron.launch({
37-
args: ['.'],
38-
env: {
39-
...process.env,
40-
JLAB_DESKTOP_HOME: tempUserData,
41-
ELECTRON_IS_TEST: '1'
42-
}
43-
});
44-
45-
await stubAllDialogs(app);
46-
await app.firstWindow();
47-
48-
const exitCode = await app.close();
49-
expect(exitCode).toBe(0);
50-
});
51-
52-
test('first-run shows env selection when no env configured', async () => {
53-
const app = await electron.launch({
54-
args: ['.'],
55-
env: {
56-
...process.env,
57-
JLAB_DESKTOP_HOME: tempUserData,
58-
ELECTRON_IS_TEST: '1',
59-
// empty userData = no prior config = first run
60-
APPDATA: tempUserData
61-
}
62-
});
63-
64-
await stubAllDialogs(app);
65-
const window = await app.firstWindow();
66-
67-
// welcome view or env select should appear within 15s
68-
await expect(
69-
window.locator(
70-
'#welcome-view, #env-select-dialog, [data-testid="env-select"]'
71-
)
72-
).toBeVisible({ timeout: 15000 });
73-
74-
await app.close();
48+
test('app shuts down cleanly without hanging', async () => {
49+
const { app, userDataDir } = await launchApp();
50+
try {
51+
await app.firstWindow();
52+
} finally {
53+
// Close in finally so the Electron process is always terminated, even if
54+
// an earlier step throws. A hang here surfaces via the job timeout.
55+
await app.close();
56+
cleanup(userDataDir);
57+
}
58+
// close() has resolved, so the process exited and the windows are gone.
59+
expect(app.windows().length).toBe(0);
7560
});

0 commit comments

Comments
 (0)