Skip to content
Merged
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
6 changes: 5 additions & 1 deletion .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
krassowski marked this conversation as resolved.
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.
Expand Down
69 changes: 66 additions & 3 deletions test/e2e/helpers.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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 {
Expand All @@ -75,14 +87,65 @@ 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);
throw error;
}
}

// 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 }
);
Comment thread
krassowski marked this conversation as resolved.

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);
Comment thread
krassowski marked this conversation as resolved.
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) {
Expand Down
90 changes: 89 additions & 1 deletion test/e2e/python-env.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
}
});
21 changes: 21 additions & 0 deletions test/e2e/smoke.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean> };
}).electronAPI.isDarkTheme()
);
Comment thread
krassowski marked this conversation as resolved.
expect(typeof isDark).toBe('boolean');
} finally {
await app.close();
cleanup(userDataDir, jupyterDir);
}
});
Loading