From 2bca975d9772bc5a4806b5db5c42c2c0ffa42661 Mon Sep 17 00:00:00 2001 From: tomas Date: Wed, 19 Aug 2026 16:06:34 +0000 Subject: [PATCH 01/12] perf(e2e): adopt a pre-baked toolkit venv instead of provisioning per suite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01MyZCw9GzL6Vq7S9MAVYwby --- .github/workflows/e2e.yml | 36 +++++-- .gitignore | 3 + build/e2e/prepareE2eVenv.js | 98 +++++++++++++++++++ package.json | 7 +- test/e2e/helpers/constants.ts | 16 +++ test/e2e/helpers/deepnoteEnvironment.ts | 66 ++++++++++++- test/e2e/suite/environment.e2e.test.ts | 5 +- test/e2e/suite/helloWorld.e2e.test.ts | 3 +- test/e2e/suite/initNotebookRunner.e2e.test.ts | 3 +- .../integrationsEnvFileInjection.e2e.test.ts | 3 +- test/e2e/suite/snapshots.e2e.test.ts | 3 +- 11 files changed, 224 insertions(+), 19 deletions(-) create mode 100644 build/e2e/prepareE2eVenv.js diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index a28c014d37..0851249ef0 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -39,6 +39,7 @@ jobs: node-version-file: '.nvmrc' - name: Setup Python # interpreter the Deepnote environment is created from + id: python uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: '3.12' @@ -72,18 +73,37 @@ jobs: run: npm run setup:e2e:deps - name: Cache pip wheel downloads - # Provisioning the Deepnote environment pip-installs the toolkit dependency tree into a - # fresh venv on first kernel connect — the bulk of the E2E runtime. Caching pip's wheel - # cache makes that warm on later runs (the installs use the cache; nothing passes - # --no-cache-dir). The key busts when the toolkit version / install set changes; the - # restore-keys prefix keeps the cache warm across unrelated changes, since pip's cache is - # additive. + # Only the deletion suite still pip-installs at run time (it creates a managed environment + # on purpose), so this is now a fallback rather than the main lever — see the baked venv + # below. The key carries github.run_id because actions/cache only writes on a miss: with a + # bare content hash the entry froze at its first save and the unpinned specs + # (ipykernel, python-lsp-server, deepnote-cli, all installed with --upgrade) were + # re-downloaded every run and never written back. restore-keys still resolves to the newest + # matching entry, so each run starts warm and saves a refreshed copy. uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.cache/pip - key: pip-${{ runner.os }}-py312-${{ hashFiles('src/kernels/deepnote/types.ts', 'src/kernels/deepnote/deepnoteToolkitInstaller.node.ts') }} + key: pip-${{ runner.os }}-py${{ steps.python.outputs.python-version }}-${{ hashFiles('src/kernels/deepnote/types.ts', 'src/kernels/deepnote/deepnoteToolkitInstaller.node.ts') }}-${{ github.run_id }} restore-keys: | - pip-${{ runner.os }}-py312- + pip-${{ runner.os }}-py${{ steps.python.outputs.python-version }}-${{ hashFiles('src/kernels/deepnote/types.ts', 'src/kernels/deepnote/deepnoteToolkitInstaller.node.ts') }}- + pip-${{ runner.os }}-py${{ steps.python.outputs.python-version }}- + + - name: Cache the baked Deepnote toolkit venv + # Every suite that connects a kernel adopts this venv instead of letting the extension build + # one, which is where most of the old E2E runtime went. Keyed on the resolved Python version + # as well as the install set: the venv records its base interpreter in pyvenv.cfg, so a + # patch bump to the hosted Python would leave a restored venv pointing at a path that no + # longer exists. prepareE2eVenv.js also self-heals — it discards and rebuilds any venv that + # cannot import deepnote_toolkit. + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: .venv-e2e + key: e2e-venv-${{ runner.os }}-py${{ steps.python.outputs.python-version }}-${{ hashFiles('src/kernels/deepnote/types.ts', 'src/kernels/deepnote/deepnoteToolkitInstaller.node.ts') }} + + - name: Bake the Deepnote toolkit venv + # No-op when the cache restored a usable venv; otherwise creates it and installs the same + # set deepnoteToolkitInstaller would have. + run: npm run setup:e2e:venv - name: Run E2E # VS Code launches with --no-sandbox (no AppArmor sysctl needed). Runs once; Mocha's retries:1 diff --git a/.gitignore b/.gitignore index 7174f36b60..7380ab3853 100644 --- a/.gitignore +++ b/.gitignore @@ -79,3 +79,6 @@ tsconfig.tsbuildinfo # ExTester (vscode-extension-tester) E2E artifacts test-resources .test-extensions + +# Generated by build/e2e/prepareE2eVenv.js (absolute interpreter path resolved at run time). +test/e2e/settings.generated.json diff --git a/build/e2e/prepareE2eVenv.js b/build/e2e/prepareE2eVenv.js new file mode 100644 index 0000000000..fe79ba9db4 --- /dev/null +++ b/build/e2e/prepareE2eVenv.js @@ -0,0 +1,98 @@ +// Bakes the Python venv the E2E suite adopts instead of letting the extension build one per test +// run. `deepnoteEnvironmentManager.createEnvironment` adopts any interpreter that already lives in a +// venv (`getVenvPathIfInVenv`), and `ensureVenvAndToolkit` returns early once `import +// deepnote_toolkit` succeeds — so a pre-installed venv skips venv creation and the whole pip install. +// +// Also emits the settings file ExTester feeds VS Code, because the interpreter must be named by +// absolute path and that path is only known at run time. + +const { execFileSync } = require('child_process'); +const fs = require('fs'); +const path = require('path'); + +const repoRoot = path.resolve(__dirname, '..', '..'); +// Matches the `**/.venv*/` rule already in .gitignore. +const venvDir = path.join(repoRoot, '.venv-e2e'); +const baseSettingsPath = path.join(repoRoot, 'test', 'e2e', 'settings.json'); +const generatedSettingsPath = path.join(repoRoot, 'test', 'e2e', 'settings.generated.json'); + +/** Reads DEEPNOTE_TOOLKIT_VERSION from source so this script cannot drift from the extension. */ +function toolkitVersion() { + const types = fs.readFileSync(path.join(repoRoot, 'src', 'kernels', 'deepnote', 'types.ts'), 'utf8'); + const match = types.match(/DEEPNOTE_TOOLKIT_VERSION\s*=\s*'([^']+)'/); + if (!match) { + throw new Error('Could not read DEEPNOTE_TOOLKIT_VERSION from src/kernels/deepnote/types.ts'); + } + + return match[1]; +} + +function venvPython() { + return process.platform === 'win32' + ? path.join(venvDir, 'Scripts', 'python.exe') + : path.join(venvDir, 'bin', 'python'); +} + +function run(command, args) { + execFileSync(command, args, { stdio: 'inherit' }); +} + +/** True when the venv exists and already imports the toolkit — the same check the extension makes. */ +function toolkitAlreadyInstalled() { + if (!fs.existsSync(venvPython())) { + return false; + } + try { + execFileSync(venvPython(), ['-c', 'import deepnote_toolkit'], { stdio: 'ignore' }); + + return true; + } catch { + return false; + } +} + +function bakeVenv() { + if (toolkitAlreadyInstalled()) { + console.log(`[e2e-venv] reusing ${venvDir}`); + + return; + } + + // A half-built venv (interrupted run, partial cache) would make pip fail confusingly. + if (fs.existsSync(venvDir)) { + console.log(`[e2e-venv] discarding incomplete venv at ${venvDir}`); + fs.rmSync(venvDir, { recursive: true, force: true }); + } + + console.log(`[e2e-venv] creating ${venvDir}`); + run(process.env.PYTHON ?? 'python3', ['-m', 'venv', venvDir]); + + // Mirrors deepnoteToolkitInstaller.installVenvAndToolkit so the baked venv satisfies the same + // checks the extension would have made after building it itself. + run(venvPython(), ['-m', 'pip', 'install', '--upgrade', 'pip']); + run(venvPython(), [ + '-m', + 'pip', + 'install', + '--upgrade', + `deepnote-toolkit[server]==${toolkitVersion()}`, + 'ipykernel', + 'python-lsp-server[all]', + 'deepnote-cli' + ]); +} + +function writeSettings() { + const settings = JSON.parse(fs.readFileSync(baseSettingsPath, 'utf8')); + + // venvPath makes the Python extension discover the baked venv so it reaches the interpreter + // quick pick; defaultInterpreterPath makes it the pick the suite lands on by default. + settings['python.venvPath'] = repoRoot; + settings['python.defaultInterpreterPath'] = venvPython(); + + fs.writeFileSync(generatedSettingsPath, `${JSON.stringify(settings, null, 4)}\n`); + console.log(`[e2e-venv] wrote ${path.relative(repoRoot, generatedSettingsPath)}`); +} + +bakeVenv(); +writeSettings(); diff --git a/package.json b/package.json index 3932b09a94..89264e4173 100644 --- a/package.json +++ b/package.json @@ -2672,9 +2672,10 @@ "compile-e2e-watch": "tsc -p ./test/e2e/tsconfig.json --watch", "setup:e2e:vscode": "extest get-vscode -c max && extest get-chromedriver -c max", "setup:e2e:deps": "extest install-from-marketplace ms-python.python -e .test-extensions", - "setup:e2e": "npm run setup:e2e:vscode && npm run setup:e2e:deps", - "test:e2e": "extest setup-and-run \"./out/e2e/suite/*.e2e.test.js\" -c max -o ./test/e2e/settings.json -e .test-extensions -m ./test/e2e/.mocharc.js -i", - "test:e2e:prebuilt": "extest run-tests \"./out/e2e/suite/*.e2e.test.js\" -c max -o ./test/e2e/settings.json -e .test-extensions -m ./test/e2e/.mocharc.js", + "setup:e2e:venv": "node ./build/e2e/prepareE2eVenv.js", + "setup:e2e": "npm run setup:e2e:vscode && npm run setup:e2e:deps && npm run setup:e2e:venv", + "test:e2e": "extest setup-and-run \"./out/e2e/suite/*.e2e.test.js\" -c max -o ./test/e2e/settings.generated.json -e .test-extensions -m ./test/e2e/.mocharc.js -i", + "test:e2e:prebuilt": "extest run-tests \"./out/e2e/suite/*.e2e.test.js\" -c max -o ./test/e2e/settings.generated.json -e .test-extensions -m ./test/e2e/.mocharc.js", "test:unittests": "mocha --config ./build/.mocha.unittests.js.json ./out/**/*.unit.test.js", "test": "npm run test:unittests", "typecheck": "tsc -p ./ --noEmit", diff --git a/test/e2e/helpers/constants.ts b/test/e2e/helpers/constants.ts index fdaa2eaecb..5b5fcbc46f 100644 --- a/test/e2e/helpers/constants.ts +++ b/test/e2e/helpers/constants.ts @@ -47,3 +47,19 @@ export const FOLDER_OK_RETRY_DELAY = 400; // Selectors that only exist inside the notebook output iframe (`#active-frame`), // so reading them cannot accidentally match the cell's source in the editor. export const OUTPUT_SELECTOR = '.output_container, .output, .rendered-output'; + +// The environment every suite shares. Creating a Deepnote environment is cheap (metadata only), but +// the first kernel connect provisions a venv + the Deepnote toolkit, so a suite that invents its own +// name pays that once more. Import this instead of writing a name; `E2E Delete Env` in +// environment.e2e.test.ts is the one deliberate exception, since that suite deletes what it creates. +export const SHARED_ENV_NAME = 'E2E Hello Env'; + +// Venv baked by build/e2e/prepareE2eVenv.js, adopted by every suite that connects a kernel. Kept in +// sync with that script by convention; the suite falls back to whatever the quick pick offers (and +// warns) when it is absent, so a missed setup step is slow rather than red. +export const PREBAKED_VENV_DIR_NAME = '.venv-e2e'; + +// How long to wait for the interpreter quick pick to actually narrow to the baked venv. The +// filter is near-instant when the venv is discoverable, so this only elapses when it is missing — +// keep it well under QUICK_PICK_TIMEOUT so a forgotten setup step does not stall every suite. +export const PREBAKED_VENV_FILTER_TIMEOUT = 10_000; diff --git a/test/e2e/helpers/deepnoteEnvironment.ts b/test/e2e/helpers/deepnoteEnvironment.ts index a4f996ce49..d3e4acfdf6 100644 --- a/test/e2e/helpers/deepnoteEnvironment.ts +++ b/test/e2e/helpers/deepnoteEnvironment.ts @@ -7,6 +7,8 @@ import { KERNEL_CONNECT_TIMEOUT, MAX_CREATE_ATTEMPTS, OPTIONAL_PROMPT_TIMEOUT, + PREBAKED_VENV_DIR_NAME, + PREBAKED_VENV_FILTER_TIMEOUT, QUICK_PICK_TIMEOUT } from './constants'; import { dismissAllNotifications, waitForNotification } from './notifications'; @@ -16,6 +18,66 @@ import { tryOpenInputBox } from './quickInput'; const CREATE_ENV_COMMAND = 'Deepnote: Create Environment'; const SELECT_ENV_COMMAND = 'Deepnote: Select Environment for Notebook'; +/** + * Chooses which interpreter the environment is built on. + * + * By default the venv baked by `build/e2e/prepareE2eVenv.js`. The extension adopts any interpreter + * that already lives in a venv, so the environment reuses it and skips provisioning entirely — that + * reuse is the point of baking it. Typing the directory name filters the pick rather than clicking a + * row, because the command enables `matchOnDescription` and the description is the interpreter path. + * + * `useManagedVenv` picks an interpreter outside that venv instead, leaving the extension to create + * and own one. Only the deletion suite needs it: `deleteEnvironment` removes the venv directory for + * managed environments only, so adopting the baked venv there would stop exercising that teardown. + * + * A missing baked venv warns and falls through to the first offered interpreter, so a skipped setup + * step makes the run slow instead of red. + */ +async function selectInterpreter(interpreterPick: InputBox, useManagedVenv: boolean): Promise { + const driver = VSBrowser.instance.driver; + + if (!useManagedVenv) { + await interpreterPick.setText(PREBAKED_VENV_DIR_NAME); + // Wait for the *top row* to be the baked venv, not merely for the list to be non-empty: + // VS Code applies the filter asynchronously, so the stale unfiltered list is briefly still + // there and confirming against it would pick an arbitrary interpreter. + const filtered = await driver + .wait(async () => { + const picks = await interpreterPick.getQuickPicks(); + if (picks.length === 0) { + return undefined; + } + const first = `${await picks[0].getLabel()} ${(await picks[0].getDescription()) ?? ''}`; + + return first.includes(PREBAKED_VENV_DIR_NAME) ? picks[0] : undefined; + }, PREBAKED_VENV_FILTER_TIMEOUT) + .catch(() => undefined); + + if (filtered) { + await interpreterPick.confirm(); + + return; + } + + console.warn( + `[deepnote-e2e] no interpreter under ${PREBAKED_VENV_DIR_NAME} was offered; falling back to ` + + 'the first entry. The run will provision a venv and take several minutes longer — ' + + 'check that `npm run setup:e2e:venv` ran.' + ); + await interpreterPick.setText(''); + } + + const picks = await interpreterPick.getQuickPicks(); + const labels = await Promise.all( + picks.map(async (pick) => `${await pick.getLabel()} ${(await pick.getDescription()) ?? ''}`) + ); + // "Not the baked venv" rather than "not any venv": in CI the only other interpreter is the one + // actions/setup-python installed, which is not a venv, so this resolves to it. + const index = labels.findIndex((label) => !label.includes(PREBAKED_VENV_DIR_NAME)); + + await picks[index >= 0 ? index : 0].select(); +} + /** * Drives `deepnote.environments.create`: pick interpreter -> name -> skip packages -> skip * description. Retries when the Python extension has not finished discovering an interpreter yet @@ -23,7 +85,7 @@ const SELECT_ENV_COMMAND = 'Deepnote: Select Environment for Notebook'; * "already exists" guard is treated as success so a leftover environment from a previous/retried run * is reused rather than colliding. */ -export async function createEnvironment(name: string): Promise { +export async function createEnvironment(name: string, options: { useManagedVenv?: boolean } = {}): Promise { const driver = VSBrowser.instance.driver; let lastError: unknown; @@ -56,7 +118,7 @@ export async function createEnvironment(name: string): Promise { continue; } - await interpreterPick.selectQuickPick(0); + await selectInterpreter(interpreterPick, options.useManagedVenv === true); const nameBox = await InputBox.create(); await nameBox.setText(name); diff --git a/test/e2e/suite/environment.e2e.test.ts b/test/e2e/suite/environment.e2e.test.ts index 9add95f1fc..d9b2431976 100644 --- a/test/e2e/suite/environment.e2e.test.ts +++ b/test/e2e/suite/environment.e2e.test.ts @@ -22,6 +22,7 @@ import { FIRST_RUN_OUTPUT_TIMEOUT, KERNEL_CONNECT_TIMEOUT, QUICK_PICK_TIMEOUT, + SHARED_ENV_NAME, SUITE_TIMEOUT, WORKBENCH_TIMEOUT, confirmModalDialog, @@ -40,7 +41,7 @@ import { const FIXTURE = 'sales-analytics.deepnote'; const CHILD = 'sales-analytics-overview.deepnote'; const PROJECT_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; -const ENV_NAME = 'E2E Hello Env'; // shared env so CI provisions one venv +const ENV_NAME = SHARED_ENV_NAME; const SPLIT_PROMPT = /multiple notebooks/i; const SPLIT_ACTION = 'Split into separate files'; const SPLIT_DONE = /Split into \d+ files\./i; @@ -293,7 +294,7 @@ describe('Deepnote — deleting an environment stops even a closed-but-running n // Servers already running from earlier suites — exclude these when isolating THIS PID. const pidsBefore = serverPids(); - await createEnvironment(DELETE_ENV_NAME); + await createEnvironment(DELETE_ENV_NAME, { useManagedVenv: true }); await openWorkspaceFile(G2_FIXTURE); await driver.wait( diff --git a/test/e2e/suite/helloWorld.e2e.test.ts b/test/e2e/suite/helloWorld.e2e.test.ts index 5090937982..605ff51e0c 100644 --- a/test/e2e/suite/helloWorld.e2e.test.ts +++ b/test/e2e/suite/helloWorld.e2e.test.ts @@ -23,6 +23,7 @@ import { EditorView, VSBrowser, WebView } from 'vscode-extension-tester'; import { FIRST_RUN_OUTPUT_TIMEOUT, + SHARED_ENV_NAME, SUITE_TIMEOUT, WORKBENCH_TIMEOUT, copyFixtureToTempDir, @@ -43,7 +44,7 @@ describe('Deepnote E2E — run "hello world"', function () { // A stable name: createEnvironment is idempotent (it treats "already exists" as success), so a // leftover environment from a previous or retried run is reused rather than colliding — which // also lets a persistent test instance reuse the already-provisioned venv. - const environmentName = 'E2E Hello Env'; + const environmentName = SHARED_ENV_NAME; // Captured in `before` and invoked in `after` to remove the throwaway temp dir. let cleanupTempDir: (() => void) | undefined; diff --git a/test/e2e/suite/initNotebookRunner.e2e.test.ts b/test/e2e/suite/initNotebookRunner.e2e.test.ts index c37e8a3d97..c806003a92 100644 --- a/test/e2e/suite/initNotebookRunner.e2e.test.ts +++ b/test/e2e/suite/initNotebookRunner.e2e.test.ts @@ -11,6 +11,7 @@ import { EditorView, InputBox, VSBrowser, WebView, Workbench } from 'vscode-exte import { FIRST_RUN_OUTPUT_TIMEOUT, OUTPUT_POLL_INTERVAL, + SHARED_ENV_NAME, SUITE_TIMEOUT, WORKBENCH_TIMEOUT, clickRunAll, @@ -94,7 +95,7 @@ async function confirmKernelPickerIfPresent(): Promise { describe('Deepnote — running the sibling init notebook in a main notebook kernel', function () { this.timeout(SUITE_TIMEOUT); - const environmentName = 'E2E Init Env'; + const environmentName = SHARED_ENV_NAME; let cleanupTempDir: (() => void) | undefined; let screenshot: (label: string) => Promise; diff --git a/test/e2e/suite/integrationsEnvFileInjection.e2e.test.ts b/test/e2e/suite/integrationsEnvFileInjection.e2e.test.ts index 337f61a73a..6571178c19 100644 --- a/test/e2e/suite/integrationsEnvFileInjection.e2e.test.ts +++ b/test/e2e/suite/integrationsEnvFileInjection.e2e.test.ts @@ -10,6 +10,7 @@ import { EditorView, VSBrowser, WebView } from 'vscode-extension-tester'; import { FIRST_RUN_OUTPUT_TIMEOUT, + SHARED_ENV_NAME, SUITE_TIMEOUT, WORKBENCH_TIMEOUT, copyFixtureToTempDir, @@ -52,7 +53,7 @@ describe('Deepnote E2E — inject integration env var from `.deepnote.env.yaml`' // A stable name: createEnvironment is idempotent (it treats "already exists" as success), so a // leftover environment from a previous or retried run is reused rather than colliding — which // also lets a persistent test instance reuse the already-provisioned venv. - const environmentName = 'E2E Integrations Env'; + const environmentName = SHARED_ENV_NAME; let cleanupTempDir: (() => void) | undefined; // The temp workspace dir, so the live-refresh assertion can rewrite `.env`. diff --git a/test/e2e/suite/snapshots.e2e.test.ts b/test/e2e/suite/snapshots.e2e.test.ts index 0e97c10327..1b97136d06 100644 --- a/test/e2e/suite/snapshots.e2e.test.ts +++ b/test/e2e/suite/snapshots.e2e.test.ts @@ -8,6 +8,7 @@ import * as path from 'path'; import { EditorView, VSBrowser, WebView } from 'vscode-extension-tester'; import { FIRST_RUN_OUTPUT_TIMEOUT, + SHARED_ENV_NAME, SUITE_TIMEOUT, WORKBENCH_TIMEOUT, copyFixtureToTempDir, @@ -90,7 +91,7 @@ describe('Deepnote — a legacy project-scoped snapshot still loads its saved ou describe('Deepnote — new snapshots are notebook-scoped and do not bleed between siblings', function () { this.timeout(SUITE_TIMEOUT); - const ENV_NAME = 'E2E Hello Env'; // shared env so CI provisions one venv + const ENV_NAME = SHARED_ENV_NAME; const SIBLINGS = [ { file: 'marketing-overview.deepnote', output: 'overview', notebookId: 'e-nb-overview' }, { file: 'marketing-campaigns.deepnote', output: 'campaigns', notebookId: 'e-nb-campaigns' } From cd47b8e4d3353eba690bf640c279f857801eb3ae Mon Sep 17 00:00:00 2001 From: tomas Date: Wed, 19 Aug 2026 20:16:48 +0000 Subject: [PATCH 02/12] fix(e2e): select the managed interpreter by keyboard, not by clicking the row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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:

