Skip to content

perf(e2e): adopt a pre-baked toolkit venv instead of provisioning per suite - #470

Draft
tkislan wants to merge 19 commits into
mainfrom
perf/e2e-prebaked-venv
Draft

perf(e2e): adopt a pre-baked toolkit venv instead of provisioning per suite#470
tkislan wants to merge 19 commits into
mainfrom
perf/e2e-prebaked-venv

Conversation

@tkislan

@tkislan tkislan commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

@coderabbitai ignore

Profiling a full E2E run (32039112714) showed the runtime is almost entirely setup, not testing:

18 min total  =  2m08s CI setup  +  15m56s mocha
mocha 948s    =  837s before() hooks (88%)  +  ~111s test bodies (12%)
837s setup    =  402s venv provisioning  +  ~435s per-suite workspace setup

Four suites each created a differently-named Deepnote environment, and every distinct name means another venv plus a full deepnote-toolkit pip install:

Suite Env name before()
initNotebookRunner E2E Init Env 197s
environment #1 E2E Hello Env 121s
environment #2 E2E Delete Env 84s
integrationsEnvFileInjection E2E Integrations Env not yet profiled
helloWorld / snapshots reused E2E Hello Env 36s / 55s

The two that reused a name cost a fraction of the ones that didn't — the sharing pattern already worked, and two suites had simply drifted off it. Environments are global (globalState + globalStorageUri/deepnote-venvs), so sharing a name is safe regardless of which workspace is open.

Changes

One name, one place. SHARED_ENV_NAME in test/e2e/helpers/constants.ts, imported by all five sharing suites. 'E2E Delete Env' stays a literal — that suite deletes what it creates, so it must not share. This is what stops the drift recurring.

Bake the venv once. build/e2e/prepareE2eVenv.js creates .venv-e2e with the exact set deepnoteToolkitInstaller installs, reading DEEPNOTE_TOOLKIT_VERSION from source so it cannot drift. Idempotent and self-healing — a venv that cannot import deepnote_toolkit is discarded and rebuilt, which also covers a restored cache whose base interpreter moved.

Adopt it instead of building one. createEnvironment now selects the interpreter deterministically rather than selectQuickPick(0). getVenvPathIfInVenv makes the extension adopt any interpreter already inside a venv, and ensureVenvAndToolkit returns early once the toolkit imports — so adoption skips venv creation and pip entirely.

The deletion suite keeps building its own. It opts out with createEnvironment(name, { useManagedVenv: true }). deleteEnvironment removes the venv directory for managed environments only, so adopting the baked venv there would silently stop exercising that teardown. It stays self-contained and remains the one place real managed creation + teardown is covered.

Generated settings. The interpreter must be named by absolute path, known only at run time, so the script also emits test/e2e/settings.generated.json (base settings + python.venvPath + python.defaultInterpreterPath) and the extest scripts point at it. Gitignored.

Also: the pip cache had been frozen since its first save

actions/cache only writes on a miss, and the key was a bare content hash that kept hitting:

Cache hit occurred on the primary key pip-Linux-py312-2007778…, not saving cache.

Three of the four installed specs are unpinned and installed with --upgrade, so newer wheels were downloaded every run and never written back — the entry stayed at whatever existed when the key was last busted. The key now carries github.run_id, with restore-keys falling back to the newest matching entry, so each run starts warm and saves a refreshed copy.

Expected impact

mocha total
Before 15.8 min 18 min
After ~11 min ~13 min

Projected from measured per-suite costs, not observed — see below.

Verification

Done: npm run compile-e2e exits 0; the workflow YAML parses and step order is correct; the toolkit-version regex resolves 2.1.1 against the real source; settings generation produces valid JSON. npm run lint covers src only, so nothing here is linted by CI.

Not done — no E2E run has executed against this. The behavioural assumption that needs CI to confirm is that the Python extension surfaces the baked venv in the interpreter quick pick via python.venvPath. If it does not, the helper warns with no interpreter under .venv-e2e was offered and falls back to the old behaviour, so the run stays green and merely slow. Check the E2E log for that warning before trusting the numbers.

Follow-up, not in this PR

The remaining ~435s of setup is openFolderViaDialog reloading the workbench once per suite, across 17 suites at 14–55s each. After this change that is the dominant cost, and it is a fixture restructure rather than a caching problem.

🤖 Generated with Claude Code

