Skip to content

Commit 4f62f36

Browse files
test: Playwright e2e suite (first-run smoke + env-backed welcome) (#976)
* 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. * test: cover the env-backed welcome actions with a seeded Python env When a Python env is configured the welcome local-server actions become enabled; pin that. The test seeds `app-data.json` with a `pythonPath` (the real config seam the app reads) and asserts the "New notebook" action loses its `disabled` class. Gated on JLAB_TEST_PYTHON_PATH (CI provisions a venv with jupyterlab and sets it; skipped locally when absent) so the suite stays green everywhere. Driving the JupyterLab content itself is left to a separate plain-Playwright suite against the server URL (Phase 3). Note: AI-assisted (Claude Code). Manually verified: passes locally with JLAB_TEST_PYTHON_PATH set to a jupyterlab env; skips cleanly when unset. * ci: pin jupyterlab and scope e2e concurrency per PR Address review: pin the CI venv to jupyterlab==4.5.7 (the app's target, matching env_installer/jlab_server.yaml) so env-backed tests don't drift with upstream releases; key the concurrency group on the PR number instead of head_ref so same-named branches across forks don't cancel each other's runs. Note: AI-assisted (Claude Code). Manually verified: prettier clean. * test: e2e the labview booting a real Jupyter server With a Python env seeded, clicking "New notebook" boots a real Jupyter server and the labview WebContentsView navigates to it; assert it reached the local server URL via waitForWindowByUrl (no driving the JupyterLab DOM through Electron). The session reads its env from two seams, so the launch helper now seeds both: app-data.json (pythonPath enables the welcome + userSetPythonEnvs registers the env so the registry resolves it) and settings.json (pythonPath the new session uses to boot). Gated on JLAB_TEST_PYTHON_PATH like the other env-backed test. Note: AI-assisted (Claude Code). Manually verified: both env-backed tests pass locally with JLAB_TEST_PYTHON_PATH set (labview reaches http://localhost:PORT/lab in ~6s); smoke tests still skip them when unset; tsc/eslint/prettier clean. * ci: drop the electron cache and install.js, just yarn install The ~/.cache/electron cache made launch nondeterministic: on a cache hit the postinstall skips extraction (empty dist -> 'Electron failed to install correctly'), and an explicit install.js step that fixes the hit case corrupts a fresh install on a miss. Removing both and letting yarn install's postinstall download + extract fresh each run is deterministic (~10s) and is the config that passed before the cache was added. Note: AI-assisted (Claude Code). Manually verified: matches the previously passing setup; the cache/install.js permutations that failed are removed. * ci: temp diagnostic for electron install state [skip-review] Note: AI-assisted (Claude Code). Temporary, reverted once the cause is known. * ci: diag electron resolution [temp] Note: AI-assisted (Claude Code). Temporary diagnostic. * ci: force a clean Electron binary install before e2e The diagnostic showed node_modules/electron/dist had the resource files but not the electron binary itself (dist/electron missing), so require('electron') threw 'Electron failed to install correctly'. yarn install left dist half-extracted and the postinstall skipped re-extraction because path.txt existed. Remove dist/path.txt/the download cache and re-run install.js to download + extract fresh, asserting the binary is present and executable. Note: AI-assisted (Claude Code). Manually verified: diagnosed from CI logs (dist/electron 'No such file or directory'); the test -x guard fails loud if the binary is still missing. * ci: debug electron install.js [temp] Note: AI-assisted (Claude Code). Temporary @electron/get debug. * ci: check electron zip integrity [temp] Note: AI-assisted (Claude Code). Temporary. * ci: extract Electron with system unzip (extract-zip drops the binary) The downloaded electron zip is complete and valid (96MB, integrity OK), but electron's postinstall via extract-zip leaves node_modules/electron/dist without the electron binary on this runner, so launch fails. Let install.js fetch the zip into the cache, then extract it with the system unzip and write path.txt. Note: AI-assisted (Claude Code). Diagnosed from CI: zip ZIP_OK + 74 files but dist/electron 'No such file'; extract-zip is the failing step, system unzip is the workaround. * ci: tidy the Electron install step comment Note: AI-assisted (Claude Code). Comment-only cleanup of the merged iteration notes. * test: simplify the env-backed e2e Merge the two env-backed cases into one flow (single app launch): with a seeded Python env, assert the welcome action is enabled and that clicking New notebook boots a server the labview reaches. Drop the redundant pip upgrade in CI. Note: AI-assisted (Claude Code). Manually verified: the merged env test passes with JLAB_TEST_PYTHON_PATH set (~12s); the 4 smoke tests still pass and the env test skips when unset; tsc/eslint/prettier clean. * test: drop the hardcoded jupyterlab version from the e2e seed The app re-detects the env's jupyterlab version on launch, so the seeded userSetPythonEnvs entry does not need a versions field; pinning it to a literal duplicated the version with the pip install in the workflow and would go stale on a jupyterlab bump. The labview test still boots a real server without it. Note: AI-assisted (Claude Code). Manually verified: the env-backed labview test passes with the versions field removed (~10s); the 5-test suite stays green; tsc/eslint/prettier clean. * test(e2e): track the bundled jupyterlab version automatically Read the pin from env_installer/jlab_server.yaml so a bump there propagates to the e2e env without a second edit, with a guard that fails the job if the format ever changes (so we notice instead of silently installing latest). Note: AI-assisted (Claude Code). Manually verified: awk extracts '4.5.7' from the current jlab_server.yaml; prettier clean on the workflow. * test(e2e): tighten the jupyterlab-pin parse to a semver match The non-empty check accepted operator-prefixed pins (`>=4.5.7`) that would make pip fail with a confusing message; require an exact x.y.z so the job fails with a clear hint pointing back at the env spec. Note: AI-assisted (Claude Code). Manually verified: the regex accepts 4.5.7 and rejects empty / operator-prefixed / multi-line inputs. --------- Co-authored-by: Michał Krassowski <5832902+krassowski@users.noreply.github.com>
1 parent aded203 commit 4f62f36

6 files changed

Lines changed: 255 additions & 69 deletions

File tree

.github/workflows/e2e.yml

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
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.event.pull_request.number || 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+
- run: yarn install --frozen-lockfile
36+
# electron's postinstall (extract-zip) leaves dist without the `electron`
37+
# binary on this runner even though the downloaded zip is complete and
38+
# valid, so launch fails with "Electron failed to install correctly". Let
39+
# install.js fetch the zip into the cache, then extract it with the system
40+
# `unzip` and point path.txt at the binary.
41+
- name: Install the Electron binary
42+
run: |
43+
set -euo pipefail
44+
node node_modules/electron/install.js || true
45+
zip=$(ls ~/.cache/electron/*/electron-*-linux-x64.zip | head -1)
46+
rm -rf node_modules/electron/dist
47+
mkdir -p node_modules/electron/dist
48+
unzip -q -o "$zip" -d node_modules/electron/dist
49+
printf 'electron' > node_modules/electron/path.txt
50+
test -x node_modules/electron/dist/electron
51+
- run: yarn build
52+
# Electron needs system libraries; xvfb provides a display on the headless
53+
# runner. The app launches up to four windows, so a real display beats
54+
# --headless here.
55+
- run: npx playwright install-deps
56+
# A minimal Python env with jupyterlab so the env-backed tests run (a venv
57+
# + pip is lighter than a full conda env for a smoke). The seeded
58+
# app-data.json points the app at this interpreter.
59+
- name: Provision a Python env for the env-backed e2e
60+
run: |
61+
set -euo pipefail
62+
# Install the same jupyterlab the app bundles, read from the env spec so
63+
# this tracks a version bump instead of drifting. Fail loudly if the
64+
# pin format changes rather than silently installing latest.
65+
ver=$(awk '$1=="-" && $2=="jupyterlab" {print $3}' env_installer/jlab_server.yaml)
66+
[[ "$ver" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { echo "unexpected jupyterlab pin '$ver' in jlab_server.yaml (want exact x.y.z)"; exit 1; }
67+
python3 -m venv "$RUNNER_TEMP/jlab-venv"
68+
"$RUNNER_TEMP/jlab-venv/bin/pip" install --quiet "jupyterlab==$ver"
69+
echo "JLAB_TEST_PYTHON_PATH=$RUNNER_TEMP/jlab-venv/bin/python" >> "$GITHUB_ENV"
70+
- run: xvfb-run -a yarn test:e2e
71+
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
72+
if: ${{ !cancelled() }}
73+
with:
74+
name: playwright-report
75+
path: |
76+
playwright-report/
77+
test-results/
78+
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: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
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 the app's config before launch so it treats that
15+
// interpreter as a configured environment. Two files are written, matching the
16+
// two seams the app reads: `app-data.json` (`pythonPath` enables the welcome
17+
// actions; `userSetPythonEnvs` registers the env so the session registry
18+
// resolves it) and `settings.json` (`pythonPath` is what a new session reads
19+
// via workspace settings to boot its server).
20+
export async function launchApp(opts?: {
21+
pythonPath?: string;
22+
}): Promise<{ app: ElectronApplication; userDataDir: string }> {
23+
const userDataDir = mkdtempSync(join(tmpdir(), 'jlab-e2e-'));
24+
if (opts?.pythonPath) {
25+
writeFileSync(
26+
join(userDataDir, 'app-data.json'),
27+
JSON.stringify({
28+
pythonPath: opts.pythonPath,
29+
userSetPythonEnvs: [
30+
{
31+
path: opts.pythonPath,
32+
name: 'e2e-env',
33+
type: 'path',
34+
defaultKernel: 'python3'
35+
}
36+
]
37+
})
38+
);
39+
writeFileSync(
40+
join(userDataDir, 'settings.json'),
41+
JSON.stringify({ pythonPath: opts.pythonPath })
42+
);
43+
}
44+
try {
45+
const app = await electron.launch({
46+
args: ['.', `--user-data-dir=${userDataDir}`]
47+
});
48+
await stubAllDialogs(app);
49+
return { app, userDataDir };
50+
} catch (error) {
51+
cleanup(userDataDir);
52+
throw error;
53+
}
54+
}
55+
56+
export function cleanup(userDataDir: string): void {
57+
rmSync(userDataDir, { recursive: true, force: true });
58+
}
59+
60+
// The app opens several windows (titlebar, welcome, session, manager) and most
61+
// have an empty title, so firstWindow() is ambiguous. Poll windows() and match
62+
// on the page title instead. Post Electron 30 a WebContentsView also surfaces
63+
// as its own page here, so title matching stays the reliable selector.
64+
export async function pageByTitle(
65+
app: ElectronApplication,
66+
pattern: RegExp,
67+
timeout = 15000
68+
) {
69+
const deadline = Date.now() + timeout;
70+
while (Date.now() < deadline) {
71+
for (const page of app.windows()) {
72+
try {
73+
if (pattern.test(await page.title())) {
74+
return page;
75+
}
76+
} catch {
77+
// page may be mid-navigation; retry on the next pass
78+
}
79+
}
80+
await new Promise(resolve => setTimeout(resolve, 100));
81+
}
82+
throw new Error(`no window title matched ${pattern}`);
83+
}

test/e2e/python-env.test.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { expect, test } from '@playwright/test';
2+
import { waitForWindowByUrl } from 'electron-playwright-helpers';
3+
import { cleanup, launchApp, pageByTitle } from './helpers';
4+
5+
// Needs a real Python env with jupyterlab. CI provisions one and points
6+
// JLAB_TEST_PYTHON_PATH at it; locally, set it to any python that has jupyterlab
7+
// installed. Skipped when absent so the suite stays green everywhere.
8+
const pythonPath = process.env.JLAB_TEST_PYTHON_PATH;
9+
10+
test('with a seeded Python env, New notebook opens the labview', async () => {
11+
test.skip(
12+
!pythonPath,
13+
'set JLAB_TEST_PYTHON_PATH to a python with jupyterlab'
14+
);
15+
test.setTimeout(120000);
16+
const { app, userDataDir } = await launchApp({ pythonPath });
17+
try {
18+
const welcome = await pageByTitle(app, /welcome/i);
19+
// The app validated the seeded env and emitted EnableLocalServerActions,
20+
// so the local-server action loses its `disabled` class and is clickable.
21+
const newNotebook = welcome.locator('#new-notebook-link');
22+
await expect(newNotebook).not.toHaveClass(/disabled/, { timeout: 20000 });
23+
await newNotebook.click();
24+
// The session boots a real Jupyter server (via the seeded env) and the
25+
// labview WebContentsView navigates to it. Assert it reached the local
26+
// server URL rather than driving the JupyterLab DOM through Electron.
27+
const lab = await waitForWindowByUrl(
28+
app,
29+
/https?:\/\/(127\.0\.0\.1|localhost):\d+/,
30+
{ timeout: 90000 }
31+
);
32+
expect(lab.url()).toMatch(/:\d+/);
33+
} finally {
34+
await app.close();
35+
cleanup(userDataDir);
36+
}
37+
});

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)