...

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

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) Claude-Session: https://claude.ai/code/session_01MyZCw9GzL6Vq7S9MAVYwby --- test/e2e/helpers/deepnoteEnvironment.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/test/e2e/helpers/deepnoteEnvironment.ts b/test/e2e/helpers/deepnoteEnvironment.ts index d3e4acfdf6..ad551e2aad 100644 --- a/test/e2e/helpers/deepnoteEnvironment.ts +++ b/test/e2e/helpers/deepnoteEnvironment.ts @@ -1,4 +1,4 @@ -import { EditorView, InputBox, VSBrowser, Workbench } from 'vscode-extension-tester'; +import { EditorView, InputBox, Key, VSBrowser, Workbench } from 'vscode-extension-tester'; import { ENV_CREATED_TIMEOUT, @@ -74,8 +74,16 @@ async function selectInterpreter(interpreterPick: InputBox, useManagedVenv: bool // "Not the baked venv" rather than "not any venv": in CI the only other interpreter is the one // actions/setup-python installed, which is not a venv, so this resolves to it. const index = labels.findIndex((label) => !label.includes(PREBAKED_VENV_DIR_NAME)); - - await picks[index >= 0 ? index : 0].select(); + const target = index >= 0 ? index : 0; + + // Walk the highlight with arrows and accept with Enter rather than calling select(), which is a + // bare click: a row's description `

