Skip to content

Revival: Electron 41, test infra, deps, CI #951

Description

@notluquis

This is a comprehensive tracking issue for reviving active maintenance of JupyterLab Desktop. Supersedes #919 (Electron v38, EOL).

Work branch: phase3/electron-42 for Phase 3; Phase 1–2 PRs land directly on master. (The earlier revival/electron-41 branch was retired: it carried obsolete Phase 2 commits already on master plus security work that belongs in the private advisory process, so Phase 3 starts from a clean branch off current master.)

Current state:

  • Electron 27.0.2 — EOL, 15 majors behind (current: 42.x)
  • JupyterLab at 4.5.7 (Update to JupyterLab v4.5.7 #950 merged)
  • TypeScript 4.2 (current: 6.x); no React in the source, the @types/react devDeps are dead weight to drop
  • Apple cert and CI secrets expired during the maintenance gap status unconfirmed from the code (no signed release since v4.2.5-1, 2024-08-29) — see To verify
  • macOS Tahoe perf regression on current Electron (Electron-based apps cause a huge system-wide lag on macOS 26 electron/electron#48311) moved to To verify (can't confirm applicability from the code)
  • Several draft security advisories pending fix/publication before next release

Previous Electron upgrade attempt: PR #910 (Electron 37, self-closed) — not merged, not rebased onto current branch. Build issues documented in #919. Phase 3 starts from scratch.


Phase 1 — Dependency security fixes

Triaged the Dependabot bumps by where each dep runs (yarn why). Build/dev-only deps don't ship in the asar (yarn 1 omits devDeps), so their runtime risk is null even when the PR is stuck.

AI-assisted (Claude Code). Rebases verified locally with yarn build.


Phase 2 — Test infrastructure

Stacked PRs against master, each atomic:


Phase 2b — Security advisories

Several draft security advisories are being addressed through GitHub's private advisory process before the next release.

  • 2 advisories (macOS-specific hardening): fixes complete, PRs pending via private advisory forks
  • 1 advisory (renderer attack surface): fix complete, PR pending via private advisory fork
  • 1 advisory (IPC token leak via third-party content): fix not yet started
  • 1 advisory (nodeIntegration/contextIsolation): closed by the Phase 3 pre-bump PR that makes contextIsolation/nodeIntegration/sandbox explicit on every view and window (no longer blocked on the Electron refactor)

Phase 3 — Electron 41 42 upgrade

Target: Electron 41.x Electron 42.x (41 is EOS ~25 Aug 2026, 42 shipped; the cumulative breaking-change set is the same). Grep-verified against this codebase, so the real surface is small (most generic breaking changes don't apply here).

Pre-bump PRs (land on master first)

The Electron bump can't be a single safe step. The TypeScript stack has to move first (Electron 42 runs on Node 24, which forces @types/node up, which forces TypeScript up), and a few code/CI changes de-risk the packaged build. These go up as separate PRs on master before the bump branch opens. The first two are ordered; the rest are independent. All of the items below are built and verified locally; PRs open once the set is complete.

  • Type-check gate — a dedicated tsc --noEmit workflow on the current TypeScript 4.2, baseline zero errors. The net for the next PR. (publish.yml only runs tsc inside the slow installer job, so a fast gate is net-new.)
  • Type + lint modernization — TypeScript 4.2 → 6.0, @types/node 14 → 24, @typescript-eslint 5 → 8, ESLint 8 → 10 (flat config, .eslintrc.jseslint.config.mjs, tseslint.config()), removed the dead @types/react/@types/react-dom/eslint-plugin-react. Forced here because @types/node@24 needs TS ≥ 5.3 and @typescript-eslint@8 supports TS <6.1. Verified outcomes: moduleResolution: nodenext (not the deprecated node), strict: false to preserve the 4.2 level (strict-null migration deferred), skipLibCheck for the transient @types/node@24 vs electron@27 clash, preloads moved to import electron = require('electron') (native ipcRenderer types, runtime stays a sandbox require), and fast-xml-parser 5.7 → 5.8 which fixed an upstream .d.cts typo and removed any need for ignoreDeprecations. Green on tsc/eslint/prettier/build/430 unit/e2e; news feed re-verified against the live feed.
  • P3-AcontextIsolation/nodeIntegration/sandbox explicit at the seven construction sites via a shared hardenedWebPreferences helper. Closes the nodeIntegration/contextIsolation advisory. sandbox:true verified non-breaking (the 12 preloads use only contextBridge/ipcRenderer) by unit + e2e in real runtime.
  • P3-B — a JLAB_DESKTOP_E2E_MODE=1 opt-out for app.relaunch() + requestSingleInstanceLock so packaged-build Playwright can launch the real artifact. Unit-tested with a mutation check; the existing e2e still pass (default behavior preserved).
  • P3-Cscripts/stub-env-tar.js producing a minimal env_installer/jlab_server.tar.gz so electron-builder --dir runs in CI without conda-lock, with a unit test on the tarball. The bundled env is unused in e2e (Python comes from JLAB_TEST_PYTHON_PATH).

Code changes (what the app actually uses)

  • BrowserViewWebContentsView (deprecated v30, removed v42) — 12 call sites in sessionwindow.ts (addBrowserViewcontentView.addChildView, removeBrowserViewremoveChildView, getBrowserViews().children) plus the BrowserView type in 4 view classes. setBounds/setBackgroundColor/webContents unchanged. Verified safe: all 4 views already call setBackgroundColor in their constructors, so the transparent-by-default change needs no new calls. Real leak introduced: WCV does NOT auto-destroy webContents on removeChildView (BrowserView did) — needs explicit webContents.close() at the three disposal sites (sessionwindow.ts:265, :1181, :1191); the reused progress/popup views must NOT be closed. Review hazard: SessionWindow has its own contentView getter that now reads next to win.contentView. Highest-risk migration, isolate in its own commit. Risk: HIGH.
  • File.pathwebUtils.getPathForFile() (removed v32) — welcomeview.ts:532 drag-drop. Trap: the handler lives in a data:text/html <script> (page/renderer world), where webUtils is unavailable, so it cannot be swapped in place. Fix is two-part: add getPathForFile to the welcomeview preload's contextBridge surface (webUtils.getPathForFile(file)), and have the page script call window.electronAPI.getPathForFile(file). Risk: MEDIUM (the renderer/preload boundary, no unit coverage for data: page scripts).
  • contextBridge whole-ipcRenderer ban (v29) — all 12 preloads already wrap each call (verified test: characterize the 12 preload contextBridge contracts #971); confirm still compliant after the bump. (draft: compliance assumed from current code, not tested on E42)
  • clipboard out of the renderer (v40) — copyToClipboard already routes via IPC (preload), not navigator.clipboard; confirm. (draft: assumed compliant, not tested on E42)

N/A here (grep-confirmed, no usage in src/): webRequest urls:[]['<all_urls>'] (v35) · session.setPreloadsregisterPreloadScript (v35) · registerFileProtocol/baseURLForDataURL (v33) · crash events→render-process-gone (v29) · WebContents nav→navigationHistory (v32) · setAutoResize removed (already manual setBounds).

Config / build

  • Electron-side dependency bumps, landed together on the Phase 3 branch (the type stack already moved to the pre-bump PR above): electron ^27 → ^42, electron-builder ^24.9 → ^26.x, electron-notarize ^1.2 → @electron/notarize ^3 (v3 is ESM-only → dynamic import() from the CJS notarize script; drop the removed tool and appBundleId keys), electron-log ^4.4 → ^5.4 (split import electron-log/main + log.initialize(); the rest of the API this app uses is unchanged), update-electron-app ^2 → ^3 (named export, still CJS-compatible).
  • electron-builder v26 electronFuses block (cookie encryption, asar integrity, only-load-app-from-asar, no NODE_OPTIONS, no inspect args, runAsNode off). Net-new (there is no afterPack today, only afterSign: notarize); builder v26 flips fuses after pack, before sign, so no resetAdHocDarwinSignature is needed. Caveat: runAsNode: false + the no-NODE_OPTIONS/no-inspect fuses overlap the macOS entitlements track (the allow-dyld-environment-variables etc. in build/entitlements.plist). Do NOT tighten entitlements in the fuses commit; coordinate with that separate work so they don't collide. Validate a packaged build launches per-OS before enabling on CI (fuses can make an app fail Gatekeeper). Risk: MEDIUM-HIGH.
  • mac.minimumSystemVersion: "12.0.0" (macOS floor: v33→11, v38→12).
  • Linux Wayland default (v38, --ozone-platform=auto) — keep auto or pin --ozone-platform=x11.
  • No native node modules in the tree (the bundled Python is conda, not a node addon), so the Node-ABI / C++20 / @electron/rebuild concern is N/A here; only electron-builder rebuilds its own runtime.

Verify only: macOS notifications now require code-signing (v42) · PDFs share the host WebContents via OOPIF (v41) · GTK4 default on GNOME (v36) · the electron npm package no longer downloads its binary in postinstall (v42) → CI install/cache impact · setTrafficLightPositionsetWindowButtonPosition and ipcRenderer.sendTo removed (v28) if ever used.

e2e hardening (Phase 3, golden standard — diagnosed 2026-05-28)

The Phase 2 e2e (#976) launches the DEV build (electron.launch({ args: ['.'] })) on Linux only, with a system-unzip step because electron 27's postinstall extract-zip drops the binary on the runner. Two upgrades belong here, where Electron/packaging change anyway:

  • Provisioning: at Electron 42 the npm electron postinstall no longer downloads the binary, so the current node install.js+unzip step stops working. The golden standard is to stop launching node_modules/electron entirely: package with electron-builder --dir and launch the real artifact via electron-playwright-helpers findLatestBuild() + parseElectronApp() (electron.launch({ executablePath, ... })). Verified locally: parseElectronApp resolves the electron-builder dist/<platform> layout and the packaged app launches + exposes CDP. The launch-stall BLOCKER (the packaged app's requestSingleInstanceLock + app.relaunch() make the monitored process exit) is resolved by P3-B (the JLAB_DESKTOP_E2E_MODE opt-out) + P3-C (the stub jlab_server.tar.gz for the extraResources copy).
  • Cross-OS macOS leg: e2e now runs a [ubuntu, macos] matrix (fail-fast: false), the Linux-only Electron-unzip workaround + xvfb-run guarded to ubuntu, plain launch on macOS. Built locally (the macOS leg verified by reading, runs in CI). Windows leg follows with the packaged-build switch.
  • Jupyter server isolation: the e2e helper now redirects HOME + JUPYTER_CONFIG_DIR + JUPYTER_DATA_DIR + JUPYTER_RUNTIME_DIR to per-launch temp dirs (pytest-jupyter jp_environ parity), so the spawned server never touches the runner's real ~/.jupyter/~/Library/Jupyter. Verified empirically: jupyter wrote jpserver-*.json into the temp and zero new files into the real runtime dir. Note: on macOS Electron's app.getPath('home') ignores $HOME (NSHomeDirectory), so HOME isolates only the spawned python server, not Electron's own logs; --user-data-dir covers Electron's userData. Added a unit test on server.ts asserting JUPYTER_CONFIG_DIR = userData.
  • Packaged-build provisioning (the remaining Phase 3 e2e): at Electron 42 the npm electron postinstall no longer downloads the binary, so package with electron-builder --dir and launch via parseElectronApp() + findLatestBuild(). The launch-stall blocker is unblocked by P3-B (e2e-mode) + P3-C (stub tar). This pulls the Windows leg in too.

Testing (2026 golden standard): stay on Playwright _electron (the Electron docs pick no winner between it, WebdriverIO and a custom driver; move to @wdio/electron-service only if main-process API mocking becomes a wall). Do not e2e the JupyterLab view through Electron — it is a web app on an HTTP URL, so test it with a plain Playwright browser against the running server; reserve full-app e2e for the Electron glue (the right windows/views appear, IPC, lifecycle).

Testing gaps to add during Phase 3 (draft — spike first): today's e2e is first-run with no Python env, so welcome actions are disabled and the labview/dialogs are never reached. Add, in a Playwright fixture (alongside the per-test --user-data-dir):

  • Seed-a-Python-env mechanism + welcome-actions-enable: DONE in Phase 2 (test: Playwright e2e suite (first-run smoke + env-backed welcome) #976) — seeds app-data.json pythonPath (the real seam; NOT JUPYTERLAB_DESKTOP_PYTHON_PATH, which the app doesn't read), CI provisions a venv with jupyterlab.
  • e2e glue: "New notebook" boots a server and the labview reaches its URL — DONE in Phase 2 (test: Playwright e2e suite (first-run smoke + env-backed welcome) #976). The session reads its env from settings.json (pythonPath) + app-data.json (userSetPythonEnvs registers it); both are seeded. waitForWindowByUrl asserts the local server URL without driving the JupyterLab DOM.
  • JupyterLab content smoke uses plain @playwright/test (already a devDep, zero new deps) against the embedded Jupyter server URL. Initial scope: assert .jp-FileBrowser + .jp-Launcher render in the labview page after waitForWindowByUrl; deeper coverage (open a notebook, run a cell) deferred unless needed. The org standard is @jupyterlab/galata, but it's rejected here on dep weight: it pulls ~20 packages (half of @jupyterlab/* + vega/vega-lite/@stdlib) and exists to test JupyterLab's own UI in depth — we only need "the labview loads", not JupyterLab-internals coverage.
  • Visual regression (if adopted) = Playwright native snapshots + a comment-triggered please update snapshots CI bot that regenerates baselines in CI (port jupyter/notebook's playwright-update.yml, gated to OWNER/COLLABORATOR/MEMBER). Generate baselines in CI, never locally. This is the org's bulletproof pattern; supersedes Fix Playwright tests to properly launch JupyterLab Desktop and capture distinct UI screenshots #909's hand-committed PNGs (no CI-regen bot) — close Fix Playwright tests to properly launch JupyterLab Desktop and capture distinct UI screenshots #909.
  • Unit stays on vitest (ahead of the org, which is jest-29-except-lumino) — intentional for the Node-side Electron logic; alignment that matters is the Galata/visual layer above.
  • Spike first (prove the stack assumption before building): confirm Playwright/CDP attaches to the labview WebContentsView and waitForWindowByUrl resolves on the launched app. (draft)

Commit + revert hygiene for Phase 3

Each substantive change is its own atomic, build-green commit (revertible on its own if a PR is ever rebase-merged instead of squashed). Phase 3 is split into separate PRs (the pre-bump PRs plus the bump) so revert granularity on master is per concern. CI-debug iterations (diagnostics, install-tooling dead-ends) get folded into the commit whose fix they justify rather than kept as standalone commits, since they are not atomic units and only add noise to the squashed body. Per-commit AI disclosure stays.


Phase 4 — Native cleanup, drop deps

The TypeScript/ESLint modernization moved up to Phase 3 (the Electron bump forces it). What's left is replacing deps with native Node 24 / Electron 42 builtins, area by area, so each PR stays reviewable. A dependency audit (usage grepped against native equivalents) sorted these:

Build-script-only, native fs, low risk (these never ship in the asar):

  • fs-extrafs.cpSync / fs.rmSync / fs.mkdirSync({ recursive: true }) (3 build scripts)
  • rimraffs.rmSync({ recursive: true, force: true }) (only in npm scripts)
  • meowutil.parseArgs (buildutil.js only; not yargs, which stays in the production CLI)
  • shx → native fs in the one cp script
  • node-watchfs.watch (copyassets.js dev watch)
  • webpack + webpack-cli → esbuild for the preload bundling (the only thing webpack does here; ~25 MB of devDeps gone)

Runtime, worth it but needs care, one area at a time:

  • ejs → template literals. Nine dialogs, and some templates do have logic (ternaries, injected conditionals), so this is per-dialog and never eval.
  • fix-path → internalize. It wraps shell-pathshell-env, which spawns the user's login shell to capture the real PATH (pyenv, conda, homebrew, nvm). A hardcoded path list would break Python-env discovery, so the replacement has to keep the shell spawn.

Opportunistic dep bump:

  • tar 6 → 7 (v7 ships its own types, so @types/tar goes; tar.x still exists, the one extraction call survives)
  • prettier 2.1 (2020) → 3.x as a dedicated format-only PR, kept off the bump diff

Keep (no reasonable native equivalent): @lumino/signaling, fast-xml-parser, js-yaml, semver, which, winreg, yargs, istextorbinary, @leeoniya/ufuzzy, @jupyter-notebook/web-components, read-package-tree (deprecated but the replacement is fragile).

  • Remaining Dependabot PRs (webpack folded into the esbuild move, js-yaml, CI actions)

Phase 5 — JupyterLab version catch-up


To verify / external blockers (can't confirm from the code; need Foundation/org or a tagged release run)

  • Apple Developer cert renewal (NumFocus → Linux Foundation migration) — without updated CSC_LINK/APPLEID/APPLE_TEAM_ID secrets, no signed macOS releases
  • GitHub Actions secrets renewal (expired during maintenance gap)
  • Snap Store credential refresh (SNAPCRAFT_STORE_CREDENTIALS)
  • Whether the macOS signing secrets are actually still expired — PR installer builds are unsigned (CSC_IDENTITY_AUTO_DISCOVERY=false off-release), so green PR CI does not test signing; only a tagged release run does. Last signed release: v4.2.5-1 (2024-08-29).
  • macOS Tahoe perf regression (Electron-based apps cause a huge system-wide lag on macOS 26 electron/electron#48311) — confirm it still reproduces and whether the target Electron (42) addresses it

I'll handle all the engineering. Phases 1–5 are unblocked on my end. The governance items need someone from the Foundation or org side.

@krassowski @ijstokes — need someone to approve and merge PRs once CI is green. Even one review every few weeks unblocks this.

@afshin — any movement on the Apple cert from the Foundation side? That's the hard blocker for signed macOS releases.

Metadata

Metadata

Assignees

No one assigned

    Type

    Fields

    No fields configured for Task.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions