Skip to content

Commit 2256bf9

Browse files
authored
Merge branch 'master' into phase3/type-modernization
2 parents 45a0ad9 + df4fce4 commit 2256bf9

5 files changed

Lines changed: 304 additions & 45 deletions

File tree

.github/workflows/e2e.yml

Lines changed: 42 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -15,15 +15,22 @@ concurrency:
1515

1616
jobs:
1717
e2e:
18-
name: 'Playwright (Linux)'
19-
runs-on: ubuntu-latest
18+
name: 'Playwright (${{ matrix.os }})'
19+
strategy:
20+
fail-fast: false
21+
matrix:
22+
os: [ubuntu-latest, macos-latest]
23+
runs-on: ${{ matrix.os }}
2024
# Bound the job so a stuck Electron launch/close fails fast instead of
2125
# tying up a runner; this suite specifically guards shutdown hangs.
2226
timeout-minutes: 15
2327
env:
2428
# Only Electron is launched here, never Playwright's bundled browsers, so
2529
# skip their download during yarn install (faster, fewer network flakes).
2630
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: '1'
31+
# electron's postinstall (extract-zip) intermittently drops files from dist
32+
# on CI runners, so skip it and install the binary deterministically below.
33+
ELECTRON_SKIP_BINARY_DOWNLOAD: '1'
2734
steps:
2835
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
2936
- uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
@@ -33,26 +40,35 @@ jobs:
3340
node-version: '24.x'
3441
cache: 'yarn'
3542
- 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.
43+
# Install the Electron binary deterministically instead of relying on the
44+
# flaky extract-zip postinstall. Resolve the cached zip with @electron/get
45+
# (electron's own download dep, same call on every OS) and extract it with
46+
# the system tool: macOS needs ditto to keep the Electron Framework
47+
# symlinks that plain unzip mangles; Linux uses unzip. Then point path.txt
48+
# at the binary and confirm require('electron') resolves.
4149
- name: Install the Electron binary
50+
shell: bash
4251
run: |
4352
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
53+
zip=$(node -e "const {downloadArtifact}=require('@electron/get');const {version}=require('electron/package.json');downloadArtifact({version,artifactName:'electron'}).then(p=>process.stdout.write(p)).catch(e=>{console.error(e);process.exit(1)})")
54+
dist=node_modules/electron/dist
55+
rm -rf "$dist" && mkdir -p "$dist"
56+
if [ "$RUNNER_OS" = "macOS" ]; then
57+
ditto -x -k "$zip" "$dist"
58+
printf 'Electron.app/Contents/MacOS/Electron' > node_modules/electron/path.txt
59+
test -f "$dist/Electron.app/Contents/Frameworks/Electron Framework.framework/Versions/A/Electron Framework"
60+
else
61+
unzip -q -o "$zip" -d "$dist"
62+
printf 'electron' > node_modules/electron/path.txt
63+
fi
64+
node -e "console.log('resolved electron:', require('electron'))"
5165
- run: yarn build
5266
# Electron needs system libraries; xvfb provides a display on the headless
5367
# runner. The app launches up to four windows, so a real display beats
54-
# --headless here.
55-
- run: npx playwright install-deps
68+
# --headless here. Both are Linux-only: macOS runners have a display and do
69+
# not use this apt-based dependency installer.
70+
- if: matrix.os == 'ubuntu-latest'
71+
run: npx playwright install-deps
5672
# A minimal Python env with jupyterlab so the env-backed tests run (a venv
5773
# + pip is lighter than a full conda env for a smoke). The seeded
5874
# app-data.json points the app at this interpreter.
@@ -67,11 +83,19 @@ jobs:
6783
python3 -m venv "$RUNNER_TEMP/jlab-venv"
6884
"$RUNNER_TEMP/jlab-venv/bin/pip" install --quiet "jupyterlab==$ver"
6985
echo "JLAB_TEST_PYTHON_PATH=$RUNNER_TEMP/jlab-venv/bin/python" >> "$GITHUB_ENV"
70-
- run: xvfb-run -a yarn test:e2e
86+
# xvfb is Linux-only; on macOS the runner has a real display, so run the
87+
# suite directly. Same yarn test:e2e command on both legs.
88+
- name: Run the e2e suite
89+
run: |
90+
if [ "$RUNNER_OS" = "Linux" ]; then
91+
xvfb-run -a yarn test:e2e
92+
else
93+
yarn test:e2e
94+
fi
7195
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
7296
if: ${{ !cancelled() }}
7397
with:
74-
name: playwright-report
98+
name: playwright-report-${{ matrix.os }}
7599
path: |
76100
playwright-report/
77101
test-results/

test/e2e/helpers.ts