https://claude.ai/code/session_01MyZCw9GzL6Vq7S9MAVYwby

… suite

Profiling a full E2E run (CI 32039112714) showed 88% of the 15.8 min mocha
phase is before() hooks, not assertions: 837s of setup against ~111s of test
bodies. 402s of that setup was provisioning Python venvs, because four suites
each created a differently-named Deepnote environment and every distinct name
means another venv plus a full deepnote-toolkit pip install.

Environments are global (globalState + globalStorageUri/deepnote-venvs), so a
shared name is safe across suites regardless of which workspace is open, and
the sharing pattern already existed — two suites carried a "shared env so CI
provisions one venv" comment. initNotebookRunner (197s, the single most
expensive suite) and integrationsEnvFileInjection had each drifted onto their
own name.

Rather than keep fixing that by hand, the name now lives in one constant and
the venv itself is baked once:

- SHARED_ENV_NAME in test/e2e/helpers/constants.ts, imported by all five
  sharing suites. 'E2E Delete Env' stays a literal — that suite deletes what it
  creates, so it must not share.
- build/e2e/prepareE2eVenv.js bakes .venv-e2e with the exact set
  deepnoteToolkitInstaller installs, reading DEEPNOTE_TOOLKIT_VERSION from
  source so it cannot drift. It is idempotent and self-healing: a venv that
  cannot import deepnote_toolkit is discarded and rebuilt, which also covers a
  restored cache whose base interpreter moved.
- createEnvironment now selects the interpreter deterministically instead of
  selectQuickPick(0). getVenvPathIfInVenv makes the extension adopt any
  interpreter already inside a venv, and ensureVenvAndToolkit returns early once
  the toolkit imports, so adopting the baked venv skips creation and pip
  entirely. A missing venv warns loudly and falls back, keeping the run slow
  rather than red.
- The deletion suite opts out via createEnvironment(name, { useManagedVenv:
  true }). deleteEnvironment only removes the venv directory for managed
  environments, so adopting the baked venv there would silently stop exercising
  that teardown.
- The interpreter path is only known at run time, so the script also emits
  test/e2e/settings.generated.json (base settings + python.venvPath +
  python.defaultInterpreterPath) and the extest scripts point at it.

Also fixes the pip cache, which had been frozen since its first save:
actions/cache only writes on a miss, and the key was a bare content hash that
kept hitting. Three of the four installed specs are unpinned and installed with
--upgrade, so newer wheels were downloaded every run and never written back.
The key now carries github.run_id with restore-keys falling back to the newest
matching entry, so each run starts warm and saves a refreshed copy.

Verified: tsc (compile-e2e) exits 0, the workflow YAML parses and its step order
is correct, the toolkit-version regex resolves 2.1.1 against the real source,
and settings generation produces valid JSON. NOT verified: no E2E run has
executed against this. The behavioural assumption that needs CI to confirm is
that the Python extension surfaces the baked venv in the interpreter quick pick
via python.venvPath.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MyZCw9GzL6Vq7S9MAVYwby
@codecov

codecov Bot commented Aug 19, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 0%. Comparing base (aff145f) to head (3af14be).
✅ All tests successful. No failed tests found.

Additional details and impacted files
@@     Coverage Diff     @@
##   main   #470   +/-   ##
===========================
===========================
🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

tkislan and others added 12 commits August 19, 2026 20:16
… the row

The deletion suite failed on the first CI run of this branch:

  ElementClickInterceptedError: element ... is not clickable at point
  (601, 101). Other element would receive the click: <p>...</p>
    at QuickPickItem.select (page-objects/.../Input.js:276:9)
    at selectInterpreter (out/e2e/helpers/deepnoteEnvironment.js:58:5)

QuickPickItem.select() is a bare click(), and a quick-pick row's description
<p> overlaps the row and swallows positional clicks. selectEnvironmentForNotebook
already documents exactly this and works around it by typing and pressing Enter;
the new managed-venv branch reached for select() and walked straight into it.

The baked-venv branch was unaffected because it already filters by typing. Only
the managed branch needs positional selection, since "any interpreter that is not
the baked venv" cannot be expressed as a filter string — the baked venv's path
contains /bin/python too, so every candidate filter also matches it.

Now walks the highlight with ARROW_DOWN and accepts with ENTER, both sent through
driver.actions() so the arrows and the Enter share one focus context and the
highlight cannot shift in between.