` overlaps the row and intercepts positional clicks. Same + // reason selectEnvironmentForNotebook types instead of clicking. Enter is sent through the same + // focus context as the arrows so the highlight cannot be disturbed in between. + for (let step = 0; step < target; step++) { + await driver.actions().sendKeys(Key.ARROW_DOWN).perform(); + } + await driver.actions().sendKeys(Key.ENTER).perform(); } /** From cc037843a6e77f271f2de26c04641e13ed7b0032 Mon Sep 17 00:00:00 2001 From: tomas Date: Thu, 20 Aug 2026 13:39:33 +0000 Subject: [PATCH 03/12] perf(e2e): open one workspace for all suites, isolating them by project id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01MyZCw9GzL6Vq7S9MAVYwby --- test/e2e/helpers/fixtures.ts | 174 +++++++++++++++++- test/e2e/helpers/workspace.ts | 8 + test/e2e/rootHooks.ts | 20 +- test/e2e/suite/environment.e2e.test.ts | 5 +- test/e2e/suite/explorerGrouping.e2e.test.ts | 8 +- test/e2e/suite/fileWatcher.e2e.test.ts | 10 +- test/e2e/suite/initNotebookRunner.e2e.test.ts | 6 +- test/e2e/suite/integrations.e2e.test.ts | 8 +- test/e2e/suite/notebookCommands.e2e.test.ts | 3 +- test/e2e/suite/projectRename.e2e.test.ts | 3 +- test/e2e/suite/snapshots.e2e.test.ts | 14 +- test/e2e/suite/statusBar.e2e.test.ts | 5 +- 12 files changed, 216 insertions(+), 48 deletions(-) diff --git a/test/e2e/helpers/fixtures.ts b/test/e2e/helpers/fixtures.ts index b799b3900d..cefcea455e 100644 --- a/test/e2e/helpers/fixtures.ts +++ b/test/e2e/helpers/fixtures.ts @@ -7,22 +7,178 @@ export interface FixtureCopy { cleanup: () => void; /** The absolute path to the copied fixture file inside `tempDir`. */ filePath: string; - /** The throwaway temp directory the fixture was copied into (suitable as a workspace folder). */ + /** The rewritten project id of the copied fixture — assert against this, not the committed one. */ + projectId: string; + /** The throwaway directory the fixture was copied into, a child of the shared workspace root. */ tempDir: string; } +const FIXTURES_DIR = path.resolve(process.cwd(), 'test', 'e2e', 'fixtures'); +const UUID_SHAPE = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/; + +// Rewritten ids are sequential rather than random so a failing run stays reproducible and an id in a +// log or screenshot points back at the copy that produced it. +let nextId = 0; + +// One mapping per temp directory, keyed by the *committed* project id. Sibling files copied into the +// same directory therefore keep sharing a project, while a copy set that deliberately mixes projects +// (explorerGrouping sits marketing siblings next to a different project) stays mixed. +const idMappings = new Map>(); + +let workspaceRoot: string | undefined; + +/** + * The single directory every fixture copy lives under. Opened once as the workspace folder (see + * rootHooks) rather than once per suite: opening a folder reloads the workbench, and that reload + * dominated suite setup. + */ +export function fixturesWorkspaceRoot(): string { + if (!workspaceRoot) { + workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'deepnote-e2e-root-')); + } + + return workspaceRoot; +} + +/** True when `folder` sits inside the already-opened shared root (so opening it would be a no-op). */ +export function isInsideFixturesWorkspaceRoot(folder: string): boolean { + return workspaceRoot !== undefined && path.resolve(folder).startsWith(path.resolve(workspaceRoot) + path.sep); +} + +export function removeFixturesWorkspaceRoot(): void { + if (workspaceRoot) { + fs.rmSync(workspaceRoot, { recursive: true, force: true }); + workspaceRoot = undefined; + } +} + +/** + * Reads `project.id` out of a fixture. Scanned line by line rather than matched with a + * multi-line regex, which backtracks catastrophically on a file that does not match. + */ +function readProjectId(contents: string): string { + const lines = contents.split('\n'); + const projectLine = lines.findIndex((line) => line.startsWith('project:')); + + for (let index = projectLine + 1; index > 0 && index < lines.length; index++) { + const match = lines[index].match(/^\s+id:\s*'?([^'\s]+)'?\s*$/); + if (match) { + return match[1]; + } + // Stop at the next top-level key so a nested id further down cannot be mistaken for it. + if (/^\S/.test(lines[index])) { + break; + } + } + + throw new Error('Could not read project.id from fixture'); +} + +/** + * Keeps the shape of the committed id — a uuid stays a uuid, a slug stays a slug — while sharing no + * prefix with it, so replacing the old token can never leave a fragment of it behind. + */ +function freshId(sourceId: string): string { + nextId += 1; + + return UUID_SHAPE.test(sourceId) + ? `00000000-0000-4000-8000-${String(nextId).padStart(12, '0')}` + : `e2e-project-${nextId}`; +} + +/** + * Replaces the project id wherever it appears, refusing to match when it is merely the start of a + * longer id: a fixture whose notebook id extends its project id would otherwise be corrupted. + * + * `_` is deliberately not a boundary character — snapshot filenames separate the id from the variant + * with one (`__latest`), and fixture ids only ever use `-` internally. + */ +function replaceId(text: string, sourceId: string, projectId: string): string { + const escaped = sourceId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + + return text.replace(new RegExp(`${escaped}(?![A-Za-z0-9-])`, 'g'), projectId); +} + +function translate(tempDir: string, sourceId: string): string { + let mapping = idMappings.get(tempDir); + if (!mapping) { + mapping = new Map(); + idMappings.set(tempDir, mapping); + } + + let replacement = mapping.get(sourceId); + if (!replacement) { + replacement = freshId(sourceId); + mapping.set(sourceId, replacement); + } + + return replacement; +} + /** - * Copies a fixture from `test/e2e/fixtures` into a fresh throwaway temp directory and returns the - * paths plus a `cleanup` callback that removes the dir. Execution dirties the notebook, so working - * on a throwaway copy keeps the committed fixture pristine and avoids save prompts. + * Copies a fixture with its project id rewritten, returning the new id. + * + * Only the project id is rewritten. That is what the extension's cross-suite caches key on — the + * notebook manager, the tree's group cache, and the `(projectId, notebookId)` lookups in the file + * watcher and snapshot service — so a unique project id makes those pairs unique too. Notebook and + * block ids are left alone, since suites assert on them directly. + */ +function writeRewritten(source: string, target: string, tempDir: string): string { + const contents = fs.readFileSync(source, 'utf8'); + const sourceProjectId = readProjectId(contents); + const projectId = translate(tempDir, sourceProjectId); + + fs.writeFileSync(target, replaceId(contents, sourceProjectId, projectId), 'utf8'); + + return projectId; +} + +/** + * Copies a fixture from `test/e2e/fixtures` into a fresh directory under the shared workspace root + * and returns the paths plus a `cleanup` callback. Execution dirties the notebook, so working on a + * throwaway copy keeps the committed fixture pristine and avoids save prompts. + * + * The copy's project id is rewritten to a fresh one. Suites share a single workspace and window now, + * so without this the extension's project-id-keyed caches would carry one suite's state into the + * next — which is what the per-suite window reload used to prevent. */ export function copyFixtureToTempDir(fixtureName: string): FixtureCopy { - const source = path.resolve(process.cwd(), 'test', 'e2e', 'fixtures', fixtureName); - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'deepnote-e2e-')); + const tempDir = fs.mkdtempSync(path.join(fixturesWorkspaceRoot(), 'suite-')); const filePath = path.join(tempDir, fixtureName); - fs.copyFileSync(source, filePath); + const projectId = writeRewritten(path.join(FIXTURES_DIR, fixtureName), filePath, tempDir); + + const cleanup = () => { + fs.rmSync(tempDir, { recursive: true, force: true }); + idMappings.delete(tempDir); + }; + + return { cleanup, filePath, projectId, tempDir }; +} + +/** + * Copies an additional fixture into an existing copy's directory, reusing that directory's mapping + * so siblings keep sharing one project id. Returns the written path. + */ +export function copyFixtureIntoDir(tempDir: string, fixtureName: string): string { + const target = path.join(tempDir, fixtureName); + writeRewritten(path.join(FIXTURES_DIR, fixtureName), target, tempDir); + + return target; +} + +/** + * Copies a snapshot fixture into `/snapshots/`, rewriting the project id inside it *and* in + * its name: `buildSnapshotPath` encodes the project id into the filename, so a rewritten project + * whose snapshot kept the committed name would silently never be found. Returns the written path. + */ +export function copySnapshotIntoDir(tempDir: string, snapshotName: string): string { + const snapshotsDir = path.join(tempDir, 'snapshots'); + fs.mkdirSync(snapshotsDir, { recursive: true }); - const cleanup = () => fs.rmSync(tempDir, { recursive: true, force: true }); + const source = path.join(FIXTURES_DIR, 'snapshots', snapshotName); + const sourceProjectId = readProjectId(fs.readFileSync(source, 'utf8')); + const target = path.join(snapshotsDir, replaceId(snapshotName, sourceProjectId, translate(tempDir, sourceProjectId))); + writeRewritten(source, target, tempDir); - return { cleanup, filePath, tempDir }; + return target; } diff --git a/test/e2e/helpers/workspace.ts b/test/e2e/helpers/workspace.ts index 76b8877241..52931608cd 100644 --- a/test/e2e/helpers/workspace.ts +++ b/test/e2e/helpers/workspace.ts @@ -7,6 +7,7 @@ import { QUICK_PICK_TIMEOUT, RELOAD_POLL_TIMEOUT } from './constants'; +import { isInsideFixturesWorkspaceRoot } from './fixtures'; import { clickDialogOkButton } from './quickInput'; /** @@ -39,6 +40,13 @@ export async function openWorkspaceFile(fileName: string): Promise { * Re-opening the dialog per attempt instead would reset navigation and fail on 2nd+ opens. */ export async function openFolderViaDialog(folder: string): Promise { + // Fixture copies all live under one root that rootHooks opens once, and opening a folder reloads + // the workbench — the reload is what made per-suite setup expensive. A directory already inside + // that root is therefore reachable without reopening anything. + if (isInsideFixturesWorkspaceRoot(folder)) { + return; + } + const driver = VSBrowser.instance.driver; const previousWorkbench = await driver.findElement(By.css('.monaco-workbench')); diff --git a/test/e2e/rootHooks.ts b/test/e2e/rootHooks.ts index c91408300d..dd50d1a171 100644 --- a/test/e2e/rootHooks.ts +++ b/test/e2e/rootHooks.ts @@ -1,9 +1,27 @@ +import { VSBrowser } from 'vscode-extension-tester'; + +import { WORKBENCH_TIMEOUT } from './helpers/constants'; +import { fixturesWorkspaceRoot, removeFixturesWorkspaceRoot } from './helpers/fixtures'; import { dismissAllNotifications } from './helpers/notifications'; +import { openFolderViaDialog } from './helpers/workspace'; // Mocha root hooks (wired via .mocharc.js `require`). ExTester runs every spec in ONE shared VS Code -// instance; dismiss notification toasts between tests so they don't pile up and slow/overlap later specs. +// instance, so this is also where the one shared workspace folder is opened: every suite's fixture +// copy is a directory inside it, and opening a folder reloads the workbench, so doing it once here +// instead of once per suite removes ~17 reloads from the run. Suites still call openFolderViaDialog; +// it short-circuits for anything already inside this root. export const mochaHooks = { async afterEach(): Promise { await dismissAllNotifications().catch(() => undefined); + }, + + async afterAll(): Promise { + removeFixturesWorkspaceRoot(); + }, + + async beforeAll(): Promise { + await VSBrowser.instance.waitForWorkbench(WORKBENCH_TIMEOUT); + await openFolderViaDialog(fixturesWorkspaceRoot()); + await VSBrowser.instance.waitForWorkbench(WORKBENCH_TIMEOUT); } }; diff --git a/test/e2e/suite/environment.e2e.test.ts b/test/e2e/suite/environment.e2e.test.ts index d9b2431976..6726d623d9 100644 --- a/test/e2e/suite/environment.e2e.test.ts +++ b/test/e2e/suite/environment.e2e.test.ts @@ -40,7 +40,6 @@ import { const FIXTURE = 'sales-analytics.deepnote'; const CHILD = 'sales-analytics-overview.deepnote'; -const PROJECT_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; const ENV_NAME = SHARED_ENV_NAME; const SPLIT_PROMPT = /multiple notebooks/i; const SPLIT_ACTION = 'Split into separate files'; @@ -87,6 +86,7 @@ describe('Deepnote — splitting a file migrates its selected environment onto e this.timeout(SUITE_TIMEOUT); this.retries(0); // destructive (retires the original to .legacy); not idempotent + let projectId = ''; let cleanupTempDir: (() => void) | undefined; let sidecarEnvId: string | undefined; @@ -94,6 +94,7 @@ describe('Deepnote — splitting a file migrates its selected environment onto e const driver = VSBrowser.instance.driver; const screenshot = createScreenshotter(this); const copy = copyFixtureToTempDir(FIXTURE); + projectId = copy.projectId; cleanupTempDir = copy.cleanup; const tempDir = copy.tempDir; @@ -160,7 +161,7 @@ describe('Deepnote — splitting a file migrates its selected environment onto e if (fs.existsSync(sidecarPath)) { try { const parsed = JSON.parse(fs.readFileSync(sidecarPath, 'utf8')); - const id = parsed?.mappings?.[PROJECT_ID]?.environmentId; + const id = parsed?.mappings?.[projectId]?.environmentId; if (typeof id === 'string' && id.length > 0) { sidecarEnvId = id; break; diff --git a/test/e2e/suite/explorerGrouping.e2e.test.ts b/test/e2e/suite/explorerGrouping.e2e.test.ts index 0171e2a2eb..7a92074c31 100644 --- a/test/e2e/suite/explorerGrouping.e2e.test.ts +++ b/test/e2e/suite/explorerGrouping.e2e.test.ts @@ -4,13 +4,12 @@ */ import { expect } from 'chai'; -import * as fs from 'fs'; -import * as path from 'path'; import { ActivityBar, EditorView, SideBarView, VSBrowser, WebView, type ViewItem } from 'vscode-extension-tester'; import { SUITE_TIMEOUT, WORKBENCH_TIMEOUT, + copyFixtureIntoDir, copyFixtureToTempDir, createScreenshotter, openFolderViaDialog @@ -87,10 +86,7 @@ describe('Deepnote — the Explorer groups sibling files by project', function ( const copy = copyFixtureToTempDir(MARKETING_FILES[0]); cleanupTempDir = copy.cleanup; for (const name of [...MARKETING_FILES.slice(1), OTHER_PROJECT_FILE]) { - fs.copyFileSync( - path.resolve(process.cwd(), 'test', 'e2e', 'fixtures', name), - path.join(copy.tempDir, name) - ); + copyFixtureIntoDir(copy.tempDir, name); } await VSBrowser.instance.waitForWorkbench(WORKBENCH_TIMEOUT); diff --git a/test/e2e/suite/fileWatcher.e2e.test.ts b/test/e2e/suite/fileWatcher.e2e.test.ts index 0970623ddc..7d594d8cfa 100644 --- a/test/e2e/suite/fileWatcher.e2e.test.ts +++ b/test/e2e/suite/fileWatcher.e2e.test.ts @@ -12,6 +12,7 @@ import { SUITE_TIMEOUT, WORKBENCH_TIMEOUT, copyFixtureToTempDir, + copySnapshotIntoDir, createScreenshotter, openFolderViaDialog, openWorkspaceFile, @@ -159,6 +160,7 @@ describe('Deepnote — the file watcher applies snapshot outputs to an open note let cleanupTempDir: (() => void) | undefined; let snapshotTargetPath = ''; + let tempDir = ''; before(async function () { const driver = VSBrowser.instance.driver; @@ -167,7 +169,7 @@ describe('Deepnote — the file watcher applies snapshot outputs to an open note // already open (with no output) when the sidecar appears. const copy = copyFixtureToTempDir(SNAPSHOT_FIXTURE); cleanupTempDir = copy.cleanup; - snapshotTargetPath = path.join(copy.tempDir, 'snapshots', SNAPSHOT); + tempDir = copy.tempDir; await VSBrowser.instance.waitForWorkbench(WORKBENCH_TIMEOUT); @@ -220,11 +222,7 @@ describe('Deepnote — the file watcher applies snapshot outputs to an open note // 2. Make the snapshot APPEAR on disk under `/snapshots/`. The fs watcher fires // onDidCreate, which routes to the snapshot-output-update path. - fs.mkdirSync(path.dirname(snapshotTargetPath), { recursive: true }); - fs.copyFileSync( - path.resolve(process.cwd(), 'test', 'e2e', 'fixtures', 'snapshots', SNAPSHOT), - snapshotTargetPath - ); + snapshotTargetPath = copySnapshotIntoDir(tempDir, SNAPSHOT); expect(fs.existsSync(snapshotTargetPath), 'snapshot file must exist on disk after the copy').to.equal(true); // 3. Poll the rendered output until the watcher (500ms debounce + read + replaceCells) applies diff --git a/test/e2e/suite/initNotebookRunner.e2e.test.ts b/test/e2e/suite/initNotebookRunner.e2e.test.ts index c806003a92..83cb92a170 100644 --- a/test/e2e/suite/initNotebookRunner.e2e.test.ts +++ b/test/e2e/suite/initNotebookRunner.e2e.test.ts @@ -4,8 +4,6 @@ */ import { expect } from 'chai'; -import * as fs from 'fs'; -import * as path from 'path'; import { EditorView, InputBox, VSBrowser, WebView, Workbench } from 'vscode-extension-tester'; import { @@ -15,6 +13,7 @@ import { SUITE_TIMEOUT, WORKBENCH_TIMEOUT, clickRunAll, + copyFixtureIntoDir, copyFixtureToTempDir, createEnvironment, createScreenshotter, @@ -106,8 +105,7 @@ describe('Deepnote — running the sibling init notebook in a main notebook kern // scanning the notebook's directory. const copy = copyFixtureToTempDir(MAIN_FILE); cleanupTempDir = copy.cleanup; - const initSrc = path.resolve(process.cwd(), 'test', 'e2e', 'fixtures', INIT_SIBLING_FILE); - fs.copyFileSync(initSrc, path.join(copy.tempDir, INIT_SIBLING_FILE)); + copyFixtureIntoDir(copy.tempDir, INIT_SIBLING_FILE); await VSBrowser.instance.waitForWorkbench(WORKBENCH_TIMEOUT); await openFolderViaDialog(copy.tempDir); diff --git a/test/e2e/suite/integrations.e2e.test.ts b/test/e2e/suite/integrations.e2e.test.ts index 65ca6ad795..9420cab395 100644 --- a/test/e2e/suite/integrations.e2e.test.ts +++ b/test/e2e/suite/integrations.e2e.test.ts @@ -4,13 +4,12 @@ */ import { expect } from 'chai'; -import * as fs from 'fs'; -import * as path from 'path'; import { By, EditorView, VSBrowser, WebView, Workbench } from 'vscode-extension-tester'; import { SUITE_TIMEOUT, WORKBENCH_TIMEOUT, + copyFixtureIntoDir, copyFixtureToTempDir, createScreenshotter, openFolderViaDialog, @@ -98,10 +97,7 @@ describe('Deepnote — the integrations UI', function () { const copy = copyFixtureToTempDir(REVENUE_FILE); cleanupTempDir = copy.cleanup; - fs.copyFileSync( - path.resolve(process.cwd(), 'test', 'e2e', 'fixtures', PLAIN_FILE), - path.join(copy.tempDir, PLAIN_FILE) - ); + copyFixtureIntoDir(copy.tempDir, PLAIN_FILE); await VSBrowser.instance.waitForWorkbench(WORKBENCH_TIMEOUT); await openFolderViaDialog(copy.tempDir); diff --git a/test/e2e/suite/notebookCommands.e2e.test.ts b/test/e2e/suite/notebookCommands.e2e.test.ts index 114cc84018..70bbd46447 100644 --- a/test/e2e/suite/notebookCommands.e2e.test.ts +++ b/test/e2e/suite/notebookCommands.e2e.test.ts @@ -23,6 +23,7 @@ import { SUITE_TIMEOUT, WORKBENCH_TIMEOUT, confirmModalDialog, + copyFixtureIntoDir, copyFixtureToTempDir, createScreenshotter, openFolderViaDialog, @@ -158,7 +159,7 @@ describe('Deepnote — notebook-management commands create and remove sibling fi cleanupTempDir = copy.cleanup; tempDir = copy.tempDir; for (const name of MARKETING_FILES.slice(1)) { - fs.copyFileSync(path.resolve(process.cwd(), 'test', 'e2e', 'fixtures', name), path.join(tempDir, name)); + copyFixtureIntoDir(tempDir, name); } await VSBrowser.instance.waitForWorkbench(WORKBENCH_TIMEOUT); diff --git a/test/e2e/suite/projectRename.e2e.test.ts b/test/e2e/suite/projectRename.e2e.test.ts index 0aba4869ad..61cc4c1714 100644 --- a/test/e2e/suite/projectRename.e2e.test.ts +++ b/test/e2e/suite/projectRename.e2e.test.ts @@ -13,6 +13,7 @@ import { By, EditorView, InputBox, VSBrowser, WebView, type ViewItem } from 'vsc import { SUITE_TIMEOUT, WORKBENCH_TIMEOUT, + copyFixtureIntoDir, copyFixtureToTempDir, createScreenshotter, findDeepnoteGroup, @@ -101,7 +102,7 @@ describe('Deepnote — renaming a project fans the new name out to every sibling cleanupTempDir = copy.cleanup; tempDir = copy.tempDir; for (const name of MARKETING_FILES.slice(1)) { - fs.copyFileSync(path.resolve(process.cwd(), 'test', 'e2e', 'fixtures', name), path.join(tempDir, name)); + copyFixtureIntoDir(tempDir, name); } await VSBrowser.instance.waitForWorkbench(WORKBENCH_TIMEOUT); diff --git a/test/e2e/suite/snapshots.e2e.test.ts b/test/e2e/suite/snapshots.e2e.test.ts index 1b97136d06..084ea106b2 100644 --- a/test/e2e/suite/snapshots.e2e.test.ts +++ b/test/e2e/suite/snapshots.e2e.test.ts @@ -11,7 +11,9 @@ import { SHARED_ENV_NAME, SUITE_TIMEOUT, WORKBENCH_TIMEOUT, + copyFixtureIntoDir, copyFixtureToTempDir, + copySnapshotIntoDir, createEnvironment, createScreenshotter, openFolderViaDialog, @@ -39,12 +41,7 @@ describe('Deepnote — a legacy project-scoped snapshot still loads its saved ou const screenshot = createScreenshotter(this); const copy = copyFixtureToTempDir(FIXTURE); cleanupTempDir = copy.cleanup; - const snapshotsDir = path.join(copy.tempDir, 'snapshots'); - fs.mkdirSync(snapshotsDir, { recursive: true }); - fs.copyFileSync( - path.resolve(process.cwd(), 'test', 'e2e', 'fixtures', 'snapshots', SNAPSHOT), - path.join(snapshotsDir, SNAPSHOT) - ); + copySnapshotIntoDir(copy.tempDir, SNAPSHOT); await VSBrowser.instance.waitForWorkbench(WORKBENCH_TIMEOUT); await openFolderViaDialog(copy.tempDir); @@ -108,10 +105,7 @@ describe('Deepnote — new snapshots are notebook-scoped and do not bleed betwee const copy = copyFixtureToTempDir(SIBLINGS[0].file); cleanupTempDir = copy.cleanup; tempDir = copy.tempDir; - fs.copyFileSync( - path.resolve(process.cwd(), 'test', 'e2e', 'fixtures', SIBLINGS[1].file), - path.join(tempDir, SIBLINGS[1].file) - ); + copyFixtureIntoDir(tempDir, SIBLINGS[1].file); await VSBrowser.instance.waitForWorkbench(WORKBENCH_TIMEOUT); await openFolderViaDialog(tempDir); diff --git a/test/e2e/suite/statusBar.e2e.test.ts b/test/e2e/suite/statusBar.e2e.test.ts index 89b7b0f8a9..de92a4128b 100644 --- a/test/e2e/suite/statusBar.e2e.test.ts +++ b/test/e2e/suite/statusBar.e2e.test.ts @@ -21,7 +21,6 @@ import { const FIXTURE = 'quick-notes.deepnote'; const SCRATCH_FILE = 'clipboard-scratch.txt'; const NOTEBOOK_NAME = 'Quick Notes'; -const PROJECT_ID = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc'; const NOTEBOOK_ID = 'c-nb-main'; const COPIED_TOAST = /Copied Deepnote notebook details to clipboard\./i; @@ -57,6 +56,7 @@ describe('Deepnote — the active-notebook status bar item', function () { this.timeout(SUITE_TIMEOUT); let cleanupTempDir: (() => void) | undefined; + let projectId = ''; let itemTextWithNotebook = ''; let itemTooltip = ''; let copyToastShown = false; @@ -68,6 +68,7 @@ describe('Deepnote — the active-notebook status bar item', function () { const screenshot = createScreenshotter(this); const copy = copyFixtureToTempDir(FIXTURE); + projectId = copy.projectId; cleanupTempDir = copy.cleanup; // A plain, non-notebook editor to paste the clipboard into (also serves the hidden-item check). fs.writeFileSync(path.join(copy.tempDir, SCRATCH_FILE), ''); @@ -157,7 +158,7 @@ describe('Deepnote — the active-notebook status bar item', function () { expect(copyToastShown, 'copied-to-clipboard toast').to.equal(true); expect(clipboardText, 'clipboard details').to.contain(`Notebook name: ${NOTEBOOK_NAME}`); expect(clipboardText, 'clipboard details').to.contain(`Notebook ID: ${NOTEBOOK_ID}`); - expect(clipboardText, 'clipboard details').to.contain(`Project ID: ${PROJECT_ID}`); + expect(clipboardText, 'clipboard details').to.contain(`Project ID: ${projectId}`); expect(clipboardText, 'clipboard details').to.contain(FIXTURE); }); }); From 346ec83a522bb21f648ca841cebb117660feb6a1 Mon Sep 17 00:00:00 2001 From: tomas Date: Thu, 20 Aug 2026 13:54:13 +0000 Subject: [PATCH 04/12] perf(e2e): shard the suite into three functional groups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01MyZCw9GzL6Vq7S9MAVYwby --- .github/workflows/e2e.yml | 16 ++++- build/e2e/checkSuiteGroups.js | 62 +++++++++++++++++++ package.json | 8 ++- .../{ => files}/initOnlyFile.e2e.test.ts | 2 +- .../{ => files}/notebookCommands.e2e.test.ts | 2 +- .../{ => files}/projectRename.e2e.test.ts | 2 +- .../{ => files}/splitInitNotebook.e2e.test.ts | 2 +- .../splitMultiNotebook.e2e.test.ts | 2 +- .../suite/{ => files}/splitSafety.e2e.test.ts | 2 +- .../{ => kernel}/environment.e2e.test.ts | 2 +- .../suite/{ => kernel}/helloWorld.e2e.test.ts | 2 +- .../initNotebookRunner.e2e.test.ts | 2 +- .../integrationsEnvFileInjection.e2e.test.ts | 2 +- .../explorerGrouping.e2e.test.ts | 2 +- .../{ => workspace}/fileWatcher.e2e.test.ts | 2 +- .../{ => workspace}/integrations.e2e.test.ts | 2 +- .../openSingleNotebook.e2e.test.ts | 2 +- .../revealInExplorer.e2e.test.ts | 2 +- .../{ => workspace}/snapshots.e2e.test.ts | 2 +- .../{ => workspace}/statusBar.e2e.test.ts | 2 +- 20 files changed, 98 insertions(+), 22 deletions(-) create mode 100644 build/e2e/checkSuiteGroups.js rename test/e2e/suite/{ => files}/initOnlyFile.e2e.test.ts (99%) rename test/e2e/suite/{ => files}/notebookCommands.e2e.test.ts (99%) rename test/e2e/suite/{ => files}/projectRename.e2e.test.ts (99%) rename test/e2e/suite/{ => files}/splitInitNotebook.e2e.test.ts (99%) rename test/e2e/suite/{ => files}/splitMultiNotebook.e2e.test.ts (99%) rename test/e2e/suite/{ => files}/splitSafety.e2e.test.ts (99%) rename test/e2e/suite/{ => kernel}/environment.e2e.test.ts (99%) rename test/e2e/suite/{ => kernel}/helloWorld.e2e.test.ts (99%) rename test/e2e/suite/{ => kernel}/initNotebookRunner.e2e.test.ts (99%) rename test/e2e/suite/{ => kernel}/integrationsEnvFileInjection.e2e.test.ts (99%) rename test/e2e/suite/{ => workspace}/explorerGrouping.e2e.test.ts (99%) rename test/e2e/suite/{ => workspace}/fileWatcher.e2e.test.ts (99%) rename test/e2e/suite/{ => workspace}/integrations.e2e.test.ts (99%) rename test/e2e/suite/{ => workspace}/openSingleNotebook.e2e.test.ts (99%) rename test/e2e/suite/{ => workspace}/revealInExplorer.e2e.test.ts (99%) rename test/e2e/suite/{ => workspace}/snapshots.e2e.test.ts (99%) rename test/e2e/suite/{ => workspace}/statusBar.e2e.test.ts (99%) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 0851249ef0..e586ddb45b 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -18,9 +18,16 @@ concurrency: jobs: e2e: - name: E2E (ExTester) + name: E2E (${{ matrix.group }}) runs-on: ubuntu-latest timeout-minutes: 45 + strategy: + # One shard per directory in test/e2e/suite/. Grouped by what the suites cover rather than by + # measured time, so a new suite has an obvious home and the split does not silently skew as + # suites are added; check:e2e:groups fails the build if a suite lands outside a shard. + fail-fast: false + matrix: + group: [kernel, files, workspace] env: # Keep ExTester's downloads (test VS Code, ChromeDriver, settings, screenshots) inside the # workspace so the artifact-upload paths are predictable. Both this and .test-extensions are @@ -53,6 +60,9 @@ jobs: - name: Compile the E2E test sources run: npm run compile-e2e + - name: Check every suite belongs to a shard + run: npm run check:e2e:groups + - name: Install Electron/Chromium runtime libraries + Xvfb run: | sudo apt-get update @@ -108,13 +118,13 @@ jobs: - name: Run E2E # VS Code launches with --no-sandbox (no AppArmor sysctl needed). Runs once; Mocha's retries:1 # and rootHooks.ts (dismiss toasts between tests) handle flakiness in the single shared instance. - run: xvfb-run --auto-servernum --server-args='-screen 0 1920x1080x24' npm run test:e2e:prebuilt + run: xvfb-run --auto-servernum --server-args='-screen 0 1920x1080x24' npm run test:e2e:${{ matrix.group }} - name: Upload failure screenshots if: failure() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: e2e-screenshots + name: e2e-screenshots-${{ matrix.group }} path: ${{ env.TEST_RESOURCES }}/screenshots/**/*.png if-no-files-found: ignore retention-days: 14 diff --git a/build/e2e/checkSuiteGroups.js b/build/e2e/checkSuiteGroups.js new file mode 100644 index 0000000000..f1656dd40c --- /dev/null +++ b/build/e2e/checkSuiteGroups.js @@ -0,0 +1,62 @@ +// Fails when an E2E suite would not be run by any shard. +// +// The E2E job is a matrix over the directories in test/e2e/suite/, each shard running one directory's +// glob. A suite added to the wrong place — or a new group directory without a matching script and +// matrix entry — does not fail anything: it just silently never runs, which looks like a faster green +// build. This turns that into a build error. + +const fs = require('fs'); +const path = require('path'); + +const repoRoot = path.resolve(__dirname, '..', '..'); +const suiteDir = path.join(repoRoot, 'test', 'e2e', 'suite'); +const workflowPath = path.join(repoRoot, '.github', 'workflows', 'e2e.yml'); + +const problems = []; + +const entries = fs.readdirSync(suiteDir, { withFileTypes: true }); + +const stray = entries.filter((entry) => entry.isFile() && entry.name.endsWith('.e2e.test.ts')); +for (const file of stray) { + problems.push(`${file.name} sits directly in test/e2e/suite/ — move it into a group directory.`); +} + +const groups = entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name); +if (groups.length === 0) { + problems.push('test/e2e/suite/ has no group directories.'); +} + +const scripts = require(path.join(repoRoot, 'package.json')).scripts; +const workflow = fs.readFileSync(workflowPath, 'utf8'); + +for (const group of groups) { + const files = fs.readdirSync(path.join(suiteDir, group)).filter((name) => name.endsWith('.e2e.test.ts')); + if (files.length === 0) { + problems.push(`Group "${group}" contains no suites.`); + } + if (!scripts[`test:e2e:${group}`]) { + problems.push(`Group "${group}" has no "test:e2e:${group}" script in package.json.`); + } + if (!workflow.includes(group)) { + problems.push(`Group "${group}" is not named in .github/workflows/e2e.yml — no shard runs it.`); + } + console.log(` ${group.padEnd(12)} ${files.length} suites`); +} + +// A script without a directory would fail the shard rather than skip it, but it is still a mistake. +for (const name of Object.keys(scripts)) { + const match = name.match(/^test:e2e:(.+)$/); + if (match && !['prebuilt'].includes(match[1]) && !groups.includes(match[1])) { + problems.push(`Script "${name}" has no matching directory under test/e2e/suite/.`); + } +} + +if (problems.length > 0) { + console.error('\nE2E suite grouping is inconsistent:'); + for (const problem of problems) { + console.error(` - ${problem}`); + } + process.exit(1); +} + +console.log('\nEvery E2E suite belongs to exactly one shard.'); diff --git a/package.json b/package.json index 89264e4173..51e1f60d2a 100644 --- a/package.json +++ b/package.json @@ -2674,8 +2674,12 @@ "setup:e2e:deps": "extest install-from-marketplace ms-python.python -e .test-extensions", "setup:e2e:venv": "node ./build/e2e/prepareE2eVenv.js", "setup:e2e": "npm run setup:e2e:vscode && npm run setup:e2e:deps && npm run setup:e2e:venv", - "test:e2e": "extest setup-and-run \"./out/e2e/suite/*.e2e.test.js\" -c max -o ./test/e2e/settings.generated.json -e .test-extensions -m ./test/e2e/.mocharc.js -i", - "test:e2e:prebuilt": "extest run-tests \"./out/e2e/suite/*.e2e.test.js\" -c max -o ./test/e2e/settings.generated.json -e .test-extensions -m ./test/e2e/.mocharc.js", + "test:e2e": "extest setup-and-run \"./out/e2e/suite/**/*.e2e.test.js\" -c max -o ./test/e2e/settings.generated.json -e .test-extensions -m ./test/e2e/.mocharc.js -i", + "test:e2e:prebuilt": "extest run-tests \"./out/e2e/suite/**/*.e2e.test.js\" -c max -o ./test/e2e/settings.generated.json -e .test-extensions -m ./test/e2e/.mocharc.js", + "test:e2e:kernel": "extest run-tests \"./out/e2e/suite/kernel/*.e2e.test.js\" -c max -o ./test/e2e/settings.generated.json -e .test-extensions -m ./test/e2e/.mocharc.js", + "test:e2e:files": "extest run-tests \"./out/e2e/suite/files/*.e2e.test.js\" -c max -o ./test/e2e/settings.generated.json -e .test-extensions -m ./test/e2e/.mocharc.js", + "test:e2e:workspace": "extest run-tests \"./out/e2e/suite/workspace/*.e2e.test.js\" -c max -o ./test/e2e/settings.generated.json -e .test-extensions -m ./test/e2e/.mocharc.js", + "check:e2e:groups": "node ./build/e2e/checkSuiteGroups.js", "test:unittests": "mocha --config ./build/.mocha.unittests.js.json ./out/**/*.unit.test.js", "test": "npm run test:unittests", "typecheck": "tsc -p ./ --noEmit", diff --git a/test/e2e/suite/initOnlyFile.e2e.test.ts b/test/e2e/suite/files/initOnlyFile.e2e.test.ts similarity index 99% rename from test/e2e/suite/initOnlyFile.e2e.test.ts rename to test/e2e/suite/files/initOnlyFile.e2e.test.ts index e46cbfa158..facb884740 100644 --- a/test/e2e/suite/initOnlyFile.e2e.test.ts +++ b/test/e2e/suite/files/initOnlyFile.e2e.test.ts @@ -22,7 +22,7 @@ import { assertNotNull, selectDeepnoteContextMenu, waitForNotification -} from '../helpers'; +} from '../../helpers'; const FIXTURE = 'bootstrap-only.deepnote'; const NOTEBOOK_NAME = 'Bootstrap'; diff --git a/test/e2e/suite/notebookCommands.e2e.test.ts b/test/e2e/suite/files/notebookCommands.e2e.test.ts similarity index 99% rename from test/e2e/suite/notebookCommands.e2e.test.ts rename to test/e2e/suite/files/notebookCommands.e2e.test.ts index 70bbd46447..06cbc79dc6 100644 --- a/test/e2e/suite/notebookCommands.e2e.test.ts +++ b/test/e2e/suite/files/notebookCommands.e2e.test.ts @@ -30,7 +30,7 @@ import { openWorkspaceFile, assertNotNull, waitForNotification -} from '../helpers'; +} from '../../helpers'; const MARKETING_FILES = ['marketing-overview.deepnote', 'marketing-campaigns.deepnote', 'marketing-metrics.deepnote']; const GROUP = 'Marketing'; diff --git a/test/e2e/suite/projectRename.e2e.test.ts b/test/e2e/suite/files/projectRename.e2e.test.ts similarity index 99% rename from test/e2e/suite/projectRename.e2e.test.ts rename to test/e2e/suite/files/projectRename.e2e.test.ts index 61cc4c1714..01f16e91d7 100644 --- a/test/e2e/suite/projectRename.e2e.test.ts +++ b/test/e2e/suite/files/projectRename.e2e.test.ts @@ -23,7 +23,7 @@ import { readDeepnoteTreeRows, selectDeepnoteContextMenu, waitForNotification -} from '../helpers'; +} from '../../helpers'; const DIRTIED_FILE = 'marketing-overview.deepnote' as const; const MARKETING_FILES = [DIRTIED_FILE, 'marketing-campaigns.deepnote', 'marketing-metrics.deepnote'] as const; diff --git a/test/e2e/suite/splitInitNotebook.e2e.test.ts b/test/e2e/suite/files/splitInitNotebook.e2e.test.ts similarity index 99% rename from test/e2e/suite/splitInitNotebook.e2e.test.ts rename to test/e2e/suite/files/splitInitNotebook.e2e.test.ts index b96e6ae2dc..6ff227dc75 100644 --- a/test/e2e/suite/splitInitNotebook.e2e.test.ts +++ b/test/e2e/suite/files/splitInitNotebook.e2e.test.ts @@ -20,7 +20,7 @@ import { assertNotNull, showView, waitForNotification -} from '../helpers'; +} from '../../helpers'; const FIXTURE = 'etl-pipeline.deepnote'; const SPLIT_ACTION = 'Split into separate files'; diff --git a/test/e2e/suite/splitMultiNotebook.e2e.test.ts b/test/e2e/suite/files/splitMultiNotebook.e2e.test.ts similarity index 99% rename from test/e2e/suite/splitMultiNotebook.e2e.test.ts rename to test/e2e/suite/files/splitMultiNotebook.e2e.test.ts index f2811688b2..6905a6b06b 100644 --- a/test/e2e/suite/splitMultiNotebook.e2e.test.ts +++ b/test/e2e/suite/files/splitMultiNotebook.e2e.test.ts @@ -20,7 +20,7 @@ import { assertNotNull, showView, waitForNotification -} from '../helpers'; +} from '../../helpers'; const FIXTURE = 'sales-analytics.deepnote'; const SPLIT_ACTION = 'Split into separate files'; diff --git a/test/e2e/suite/splitSafety.e2e.test.ts b/test/e2e/suite/files/splitSafety.e2e.test.ts similarity index 99% rename from test/e2e/suite/splitSafety.e2e.test.ts rename to test/e2e/suite/files/splitSafety.e2e.test.ts index 92c29caccb..99d9a855e9 100644 --- a/test/e2e/suite/splitSafety.e2e.test.ts +++ b/test/e2e/suite/files/splitSafety.e2e.test.ts @@ -17,7 +17,7 @@ import { openWorkspaceFile, assertNotNull, waitForNotification -} from '../helpers'; +} from '../../helpers'; const DISMISS_FIXTURE = 'sales-analytics.deepnote'; const SPLIT_PROMPT = /multiple notebooks/i; diff --git a/test/e2e/suite/environment.e2e.test.ts b/test/e2e/suite/kernel/environment.e2e.test.ts similarity index 99% rename from test/e2e/suite/environment.e2e.test.ts rename to test/e2e/suite/kernel/environment.e2e.test.ts index 6726d623d9..d2eda0f2f3 100644 --- a/test/e2e/suite/environment.e2e.test.ts +++ b/test/e2e/suite/kernel/environment.e2e.test.ts @@ -36,7 +36,7 @@ import { selectDeepnoteContextMenu, selectEnvironmentForNotebook, waitForNotification -} from '../helpers'; +} from '../../helpers'; const FIXTURE = 'sales-analytics.deepnote'; const CHILD = 'sales-analytics-overview.deepnote'; diff --git a/test/e2e/suite/helloWorld.e2e.test.ts b/test/e2e/suite/kernel/helloWorld.e2e.test.ts similarity index 99% rename from test/e2e/suite/helloWorld.e2e.test.ts rename to test/e2e/suite/kernel/helloWorld.e2e.test.ts index 605ff51e0c..db0ee5d230 100644 --- a/test/e2e/suite/helloWorld.e2e.test.ts +++ b/test/e2e/suite/kernel/helloWorld.e2e.test.ts @@ -32,7 +32,7 @@ import { openWorkspaceFile, runOnceAndAwaitOutput, selectEnvironmentForNotebook -} from '../helpers'; +} from '../../helpers'; const NOTEBOOK_FILE_NAME = 'hello-world.deepnote'; const EXPECTED_OUTPUT = 'hello world'; diff --git a/test/e2e/suite/initNotebookRunner.e2e.test.ts b/test/e2e/suite/kernel/initNotebookRunner.e2e.test.ts similarity index 99% rename from test/e2e/suite/initNotebookRunner.e2e.test.ts rename to test/e2e/suite/kernel/initNotebookRunner.e2e.test.ts index 83cb92a170..a59ed2e746 100644 --- a/test/e2e/suite/initNotebookRunner.e2e.test.ts +++ b/test/e2e/suite/kernel/initNotebookRunner.e2e.test.ts @@ -23,7 +23,7 @@ import { readRenderedOutput, runOnceAndAwaitOutput, selectEnvironmentForNotebook -} from '../helpers'; +} from '../../helpers'; const MAIN_FILE = 'etl-pipeline-extract.deepnote'; const INIT_SIBLING_FILE = 'etl-pipeline-init.deepnote'; diff --git a/test/e2e/suite/integrationsEnvFileInjection.e2e.test.ts b/test/e2e/suite/kernel/integrationsEnvFileInjection.e2e.test.ts similarity index 99% rename from test/e2e/suite/integrationsEnvFileInjection.e2e.test.ts rename to test/e2e/suite/kernel/integrationsEnvFileInjection.e2e.test.ts index 6571178c19..29b8fe907d 100644 --- a/test/e2e/suite/integrationsEnvFileInjection.e2e.test.ts +++ b/test/e2e/suite/kernel/integrationsEnvFileInjection.e2e.test.ts @@ -19,7 +19,7 @@ import { openWorkspaceFile, runOnceAndAwaitOutput, selectEnvironmentForNotebook -} from '../helpers'; +} from '../../helpers'; const NOTEBOOK_FILE_NAME = 'integrations-env-file.deepnote'; const EXPECTED_OUTPUT = 'injected-host.example.com'; diff --git a/test/e2e/suite/explorerGrouping.e2e.test.ts b/test/e2e/suite/workspace/explorerGrouping.e2e.test.ts similarity index 99% rename from test/e2e/suite/explorerGrouping.e2e.test.ts rename to test/e2e/suite/workspace/explorerGrouping.e2e.test.ts index 7a92074c31..a4b470ae8b 100644 --- a/test/e2e/suite/explorerGrouping.e2e.test.ts +++ b/test/e2e/suite/workspace/explorerGrouping.e2e.test.ts @@ -13,7 +13,7 @@ import { copyFixtureToTempDir, createScreenshotter, openFolderViaDialog -} from '../helpers'; +} from '../../helpers'; const MARKETING_FILES = ['marketing-overview.deepnote', 'marketing-campaigns.deepnote', 'marketing-metrics.deepnote']; const OTHER_PROJECT_FILE = 'quick-notes.deepnote'; diff --git a/test/e2e/suite/fileWatcher.e2e.test.ts b/test/e2e/suite/workspace/fileWatcher.e2e.test.ts similarity index 99% rename from test/e2e/suite/fileWatcher.e2e.test.ts rename to test/e2e/suite/workspace/fileWatcher.e2e.test.ts index 7d594d8cfa..583394d5b7 100644 --- a/test/e2e/suite/fileWatcher.e2e.test.ts +++ b/test/e2e/suite/workspace/fileWatcher.e2e.test.ts @@ -17,7 +17,7 @@ import { openFolderViaDialog, openWorkspaceFile, readRenderedOutput -} from '../helpers'; +} from '../../helpers'; const FIXTURE = 'hello-world.deepnote'; const ORIGINAL_SOURCE = 'hello world'; diff --git a/test/e2e/suite/integrations.e2e.test.ts b/test/e2e/suite/workspace/integrations.e2e.test.ts similarity index 99% rename from test/e2e/suite/integrations.e2e.test.ts rename to test/e2e/suite/workspace/integrations.e2e.test.ts index 9420cab395..22f903aae4 100644 --- a/test/e2e/suite/integrations.e2e.test.ts +++ b/test/e2e/suite/workspace/integrations.e2e.test.ts @@ -14,7 +14,7 @@ import { createScreenshotter, openFolderViaDialog, openWorkspaceFile -} from '../helpers'; +} from '../../helpers'; const REVENUE_FILE = 'sales-analytics-revenue.deepnote'; const PLAIN_FILE = 'quick-notes.deepnote'; diff --git a/test/e2e/suite/openSingleNotebook.e2e.test.ts b/test/e2e/suite/workspace/openSingleNotebook.e2e.test.ts similarity index 99% rename from test/e2e/suite/openSingleNotebook.e2e.test.ts rename to test/e2e/suite/workspace/openSingleNotebook.e2e.test.ts index 97a67a5ecd..0c4dab78f8 100644 --- a/test/e2e/suite/openSingleNotebook.e2e.test.ts +++ b/test/e2e/suite/workspace/openSingleNotebook.e2e.test.ts @@ -15,7 +15,7 @@ import { openWorkspaceFile, readStatusBarText, waitForNotification -} from '../helpers'; +} from '../../helpers'; const FIXTURE = 'quick-notes.deepnote'; const NOTEBOOK_NAME = 'Quick Notes'; diff --git a/test/e2e/suite/revealInExplorer.e2e.test.ts b/test/e2e/suite/workspace/revealInExplorer.e2e.test.ts similarity index 99% rename from test/e2e/suite/revealInExplorer.e2e.test.ts rename to test/e2e/suite/workspace/revealInExplorer.e2e.test.ts index 57b8a037a7..493fa2ac0b 100644 --- a/test/e2e/suite/revealInExplorer.e2e.test.ts +++ b/test/e2e/suite/workspace/revealInExplorer.e2e.test.ts @@ -16,7 +16,7 @@ import { openFolderViaDialog, openWorkspaceFile, waitForNotification -} from '../helpers'; +} from '../../helpers'; const FIXTURE = 'quick-notes.deepnote'; const NOTEBOOK_NAME = 'Quick Notes'; diff --git a/test/e2e/suite/snapshots.e2e.test.ts b/test/e2e/suite/workspace/snapshots.e2e.test.ts similarity index 99% rename from test/e2e/suite/snapshots.e2e.test.ts rename to test/e2e/suite/workspace/snapshots.e2e.test.ts index 084ea106b2..44f688bced 100644 --- a/test/e2e/suite/snapshots.e2e.test.ts +++ b/test/e2e/suite/workspace/snapshots.e2e.test.ts @@ -22,7 +22,7 @@ import { runOnceAndAwaitOutput, selectEnvironmentForNotebook, waitForNotification -} from '../helpers'; +} from '../../helpers'; const FIXTURE = 'legacy-snapshot-demo.deepnote'; const SNAPSHOT = 'legacy-snapshot-demo_ffffffff-ffff-4fff-8fff-ffffffffffff_latest.snapshot.deepnote'; diff --git a/test/e2e/suite/statusBar.e2e.test.ts b/test/e2e/suite/workspace/statusBar.e2e.test.ts similarity index 99% rename from test/e2e/suite/statusBar.e2e.test.ts rename to test/e2e/suite/workspace/statusBar.e2e.test.ts index de92a4128b..167bad3005 100644 --- a/test/e2e/suite/statusBar.e2e.test.ts +++ b/test/e2e/suite/workspace/statusBar.e2e.test.ts @@ -16,7 +16,7 @@ import { openFolderViaDialog, openWorkspaceFile, waitForNotification -} from '../helpers'; +} from '../../helpers'; const FIXTURE = 'quick-notes.deepnote'; const SCRATCH_FILE = 'clipboard-scratch.txt'; From 96f6e585e7a0a93909801c9b8cf8b7f413d5c497 Mon Sep 17 00:00:00 2001 From: tomas Date: Thu, 20 Aug 2026 14:15:35 +0000 Subject: [PATCH 05/12] ci(e2e): define the shard list once and verify it in its own job MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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:` 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) Claude-Session: https://claude.ai/code/session_01MyZCw9GzL6Vq7S9MAVYwby --- .github/workflows/e2e.yml | 45 ++++++++++++++++--- build/e2e/checkSuiteGroups.js | 81 +++++++++++++++++++++-------------- 2 files changed, 88 insertions(+), 38 deletions(-) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index e586ddb45b..06fe39a8b3 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -17,17 +17,53 @@ concurrency: cancel-in-progress: true jobs: + # Single source of truth for the shard list. GitHub Actions does not support YAML anchors, and + # `env` is not a permitted context inside `strategy`, so a job output is the only way to define the + # list once and have both the matrix and the verification job read the same value. + groups: + name: E2E groups + runs-on: ubuntu-latest + outputs: + list: ${{ steps.define.outputs.list }} + steps: + - name: Define the shard list + id: define + run: echo 'list=["kernel","files","workspace"]' >> "$GITHUB_OUTPUT" + + verify-groups: + name: Verify suite directories + needs: groups + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Setup Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version-file: '.nvmrc' + + # A suite directory missing from the matrix is not a failure on its own — nothing runs it, and + # the build goes green faster. This is the job that turns that into an error. + - name: Check every suite directory has a shard + run: node ./build/e2e/checkSuiteGroups.js + env: + E2E_GROUPS: ${{ needs.groups.outputs.list }} + e2e: name: E2E (${{ matrix.group }}) + needs: groups runs-on: ubuntu-latest timeout-minutes: 45 strategy: - # One shard per directory in test/e2e/suite/. Grouped by what the suites cover rather than by + # One shard per directory in test/e2e/suite/, grouped by what the suites cover rather than by # measured time, so a new suite has an obvious home and the split does not silently skew as - # suites are added; check:e2e:groups fails the build if a suite lands outside a shard. + # suites are added. The verify-groups job above fails the build if the two ever diverge. fail-fast: false matrix: - group: [kernel, files, workspace] + group: ${{ fromJson(needs.groups.outputs.list) }} env: # Keep ExTester's downloads (test VS Code, ChromeDriver, settings, screenshots) inside the # workspace so the artifact-upload paths are predictable. Both this and .test-extensions are @@ -60,9 +96,6 @@ jobs: - name: Compile the E2E test sources run: npm run compile-e2e - - name: Check every suite belongs to a shard - run: npm run check:e2e:groups - - name: Install Electron/Chromium runtime libraries + Xvfb run: | sudo apt-get update diff --git a/build/e2e/checkSuiteGroups.js b/build/e2e/checkSuiteGroups.js index f1656dd40c..2a3b12e829 100644 --- a/build/e2e/checkSuiteGroups.js +++ b/build/e2e/checkSuiteGroups.js @@ -1,62 +1,79 @@ -// Fails when an E2E suite would not be run by any shard. +// Fails when the E2E shard list and the suite directories disagree. // -// The E2E job is a matrix over the directories in test/e2e/suite/, each shard running one directory's -// glob. A suite added to the wrong place — or a new group directory without a matching script and -// matrix entry — does not fail anything: it just silently never runs, which looks like a faster green -// build. This turns that into a build error. +// The E2E job is a matrix over a list of group names, each shard running one directory's glob. A +// directory that is missing from that list does not fail anything: nothing runs it, and the build +// goes green sooner. Same for a suite left outside a group directory. This turns both into errors. +// +// The authoritative list comes from E2E_GROUPS (set by the workflow from the same job output the +// matrix reads, so the two cannot drift). Run locally without it and the list is inferred from the +// `test:e2e:` scripts instead. const fs = require('fs'); const path = require('path'); const repoRoot = path.resolve(__dirname, '..', '..'); const suiteDir = path.join(repoRoot, 'test', 'e2e', 'suite'); -const workflowPath = path.join(repoRoot, '.github', 'workflows', 'e2e.yml'); +const scripts = require(path.join(repoRoot, 'package.json')).scripts; -const problems = []; +const RESERVED_SCRIPT_SUFFIXES = ['prebuilt']; + +function declaredGroups() { + const fromEnv = process.env.E2E_GROUPS; + if (!fromEnv) { + return Object.keys(scripts) + .map((name) => name.match(/^test:e2e:(.+)$/)?.[1]) + .filter((group) => group && !RESERVED_SCRIPT_SUFFIXES.includes(group)); + } + + const parsed = JSON.parse(fromEnv); + if (!Array.isArray(parsed) || parsed.length === 0) { + throw new Error(`E2E_GROUPS must be a non-empty JSON array, got: ${fromEnv}`); + } + + return parsed; +} +const groups = declaredGroups(); +const problems = []; const entries = fs.readdirSync(suiteDir, { withFileTypes: true }); -const stray = entries.filter((entry) => entry.isFile() && entry.name.endsWith('.e2e.test.ts')); -for (const file of stray) { +for (const file of entries.filter((entry) => entry.isFile() && entry.name.endsWith('.e2e.test.ts'))) { problems.push(`${file.name} sits directly in test/e2e/suite/ — move it into a group directory.`); } -const groups = entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name); -if (groups.length === 0) { - problems.push('test/e2e/suite/ has no group directories.'); +// The check this job exists for: a directory nobody shards. +for (const dir of entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name)) { + if (!groups.includes(dir)) { + problems.push(`test/e2e/suite/${dir}/ is not in the shard list [${groups.join(', ')}] — no job runs it.`); + } } -const scripts = require(path.join(repoRoot, 'package.json')).scripts; -const workflow = fs.readFileSync(workflowPath, 'utf8'); - for (const group of groups) { - const files = fs.readdirSync(path.join(suiteDir, group)).filter((name) => name.endsWith('.e2e.test.ts')); - if (files.length === 0) { - problems.push(`Group "${group}" contains no suites.`); + const dir = path.join(suiteDir, group); + if (!fs.existsSync(dir)) { + problems.push(`Shard "${group}" has no test/e2e/suite/${group}/ directory.`); + continue; } - if (!scripts[`test:e2e:${group}`]) { - problems.push(`Group "${group}" has no "test:e2e:${group}" script in package.json.`); + + const suites = fs.readdirSync(dir).filter((name) => name.endsWith('.e2e.test.ts')); + if (suites.length === 0) { + problems.push(`Shard "${group}" contains no suites.`); } - if (!workflow.includes(group)) { - problems.push(`Group "${group}" is not named in .github/workflows/e2e.yml — no shard runs it.`); + if (!scripts[`test:e2e:${group}`]) { + problems.push(`Shard "${group}" has no "test:e2e:${group}" script in package.json.`); } - console.log(` ${group.padEnd(12)} ${files.length} suites`); -} -// A script without a directory would fail the shard rather than skip it, but it is still a mistake. -for (const name of Object.keys(scripts)) { - const match = name.match(/^test:e2e:(.+)$/); - if (match && !['prebuilt'].includes(match[1]) && !groups.includes(match[1])) { - problems.push(`Script "${name}" has no matching directory under test/e2e/suite/.`); - } + console.log(` ${group.padEnd(12)} ${suites.length} suites`); } if (problems.length > 0) { - console.error('\nE2E suite grouping is inconsistent:'); + console.error('\nE2E shard list and suite directories disagree:'); for (const problem of problems) { console.error(` - ${problem}`); } process.exit(1); } -console.log('\nEvery E2E suite belongs to exactly one shard.'); +console.log( + `\nEvery suite directory is covered by a shard (${process.env.E2E_GROUPS ? 'E2E_GROUPS' : 'package.json'}).` +); From b177251504af822bcb09c050bb5329a13858c061 Mon Sep 17 00:00:00 2001 From: tomas Date: Thu, 20 Aug 2026 14:19:01 +0000 Subject: [PATCH 06/12] test(ci): probe whether a custom top-level key can hold a YAML anchor 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) --- .github/workflows/anchor-probe.yml | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 .github/workflows/anchor-probe.yml diff --git a/.github/workflows/anchor-probe.yml b/.github/workflows/anchor-probe.yml new file mode 100644 index 0000000000..bb58bdf1db --- /dev/null +++ b/.github/workflows/anchor-probe.yml @@ -0,0 +1,17 @@ +name: Anchor probe + +# Throwaway: checks whether GitHub accepts a custom top-level key holding a YAML anchor. +# Never runs on its own. Deleted once the answer is known. +on: + workflow_dispatch: + +x-e2e-groups: &e2e-groups [kernel, files, workspace] + +jobs: + probe: + runs-on: ubuntu-latest + strategy: + matrix: + group: *e2e-groups + steps: + - run: echo "${{ matrix.group }}" From 9dd4003112926952bdab91cf3ad1814d9033f15e Mon Sep 17 00:00:00 2001 From: tomas Date: Thu, 20 Aug 2026 14:22:52 +0000 Subject: [PATCH 07/12] ci(e2e): anchor the shard list and read it back in the verify job MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01MyZCw9GzL6Vq7S9MAVYwby --- .github/workflows/anchor-probe.yml | 17 -------------- .github/workflows/e2e.yml | 31 ++++++++++--------------- build/e2e/checkSuiteGroups.js | 37 +++++++++++++++++------------- 3 files changed, 33 insertions(+), 52 deletions(-) delete mode 100644 .github/workflows/anchor-probe.yml diff --git a/.github/workflows/anchor-probe.yml b/.github/workflows/anchor-probe.yml deleted file mode 100644 index bb58bdf1db..0000000000 --- a/.github/workflows/anchor-probe.yml +++ /dev/null @@ -1,17 +0,0 @@ -name: Anchor probe - -# Throwaway: checks whether GitHub accepts a custom top-level key holding a YAML anchor. -# Never runs on its own. Deleted once the answer is known. -on: - workflow_dispatch: - -x-e2e-groups: &e2e-groups [kernel, files, workspace] - -jobs: - probe: - runs-on: ubuntu-latest - strategy: - matrix: - group: *e2e-groups - steps: - - run: echo "${{ matrix.group }}" diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 06fe39a8b3..5b66df5910 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -17,22 +17,8 @@ concurrency: cancel-in-progress: true jobs: - # Single source of truth for the shard list. GitHub Actions does not support YAML anchors, and - # `env` is not a permitted context inside `strategy`, so a job output is the only way to define the - # list once and have both the matrix and the verification job read the same value. - groups: - name: E2E groups - runs-on: ubuntu-latest - outputs: - list: ${{ steps.define.outputs.list }} - steps: - - name: Define the shard list - id: define - run: echo 'list=["kernel","files","workspace"]' >> "$GITHUB_OUTPUT" - verify-groups: name: Verify suite directories - needs: groups runs-on: ubuntu-latest steps: - name: Checkout @@ -43,27 +29,34 @@ jobs: - name: Setup Node.js uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: + cache: 'npm' node-version-file: '.nvmrc' + # Only js-yaml is needed, to resolve the matrix anchor out of this file. + - name: Install dependencies + run: npm ci --omit=dev --prefer-offline --no-audit --ignore-scripts + # A suite directory missing from the matrix is not a failure on its own — nothing runs it, and # the build goes green faster. This is the job that turns that into an error. - name: Check every suite directory has a shard run: node ./build/e2e/checkSuiteGroups.js - env: - E2E_GROUPS: ${{ needs.groups.outputs.list }} e2e: name: E2E (${{ matrix.group }}) - needs: groups runs-on: ubuntu-latest timeout-minutes: 45 strategy: # One shard per directory in test/e2e/suite/, grouped by what the suites cover rather than by # measured time, so a new suite has an obvious home and the split does not silently skew as - # suites are added. The verify-groups job above fails the build if the two ever diverge. + # suites are added. + # + # This list is the single definition of the shards. It carries an anchor so other jobs can + # alias it; it cannot live at the top of the file because the workflow schema rejects custom + # top-level keys, and `env` is not a permitted context inside `strategy`. verify-groups reads + # the resolved value straight out of this file, so the two cannot drift. fail-fast: false matrix: - group: ${{ fromJson(needs.groups.outputs.list) }} + group: &e2e-groups [kernel, files, workspace] env: # Keep ExTester's downloads (test VS Code, ChromeDriver, settings, screenshots) inside the # workspace so the artifact-upload paths are predictable. Both this and .test-extensions are diff --git a/build/e2e/checkSuiteGroups.js b/build/e2e/checkSuiteGroups.js index 2a3b12e829..b9e6b4964d 100644 --- a/build/e2e/checkSuiteGroups.js +++ b/build/e2e/checkSuiteGroups.js @@ -4,33 +4,40 @@ // directory that is missing from that list does not fail anything: nothing runs it, and the build // goes green sooner. Same for a suite left outside a group directory. This turns both into errors. // -// The authoritative list comes from E2E_GROUPS (set by the workflow from the same job output the -// matrix reads, so the two cannot drift). Run locally without it and the list is inferred from the -// `test:e2e:` scripts instead. +// The authoritative list is the E2E workflow's own shard matrix, read from the file and resolved by +// js-yaml — which expands the `&e2e-groups` anchor for us. Reading the matrix rather than a copy of +// it is what makes drift impossible: there is one list, and it is the one the shards actually run. +// +// E2E_GROUPS overrides it. That seam exists so the failure paths below can be exercised without +// editing the workflow. const fs = require('fs'); const path = require('path'); +const yaml = require('js-yaml'); const repoRoot = path.resolve(__dirname, '..', '..'); const suiteDir = path.join(repoRoot, 'test', 'e2e', 'suite'); +const workflowPath = path.join(repoRoot, '.github', 'workflows', 'e2e.yml'); const scripts = require(path.join(repoRoot, 'package.json')).scripts; -const RESERVED_SCRIPT_SUFFIXES = ['prebuilt']; - function declaredGroups() { const fromEnv = process.env.E2E_GROUPS; - if (!fromEnv) { - return Object.keys(scripts) - .map((name) => name.match(/^test:e2e:(.+)$/)?.[1]) - .filter((group) => group && !RESERVED_SCRIPT_SUFFIXES.includes(group)); + if (fromEnv) { + const parsed = JSON.parse(fromEnv); + if (!Array.isArray(parsed) || parsed.length === 0) { + throw new Error(`E2E_GROUPS must be a non-empty JSON array, got: ${fromEnv}`); + } + + return parsed; } - const parsed = JSON.parse(fromEnv); - if (!Array.isArray(parsed) || parsed.length === 0) { - throw new Error(`E2E_GROUPS must be a non-empty JSON array, got: ${fromEnv}`); + const workflow = yaml.load(fs.readFileSync(workflowPath, 'utf8')); + const groups = workflow?.jobs?.e2e?.strategy?.matrix?.group; + if (!Array.isArray(groups) || groups.length === 0) { + throw new Error(`Could not read jobs.e2e.strategy.matrix.group from ${path.basename(workflowPath)}`); } - return parsed; + return groups; } const groups = declaredGroups(); @@ -74,6 +81,4 @@ if (problems.length > 0) { process.exit(1); } -console.log( - `\nEvery suite directory is covered by a shard (${process.env.E2E_GROUPS ? 'E2E_GROUPS' : 'package.json'}).` -); +console.log(`\nEvery suite directory is covered by the shard matrix: [${groups.join(', ')}].`); From f497ebcdcd104d54347d0d2e1bfcfb922b2302e0 Mon Sep 17 00:00:00 2001 From: tomas Date: Thu, 20 Aug 2026 14:38:44 +0000 Subject: [PATCH 08/12] ci(e2e): derive the shard matrix from the suite directories MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01MyZCw9GzL6Vq7S9MAVYwby --- .github/workflows/e2e.yml | 70 ++++++++++++++++++----------- build/e2e/checkSuiteGroups.js | 84 ----------------------------------- package.json | 4 -- 3 files changed, 45 insertions(+), 113 deletions(-) delete mode 100644 build/e2e/checkSuiteGroups.js diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 5b66df5910..5d0788f381 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -17,46 +17,63 @@ concurrency: cancel-in-progress: true jobs: - verify-groups: - name: Verify suite directories + # The shard list IS the directory listing under test/e2e/suite/ — there is no second copy to keep + # in sync, so a new group directory gets a shard automatically and one can never be left unrun. + discover: + name: Discover suites runs-on: ubuntu-latest + outputs: + groups: ${{ steps.scan.outputs.groups }} steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - name: Setup Node.js - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 - with: - cache: 'npm' - node-version-file: '.nvmrc' - - # Only js-yaml is needed, to resolve the matrix anchor out of this file. - - name: Install dependencies - run: npm ci --omit=dev --prefer-offline --no-audit --ignore-scripts - - # A suite directory missing from the matrix is not a failure on its own — nothing runs it, and - # the build goes green faster. This is the job that turns that into an error. - - name: Check every suite directory has a shard - run: node ./build/e2e/checkSuiteGroups.js + - name: Scan test/e2e/suite for shard directories + id: scan + shell: bash + run: | + set -euo pipefail + cd test/e2e/suite + + # A suite outside a group directory would never be globbed by any shard. + if compgen -G '*.e2e.test.ts' > /dev/null; then + echo "::error::Suites sit directly in test/e2e/suite/; move them into a group directory:" + ls *.e2e.test.ts + exit 1 + fi + + groups=() + for dir in */; do + group="${dir%/}" + if ! compgen -G "${group}/*.e2e.test.ts" > /dev/null; then + echo "::error::test/e2e/suite/${group}/ contains no suites." + exit 1 + fi + groups+=("${group}") + done + + if [ ${#groups[@]} -eq 0 ]; then + echo "::error::test/e2e/suite/ has no group directories." + exit 1 + fi + + printf 'Sharding: %s\n' "${groups[*]}" + printf 'groups=%s\n' "$(printf '%s\n' "${groups[@]}" | jq -R . | jq -sc .)" >> "$GITHUB_OUTPUT" e2e: name: E2E (${{ matrix.group }}) + needs: discover runs-on: ubuntu-latest timeout-minutes: 45 strategy: # One shard per directory in test/e2e/suite/, grouped by what the suites cover rather than by - # measured time, so a new suite has an obvious home and the split does not silently skew as - # suites are added. - # - # This list is the single definition of the shards. It carries an anchor so other jobs can - # alias it; it cannot live at the top of the file because the workflow schema rejects custom - # top-level keys, and `env` is not a permitted context inside `strategy`. verify-groups reads - # the resolved value straight out of this file, so the two cannot drift. + # measured time, so a new suite has an obvious home and the split does not skew as suites are + # added. fail-fast: false matrix: - group: &e2e-groups [kernel, files, workspace] + group: ${{ fromJson(needs.discover.outputs.groups) }} env: # Keep ExTester's downloads (test VS Code, ChromeDriver, settings, screenshots) inside the # workspace so the artifact-upload paths are predictable. Both this and .test-extensions are @@ -144,7 +161,10 @@ jobs: - name: Run E2E # VS Code launches with --no-sandbox (no AppArmor sysctl needed). Runs once; Mocha's retries:1 # and rootHooks.ts (dismiss toasts between tests) handle flakiness in the single shared instance. - run: xvfb-run --auto-servernum --server-args='-screen 0 1920x1080x24' npm run test:e2e:${{ matrix.group }} + run: | + xvfb-run --auto-servernum --server-args='-screen 0 1920x1080x24' \ + npx extest run-tests "./out/e2e/suite/${{ matrix.group }}/*.e2e.test.js" \ + -c max -o ./test/e2e/settings.generated.json -e .test-extensions -m ./test/e2e/.mocharc.js - name: Upload failure screenshots if: failure() diff --git a/build/e2e/checkSuiteGroups.js b/build/e2e/checkSuiteGroups.js deleted file mode 100644 index b9e6b4964d..0000000000 --- a/build/e2e/checkSuiteGroups.js +++ /dev/null @@ -1,84 +0,0 @@ -// Fails when the E2E shard list and the suite directories disagree. -// -// The E2E job is a matrix over a list of group names, each shard running one directory's glob. A -// directory that is missing from that list does not fail anything: nothing runs it, and the build -// goes green sooner. Same for a suite left outside a group directory. This turns both into errors. -// -// The authoritative list is the E2E workflow's own shard matrix, read from the file and resolved by -// js-yaml — which expands the `&e2e-groups` anchor for us. Reading the matrix rather than a copy of -// it is what makes drift impossible: there is one list, and it is the one the shards actually run. -// -// E2E_GROUPS overrides it. That seam exists so the failure paths below can be exercised without -// editing the workflow. - -const fs = require('fs'); -const path = require('path'); -const yaml = require('js-yaml'); - -const repoRoot = path.resolve(__dirname, '..', '..'); -const suiteDir = path.join(repoRoot, 'test', 'e2e', 'suite'); -const workflowPath = path.join(repoRoot, '.github', 'workflows', 'e2e.yml'); -const scripts = require(path.join(repoRoot, 'package.json')).scripts; - -function declaredGroups() { - const fromEnv = process.env.E2E_GROUPS; - if (fromEnv) { - const parsed = JSON.parse(fromEnv); - if (!Array.isArray(parsed) || parsed.length === 0) { - throw new Error(`E2E_GROUPS must be a non-empty JSON array, got: ${fromEnv}`); - } - - return parsed; - } - - const workflow = yaml.load(fs.readFileSync(workflowPath, 'utf8')); - const groups = workflow?.jobs?.e2e?.strategy?.matrix?.group; - if (!Array.isArray(groups) || groups.length === 0) { - throw new Error(`Could not read jobs.e2e.strategy.matrix.group from ${path.basename(workflowPath)}`); - } - - return groups; -} - -const groups = declaredGroups(); -const problems = []; -const entries = fs.readdirSync(suiteDir, { withFileTypes: true }); - -for (const file of entries.filter((entry) => entry.isFile() && entry.name.endsWith('.e2e.test.ts'))) { - problems.push(`${file.name} sits directly in test/e2e/suite/ — move it into a group directory.`); -} - -// The check this job exists for: a directory nobody shards. -for (const dir of entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name)) { - if (!groups.includes(dir)) { - problems.push(`test/e2e/suite/${dir}/ is not in the shard list [${groups.join(', ')}] — no job runs it.`); - } -} - -for (const group of groups) { - const dir = path.join(suiteDir, group); - if (!fs.existsSync(dir)) { - problems.push(`Shard "${group}" has no test/e2e/suite/${group}/ directory.`); - continue; - } - - const suites = fs.readdirSync(dir).filter((name) => name.endsWith('.e2e.test.ts')); - if (suites.length === 0) { - problems.push(`Shard "${group}" contains no suites.`); - } - if (!scripts[`test:e2e:${group}`]) { - problems.push(`Shard "${group}" has no "test:e2e:${group}" script in package.json.`); - } - - console.log(` ${group.padEnd(12)} ${suites.length} suites`); -} - -if (problems.length > 0) { - console.error('\nE2E shard list and suite directories disagree:'); - for (const problem of problems) { - console.error(` - ${problem}`); - } - process.exit(1); -} - -console.log(`\nEvery suite directory is covered by the shard matrix: [${groups.join(', ')}].`); diff --git a/package.json b/package.json index 51e1f60d2a..806e96c89c 100644 --- a/package.json +++ b/package.json @@ -2676,10 +2676,6 @@ "setup:e2e": "npm run setup:e2e:vscode && npm run setup:e2e:deps && npm run setup:e2e:venv", "test:e2e": "extest setup-and-run \"./out/e2e/suite/**/*.e2e.test.js\" -c max -o ./test/e2e/settings.generated.json -e .test-extensions -m ./test/e2e/.mocharc.js -i", "test:e2e:prebuilt": "extest run-tests \"./out/e2e/suite/**/*.e2e.test.js\" -c max -o ./test/e2e/settings.generated.json -e .test-extensions -m ./test/e2e/.mocharc.js", - "test:e2e:kernel": "extest run-tests \"./out/e2e/suite/kernel/*.e2e.test.js\" -c max -o ./test/e2e/settings.generated.json -e .test-extensions -m ./test/e2e/.mocharc.js", - "test:e2e:files": "extest run-tests \"./out/e2e/suite/files/*.e2e.test.js\" -c max -o ./test/e2e/settings.generated.json -e .test-extensions -m ./test/e2e/.mocharc.js", - "test:e2e:workspace": "extest run-tests \"./out/e2e/suite/workspace/*.e2e.test.js\" -c max -o ./test/e2e/settings.generated.json -e .test-extensions -m ./test/e2e/.mocharc.js", - "check:e2e:groups": "node ./build/e2e/checkSuiteGroups.js", "test:unittests": "mocha --config ./build/.mocha.unittests.js.json ./out/**/*.unit.test.js", "test": "npm run test:unittests", "typecheck": "tsc -p ./ --noEmit", From acd647f8ced7b8feffc93adf8c590c97dec8f754 Mon Sep 17 00:00:00 2001 From: tomas Date: Thu, 20 Aug 2026 15:15:53 +0000 Subject: [PATCH 09/12] ci(e2e): verify shard coverage from what each shard reports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01MyZCw9GzL6Vq7S9MAVYwby --- .github/workflows/e2e.yml | 110 +++++++++++++++++++++----------------- 1 file changed, 62 insertions(+), 48 deletions(-) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 5d0788f381..412399a95e 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -17,63 +17,18 @@ concurrency: cancel-in-progress: true jobs: - # The shard list IS the directory listing under test/e2e/suite/ — there is no second copy to keep - # in sync, so a new group directory gets a shard automatically and one can never be left unrun. - discover: - name: Discover suites - runs-on: ubuntu-latest - outputs: - groups: ${{ steps.scan.outputs.groups }} - steps: - - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - - name: Scan test/e2e/suite for shard directories - id: scan - shell: bash - run: | - set -euo pipefail - cd test/e2e/suite - - # A suite outside a group directory would never be globbed by any shard. - if compgen -G '*.e2e.test.ts' > /dev/null; then - echo "::error::Suites sit directly in test/e2e/suite/; move them into a group directory:" - ls *.e2e.test.ts - exit 1 - fi - - groups=() - for dir in */; do - group="${dir%/}" - if ! compgen -G "${group}/*.e2e.test.ts" > /dev/null; then - echo "::error::test/e2e/suite/${group}/ contains no suites." - exit 1 - fi - groups+=("${group}") - done - - if [ ${#groups[@]} -eq 0 ]; then - echo "::error::test/e2e/suite/ has no group directories." - exit 1 - fi - - printf 'Sharding: %s\n' "${groups[*]}" - printf 'groups=%s\n' "$(printf '%s\n' "${groups[@]}" | jq -R . | jq -sc .)" >> "$GITHUB_OUTPUT" - e2e: name: E2E (${{ matrix.group }}) - needs: discover runs-on: ubuntu-latest timeout-minutes: 45 strategy: # One shard per directory in test/e2e/suite/, grouped by what the suites cover rather than by # measured time, so a new suite has an obvious home and the split does not skew as suites are - # added. + # added. Adding a directory means adding it here; verify-coverage below fails the run if you + # forget, since nothing would otherwise run it. fail-fast: false matrix: - group: ${{ fromJson(needs.discover.outputs.groups) }} + group: [kernel, files, workspace] env: # Keep ExTester's downloads (test VS Code, ChromeDriver, settings, screenshots) inside the # workspace so the artifact-upload paths are predictable. Both this and .test-extensions are @@ -166,6 +121,19 @@ jobs: npx extest run-tests "./out/e2e/suite/${{ matrix.group }}/*.e2e.test.js" \ -c max -o ./test/e2e/settings.generated.json -e .test-extensions -m ./test/e2e/.mocharc.js + # Written even when the tests fail, so a shard that ran is never mistaken for one that was + # never scheduled. verify-coverage collects these. + - name: Record the shard that ran + if: always() + run: mkdir -p shard-ran && touch "shard-ran/${{ matrix.group }}" + + - name: Upload the shard record + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: e2e-ran-${{ matrix.group }} + path: shard-ran/ + - name: Upload failure screenshots if: failure() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 @@ -174,3 +142,49 @@ jobs: path: ${{ env.TEST_RESOURCES }}/screenshots/**/*.png if-no-files-found: ignore retention-days: 14 + + verify-coverage: + name: Verify shard coverage + needs: e2e + # Runs even when a shard failed: a directory nobody runs is a separate problem from a failing + # test, and it is the one that hides — nothing runs it and the build goes green sooner. Not on + # cancellation though, or superseding a run (cancel-in-progress) would report every directory as + # unrun. + if: ${{ !cancelled() }} + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Collect what each shard reported + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 + with: + merge-multiple: true + path: shard-ran + pattern: e2e-ran-* + + - name: Every suite directory must have been run + shell: bash + run: | + set -uo pipefail + echo "Shards that reported in: $(ls shard-ran 2>/dev/null | tr '\n' ' ')" + + status=0 + for dir in test/e2e/suite/*/; do + group="$(basename "${dir}")" + if [ ! -e "shard-ran/${group}" ]; then + echo "::error::test/e2e/suite/${group}/ was not run by any shard — add it to the matrix in this workflow." + status=1 + fi + done + + # A suite outside a group directory is never globbed by any shard either. + if compgen -G 'test/e2e/suite/*.e2e.test.ts' > /dev/null; then + echo "::error::Suites sit directly in test/e2e/suite/; move them into a group directory:" + ls test/e2e/suite/*.e2e.test.ts + status=1 + fi + + exit "${status}" From 0a4e9013d950de8b8004cb47c014f3b4e179836e Mon Sep 17 00:00:00 2001 From: tomas Date: Thu, 20 Aug 2026 18:40:44 +0000 Subject: [PATCH 10/12] fix(e2e): open the shared workspace lazily, not from a Mocha root hook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every shard failed with 0 passing: 5 suites in kernel, 10 in workspace, each timing out on ".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) Claude-Session: https://claude.ai/code/session_01MyZCw9GzL6Vq7S9MAVYwby --- test/e2e/helpers/fixtures.ts | 13 +++---------- test/e2e/helpers/workspace.ts | 25 ++++++++++++++++++------- test/e2e/rootHooks.ts | 25 ++++++------------------- 3 files changed, 27 insertions(+), 36 deletions(-) diff --git a/test/e2e/helpers/fixtures.ts b/test/e2e/helpers/fixtures.ts index cefcea455e..08cfce689b 100644 --- a/test/e2e/helpers/fixtures.ts +++ b/test/e2e/helpers/fixtures.ts @@ -28,9 +28,9 @@ const idMappings = new Map>(); let workspaceRoot: string | undefined; /** - * The single directory every fixture copy lives under. Opened once as the workspace folder (see - * rootHooks) rather than once per suite: opening a folder reloads the workbench, and that reload - * dominated suite setup. + * The single directory every fixture copy lives under. openFolderViaDialog opens it once, on the + * first suite that asks, rather than once per suite: opening a folder reloads the workbench, and + * that reload dominated suite setup. Each suite still removes its own subdirectory in `after`. */ export function fixturesWorkspaceRoot(): string { if (!workspaceRoot) { @@ -45,13 +45,6 @@ export function isInsideFixturesWorkspaceRoot(folder: string): boolean { return workspaceRoot !== undefined && path.resolve(folder).startsWith(path.resolve(workspaceRoot) + path.sep); } -export function removeFixturesWorkspaceRoot(): void { - if (workspaceRoot) { - fs.rmSync(workspaceRoot, { recursive: true, force: true }); - workspaceRoot = undefined; - } -} - /** * Reads `project.id` out of a fixture. Scanned line by line rather than matched with a * multi-line regex, which backtracks catastrophically on a file that does not match. diff --git a/test/e2e/helpers/workspace.ts b/test/e2e/helpers/workspace.ts index 52931608cd..636d759796 100644 --- a/test/e2e/helpers/workspace.ts +++ b/test/e2e/helpers/workspace.ts @@ -7,9 +7,14 @@ import { QUICK_PICK_TIMEOUT, RELOAD_POLL_TIMEOUT } from './constants'; -import { isInsideFixturesWorkspaceRoot } from './fixtures'; +import { fixturesWorkspaceRoot, isInsideFixturesWorkspaceRoot } from './fixtures'; import { clickDialogOkButton } from './quickInput'; +// Whether the shared fixtures root has been opened as the workspace folder yet. Tracked here rather +// than opened from a Mocha root hook: ExTester's runner does not fire `mochaHooks.beforeAll`, so a +// root hook silently never runs and every suite ends up with no workspace at all. +let fixturesRootOpened = false; + /** * Opens a file that lives in the currently-open workspace folder via Quick Open ("Go to File..."), * matching by file name. Unlike the simple Open File dialog (where Enter does not accept a typed @@ -40,11 +45,15 @@ export async function openWorkspaceFile(fileName: string): Promise { * Re-opening the dialog per attempt instead would reset navigation and fail on 2nd+ opens. */ export async function openFolderViaDialog(folder: string): Promise { - // Fixture copies all live under one root that rootHooks opens once, and opening a folder reloads - // the workbench — the reload is what made per-suite setup expensive. A directory already inside - // that root is therefore reachable without reopening anything. + // Fixture copies all live under one shared root, and opening a folder reloads the workbench — + // that reload is what made per-suite setup expensive. So the root is opened once, by whichever + // suite asks first, and every later request for a directory inside it is already satisfied. + let target = folder; if (isInsideFixturesWorkspaceRoot(folder)) { - return; + if (fixturesRootOpened) { + return; + } + target = fixturesWorkspaceRoot(); } const driver = VSBrowser.instance.driver; @@ -52,7 +61,7 @@ export async function openFolderViaDialog(folder: string): Promise { await new Workbench().executeCommand('File: Open Folder...'); const dialog = await InputBox.create(QUICK_PICK_TIMEOUT); - await dialog.setText(folder); + await dialog.setText(target); // The simple dialog resolves the typed path asynchronously; wait for the listing, then settle. await driver @@ -80,6 +89,8 @@ export async function openFolderViaDialog(folder: string): Promise { .then(() => true) .catch(() => false); if (reloaded) { + fixturesRootOpened = target === fixturesWorkspaceRoot(); + return; } @@ -90,5 +101,5 @@ export async function openFolderViaDialog(folder: string): Promise { console.warn('[deepnote-e2e] cancel folder dialog:', error); }); - throw new Error(`Failed to open folder "${folder}": the dialog never accepted the target`); + throw new Error(`Failed to open folder "${target}": the dialog never accepted the target`); } diff --git a/test/e2e/rootHooks.ts b/test/e2e/rootHooks.ts index dd50d1a171..269fff1100 100644 --- a/test/e2e/rootHooks.ts +++ b/test/e2e/rootHooks.ts @@ -1,27 +1,14 @@ -import { VSBrowser } from 'vscode-extension-tester'; - -import { WORKBENCH_TIMEOUT } from './helpers/constants'; -import { fixturesWorkspaceRoot, removeFixturesWorkspaceRoot } from './helpers/fixtures'; import { dismissAllNotifications } from './helpers/notifications'; -import { openFolderViaDialog } from './helpers/workspace'; // Mocha root hooks (wired via .mocharc.js `require`). ExTester runs every spec in ONE shared VS Code -// instance, so this is also where the one shared workspace folder is opened: every suite's fixture -// copy is a directory inside it, and opening a folder reloads the workbench, so doing it once here -// instead of once per suite removes ~17 reloads from the run. Suites still call openFolderViaDialog; -// it short-circuits for anything already inside this root. +// instance; dismiss notification toasts between tests so they don't pile up and slow/overlap later specs. +// +// Only `afterEach` lives here. ExTester's runner does not fire `mochaHooks.beforeAll` — a root +// beforeAll returns before the first suite starts without ever executing — so anything that must run +// once before the suites has to arrange that itself. The shared fixtures workspace does exactly that: +// openFolderViaDialog opens it on the first request and no-ops afterwards. export const mochaHooks = { async afterEach(): Promise { await dismissAllNotifications().catch(() => undefined); - }, - - async afterAll(): Promise { - removeFixturesWorkspaceRoot(); - }, - - async beforeAll(): Promise { - await VSBrowser.instance.waitForWorkbench(WORKBENCH_TIMEOUT); - await openFolderViaDialog(fixturesWorkspaceRoot()); - await VSBrowser.instance.waitForWorkbench(WORKBENCH_TIMEOUT); } }; From f61e3f33f0a5cbe32e7afb6c3cd77433b60f8c8b Mon Sep 17 00:00:00 2001 From: tomas Date: Thu, 20 Aug 2026 20:21:32 +0000 Subject: [PATCH 11/12] fix(e2e): restore what the per-suite window reload used to clean up MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01MyZCw9GzL6Vq7S9MAVYwby --- .vscodeignore | 1 + test/e2e/helpers/deepnoteEnvironment.ts | 44 +++++++++++++------ test/e2e/helpers/workspace.ts | 17 +++++++ test/e2e/settings.json | 1 + test/e2e/suite/kernel/environment.e2e.test.ts | 9 +++- 5 files changed, 56 insertions(+), 16 deletions(-) diff --git a/.vscodeignore b/.vscodeignore index 3817954b44..fa9a218992 100644 --- a/.vscodeignore +++ b/.vscodeignore @@ -85,6 +85,7 @@ obj/** logs/** out/** .test-extensions/** +.venv-e2e/** test-resources/** precommit.hook pythonFiles/.env diff --git a/test/e2e/helpers/deepnoteEnvironment.ts b/test/e2e/helpers/deepnoteEnvironment.ts index ad551e2aad..52b4de947d 100644 --- a/test/e2e/helpers/deepnoteEnvironment.ts +++ b/test/e2e/helpers/deepnoteEnvironment.ts @@ -1,4 +1,4 @@ -import { EditorView, InputBox, Key, VSBrowser, Workbench } from 'vscode-extension-tester'; +import { EditorView, InputBox, VSBrowser, Workbench } from 'vscode-extension-tester'; import { ENV_CREATED_TIMEOUT, @@ -68,22 +68,38 @@ async function selectInterpreter(interpreterPick: InputBox, useManagedVenv: bool } const picks = await interpreterPick.getQuickPicks(); - const labels = await Promise.all( - picks.map(async (pick) => `${await pick.getLabel()} ${(await pick.getDescription()) ?? ''}`) - ); + const labels = await Promise.all(picks.map(async (pick) => pick.getLabel())); // "Not the baked venv" rather than "not any venv": in CI the only other interpreter is the one // actions/setup-python installed, which is not a venv, so this resolves to it. - const index = labels.findIndex((label) => !label.includes(PREBAKED_VENV_DIR_NAME)); - const target = index >= 0 ? index : 0; - - // Walk the highlight with arrows and accept with Enter rather than calling select(), which is a - // bare click: a row's description `

