diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 7c99bfc1..b5676a4b 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -81,7 +81,11 @@ jobs: ver=$(awk '$1=="-" && $2=="jupyterlab" {print $3}' env_installer/jlab_server.yaml) [[ "$ver" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { echo "unexpected jupyterlab pin '$ver' in jlab_server.yaml (want exact x.y.z)"; exit 1; } python3 -m venv "$RUNNER_TEMP/jlab-venv" - "$RUNNER_TEMP/jlab-venv/bin/pip" install --quiet "jupyterlab==$ver" + # jupyterlab pulls in ipykernel, so the venv already has a python3 + # kernel for the cell-run test. ipywidgets is one of the extensions the + # app bundles (see jlab_server.yaml); install it so the widget-render + # test can confirm a labextension actually draws through the WebContentsView. + "$RUNNER_TEMP/jlab-venv/bin/pip" install --quiet "jupyterlab==$ver" ipywidgets echo "JLAB_TEST_PYTHON_PATH=$RUNNER_TEMP/jlab-venv/bin/python" >> "$GITHUB_ENV" # xvfb is Linux-only; on macOS the runner has a real display, so run the # suite directly. Same yarn test:e2e command on both legs. diff --git a/test/e2e/helpers.ts b/test/e2e/helpers.ts index 6944c55b..d29a5f5f 100644 --- a/test/e2e/helpers.ts +++ b/test/e2e/helpers.ts @@ -1,4 +1,9 @@ -import { _electron as electron, ElectronApplication } from '@playwright/test'; +import { + _electron as electron, + ElectronApplication, + Locator, + Page +} from '@playwright/test'; import { stubAllDialogs } from 'electron-playwright-helpers'; import { mkdtempSync, rmSync, writeFileSync } from 'fs'; import { join } from 'path'; @@ -62,9 +67,16 @@ export async function launchApp(opts?: { ] }) ); + // Point new sessions at the temp jupyter dir so notebooks the test creates + // (New notebook) land there and get removed with it, instead of the real + // home. On macOS the spawned server's cwd follows this setting; HOME alone + // does not move it because Electron resolves home via NSHomeDirectory. writeFileSync( join(userDataDir, 'settings.json'), - JSON.stringify({ pythonPath: opts.pythonPath }) + JSON.stringify({ + pythonPath: opts.pythonPath, + defaultWorkingDirectory: jupyterDir + }) ); } try { @@ -75,7 +87,15 @@ export async function launchApp(opts?: { HOME: jupyterDir } }); - await stubAllDialogs(app); + // Wait for a window to exist, then stub dialogs. stubAllDialogs evaluates + // the main process, and at launch a window can be mid-navigation, which + // makes that evaluate throw "execution context was destroyed". Retry once + // after a short settle so the race doesn't flake the whole suite. + await app.firstWindow(); + await stubAllDialogs(app).catch(async () => { + await new Promise(resolve => setTimeout(resolve, 1000)); + await stubAllDialogs(app); + }); return { app, userDataDir, jupyterDir }; } catch (error) { cleanup(userDataDir, jupyterDir); @@ -83,6 +103,49 @@ export async function launchApp(opts?: { } } +// Open a new notebook from the welcome, run `code` in its first cell, and +// return the labview page plus that cell so the caller can assert on the output +// it produced. Shared by the cell-run and widget-render tests, which differ +// only in the code and what they look for. The cell handling follows Galata +// (textbox role + pressSequentially, the status-bar kernel-ready poll), and the +// kernel picker is accepted only if it appears. +export async function runFirstNotebookCell( + app: ElectronApplication, + code: string +): Promise<{ lab: Page; cell: Locator }> { + const welcome = await pageByTitle(app, /welcome/i); + const newNotebook = welcome.locator('#new-notebook-link'); + await newNotebook.waitFor({ state: 'visible', timeout: 20000 }); + await newNotebook.click(); + + const lab = await pageByUrl(app, LAB_URL); + await lab + .locator('.jp-Dialog button.jp-mod-accept') + .click({ timeout: 15000 }) + .catch(() => undefined); + await lab.waitForFunction( + () => { + const el = document.querySelector('#jp-main-statusbar'); + const text = el?.textContent ?? ''; + if (!el || text.trim().length === 0) return false; + return !['Connecting', 'Initializing', 'Starting'].some(s => + text.includes(s) + ); + }, + { timeout: 90000 } + ); + + const cell = lab + .locator('.jp-NotebookPanel:not(.lm-mod-hidden) .jp-Notebook .jp-Cell') + .first(); + const editor = cell.getByRole('textbox'); + await editor.click(); + await editor.press('Control+A'); + await editor.pressSequentially(code); + await lab.keyboard.press('Shift+Enter'); + return { lab, cell }; +} + export function cleanup(userDataDir: string, jupyterDir?: string): void { rmSync(userDataDir, { recursive: true, force: true }); if (jupyterDir) { diff --git a/test/e2e/python-env.test.ts b/test/e2e/python-env.test.ts index 4ec9efe7..622bb764 100644 --- a/test/e2e/python-env.test.ts +++ b/test/e2e/python-env.test.ts @@ -5,7 +5,8 @@ import { launchApp, NEEDS_PYTHON, pageByTitle, - pageByUrl + pageByUrl, + runFirstNotebookCell } from './helpers'; // Needs a real Python env with jupyterlab. CI provisions one and points @@ -34,3 +35,90 @@ test('with a seeded Python env, New notebook opens the labview', async () => { cleanup(userDataDir, jupyterDir); } }); + +// The point of this one is the Electron glue, not JupyterLab: a notebook cell +// evaluated through the labview WebContentsView exercises the whole embedded +// chain (server boots, the kernel connects over the websocket Electron's net +// stack carries, output renders back in the view). 1 + 1 is enough; testing +// numpy here would be testing Python, not Electron. +test('a notebook cell runs through the Electron labview', async () => { + test.skip(!pythonPath, NEEDS_PYTHON); + test.setTimeout(180000); + const { app, userDataDir, jupyterDir } = await launchApp({ pythonPath }); + try { + const { cell } = await runFirstNotebookCell(app, '1 + 1'); + const output = cell.locator('.jp-OutputArea-output').first(); + await expect(output).toContainText('2', { timeout: 90000 }); + } finally { + await app.close(); + cleanup(userDataDir, jupyterDir); + } +}); + +// ipywidgets is one of the bundled labextensions. A widget rendering is the +// Electron-relevant part: the widget comm rides the kernel websocket Electron +// carries and the view draws the control in the WebContentsView. A jlab bump +// that breaks widget rendering in the embedded view shows up here, where +// jupyter labextension list (a plain compatibility check) would not. +test('a bundled labextension (ipywidgets) renders in the labview', async () => { + test.skip(!pythonPath, NEEDS_PYTHON); + test.setTimeout(180000); + const { app, userDataDir, jupyterDir } = await launchApp({ pythonPath }); + try { + const { cell } = await runFirstNotebookCell( + app, + 'from ipywidgets import IntSlider; IntSlider(value=7)' + ); + const slider = cell.locator('.jupyter-widgets.widget-slider').first(); + await expect(slider).toBeVisible({ timeout: 90000 }); + } finally { + await app.close(); + cleanup(userDataDir, jupyterDir); + } +}); + +// A jlab bump can break how the lab UI itself draws in the WebContentsView +// without touching the notebook path the cell test exercises. Assert the +// chrome the labview renders on load: the file browser, the side bars, the menu +// bar and the status bar. +test('the labview renders the JupyterLab UI chrome', async () => { + test.skip(!pythonPath, NEEDS_PYTHON); + test.setTimeout(120000); + const { app, userDataDir, jupyterDir } = await launchApp({ pythonPath }); + try { + const welcome = await pageByTitle(app, /welcome/i); + await welcome.locator('#new-session-link').click({ timeout: 20000 }); + const lab = await pageByUrl(app, LAB_URL); + await expect(lab.locator('.jp-FileBrowser').first()).toBeVisible({ + timeout: 60000 + }); + await expect(lab.locator('.jp-SideBar').first()).toBeVisible(); + await expect(lab.locator('.lm-MenuBar').first()).toBeVisible(); + await expect(lab.locator('#jp-main-statusbar')).toBeVisible(); + } finally { + await app.close(); + cleanup(userDataDir, jupyterDir); + } +}); + +// Non-ASCII output rendering through the WebContentsView, the manual check from +// past JupyterLab bump PRs (arm64 ICU). A cell that prints accented, CJK and +// Greek text and renders it back confirms text rendering is intact in the +// embedded view after a bump. +test('the labview renders non-ASCII output through Electron', async () => { + test.skip(!pythonPath, NEEDS_PYTHON); + test.setTimeout(180000); + const { app, userDataDir, jupyterDir } = await launchApp({ pythonPath }); + try { + const text = 'café ñoño 日本語 αβγ'; + const { cell } = await runFirstNotebookCell( + app, + `print(${JSON.stringify(text)})` + ); + const output = cell.locator('.jp-OutputArea-output').first(); + await expect(output).toContainText(text, { timeout: 90000 }); + } finally { + await app.close(); + cleanup(userDataDir, jupyterDir); + } +}); diff --git a/test/e2e/smoke.test.ts b/test/e2e/smoke.test.ts index 5c6cda24..0a1975da 100644 --- a/test/e2e/smoke.test.ts +++ b/test/e2e/smoke.test.ts @@ -58,3 +58,24 @@ test('app shuts down cleanly without hanging', async () => { // close() has resolved, so the process exited and the windows are gone. expect(app.windows().length).toBe(0); }); + +// The preload contract tests cover the exposed API shape with mocks; this +// checks the wiring end to end in the running app: a renderer call over the +// contextBridge reaches the ipcMain handler and a value comes back. isDarkTheme +// returns a boolean and needs no Python env, so it is the cheapest round-trip. +test('an electronAPI channel round-trips to the main process', async () => { + const { app, userDataDir, jupyterDir } = await launchApp(); + try { + const welcome = await pageByTitle(app, /welcome/i); + await welcome.waitForLoadState('domcontentloaded'); + const isDark = await welcome.evaluate(() => + ((window as unknown) as { + electronAPI: { isDarkTheme(): Promise }; + }).electronAPI.isDarkTheme() + ); + expect(typeof isDark).toBe('boolean'); + } finally { + await app.close(); + cleanup(userDataDir, jupyterDir); + } +});