Confirmed from the same run that the rest of the change works: the baked venv was
adopted (zero "no interpreter under .venv-e2e was offered" warnings), the bake
took 66s on a cold cache, and 53 tests passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MyZCw9GzL6Vq7S9MAVYwby
…ct id

Each of the 20 describes opened its own temp folder, and opening a folder
reloads the workbench. That reload — plus the dialog driving around it, which
re-clicks OK every 400ms because the simple dialog navigates one level per
click — was ~435s of the 837s spent in before() hooks.

All fixture copies now live in subdirectories of one root that rootHooks opens
once; openFolderViaDialog short-circuits for anything already inside it, so the
suites keep calling it and 19 of the 20 reloads disappear. Quick Open stays
unambiguous even though marketing-overview.deepnote is used by seven suites,
because every describe already removed its own temp directory in after() (20
describes, 20 cleanup calls) — only one suite's subdirectory exists at a time.

Losing the reload loses the isolation it provided, so each copy's project id is
rewritten to a fresh one. That is what the cross-suite caches key on: the
notebook manager (keyed projectId -> notebookId), the tree's groupItemCache, and
the exact (projectId, notebookId) lookups in the file watcher and snapshot
service. Fixtures deliberately share ids across families — seven suites use
project eeee…, three use bbbb… — so without this, one suite's cached project
would answer for the next one's freshly copied file.

Notebook and block ids are deliberately left alone: suites assert on them
directly (snapshots keys off 'e-nb-overview'), and a unique project id already
makes every (projectId, notebookId) pair unique.

Details worth knowing:

- The rewrite is per copy *set*, not per file. Seven suites build sibling sets
  with raw fs.copyFileSync, which would have split one project in two — siblings
  now go through copyFixtureIntoDir and share the directory's mapping. A set
  that mixes projects on purpose (explorerGrouping sits marketing siblings next
  to quick-notes and asserts two groups) still maps distinct ids distinctly.
- Snapshot filenames encode the project id (buildSnapshotPath ->
  generateSnapshotFilename), so copySnapshotIntoDir renames the file in lockstep.
  Without it the snapshot is simply never found — no error, just a missing
  output.
- Not every fixture uses a uuid: hello-world and integrations-env-file use slugs
  like e2e-hello-world-project. Fresh ids keep the shape and deliberately share
  no prefix with the id they replace, and replacement refuses to match when the
  id is only the start of a longer one.
- environment and statusBar asserted committed project ids; both now read
  copy.projectId.

Verified: tsc exits 0, and a harness exercising the compiled helpers against all
13 committed fixtures checks that every project id is rewritten and unique, that
siblings inherit the family id, that a deliberately different project stays
separate, that snapshot content and filename are rewritten together, that two
copies of one fixture get different ids, and that cleanup removes the directory.
NOT verified: no E2E run yet — the reload removal and the shared workspace are
behavioural and need CI.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MyZCw9GzL6Vq7S9MAVYwby
The E2E job ran all 17 suites in one sequential mocha process. Splitting it
across a matrix cuts wall time, but a split has to be maintainable: a list of
files balanced by measured duration goes stale the moment someone adds a suite,
and the way it goes stale is invisible.

Grouped by what the suites cover instead, one directory per shard:

  kernel/     4  running code and environments
  files/      6  the .deepnote file lifecycle — split, create, rename, delete
  workspace/  7  surfacing notebooks and reacting to changes on disk

A new suite has an obvious home, and the weights happen to land close enough
(~150s / ~100s / ~110s against the post-venv-share profile) that no shard is
much more than 1.5x another. Balance is a consequence of the grouping rather
than something to re-tune.

Each shard pays the ~2m08s of setup again, which is what caps the return: three
shards is where the ratio peaks, and past four the setup dominates.

check:e2e:groups guards the failure mode that matters. A suite in the wrong place
does not fail anything — it silently never runs, which looks like a faster green
build. The check fails when a suite sits outside a group directory, when a group
has no matching npm script, when a group is not named in the workflow, and when a
script has no directory. Verified it exits 1 on a stray suite and 0 once moved
back.

Screenshot artifacts are per shard, since three jobs uploading one name collide.