Lines changed: 64 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,16 @@ import { mkdtempSync, rmSync, writeFileSync } from 'fs';
44
import { join } from 'path';
55
import { tmpdir } from 'os';
66

7+
// A booted JupyterLab server URL: the labview navigates here once a session is
8+
// up. Shared so every server-backed test waits on the same pattern.
9+
export const LAB_URL = /https?:\/\/(127\.0\.0\.1|localhost):\d+/;
10+
11+
// The env-backed tests need a real Python with jupyterlab; CI provisions one and
12+
// points JLAB_TEST_PYTHON_PATH at it. Shared so the skip reason reads the same
13+
// everywhere.
14+
export const NEEDS_PYTHON =
15+
'set JLAB_TEST_PYTHON_PATH to a python with jupyterlab';
16+
717
// A fresh userData dir per launch keeps tests independent. Electron's native
818
// --user-data-dir flag is honored because the app reads app.getPath('userData')
919
// (see getUserDataDir in utils) and only overrides it on Snap. Returns the app
@@ -17,10 +27,26 @@ import { tmpdir } from 'os';
1727
// actions; `userSetPythonEnvs` registers the env so the session registry
1828
// resolves it) and `settings.json` (`pythonPath` is what a new session reads
1929
// via workspace settings to boot its server).
30+
//
31+
// The spawned Jupyter server inherits the launch env via `{ ...process.env }`
32+
// in src/main/server.ts. Redirecting HOME alone isolates it: jupyter derives its
33+
// config, data and runtime dirs from HOME (~/.jupyter, ~/Library/Jupyter, etc.),
34+
// so with HOME pointed at a temp the server and its kernels/terminal never read
35+
// or write the runner's real Jupyter dirs. Setting JUPYTER_CONFIG_DIR/DATA_DIR/
36+
// RUNTIME_DIR explicitly would be redundant. On macOS, HOME does not move
37+
// Electron's own app.getPath('home') (it uses NSHomeDirectory), but the app's
38+
// own state is already isolated by --user-data-dir; HOME only needs to reach the
39+
// python server, which it does through `...process.env`. The jupyter temp dir is
40+
// returned so the caller can remove it alongside userDataDir.
2041
export async function launchApp(opts?: {
2142
pythonPath?: string;
22-
}): Promise<{ app: ElectronApplication; userDataDir: string }> {
43+
}): Promise<{
44+
app: ElectronApplication;
45+
userDataDir: string;
46+
jupyterDir: string;
47+
}> {
2348
const userDataDir = mkdtempSync(join(tmpdir(), 'jlab-e2e-'));
49+
const jupyterDir = mkdtempSync(join(tmpdir(), 'jlab-e2e-jupyter-'));
2450
if (opts?.pythonPath) {
2551
writeFileSync(
2652
join(userDataDir, 'app-data.json'),
@@ -43,18 +69,25 @@ export async function launchApp(opts?: {
4369
}
4470
try {
4571
const app = await electron.launch({
46-
args: ['.', `--user-data-dir=${userDataDir}`]
72+
args: ['.', `--user-data-dir=${userDataDir}`],
73+
env: {
74+
...process.env,
75+
HOME: jupyterDir
76+
}
4777
});
4878
await stubAllDialogs(app);
49-
return { app, userDataDir };
79+
return { app, userDataDir, jupyterDir };
5080
} catch (error) {
51-
cleanup(userDataDir);
81+
cleanup(userDataDir, jupyterDir);
5282
throw error;
5383
}
5484
}
5585

56-
export function cleanup(userDataDir: string): void {
86+
export function cleanup(userDataDir: string, jupyterDir?: string): void {
5787
rmSync(userDataDir, { recursive: true, force: true });
88+
if (jupyterDir) {
89+
rmSync(jupyterDir, { recursive: true, force: true });
90+
}
5891
}
5992

6093
// The app opens several windows (titlebar, welcome, session, manager) and most
@@ -81,3 +114,29 @@ export async function pageByTitle(
81114
}
82115
throw new Error(`no window title matched ${pattern}`);
83116
}
117+
118+
// Wait for any open page whose URL matches `pattern`. Unlike
119+
// electron-playwright-helpers' waitForWindowByUrl, which only resolves on a new
120+
// "window" event, this also catches an in-place navigation of an existing page
121+
// (e.g. the session content view swapping to a remote server URL), so it is the
122+
// reliable wait for "a view reached this URL" regardless of how it got there.
123+
export async function pageByUrl(
124+
app: ElectronApplication,
125+
pattern: RegExp,
126+
timeout = 90000
127+
) {
128+
const deadline = Date.now() + timeout;
129+
while (Date.now() < deadline) {
130+
for (const page of app.windows()) {
131+
try {
132+
if (pattern.test(page.url())) {
133+
return page;
134+
}
135+
} catch {
136+
// page may be closing; retry on the next pass
137+
}
138+
}
139+
await new Promise(resolve => setTimeout(resolve, 150));
140+
}
141+
throw new Error(`no window URL matched ${pattern}`);
142+
}

test/e2e/python-env.test.ts

Lines changed: 13 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,22 @@
11
import { expect, test } from '@playwright/test';
2-
import { waitForWindowByUrl } from 'electron-playwright-helpers';
3-
import { cleanup, launchApp, pageByTitle } from './helpers';
2+
import {
3+
cleanup,
4+
LAB_URL,
5+
launchApp,
6+
NEEDS_PYTHON,
7+
pageByTitle,
8+
pageByUrl
9+
} from './helpers';
410

511
// Needs a real Python env with jupyterlab. CI provisions one and points
612
// JLAB_TEST_PYTHON_PATH at it; locally, set it to any python that has jupyterlab
713
// installed. Skipped when absent so the suite stays green everywhere.
814
const pythonPath = process.env.JLAB_TEST_PYTHON_PATH;
915

1016
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-
);
17+
test.skip(!pythonPath, NEEDS_PYTHON);
1518
test.setTimeout(120000);
16-
const { app, userDataDir } = await launchApp({ pythonPath });
19+
const { app, userDataDir, jupyterDir } = await launchApp({ pythonPath });
1720
try {
1821
const welcome = await pageByTitle(app, /welcome/i);
1922
// The app validated the seeded env and emitted EnableLocalServerActions,
@@ -24,14 +27,10 @@ test('with a seeded Python env, New notebook opens the labview', async () => {
2427
// The session boots a real Jupyter server (via the seeded env) and the
2528
// labview WebContentsView navigates to it. Assert it reached the local
2629
// 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+/);
30+
const lab = await pageByUrl(app, LAB_URL);
31+
expect(lab.url()).toMatch(/\/lab/);
3332
} finally {
3433
await app.close();
35-
cleanup(userDataDir);
34+
cleanup(userDataDir, jupyterDir);
3635
}
3736
});

test/e2e/smoke.test.ts

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,18 @@ import { expect, test } from '@playwright/test';
22
import { cleanup, launchApp, pageByTitle } from './helpers';
33

44
test('app launches and opens at least one window', async () => {
5-
const { app, userDataDir } = await launchApp();
5+
const { app, userDataDir, jupyterDir } = await launchApp();
66
try {
77
await app.firstWindow();
88
expect(app.windows().length).toBeGreaterThan(0);
99
} finally {
1010
await app.close();
11-
cleanup(userDataDir);
11+
cleanup(userDataDir, jupyterDir);
1212
}
1313
});
1414

1515
test('on first run the Welcome window renders', async () => {
16-
const { app, userDataDir } = await launchApp();
16+
const { app, userDataDir, jupyterDir } = await launchApp();
1717
try {
1818
// Selecting by title rather than firstWindow(): the app opens several
1919
// windows and only this one is the welcome view.
@@ -24,12 +24,12 @@ test('on first run the Welcome window renders', async () => {
2424
await expect(welcome.locator('#new-notebook-link')).toBeVisible();
2525
} finally {
2626
await app.close();
27-
cleanup(userDataDir);
27+
cleanup(userDataDir, jupyterDir);
2828
}
2929
});
3030

3131
test('the session window composes multiple views (titlebar + welcome + content)', async () => {
32-
const { app, userDataDir } = await launchApp();
32+
const { app, userDataDir, jupyterDir } = await launchApp();
3333
try {
3434
// Once the welcome view has settled, the BrowserView/WebContentsView
3535
// composition should expose several views (titlebar, welcome, content area)
@@ -41,19 +41,19 @@ test('the session window composes multiple views (titlebar + welcome + content)'
4141
.toBeGreaterThanOrEqual(3);
4242
} finally {
4343
await app.close();
44-
cleanup(userDataDir);
44+
cleanup(userDataDir, jupyterDir);
4545
}
4646
});
4747

4848
test('app shuts down cleanly without hanging', async () => {
49-
const { app, userDataDir } = await launchApp();
49+
const { app, userDataDir, jupyterDir } = await launchApp();
5050
try {
5151
await app.firstWindow();
5252
} finally {
5353
// Close in finally so the Electron process is always terminated, even if
5454
// an earlier step throws. A hang here surfaces via the job timeout.
5555
await app.close();
56-
cleanup(userDataDir);
56+
cleanup(userDataDir, jupyterDir);
5757
}
5858
// close() has resolved, so the process exited and the windows are gone.
5959
expect(app.windows().length).toBe(0);

0 commit comments

Comments
 (0)