` overlaps the row and intercepts positional clicks. Same - // reason selectEnvironmentForNotebook types instead of clicking. Enter is sent through the same - // focus context as the arrows so the highlight cannot be disturbed in between. - for (let step = 0; step < target; step++) { - await driver.actions().sendKeys(Key.ARROW_DOWN).perform(); + const wanted = labels.find((label) => !label.includes(PREBAKED_VENV_DIR_NAME)); + + if (wanted) { + // Filter to it and accept with Enter, the same way the baked-venv branch does, rather than + // clicking a row or walking the list: rows intercept positional clicks, and an arrow-key walk + // silently lands on the wrong entry whenever the list scrolls or reorders under it. + await interpreterPick.setText(wanted); + const narrowed = await driver + .wait(async () => { + const filtered = await interpreterPick.getQuickPicks(); + + return filtered.length > 0 && !(await filtered[0].getLabel()).includes(PREBAKED_VENV_DIR_NAME); + }, PREBAKED_VENV_FILTER_TIMEOUT) + .catch(() => false); + + if (narrowed) { + await interpreterPick.confirm(); + + return; + } + + await interpreterPick.setText(''); } - await driver.actions().sendKeys(Key.ENTER).perform(); + + console.warn( + `[deepnote-e2e] no interpreter outside ${PREBAKED_VENV_DIR_NAME} could be filtered to; ` + + `accepting the first entry. Offered: ${JSON.stringify(labels)}` + ); + await interpreterPick.confirm(); } /** diff --git a/test/e2e/helpers/workspace.ts b/test/e2e/helpers/workspace.ts index 636d759796..bbd3daf609 100644 --- a/test/e2e/helpers/workspace.ts +++ b/test/e2e/helpers/workspace.ts @@ -15,6 +15,10 @@ import { clickDialogOkButton } from './quickInput'; // root hook silently never runs and every suite ends up with no workspace at all. let fixturesRootOpened = false; +// Exact palette labels (category + title) the way `Workbench.executeCommand` matches them. +const CLOSE_ALL_EDITORS_COMMAND = 'View: Close All Editors'; +const REFRESH_EXPLORER_COMMAND = 'Deepnote: Refresh Explorer'; + /** * Opens a file that lives in the currently-open workspace folder via Quick Open ("Go to File..."), * matching by file name. Unlike the simple Open File dialog (where Enter does not accept a typed @@ -51,6 +55,19 @@ export async function openFolderViaDialog(folder: string): Promise { let target = folder; if (isInsideFixturesWorkspaceRoot(folder)) { if (fixturesRootOpened) { + // Two things the window reload used to do, which now have to happen explicitly. + // + // Editors first: a suite that renamed or deleted 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. Driven through the palette rather than EditorView.closeAllEditors, + // which clicks each tab's close button and fails with ElementNotInteractableError on + // notebook tabs — the reason each suite's own cleanup silently never worked either. + await new Workbench().executeCommand(CLOSE_ALL_EDITORS_COMMAND); + + // Then the tree: it keeps showing a group for the previous suite's directory after that + // directory was removed, and a lookup by notebook name finds the dead entry instead. + await new Workbench().executeCommand(REFRESH_EXPLORER_COMMAND); + return; } target = fixturesWorkspaceRoot(); diff --git a/test/e2e/settings.json b/test/e2e/settings.json index 5b26dfc94c..d58f7fe90b 100644 --- a/test/e2e/settings.json +++ b/test/e2e/settings.json @@ -3,6 +3,7 @@ "window.dialogStyle": "custom", "window.openFoldersInNewWindow": "off", "workbench.editor.enablePreview": false, + "breadcrumbs.enabled": false, "workbench.startupEditor": "none", "extensions.ignoreRecommendations": true, "workbench.remoteIndicator.showExtensionRecommendations": false, diff --git a/test/e2e/suite/kernel/environment.e2e.test.ts b/test/e2e/suite/kernel/environment.e2e.test.ts index d2eda0f2f3..b7957e494c 100644 --- a/test/e2e/suite/kernel/environment.e2e.test.ts +++ b/test/e2e/suite/kernel/environment.e2e.test.ts @@ -23,6 +23,7 @@ import { KERNEL_CONNECT_TIMEOUT, QUICK_PICK_TIMEOUT, SHARED_ENV_NAME, + fixturesWorkspaceRoot, SUITE_TIMEOUT, WORKBENCH_TIMEOUT, confirmModalDialog, @@ -145,7 +146,11 @@ describe('Deepnote — splitting a file migrates its selected environment onto e // Delete the sidecar so only a child's migrated mapping can rewrite it (proves migration, not // a stale pre-split entry). - const sidecarPath = path.join(tempDir, '.vscode', 'deepnote.json'); + // The sidecar is written to workspace.workspaceFolders[0] (deepnoteExtensionSidecarWriter + // .node.ts:300), which is the shared fixtures root rather than this suite's directory. Its + // mappings are keyed by project id, and every copy gets a fresh one, so a shared file still + // isolates suites from each other. + const sidecarPath = path.join(fixturesWorkspaceRoot(), '.vscode', 'deepnote.json'); fs.rmSync(sidecarPath, { force: true }); await new EditorView().closeAllEditors().catch(() => undefined); @@ -174,7 +179,7 @@ describe('Deepnote — splitting a file migrates its selected environment onto e } if (!sidecarEnvId) { - const vscodeDir = path.join(tempDir, '.vscode'); + const vscodeDir = path.join(fixturesWorkspaceRoot(), '.vscode'); const listing = fs.existsSync(vscodeDir) ? fs.readdirSync(vscodeDir) : '(.vscode missing)'; console.log('[G1] .vscode listing after opening child:', JSON.stringify(listing)); if (fs.existsSync(sidecarPath)) { From e364e5a2a95e19e54d0ef6d7c50eb8fb94db338d Mon Sep 17 00:00:00 2001 From: tomas Date: Fri, 21 Aug 2026 06:59:51 +0000 Subject: [PATCH 12/12] refactor(e2e): one workspace helper for every suite, and run through npm scripts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01MyZCw9GzL6Vq7S9MAVYwby --- .github/workflows/e2e.yml | 18 ++--- package.json | 5 +- test/e2e/helpers/fixtures.ts | 5 -- test/e2e/helpers/workspace.ts | 67 ++++++++++--------- test/e2e/suite/files/initOnlyFile.e2e.test.ts | 8 +-- .../suite/files/notebookCommands.e2e.test.ts | 6 +- .../e2e/suite/files/projectRename.e2e.test.ts | 4 +- .../suite/files/splitInitNotebook.e2e.test.ts | 6 +- .../files/splitMultiNotebook.e2e.test.ts | 6 +- test/e2e/suite/files/splitSafety.e2e.test.ts | 6 +- test/e2e/suite/kernel/environment.e2e.test.ts | 10 +-- test/e2e/suite/kernel/helloWorld.e2e.test.ts | 4 +- .../kernel/initNotebookRunner.e2e.test.ts | 4 +- .../integrationsEnvFileInjection.e2e.test.ts | 4 +- .../workspace/explorerGrouping.e2e.test.ts | 4 +- .../suite/workspace/fileWatcher.e2e.test.ts | 6 +- .../suite/workspace/integrations.e2e.test.ts | 4 +- .../workspace/openSingleNotebook.e2e.test.ts | 4 +- .../workspace/revealInExplorer.e2e.test.ts | 4 +- .../e2e/suite/workspace/snapshots.e2e.test.ts | 6 +- .../e2e/suite/workspace/statusBar.e2e.test.ts | 4 +- 21 files changed, 87 insertions(+), 98 deletions(-) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 412399a95e..abc430412e 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -24,8 +24,7 @@ jobs: strategy: # One shard per directory in test/e2e/suite/, grouped by what the suites cover rather than by # measured time, so a new suite has an obvious home and the split does not skew as suites are - # added. Adding a directory means adding it here; verify-coverage below fails the run if you - # forget, since nothing would otherwise run it. + # added. fail-fast: false matrix: group: [kernel, files, workspace] @@ -101,25 +100,18 @@ jobs: # one, which is where most of the old E2E runtime went. Keyed on the resolved Python version # as well as the install set: the venv records its base interpreter in pyvenv.cfg, so a # patch bump to the hosted Python would leave a restored venv pointing at a path that no - # longer exists. prepareE2eVenv.js also self-heals — it discards and rebuilds any venv that - # cannot import deepnote_toolkit. + # longer exists. uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: .venv-e2e key: e2e-venv-${{ runner.os }}-py${{ steps.python.outputs.python-version }}-${{ hashFiles('src/kernels/deepnote/types.ts', 'src/kernels/deepnote/deepnoteToolkitInstaller.node.ts') }} - - name: Bake the Deepnote toolkit venv - # No-op when the cache restored a usable venv; otherwise creates it and installs the same - # set deepnoteToolkitInstaller would have. - run: npm run setup:e2e:venv - - name: Run E2E # VS Code launches with --no-sandbox (no AppArmor sysctl needed). Runs once; Mocha's retries:1 # and rootHooks.ts (dismiss toasts between tests) handle flakiness in the single shared instance. - run: | - xvfb-run --auto-servernum --server-args='-screen 0 1920x1080x24' \ - npx extest run-tests "./out/e2e/suite/${{ matrix.group }}/*.e2e.test.js" \ - -c max -o ./test/e2e/settings.generated.json -e .test-extensions -m ./test/e2e/.mocharc.js + run: xvfb-run --auto-servernum --server-args='-screen 0 1920x1080x24' npm run test:e2e:ci + env: + E2E_GROUP: ${{ matrix.group }} # Written even when the tests fail, so a shard that ran is never mistaken for one that was # never scheduled. verify-coverage collects these. diff --git a/package.json b/package.json index 806e96c89c..265fc65fd5 100644 --- a/package.json +++ b/package.json @@ -2674,8 +2674,9 @@ "setup:e2e:deps": "extest install-from-marketplace ms-python.python -e .test-extensions", "setup:e2e:venv": "node ./build/e2e/prepareE2eVenv.js", "setup:e2e": "npm run setup:e2e:vscode && npm run setup:e2e:deps && npm run setup:e2e:venv", - "test:e2e": "extest setup-and-run \"./out/e2e/suite/**/*.e2e.test.js\" -c max -o ./test/e2e/settings.generated.json -e .test-extensions -m ./test/e2e/.mocharc.js -i", - "test:e2e:prebuilt": "extest run-tests \"./out/e2e/suite/**/*.e2e.test.js\" -c max -o ./test/e2e/settings.generated.json -e .test-extensions -m ./test/e2e/.mocharc.js", + "test:e2e": "npm run setup:e2e:venv && extest setup-and-run \"./out/e2e/suite/**/*.e2e.test.js\" -c max -o ./test/e2e/settings.generated.json -e .test-extensions -m ./test/e2e/.mocharc.js -i", + "test:e2e:prebuilt": "npm run setup:e2e:venv && extest run-tests \"./out/e2e/suite/**/*.e2e.test.js\" -c max -o ./test/e2e/settings.generated.json -e .test-extensions -m ./test/e2e/.mocharc.js", + "test:e2e:ci": "npm run setup:e2e:venv && extest run-tests \"./out/e2e/suite/$E2E_GROUP/*.e2e.test.js\" -c max -o ./test/e2e/settings.generated.json -e .test-extensions -m ./test/e2e/.mocharc.js", "test:unittests": "mocha --config ./build/.mocha.unittests.js.json ./out/**/*.unit.test.js", "test": "npm run test:unittests", "typecheck": "tsc -p ./ --noEmit", diff --git a/test/e2e/helpers/fixtures.ts b/test/e2e/helpers/fixtures.ts index 08cfce689b..592a73d136 100644 --- a/test/e2e/helpers/fixtures.ts +++ b/test/e2e/helpers/fixtures.ts @@ -40,11 +40,6 @@ export function fixturesWorkspaceRoot(): string { return workspaceRoot; } -/** True when `folder` sits inside the already-opened shared root (so opening it would be a no-op). */ -export function isInsideFixturesWorkspaceRoot(folder: string): boolean { - return workspaceRoot !== undefined && path.resolve(folder).startsWith(path.resolve(workspaceRoot) + path.sep); -} - /** * Reads `project.id` out of a fixture. Scanned line by line rather than matched with a * multi-line regex, which backtracks catastrophically on a file that does not match. diff --git a/test/e2e/helpers/workspace.ts b/test/e2e/helpers/workspace.ts index bbd3daf609..eda406c28d 100644 --- a/test/e2e/helpers/workspace.ts +++ b/test/e2e/helpers/workspace.ts @@ -7,12 +7,12 @@ import { QUICK_PICK_TIMEOUT, RELOAD_POLL_TIMEOUT } from './constants'; -import { fixturesWorkspaceRoot, isInsideFixturesWorkspaceRoot } from './fixtures'; +import { fixturesWorkspaceRoot } from './fixtures'; import { clickDialogOkButton } from './quickInput'; -// Whether the shared fixtures root has been opened as the workspace folder yet. Tracked here rather -// than opened from a Mocha root hook: ExTester's runner does not fire `mochaHooks.beforeAll`, so a -// root hook silently never runs and every suite ends up with no workspace at all. +// Whether the shared fixtures root has been opened yet. Tracked here rather than opened from a Mocha +// root hook: ExTester's runner does not fire `mochaHooks.beforeAll`, so a root hook silently never +// runs and every suite ends up with no workspace at all. let fixturesRootOpened = false; // Exact palette labels (category + title) the way `Workbench.executeCommand` matches them. @@ -48,37 +48,13 @@ export async function openWorkspaceFile(fileName: string): Promise { * the path once and re-click OK in the SAME dialog until the pre-open workbench detaches (= accepted). * Re-opening the dialog per attempt instead would reset navigation and fail on 2nd+ opens. */ -export async function openFolderViaDialog(folder: string): Promise { - // Fixture copies all live under one shared root, and opening a folder reloads the workbench — - // that reload is what made per-suite setup expensive. So the root is opened once, by whichever - // suite asks first, and every later request for a directory inside it is already satisfied. - let target = folder; - if (isInsideFixturesWorkspaceRoot(folder)) { - if (fixturesRootOpened) { - // Two things the window reload used to do, which now have to happen explicitly. - // - // Editors first: a suite that renamed or deleted 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. Driven through the palette rather than EditorView.closeAllEditors, - // which clicks each tab's close button and fails with ElementNotInteractableError on - // notebook tabs — the reason each suite's own cleanup silently never worked either. - await new Workbench().executeCommand(CLOSE_ALL_EDITORS_COMMAND); - - // Then the tree: it keeps showing a group for the previous suite's directory after that - // directory was removed, and a lookup by notebook name finds the dead entry instead. - await new Workbench().executeCommand(REFRESH_EXPLORER_COMMAND); - - return; - } - target = fixturesWorkspaceRoot(); - } - +async function openFolderViaDialog(folder: string): Promise { const driver = VSBrowser.instance.driver; const previousWorkbench = await driver.findElement(By.css('.monaco-workbench')); await new Workbench().executeCommand('File: Open Folder...'); const dialog = await InputBox.create(QUICK_PICK_TIMEOUT); - await dialog.setText(target); + await dialog.setText(folder); // The simple dialog resolves the typed path asynchronously; wait for the listing, then settle. await driver @@ -106,8 +82,6 @@ export async function openFolderViaDialog(folder: string): Promise { .then(() => true) .catch(() => false); if (reloaded) { - fixturesRootOpened = target === fixturesWorkspaceRoot(); - return; } @@ -118,5 +92,32 @@ export async function openFolderViaDialog(folder: string): Promise { console.warn('[deepnote-e2e] cancel folder dialog:', error); }); - throw new Error(`Failed to open folder "${target}": the dialog never accepted the target`); + throw new Error(`Failed to open folder "${folder}": the dialog never accepted the target`); +} + +/** + * Puts the window into the state a suite expects: the shared fixtures workspace open, no editors + * from the previous suite, and a Deepnote Explorer that reflects what is actually on disk. + * + * Every suite calls this in `before()` and gets the same behaviour. The folder itself is opened once + * — opening one reloads the workbench, and doing that per suite is what made setup slow — so the + * rest of what the reload used to do has to happen explicitly on every call: + * + * - Editors: a suite that renames or deletes notebooks leaves tabs open on files that no longer + * exist, and the next suite would start with the wrong notebook active. Driven through the palette + * rather than `EditorView.closeAllEditors`, which clicks each tab's close button and fails on + * notebook tabs with ElementNotInteractableError. + * - The Explorer: it keeps a group for a directory that has been removed, and a lookup by notebook + * name would find the dead entry. + */ +export async function enterFixturesWorkspace(): Promise { + if (!fixturesRootOpened) { + await openFolderViaDialog(fixturesWorkspaceRoot()); + fixturesRootOpened = true; + + return; + } + + await new Workbench().executeCommand(CLOSE_ALL_EDITORS_COMMAND); + await new Workbench().executeCommand(REFRESH_EXPLORER_COMMAND); } diff --git a/test/e2e/suite/files/initOnlyFile.e2e.test.ts b/test/e2e/suite/files/initOnlyFile.e2e.test.ts index facb884740..0035237d69 100644 --- a/test/e2e/suite/files/initOnlyFile.e2e.test.ts +++ b/test/e2e/suite/files/initOnlyFile.e2e.test.ts @@ -10,16 +10,16 @@ import { EditorView, VSBrowser, WebView } from 'vscode-extension-tester'; import { SUITE_TIMEOUT, WORKBENCH_TIMEOUT, + assertNotNull, confirmModalDialog, copyFixtureToTempDir, createScreenshotter, + enterFixturesWorkspace, findDeepnoteLeaf, getDeepnoteExplorerSection, - openFolderViaDialog, openWorkspaceFile, readDeepnoteTreeRows, readStatusBarText, - assertNotNull, selectDeepnoteContextMenu, waitForNotification } from '../../helpers'; @@ -48,7 +48,7 @@ describe('Deepnote — opening a file whose only notebook is the init notebook', cleanupTempDir = copy.cleanup; await VSBrowser.instance.waitForWorkbench(WORKBENCH_TIMEOUT); - await openFolderViaDialog(copy.tempDir); + await enterFixturesWorkspace(); await VSBrowser.instance.waitForWorkbench(WORKBENCH_TIMEOUT); await openWorkspaceFile(FIXTURE); @@ -125,7 +125,7 @@ describe('Deepnote — deleting an init-only leaf removes the whole file', funct filePath = copy.filePath; await VSBrowser.instance.waitForWorkbench(WORKBENCH_TIMEOUT); - await openFolderViaDialog(tempDir); + await enterFixturesWorkspace(); await VSBrowser.instance.waitForWorkbench(WORKBENCH_TIMEOUT); const section = await getDeepnoteExplorerSection(); diff --git a/test/e2e/suite/files/notebookCommands.e2e.test.ts b/test/e2e/suite/files/notebookCommands.e2e.test.ts index 06cbc79dc6..17af78b769 100644 --- a/test/e2e/suite/files/notebookCommands.e2e.test.ts +++ b/test/e2e/suite/files/notebookCommands.e2e.test.ts @@ -22,13 +22,13 @@ import { import { SUITE_TIMEOUT, WORKBENCH_TIMEOUT, + assertNotNull, confirmModalDialog, copyFixtureIntoDir, copyFixtureToTempDir, createScreenshotter, - openFolderViaDialog, + enterFixturesWorkspace, openWorkspaceFile, - assertNotNull, waitForNotification } from '../../helpers'; @@ -163,7 +163,7 @@ describe('Deepnote — notebook-management commands create and remove sibling fi } await VSBrowser.instance.waitForWorkbench(WORKBENCH_TIMEOUT); - await openFolderViaDialog(tempDir); + await enterFixturesWorkspace(); await VSBrowser.instance.waitForWorkbench(WORKBENCH_TIMEOUT); const section = await getExplorerSection(); diff --git a/test/e2e/suite/files/projectRename.e2e.test.ts b/test/e2e/suite/files/projectRename.e2e.test.ts index 01f16e91d7..68f70f76f2 100644 --- a/test/e2e/suite/files/projectRename.e2e.test.ts +++ b/test/e2e/suite/files/projectRename.e2e.test.ts @@ -16,9 +16,9 @@ import { copyFixtureIntoDir, copyFixtureToTempDir, createScreenshotter, + enterFixturesWorkspace, findDeepnoteGroup, getDeepnoteExplorerSection, - openFolderViaDialog, openWorkspaceFile, readDeepnoteTreeRows, selectDeepnoteContextMenu, @@ -106,7 +106,7 @@ describe('Deepnote — renaming a project fans the new name out to every sibling } await VSBrowser.instance.waitForWorkbench(WORKBENCH_TIMEOUT); - await openFolderViaDialog(tempDir); + await enterFixturesWorkspace(); await VSBrowser.instance.waitForWorkbench(WORKBENCH_TIMEOUT); let section = await getDeepnoteExplorerSection(); diff --git a/test/e2e/suite/files/splitInitNotebook.e2e.test.ts b/test/e2e/suite/files/splitInitNotebook.e2e.test.ts index 6ff227dc75..cee69d459a 100644 --- a/test/e2e/suite/files/splitInitNotebook.e2e.test.ts +++ b/test/e2e/suite/files/splitInitNotebook.e2e.test.ts @@ -11,13 +11,13 @@ import { EditorView, VSBrowser, WebView } from 'vscode-extension-tester'; import { SUITE_TIMEOUT, WORKBENCH_TIMEOUT, + assertNotNull, copyFixtureToTempDir, createScreenshotter, dismissAllNotifications, + enterFixturesWorkspace, notebookCount, - openFolderViaDialog, openWorkspaceFile, - assertNotNull, showView, waitForNotification } from '../../helpers'; @@ -64,7 +64,7 @@ describe('Deepnote — splitting a legacy multi-notebook .deepnote file that has // Open the workspace folder FIRST: the serializer reads snapshots relative to it, and // without one deserialization blocks headlessly. - await openFolderViaDialog(tempDir); + await enterFixturesWorkspace(); await VSBrowser.instance.waitForWorkbench(WORKBENCH_TIMEOUT); await showView('Deepnote', '[split-init]'); diff --git a/test/e2e/suite/files/splitMultiNotebook.e2e.test.ts b/test/e2e/suite/files/splitMultiNotebook.e2e.test.ts index 6905a6b06b..5922054ecb 100644 --- a/test/e2e/suite/files/splitMultiNotebook.e2e.test.ts +++ b/test/e2e/suite/files/splitMultiNotebook.e2e.test.ts @@ -11,13 +11,13 @@ import { EditorView, VSBrowser, WebView } from 'vscode-extension-tester'; import { SUITE_TIMEOUT, WORKBENCH_TIMEOUT, + assertNotNull, copyFixtureToTempDir, createScreenshotter, dismissAllNotifications, + enterFixturesWorkspace, notebookCount, - openFolderViaDialog, openWorkspaceFile, - assertNotNull, showView, waitForNotification } from '../../helpers'; @@ -74,7 +74,7 @@ describe('Deepnote — splitting a legacy multi-notebook .deepnote file into sin // Open the workspace folder FIRST: the serializer reads snapshots relative to it, and // without one deserialization blocks on a warning that never resolves headlessly. - await openFolderViaDialog(tempDir); + await enterFixturesWorkspace(); await VSBrowser.instance.waitForWorkbench(WORKBENCH_TIMEOUT); await showView('Deepnote', '[split]'); diff --git a/test/e2e/suite/files/splitSafety.e2e.test.ts b/test/e2e/suite/files/splitSafety.e2e.test.ts index 99d9a855e9..7b76a400c9 100644 --- a/test/e2e/suite/files/splitSafety.e2e.test.ts +++ b/test/e2e/suite/files/splitSafety.e2e.test.ts @@ -11,11 +11,11 @@ import { EditorView, VSBrowser, WebView } from 'vscode-extension-tester'; import { SUITE_TIMEOUT, WORKBENCH_TIMEOUT, + assertNotNull, copyFixtureToTempDir, createScreenshotter, - openFolderViaDialog, + enterFixturesWorkspace, openWorkspaceFile, - assertNotNull, waitForNotification } from '../../helpers'; @@ -34,7 +34,7 @@ describe('Deepnote — split-prompt safety', function () { tempDir = copy.tempDir; await VSBrowser.instance.waitForWorkbench(WORKBENCH_TIMEOUT); - await openFolderViaDialog(tempDir); + await enterFixturesWorkspace(); await VSBrowser.instance.waitForWorkbench(WORKBENCH_TIMEOUT); }); diff --git a/test/e2e/suite/kernel/environment.e2e.test.ts b/test/e2e/suite/kernel/environment.e2e.test.ts index b7957e494c..58620aaafa 100644 --- a/test/e2e/suite/kernel/environment.e2e.test.ts +++ b/test/e2e/suite/kernel/environment.e2e.test.ts @@ -23,16 +23,16 @@ import { KERNEL_CONNECT_TIMEOUT, QUICK_PICK_TIMEOUT, SHARED_ENV_NAME, - fixturesWorkspaceRoot, SUITE_TIMEOUT, WORKBENCH_TIMEOUT, + assertNotNull, confirmModalDialog, copyFixtureToTempDir, createEnvironment, createScreenshotter, - openFolderViaDialog, + enterFixturesWorkspace, + fixturesWorkspaceRoot, openWorkspaceFile, - assertNotNull, runOnceAndAwaitOutput, selectDeepnoteContextMenu, selectEnvironmentForNotebook, @@ -100,7 +100,7 @@ describe('Deepnote — splitting a file migrates its selected environment onto e const tempDir = copy.tempDir; await VSBrowser.instance.waitForWorkbench(WORKBENCH_TIMEOUT); - await openFolderViaDialog(tempDir); + await enterFixturesWorkspace(); await VSBrowser.instance.waitForWorkbench(WORKBENCH_TIMEOUT); await createEnvironment(ENV_NAME); @@ -294,7 +294,7 @@ describe('Deepnote — deleting an environment stops even a closed-but-running n cleanupTempDir = copy.cleanup; await VSBrowser.instance.waitForWorkbench(WORKBENCH_TIMEOUT); - await openFolderViaDialog(copy.tempDir); + await enterFixturesWorkspace(); await VSBrowser.instance.waitForWorkbench(WORKBENCH_TIMEOUT); // Servers already running from earlier suites — exclude these when isolating THIS PID. diff --git a/test/e2e/suite/kernel/helloWorld.e2e.test.ts b/test/e2e/suite/kernel/helloWorld.e2e.test.ts index db0ee5d230..5111e6d36a 100644 --- a/test/e2e/suite/kernel/helloWorld.e2e.test.ts +++ b/test/e2e/suite/kernel/helloWorld.e2e.test.ts @@ -28,7 +28,7 @@ import { WORKBENCH_TIMEOUT, copyFixtureToTempDir, createEnvironment, - openFolderViaDialog, + enterFixturesWorkspace, openWorkspaceFile, runOnceAndAwaitOutput, selectEnvironmentForNotebook @@ -62,7 +62,7 @@ describe('Deepnote E2E — run "hello world"', function () { // resolves headlessly — leaving the notebook blank. A workspace folder also provides the // requirements.txt path the kernel auto-selector needs. (Opening a folder reloads the // window, so we re-wait for the workbench afterwards.) - await openFolderViaDialog(tempDir); + await enterFixturesWorkspace(); await VSBrowser.instance.waitForWorkbench(WORKBENCH_TIMEOUT); // Now that the containing folder is the workspace, the notebook is reachable by name. diff --git a/test/e2e/suite/kernel/initNotebookRunner.e2e.test.ts b/test/e2e/suite/kernel/initNotebookRunner.e2e.test.ts index a59ed2e746..1ce55dcd10 100644 --- a/test/e2e/suite/kernel/initNotebookRunner.e2e.test.ts +++ b/test/e2e/suite/kernel/initNotebookRunner.e2e.test.ts @@ -18,7 +18,7 @@ import { createEnvironment, createScreenshotter, dismissAllNotifications, - openFolderViaDialog, + enterFixturesWorkspace, openWorkspaceFile, readRenderedOutput, runOnceAndAwaitOutput, @@ -108,7 +108,7 @@ describe('Deepnote — running the sibling init notebook in a main notebook kern copyFixtureIntoDir(copy.tempDir, INIT_SIBLING_FILE); await VSBrowser.instance.waitForWorkbench(WORKBENCH_TIMEOUT); - await openFolderViaDialog(copy.tempDir); + await enterFixturesWorkspace(); await VSBrowser.instance.waitForWorkbench(WORKBENCH_TIMEOUT); await openWorkspaceFile(MAIN_FILE); diff --git a/test/e2e/suite/kernel/integrationsEnvFileInjection.e2e.test.ts b/test/e2e/suite/kernel/integrationsEnvFileInjection.e2e.test.ts index 29b8fe907d..ff0b54cd36 100644 --- a/test/e2e/suite/kernel/integrationsEnvFileInjection.e2e.test.ts +++ b/test/e2e/suite/kernel/integrationsEnvFileInjection.e2e.test.ts @@ -15,7 +15,7 @@ import { WORKBENCH_TIMEOUT, copyFixtureToTempDir, createEnvironment, - openFolderViaDialog, + enterFixturesWorkspace, openWorkspaceFile, runOnceAndAwaitOutput, selectEnvironmentForNotebook @@ -72,7 +72,7 @@ describe('Deepnote E2E — inject integration env var from `.deepnote.env.yaml`' await VSBrowser.instance.waitForWorkbench(WORKBENCH_TIMEOUT); // Open the folder as the workspace FIRST (the serializer's snapshot read blocks headlessly without one). - await openFolderViaDialog(tempDir); + await enterFixturesWorkspace(); await VSBrowser.instance.waitForWorkbench(WORKBENCH_TIMEOUT); await openWorkspaceFile(NOTEBOOK_FILE_NAME); diff --git a/test/e2e/suite/workspace/explorerGrouping.e2e.test.ts b/test/e2e/suite/workspace/explorerGrouping.e2e.test.ts index a4b470ae8b..15579e1680 100644 --- a/test/e2e/suite/workspace/explorerGrouping.e2e.test.ts +++ b/test/e2e/suite/workspace/explorerGrouping.e2e.test.ts @@ -12,7 +12,7 @@ import { copyFixtureIntoDir, copyFixtureToTempDir, createScreenshotter, - openFolderViaDialog + enterFixturesWorkspace, } from '../../helpers'; const MARKETING_FILES = ['marketing-overview.deepnote', 'marketing-campaigns.deepnote', 'marketing-metrics.deepnote']; @@ -90,7 +90,7 @@ describe('Deepnote — the Explorer groups sibling files by project', function ( } await VSBrowser.instance.waitForWorkbench(WORKBENCH_TIMEOUT); - await openFolderViaDialog(copy.tempDir); + await enterFixturesWorkspace(); await VSBrowser.instance.waitForWorkbench(WORKBENCH_TIMEOUT); const control = await new ActivityBar().getViewControl('Deepnote'); diff --git a/test/e2e/suite/workspace/fileWatcher.e2e.test.ts b/test/e2e/suite/workspace/fileWatcher.e2e.test.ts index 583394d5b7..79523e7173 100644 --- a/test/e2e/suite/workspace/fileWatcher.e2e.test.ts +++ b/test/e2e/suite/workspace/fileWatcher.e2e.test.ts @@ -14,7 +14,7 @@ import { copyFixtureToTempDir, copySnapshotIntoDir, createScreenshotter, - openFolderViaDialog, + enterFixturesWorkspace, openWorkspaceFile, readRenderedOutput } from '../../helpers'; @@ -47,7 +47,7 @@ describe('Deepnote — the file watcher reloads an open notebook when its .deepn // Open the temp dir as workspace root first: the serializer reads snapshots relative to a // workspace folder, else deserialization blocks headlessly. Opening a folder reloads the window. - await openFolderViaDialog(copy.tempDir); + await enterFixturesWorkspace(); await VSBrowser.instance.waitForWorkbench(WORKBENCH_TIMEOUT); await openWorkspaceFile(FIXTURE); @@ -175,7 +175,7 @@ describe('Deepnote — the file watcher applies snapshot outputs to an open note // Open the temp dir as workspace root first: the serializer reads snapshots relative to a // workspace folder, else deserialization blocks headlessly. Opening a folder reloads the window. - await openFolderViaDialog(copy.tempDir); + await enterFixturesWorkspace(); await VSBrowser.instance.waitForWorkbench(WORKBENCH_TIMEOUT); await openWorkspaceFile(SNAPSHOT_FIXTURE); diff --git a/test/e2e/suite/workspace/integrations.e2e.test.ts b/test/e2e/suite/workspace/integrations.e2e.test.ts index 22f903aae4..ade0f654c6 100644 --- a/test/e2e/suite/workspace/integrations.e2e.test.ts +++ b/test/e2e/suite/workspace/integrations.e2e.test.ts @@ -12,7 +12,7 @@ import { copyFixtureIntoDir, copyFixtureToTempDir, createScreenshotter, - openFolderViaDialog, + enterFixturesWorkspace, openWorkspaceFile } from '../../helpers'; @@ -100,7 +100,7 @@ describe('Deepnote — the integrations UI', function () { copyFixtureIntoDir(copy.tempDir, PLAIN_FILE); await VSBrowser.instance.waitForWorkbench(WORKBENCH_TIMEOUT); - await openFolderViaDialog(copy.tempDir); + await enterFixturesWorkspace(); await VSBrowser.instance.waitForWorkbench(WORKBENCH_TIMEOUT); }); diff --git a/test/e2e/suite/workspace/openSingleNotebook.e2e.test.ts b/test/e2e/suite/workspace/openSingleNotebook.e2e.test.ts index 0c4dab78f8..c4ef5d7428 100644 --- a/test/e2e/suite/workspace/openSingleNotebook.e2e.test.ts +++ b/test/e2e/suite/workspace/openSingleNotebook.e2e.test.ts @@ -11,7 +11,7 @@ import { WORKBENCH_TIMEOUT, copyFixtureToTempDir, createScreenshotter, - openFolderViaDialog, + enterFixturesWorkspace, openWorkspaceFile, readStatusBarText, waitForNotification @@ -42,7 +42,7 @@ describe('Deepnote — opening a plain single-notebook .deepnote file', function // Open the temp dir as workspace root first: the serializer reads snapshots relative to a // workspace folder, else deserialization blocks headlessly. Opening a folder reloads the window. - await openFolderViaDialog(copy.tempDir); + await enterFixturesWorkspace(); await VSBrowser.instance.waitForWorkbench(WORKBENCH_TIMEOUT); await openWorkspaceFile(FIXTURE); diff --git a/test/e2e/suite/workspace/revealInExplorer.e2e.test.ts b/test/e2e/suite/workspace/revealInExplorer.e2e.test.ts index 493fa2ac0b..331134f769 100644 --- a/test/e2e/suite/workspace/revealInExplorer.e2e.test.ts +++ b/test/e2e/suite/workspace/revealInExplorer.e2e.test.ts @@ -11,9 +11,9 @@ import { WORKBENCH_TIMEOUT, copyFixtureToTempDir, createScreenshotter, + enterFixturesWorkspace, findDeepnoteLeaf, getDeepnoteExplorerSection, - openFolderViaDialog, openWorkspaceFile, waitForNotification } from '../../helpers'; @@ -51,7 +51,7 @@ describe('Deepnote — Reveal in Explorer', function () { cleanupTempDir = copy.cleanup; await VSBrowser.instance.waitForWorkbench(WORKBENCH_TIMEOUT); - await openFolderViaDialog(copy.tempDir); + await enterFixturesWorkspace(); await VSBrowser.instance.waitForWorkbench(WORKBENCH_TIMEOUT); // Open the single-notebook file so there is an active Deepnote notebook editor to reveal. diff --git a/test/e2e/suite/workspace/snapshots.e2e.test.ts b/test/e2e/suite/workspace/snapshots.e2e.test.ts index 44f688bced..a621034b4c 100644 --- a/test/e2e/suite/workspace/snapshots.e2e.test.ts +++ b/test/e2e/suite/workspace/snapshots.e2e.test.ts @@ -16,7 +16,7 @@ import { copySnapshotIntoDir, createEnvironment, createScreenshotter, - openFolderViaDialog, + enterFixturesWorkspace, openWorkspaceFile, readRenderedOutput, runOnceAndAwaitOutput, @@ -44,7 +44,7 @@ describe('Deepnote — a legacy project-scoped snapshot still loads its saved ou copySnapshotIntoDir(copy.tempDir, SNAPSHOT); await VSBrowser.instance.waitForWorkbench(WORKBENCH_TIMEOUT); - await openFolderViaDialog(copy.tempDir); + await enterFixturesWorkspace(); await VSBrowser.instance.waitForWorkbench(WORKBENCH_TIMEOUT); await openWorkspaceFile(FIXTURE); @@ -108,7 +108,7 @@ describe('Deepnote — new snapshots are notebook-scoped and do not bleed betwee copyFixtureIntoDir(tempDir, SIBLINGS[1].file); await VSBrowser.instance.waitForWorkbench(WORKBENCH_TIMEOUT); - await openFolderViaDialog(tempDir); + await enterFixturesWorkspace(); await VSBrowser.instance.waitForWorkbench(WORKBENCH_TIMEOUT); await createEnvironment(ENV_NAME); diff --git a/test/e2e/suite/workspace/statusBar.e2e.test.ts b/test/e2e/suite/workspace/statusBar.e2e.test.ts index 167bad3005..b523ef1bf1 100644 --- a/test/e2e/suite/workspace/statusBar.e2e.test.ts +++ b/test/e2e/suite/workspace/statusBar.e2e.test.ts @@ -13,7 +13,7 @@ import { WORKBENCH_TIMEOUT, copyFixtureToTempDir, createScreenshotter, - openFolderViaDialog, + enterFixturesWorkspace, openWorkspaceFile, waitForNotification } from '../../helpers'; @@ -74,7 +74,7 @@ describe('Deepnote — the active-notebook status bar item', function () { fs.writeFileSync(path.join(copy.tempDir, SCRATCH_FILE), ''); await VSBrowser.instance.waitForWorkbench(WORKBENCH_TIMEOUT); - await openFolderViaDialog(copy.tempDir); + await enterFixturesWorkspace(); await VSBrowser.instance.waitForWorkbench(WORKBENCH_TIMEOUT); await openWorkspaceFile(FIXTURE);