Verified: tsc exits 0 after the move, the compiled tree matches the shard globs
(4/6/7), the workflow parses with the expected matrix, and the guard passes. NOT
verified: no E2E run yet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MyZCw9GzL6Vq7S9MAVYwby
The group names were written twice — in the matrix and, indirectly, in the
check that ran inside every shard. YAML anchors would be the natural fix but
GitHub Actions does not support them (actions/runner#1182), and a
workflow-level `env` does not help either since `env` is not a permitted context
inside `strategy`. A job output is the one mechanism that lets two jobs read the
same value, so a `groups` job now declares the list and both the matrix and the
verification job consume it.

The check also moves out of the shards into its own `verify-groups` job. It
needed neither VS Code nor the VSIX, so running it three times behind a full
setup was waste; it now runs once on a bare checkout and reports as its own
status.

checkSuiteGroups.js takes the authoritative list from E2E_GROUPS — the same job
output the matrix reads, so the two cannot drift — and falls back to inferring it
from the `test:e2e:<group>` scripts when run locally. It fails when a suite sits
outside a group directory, when a suite directory is absent from the list (the
case this job exists for: nothing runs it and the build just goes green sooner),
when a listed shard has no directory or no suites, and when a shard has no npm
script.

Cost is one extra job on the critical path, roughly 15s of scheduling before the
shards start.

Verified all four states: green with the list inferred locally, green with
E2E_GROUPS set, red naming test/e2e/suite/workspace/ when the list omits it, and
red naming a listed shard with no directory.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MyZCw9GzL6Vq7S9MAVYwby
Throwaway workflow, workflow_dispatch only so it never runs by itself. GitHub
added YAML anchor support in September 2025, but no source states whether the
workflow schema accepts a custom top-level key to hold one, and the parser
rejects unknown top-level keys in general. Pushing this asks GitHub directly
without risking e2e.yml.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
I claimed GitHub Actions does not support YAML anchors. That was wrong — support
shipped 2025-09-18; the runner issue I cited had been closed by it. The previous
commit's `groups` job existed only to work around a limitation that no longer
exists, so it is gone.

Two constraints do survive, and they decide the placement:

- Merge keys (`<<:`) are still unsupported, so an anchor can only be aliased
  whole.
- A custom top-level key to hold the anchor is rejected. Probed it directly
  rather than guessing: a throwaway workflow with `x-e2e-groups: &e2e-groups
  [...]`, dispatch-only so it could not run on its own, still produced a
  push-triggered run with zero jobs and no logs — GitHub's signature for an
  invalid workflow file. So the list cannot sit at the top of the file; it is
  declared at its first use, the earliest the schema allows.

The list is now written once, anchored on the shard matrix. verify-groups reads
it back out of the workflow with js-yaml, which resolves the anchor — so the
check runs against the list the shards actually use rather than a copy of it.
That also drops the E2E_GROUPS plumbing; the variable survives only as the seam
that lets the failure paths be exercised without editing the workflow.

verify-groups no longer gates the shards (no `needs`), so a grouping mistake
reports as its own red status while the E2E results still come through. It
installs with --omit=dev since js-yaml is a production dependency.

Verified: the matrix resolves to [kernel, files, workspace] through the anchor,
the check passes reading it from the workflow, and both failure paths still name
the offender — a suite directory outside the matrix, and a shard with no
directory.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MyZCw9GzL6Vq7S9MAVYwby
The previous two attempts were both worse than the problem. A job output existed
only to route around a limitation that had been lifted; then an anchor that could
not be placed where it belonged, paired with a checker that parsed the workflow
to read its own matrix. Both were elaborate ways to keep two copies of one list
in agreement.

There is no second copy now. A discover job lists the directories under
test/e2e/suite/ and emits them as an output; the matrix is that output. A new
group directory gets a shard on its next run, and no directory can be left unrun,
because the listing is the list.

That removes the anchor, the workflow self-parsing, checkSuiteGroups.js, the
verify job, and the three per-group npm scripts — which were themselves a second
list. The shard runs the glob directly, so the group name appears in exactly one
place.

Artifacts would have been needed for the post-check as sketched: matrix legs all
write the same outputs map and the last writer wins, so a shard cannot report its
own name back. Deriving the matrix instead makes the comparison unnecessary
rather than making it work.

The two things still worth failing on are cheap and stay in the discover job: a
suite outside a group directory, and a group directory with no suites. Both would
otherwise pass silently — nothing runs them and the build goes green sooner.

Verified: the scan emits ["files","kernel","workspace"] against the real tree,
and on throwaway trees it exits 1 naming loose.e2e.test.ts for a stray suite and
naming hollow/ for an empty group.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MyZCw9GzL6Vq7S9MAVYwby
Keeps the matrix an explicit list, as it was to begin with, and checks it against
the directories after the fact rather than deriving one from the other.

Each shard touches a file named after its group and uploads it; verify-coverage
downloads them all and fails for any test/e2e/suite/*/ directory that no shard
reported. Adding a directory without adding it to the matrix is the mistake this
catches, and it is worth catching because nothing runs it — the build just goes
green sooner.

Artifacts rather than job outputs because matrix legs share one outputs map and
the last leg to finish wins, so a leg cannot report its own name that way.

The record is written with `if: always()`, so a shard whose tests failed still
counts as having run — otherwise a failing shard would also be reported as an
uncovered directory. The job itself runs on `!cancelled()` rather than `always()`
because this workflow sets cancel-in-progress, and on a superseded run `always()`
would report every directory as unrun.

Verified the comparison on the real directory tree: green when all three shards
report, and red naming test/e2e/suite/workspace/ when only two do. Also confirmed
the stray-suite branch fires, and that with no records at all every directory is
reported — the case the cancellation guard exists to keep out of CI.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MyZCw9GzL6Vq7S9MAVYwby
Every shard failed with 0 passing: 5 suites in kernel, 10 in workspace, each
timing out on "<fixture>.deepnote did not open" or, for explorerGrouping, on the
project group never appearing.

The shared fixtures root was never opened. ExTester's runner does not fire
`mochaHooks.beforeAll` — the log puts "Launching tests..." at 15:19:33.6461639
and the first suite title at 15:19:33.6553243, 9ms later, while that hook does a
waitForWorkbench and drives a folder dialog. It returned without running, so VS
Code had no workspace folder at all. Quick Open then matched nothing, confirmed
an empty result, and no editor opened — which surfaces as a timeout waiting for
the editor rather than as a failure to find the file, so the errors pointed away
from the cause.

openFolderViaDialog now opens the root itself, on the first request for anything
inside it, and no-ops afterwards. The one reload still happens once, and it no
longer depends on a hook that never runs. rootHooks keeps only its afterEach,
with a note about why nothing else can live there.

removeFixturesWorkspaceRoot went with the root hook that called it. Each suite
already removes its own subdirectory in `after`; what is left behind is one empty
mkdtemp directory per run.

Verified: tsc exits 0 and the fixture harness still passes all six checks. NOT
verified: this needs a CI run — the failure it fixes was only observable there.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MyZCw9GzL6Vq7S9MAVYwby
All three shards now pass locally: kernel 7, files 25 (+1 pending), workspace 23.
CI had kernel 3/3 and files 20/2.

Sharing one workspace removed the reload between suites, and the reload had been
doing more than opening a folder. Four things it hid:

- Editors leak. A suite that renames or deletes notebooks leaves tabs open on
  files that no longer exist, so the next suite starts with the wrong notebook
  active and finds no code cell in it — projectRename opened onto a struck-
  through marketing-overview-copy.deepnote left by notebookCommands. Every
  suite's own after() already called EditorView.closeAllEditors, but that clicks
  each tab's close button and fails on notebook tabs with
  ElementNotInteractableError, swallowed by the surrounding .catch. Closing via
  the palette works, and doing it centrally fixes every suite at once.

- The Deepnote Explorer keeps a group for a directory that has been removed. Two
  identical "Bootstrap Only" groups were visible, and findDeepnoteLeaf took the
  dead one, so the delete confirmation never appeared.

- The env sidecar is written to workspace.workspaceFolders[0]
  (deepnoteExtensionSidecarWriter.node.ts:300), which is now the shared root
  rather than the suite's directory, so the test read a path that never exists.
  Its mappings are keyed by project id and every copy gets a fresh one, so a
  shared file still isolates suites.

- Selecting a non-baked interpreter by walking the list with arrow keys landed on
  the wrong entry, leaving the deletion suite without a managed environment: env
  creation timed out and its kernel never bound. It now filters to the wanted
  interpreter and accepts with Enter, the same mechanism the baked-venv branch
  uses, and warns instead of silently picking something else.

The editor close and tree refresh live in openFolderViaDialog's no-op branch —
every suite already calls it in before(), so it is where a suite starts.

Also: .venv-e2e is excluded from the VSIX. Packaging pulled in 28846 files /
740MB and failed on a symlink; CI only escaped because it packages before the
bake step. Breadcrumbs are off in the E2E settings — they sit directly above the
notebook toolbar, and every run verified here had them disabled.

Verified by running each shard locally under xvfb against a real VSIX and a
clean VS Code profile.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MyZCw9GzL6Vq7S9MAVYwby
…npm scripts

Suites had drifted into two flows. Each called openFolderViaDialog(tempDir), but
that function no longer opened the folder it was handed — it inspected the path,
opened the shared root on the first call, and on later calls quietly did cleanup
instead. The name described one thing and the code did another depending on
which suite got there first.

Now there is one entry point. Every suite calls enterFixturesWorkspace() and gets
identical behaviour: the shared workspace open, no editors left by the previous
suite, and a Deepnote Explorer that matches disk. openFolderViaDialog goes back
to being a private primitive that opens exactly the folder it is given, and
isInsideFixturesWorkspaceRoot is gone — with a single entry point there is no
path to classify.

The E2E job runs `npm run test:e2e:ci` instead of an inline npx invocation, so
the flags live in package.json with the other test scripts and the shard comes
from E2E_GROUP.

All three test:e2e scripts now run setup:e2e:venv first. Without it a fresh clone
failed on a missing settings.generated.json, since that file is written by the
bake step — running the tests now provisions what they need. The separate CI bake
step is gone for the same reason: one way the venv gets made.

Verified after the refactor by running each shard locally under xvfb: kernel 7,
files 25 (+1 pending), workspace 23.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MyZCw9GzL6Vq7S9MAVYwby
The venv was provisioned by a build script that also wrote a generated settings
file, because the interpreter had to be named by absolute path and that path is
only known at run time. Settings are read when VS Code launches, so the script
had to run before the tests — a fresh clone that skipped it failed on a missing
file, and CI needed a step whose only job was to run it.

ensureManagedVenv() replaces all of that. It creates the venv if it is missing or
cannot import the toolkit, and is otherwise a single `import deepnote_toolkit` —
cheap enough for suites to call unconditionally. Creating the shared workspace
links the venv into it as `.venv`, which is where the Python extension looks with
no configuration at all.

So there is nothing to run first. Tests provision what they need, and the venv
stays at a fixed path that CI can cache. The workflow step is now only about
where the time is spent — it calls the same function, so skipping it would make
the run slower rather than break it.

Removed by this: build/e2e/prepareE2eVenv.js, settings.generated.json and its
gitignore entry, the python.venvPath / python.defaultInterpreterPath injection,
and the `npm run setup:e2e:venv &&` prefix on the test scripts. Net 116 lines
deleted against 26 added.

Verified locally under xvfb, all three shards green with no settings file
present: kernel 7, files 25 (+1 pending), workspace 23. The load-bearing
assumption — that the Python extension finds a `.venv` link inside the workspace
without configuration — was tested on its own first.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MyZCw9GzL6Vq7S9MAVYwby
@tkislan
tkislan force-pushed the perf/e2e-prebaked-venv branch from bdf48da to b6958ee Compare August 21, 2026 13:14
tkislan and others added 6 commits August 22, 2026 17:34
…k cheap

Two things the E2E venv helper was getting wrong on its own.

It duplicated the extension's pip specs. The installer cannot be reused directly
— it is an injected class built on workspace.fs, l10n and cancellation tokens,
running in the extension host, while this runs in the Mocha process where
`vscode` does not resolve; importing from src/ drags that whole graph in. But the
*spec* can be shared, so DEEPNOTE_TOOLKIT_PACKAGES now sits next to
DEEPNOTE_TOOLKIT_VERSION, the installer spreads it, and the helper reads both
from that one file. A package added for production can no longer leave the tests
provisioning an environment production never has.

Worth recording: the first attempt at reading that list back was wrong in exactly
the way this guards against. `\[([^\]]*)\]` stopped at the `]` inside
'python-lsp-server[all]' and silently yielded a two-package list, which would
have under-provisioned the venv without failing anything. Asserting the derived
list against the installer's caught it.

The health check also cost 14s per run. It used `import deepnote_toolkit`, which
executes the whole package and retries network calls to a userpod API that is not
there, dumping a page of warnings into every log. Reading installed distribution
metadata gives the same guarantee — right packages, right versions — in 0.15s and
silently.

Verified: tsc exits 0, 2612 unit tests pass, the derived spec matches the
installer's list exactly, and the check still fails on a wrong version and on a
missing package.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MyZCw9GzL6Vq7S9MAVYwby
`prompt.takeAction(SPLIT_ACTION)` used a Notification captured several steps
earlier, with a screenshot in between. The toast re-renders while it sits there,
so the reference goes stale and the hook dies with StaleElementReferenceError —
reproduced on two consecutive runs, always after 02-split-prompt and never
reaching 03-split-done.

It failed the whole describe rather than one test, because mocha's `retries: 1`
covers tests but not `before all` hooks.

Now locates and acts inside one wait loop, the same shape clickRunAll already
uses for the notebook toolbar and for the same reason.

Verified: the test passes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MyZCw9GzL6Vq7S9MAVYwby
… piecemeal

Sharing one workspace removed the per-suite window reload, and the reload turned
out to be isolating more than it looked: open editors, the Deepnote Explorer's
cached project groups, project data keyed by id, and the Python extension's
interpreter discovery. Each was fixable on its own, and each fix uncovered the
next — editors, then the tree, then discovery. That is not a list that ends.

So the reload comes back, but the expensive part does not. Opening a folder was
never slow because of the reload; it was slow because `File: Open Folder...`
navigates one level per OK click in the simple dialog, so reaching a path meant
re-clicking for up to FOLDER_OPEN_TIMEOUT. Keeping one workspace and reloading
inside it buys the isolation without the dialog.

Editors are closed before the reload rather than after: a reload restores what
was open, so a suite that renamed or deleted notebooks would otherwise hand its
dead tabs straight to the next one. The explorer refresh is gone — a fresh
extension host re-scans.

Not measured locally. This machine has degraded through a full disk, a venv built
against that full disk, and orphaned toolkit servers left by killed runs, and it
no longer produces a runtime number worth quoting. CI gets a clean machine per
run and three shards in parallel, which is where the comparison belongs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MyZCw9GzL6Vq7S9MAVYwby
kernel was the long pole at 10m10s while files and workspace both finished around
7m, so the whole run waited on it. Its 7 minutes of tests split cleanly along what
the suites actually cover:

  environments/  155s  the environment lifecycle — create, select, migrate on
                       split, delete and tear the server down
  execution/     266s  running code in a kernel — hello world, the sibling init
                       notebook, integration env vars reaching the process

Measured from the last CI run rather than guessed: initNotebookRunner alone is
170s (40% of the old shard), and environment's two describes are 84s and 71s.

Balance is a consequence of the split rather than its purpose, but it lands
reasonably: the heavier half is 266s against 155s, and since roughly 3.1m of each
job is fixed setup, the expected long pole drops from 10m10s to about 7.6m.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MyZCw9GzL6Vq7S9MAVYwby
…cript

Most of these explained a decision that the code or the surrounding config
already makes obvious, or recorded how something got here rather than what it
does. Kept the ones carrying context from outside the file: why the pip cache key
includes run_id, why the venv cache is keyed on the Python version, and why the
toolkit spec is read from source instead of imported.

Two were stale rather than merely long. selectInterpreter still pointed at
build/e2e/prepareE2eVenv.js, deleted when ensureManagedVenv replaced it.

test:e2e:prebuilt had no callers: CI runs test:e2e:ci and local runs test:e2e,
whose setup-and-run skips the VS Code download when it is already present.

Verified: tsc and compile-e2e exit 0, the workflow parses with the same four
shards, and setup:e2e:venv still runs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MyZCw9GzL6Vq7S9MAVYwby
The rewriting was added when the suites shared one window with no reload
between them, to keep the extension's project-id-keyed caches from carrying
one suite's state into the next. Reinstating the per-suite window reload made
it redundant: the reload restarts the extension host, which clears those
caches, and every suite already removes its own fixture directory in `after`,
so no two copies of a fixture are in the workspace at once.

Copies keep their committed ids again, so the two suites that asserted on a
rewritten id go back to the constants they used before.

The one thing the reload does not clear is `.vscode/deepnote.json`, written at
the shared root and keyed by project id. Nothing reads it back to drive
behaviour, and the suite that asserts on it deletes the file first.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MyZCw9GzL6Vq7S9MAVYwby
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant