diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index a28c014d37..abc430412e 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 skew as suites are + # added. + 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 @@ -39,6 +46,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,29 +80,103 @@ 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. + 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: 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: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. + - 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 with: - name: e2e-screenshots + name: e2e-screenshots-${{ matrix.group }} 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}" 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/.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/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..265fc65fd5 100644 --- a/package.json +++ b/package.json @@ -2672,9 +2672,11 @@ "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": "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/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..52b4de947d 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,90 @@ 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) => 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 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(''); + } + + 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(); +} + /** * 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 +109,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 +142,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/helpers/fixtures.ts b/test/e2e/helpers/fixtures.ts index b799b3900d..592a73d136 100644 --- a/test/e2e/helpers/fixtures.ts +++ b/test/e2e/helpers/fixtures.ts @@ -7,22 +7,166 @@ 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. 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) { + workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'deepnote-e2e-root-')); + } + + return workspaceRoot; +} + +/** + * 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 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 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 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..eda406c28d 100644 --- a/test/e2e/helpers/workspace.ts +++ b/test/e2e/helpers/workspace.ts @@ -7,8 +7,18 @@ import { QUICK_PICK_TIMEOUT, RELOAD_POLL_TIMEOUT } from './constants'; +import { fixturesWorkspaceRoot } from './fixtures'; import { clickDialogOkButton } from './quickInput'; +// 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. +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 @@ -38,7 +48,7 @@ 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 { +async function openFolderViaDialog(folder: string): Promise { const driver = VSBrowser.instance.driver; const previousWorkbench = await driver.findElement(By.css('.monaco-workbench')); @@ -84,3 +94,30 @@ export async function openFolderViaDialog(folder: string): Promise { 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/rootHooks.ts b/test/e2e/rootHooks.ts index c91408300d..269fff1100 100644 --- a/test/e2e/rootHooks.ts +++ b/test/e2e/rootHooks.ts @@ -2,6 +2,11 @@ import { dismissAllNotifications } from './helpers/notifications'; // 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. +// +// 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); 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/initOnlyFile.e2e.test.ts b/test/e2e/suite/files/initOnlyFile.e2e.test.ts similarity index 97% rename from test/e2e/suite/initOnlyFile.e2e.test.ts rename to test/e2e/suite/files/initOnlyFile.e2e.test.ts index e46cbfa158..0035237d69 100644 --- a/test/e2e/suite/initOnlyFile.e2e.test.ts +++ b/test/e2e/suite/files/initOnlyFile.e2e.test.ts @@ -10,19 +10,19 @@ 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'; +} from '../../helpers'; const FIXTURE = 'bootstrap-only.deepnote'; const NOTEBOOK_NAME = 'Bootstrap'; @@ -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/notebookCommands.e2e.test.ts b/test/e2e/suite/files/notebookCommands.e2e.test.ts similarity index 98% rename from test/e2e/suite/notebookCommands.e2e.test.ts rename to test/e2e/suite/files/notebookCommands.e2e.test.ts index 114cc84018..17af78b769 100644 --- a/test/e2e/suite/notebookCommands.e2e.test.ts +++ b/test/e2e/suite/files/notebookCommands.e2e.test.ts @@ -22,14 +22,15 @@ import { import { SUITE_TIMEOUT, WORKBENCH_TIMEOUT, + assertNotNull, confirmModalDialog, + copyFixtureIntoDir, copyFixtureToTempDir, createScreenshotter, - openFolderViaDialog, + enterFixturesWorkspace, openWorkspaceFile, - assertNotNull, waitForNotification -} from '../helpers'; +} from '../../helpers'; const MARKETING_FILES = ['marketing-overview.deepnote', 'marketing-campaigns.deepnote', 'marketing-metrics.deepnote']; const GROUP = 'Marketing'; @@ -158,11 +159,11 @@ 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); - await openFolderViaDialog(tempDir); + await enterFixturesWorkspace(); await VSBrowser.instance.waitForWorkbench(WORKBENCH_TIMEOUT); const section = await getExplorerSection(); diff --git a/test/e2e/suite/projectRename.e2e.test.ts b/test/e2e/suite/files/projectRename.e2e.test.ts similarity index 97% rename from test/e2e/suite/projectRename.e2e.test.ts rename to test/e2e/suite/files/projectRename.e2e.test.ts index 0aba4869ad..68f70f76f2 100644 --- a/test/e2e/suite/projectRename.e2e.test.ts +++ b/test/e2e/suite/files/projectRename.e2e.test.ts @@ -13,16 +13,17 @@ import { By, EditorView, InputBox, VSBrowser, WebView, type ViewItem } from 'vsc import { SUITE_TIMEOUT, WORKBENCH_TIMEOUT, + copyFixtureIntoDir, copyFixtureToTempDir, createScreenshotter, + enterFixturesWorkspace, findDeepnoteGroup, getDeepnoteExplorerSection, - openFolderViaDialog, openWorkspaceFile, 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; @@ -101,11 +102,11 @@ 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); - await openFolderViaDialog(tempDir); + await enterFixturesWorkspace(); await VSBrowser.instance.waitForWorkbench(WORKBENCH_TIMEOUT); let section = await getDeepnoteExplorerSection(); diff --git a/test/e2e/suite/splitInitNotebook.e2e.test.ts b/test/e2e/suite/files/splitInitNotebook.e2e.test.ts similarity index 98% rename from test/e2e/suite/splitInitNotebook.e2e.test.ts rename to test/e2e/suite/files/splitInitNotebook.e2e.test.ts index b96e6ae2dc..cee69d459a 100644 --- a/test/e2e/suite/splitInitNotebook.e2e.test.ts +++ b/test/e2e/suite/files/splitInitNotebook.e2e.test.ts @@ -11,16 +11,16 @@ 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'; +} from '../../helpers'; const FIXTURE = 'etl-pipeline.deepnote'; const SPLIT_ACTION = 'Split into separate files'; @@ -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/splitMultiNotebook.e2e.test.ts b/test/e2e/suite/files/splitMultiNotebook.e2e.test.ts similarity index 98% rename from test/e2e/suite/splitMultiNotebook.e2e.test.ts rename to test/e2e/suite/files/splitMultiNotebook.e2e.test.ts index f2811688b2..5922054ecb 100644 --- a/test/e2e/suite/splitMultiNotebook.e2e.test.ts +++ b/test/e2e/suite/files/splitMultiNotebook.e2e.test.ts @@ -11,16 +11,16 @@ 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'; +} from '../../helpers'; const FIXTURE = 'sales-analytics.deepnote'; const SPLIT_ACTION = 'Split into separate files'; @@ -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/splitSafety.e2e.test.ts b/test/e2e/suite/files/splitSafety.e2e.test.ts similarity index 97% rename from test/e2e/suite/splitSafety.e2e.test.ts rename to test/e2e/suite/files/splitSafety.e2e.test.ts index 92c29caccb..7b76a400c9 100644 --- a/test/e2e/suite/splitSafety.e2e.test.ts +++ b/test/e2e/suite/files/splitSafety.e2e.test.ts @@ -11,13 +11,13 @@ import { EditorView, VSBrowser, WebView } from 'vscode-extension-tester'; import { SUITE_TIMEOUT, WORKBENCH_TIMEOUT, + assertNotNull, copyFixtureToTempDir, createScreenshotter, - openFolderViaDialog, + enterFixturesWorkspace, openWorkspaceFile, - assertNotNull, waitForNotification -} from '../helpers'; +} from '../../helpers'; const DISMISS_FIXTURE = 'sales-analytics.deepnote'; const SPLIT_PROMPT = /multiple notebooks/i; @@ -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/environment.e2e.test.ts b/test/e2e/suite/kernel/environment.e2e.test.ts similarity index 94% rename from test/e2e/suite/environment.e2e.test.ts rename to test/e2e/suite/kernel/environment.e2e.test.ts index 9add95f1fc..58620aaafa 100644 --- a/test/e2e/suite/environment.e2e.test.ts +++ b/test/e2e/suite/kernel/environment.e2e.test.ts @@ -22,25 +22,26 @@ import { FIRST_RUN_OUTPUT_TIMEOUT, KERNEL_CONNECT_TIMEOUT, QUICK_PICK_TIMEOUT, + SHARED_ENV_NAME, SUITE_TIMEOUT, WORKBENCH_TIMEOUT, + assertNotNull, confirmModalDialog, copyFixtureToTempDir, createEnvironment, createScreenshotter, - openFolderViaDialog, + enterFixturesWorkspace, + fixturesWorkspaceRoot, openWorkspaceFile, - assertNotNull, runOnceAndAwaitOutput, selectDeepnoteContextMenu, selectEnvironmentForNotebook, waitForNotification -} from '../helpers'; +} from '../../helpers'; 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; @@ -86,6 +87,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; @@ -93,11 +95,12 @@ 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; await VSBrowser.instance.waitForWorkbench(WORKBENCH_TIMEOUT); - await openFolderViaDialog(tempDir); + await enterFixturesWorkspace(); await VSBrowser.instance.waitForWorkbench(WORKBENCH_TIMEOUT); await createEnvironment(ENV_NAME); @@ -143,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); @@ -159,7 +166,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; @@ -172,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)) { @@ -287,13 +294,13 @@ 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. 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/kernel/helloWorld.e2e.test.ts similarity index 96% rename from test/e2e/suite/helloWorld.e2e.test.ts rename to test/e2e/suite/kernel/helloWorld.e2e.test.ts index 5090937982..5111e6d36a 100644 --- a/test/e2e/suite/helloWorld.e2e.test.ts +++ b/test/e2e/suite/kernel/helloWorld.e2e.test.ts @@ -23,15 +23,16 @@ import { EditorView, VSBrowser, WebView } from 'vscode-extension-tester'; import { FIRST_RUN_OUTPUT_TIMEOUT, + SHARED_ENV_NAME, SUITE_TIMEOUT, WORKBENCH_TIMEOUT, copyFixtureToTempDir, createEnvironment, - openFolderViaDialog, + enterFixturesWorkspace, openWorkspaceFile, runOnceAndAwaitOutput, selectEnvironmentForNotebook -} from '../helpers'; +} from '../../helpers'; const NOTEBOOK_FILE_NAME = 'hello-world.deepnote'; const EXPECTED_OUTPUT = 'hello world'; @@ -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; @@ -61,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/initNotebookRunner.e2e.test.ts b/test/e2e/suite/kernel/initNotebookRunner.e2e.test.ts similarity index 95% rename from test/e2e/suite/initNotebookRunner.e2e.test.ts rename to test/e2e/suite/kernel/initNotebookRunner.e2e.test.ts index c37e8a3d97..1ce55dcd10 100644 --- a/test/e2e/suite/initNotebookRunner.e2e.test.ts +++ b/test/e2e/suite/kernel/initNotebookRunner.e2e.test.ts @@ -4,26 +4,26 @@ */ 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 { FIRST_RUN_OUTPUT_TIMEOUT, OUTPUT_POLL_INTERVAL, + SHARED_ENV_NAME, SUITE_TIMEOUT, WORKBENCH_TIMEOUT, clickRunAll, + copyFixtureIntoDir, copyFixtureToTempDir, createEnvironment, createScreenshotter, dismissAllNotifications, - openFolderViaDialog, + enterFixturesWorkspace, openWorkspaceFile, readRenderedOutput, runOnceAndAwaitOutput, selectEnvironmentForNotebook -} from '../helpers'; +} from '../../helpers'; const MAIN_FILE = 'etl-pipeline-extract.deepnote'; const INIT_SIBLING_FILE = 'etl-pipeline-init.deepnote'; @@ -94,7 +94,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; @@ -105,11 +105,10 @@ 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); + await enterFixturesWorkspace(); await VSBrowser.instance.waitForWorkbench(WORKBENCH_TIMEOUT); await openWorkspaceFile(MAIN_FILE); diff --git a/test/e2e/suite/integrationsEnvFileInjection.e2e.test.ts b/test/e2e/suite/kernel/integrationsEnvFileInjection.e2e.test.ts similarity index 97% rename from test/e2e/suite/integrationsEnvFileInjection.e2e.test.ts rename to test/e2e/suite/kernel/integrationsEnvFileInjection.e2e.test.ts index 337f61a73a..ff0b54cd36 100644 --- a/test/e2e/suite/integrationsEnvFileInjection.e2e.test.ts +++ b/test/e2e/suite/kernel/integrationsEnvFileInjection.e2e.test.ts @@ -10,15 +10,16 @@ import { EditorView, VSBrowser, WebView } from 'vscode-extension-tester'; import { FIRST_RUN_OUTPUT_TIMEOUT, + SHARED_ENV_NAME, SUITE_TIMEOUT, WORKBENCH_TIMEOUT, copyFixtureToTempDir, createEnvironment, - openFolderViaDialog, + enterFixturesWorkspace, openWorkspaceFile, runOnceAndAwaitOutput, selectEnvironmentForNotebook -} from '../helpers'; +} from '../../helpers'; const NOTEBOOK_FILE_NAME = 'integrations-env-file.deepnote'; const EXPECTED_OUTPUT = 'injected-host.example.com'; @@ -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`. @@ -71,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/explorerGrouping.e2e.test.ts b/test/e2e/suite/workspace/explorerGrouping.e2e.test.ts similarity index 94% rename from test/e2e/suite/explorerGrouping.e2e.test.ts rename to test/e2e/suite/workspace/explorerGrouping.e2e.test.ts index 0171e2a2eb..15579e1680 100644 --- a/test/e2e/suite/explorerGrouping.e2e.test.ts +++ b/test/e2e/suite/workspace/explorerGrouping.e2e.test.ts @@ -4,17 +4,16 @@ */ 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 -} from '../helpers'; + enterFixturesWorkspace, +} from '../../helpers'; const MARKETING_FILES = ['marketing-overview.deepnote', 'marketing-campaigns.deepnote', 'marketing-metrics.deepnote']; const OTHER_PROJECT_FILE = 'quick-notes.deepnote'; @@ -87,14 +86,11 @@ 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); - 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/fileWatcher.e2e.test.ts b/test/e2e/suite/workspace/fileWatcher.e2e.test.ts similarity index 96% rename from test/e2e/suite/fileWatcher.e2e.test.ts rename to test/e2e/suite/workspace/fileWatcher.e2e.test.ts index 0970623ddc..79523e7173 100644 --- a/test/e2e/suite/fileWatcher.e2e.test.ts +++ b/test/e2e/suite/workspace/fileWatcher.e2e.test.ts @@ -12,11 +12,12 @@ import { SUITE_TIMEOUT, WORKBENCH_TIMEOUT, copyFixtureToTempDir, + copySnapshotIntoDir, createScreenshotter, - openFolderViaDialog, + enterFixturesWorkspace, openWorkspaceFile, readRenderedOutput -} from '../helpers'; +} from '../../helpers'; const FIXTURE = 'hello-world.deepnote'; const ORIGINAL_SOURCE = 'hello world'; @@ -46,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); @@ -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,13 +169,13 @@ 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); // 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); @@ -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/integrations.e2e.test.ts b/test/e2e/suite/workspace/integrations.e2e.test.ts similarity index 93% rename from test/e2e/suite/integrations.e2e.test.ts rename to test/e2e/suite/workspace/integrations.e2e.test.ts index 65ca6ad795..ade0f654c6 100644 --- a/test/e2e/suite/integrations.e2e.test.ts +++ b/test/e2e/suite/workspace/integrations.e2e.test.ts @@ -4,18 +4,17 @@ */ 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, + enterFixturesWorkspace, openWorkspaceFile -} from '../helpers'; +} from '../../helpers'; const REVENUE_FILE = 'sales-analytics-revenue.deepnote'; const PLAIN_FILE = 'quick-notes.deepnote'; @@ -98,13 +97,10 @@ 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); + await enterFixturesWorkspace(); await VSBrowser.instance.waitForWorkbench(WORKBENCH_TIMEOUT); }); diff --git a/test/e2e/suite/openSingleNotebook.e2e.test.ts b/test/e2e/suite/workspace/openSingleNotebook.e2e.test.ts similarity index 97% rename from test/e2e/suite/openSingleNotebook.e2e.test.ts rename to test/e2e/suite/workspace/openSingleNotebook.e2e.test.ts index 97a67a5ecd..c4ef5d7428 100644 --- a/test/e2e/suite/openSingleNotebook.e2e.test.ts +++ b/test/e2e/suite/workspace/openSingleNotebook.e2e.test.ts @@ -11,11 +11,11 @@ import { WORKBENCH_TIMEOUT, copyFixtureToTempDir, createScreenshotter, - openFolderViaDialog, + enterFixturesWorkspace, openWorkspaceFile, readStatusBarText, waitForNotification -} from '../helpers'; +} from '../../helpers'; const FIXTURE = 'quick-notes.deepnote'; const NOTEBOOK_NAME = 'Quick Notes'; @@ -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/revealInExplorer.e2e.test.ts b/test/e2e/suite/workspace/revealInExplorer.e2e.test.ts similarity index 98% rename from test/e2e/suite/revealInExplorer.e2e.test.ts rename to test/e2e/suite/workspace/revealInExplorer.e2e.test.ts index 57b8a037a7..331134f769 100644 --- a/test/e2e/suite/revealInExplorer.e2e.test.ts +++ b/test/e2e/suite/workspace/revealInExplorer.e2e.test.ts @@ -11,12 +11,12 @@ import { WORKBENCH_TIMEOUT, copyFixtureToTempDir, createScreenshotter, + enterFixturesWorkspace, findDeepnoteLeaf, getDeepnoteExplorerSection, - openFolderViaDialog, openWorkspaceFile, waitForNotification -} from '../helpers'; +} from '../../helpers'; const FIXTURE = 'quick-notes.deepnote'; const NOTEBOOK_NAME = 'Quick Notes'; @@ -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/snapshots.e2e.test.ts b/test/e2e/suite/workspace/snapshots.e2e.test.ts similarity index 92% rename from test/e2e/suite/snapshots.e2e.test.ts rename to test/e2e/suite/workspace/snapshots.e2e.test.ts index 0e97c10327..a621034b4c 100644 --- a/test/e2e/suite/snapshots.e2e.test.ts +++ b/test/e2e/suite/workspace/snapshots.e2e.test.ts @@ -8,18 +8,21 @@ 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, + copyFixtureIntoDir, copyFixtureToTempDir, + copySnapshotIntoDir, createEnvironment, createScreenshotter, - openFolderViaDialog, + enterFixturesWorkspace, openWorkspaceFile, readRenderedOutput, 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'; @@ -38,15 +41,10 @@ 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); + await enterFixturesWorkspace(); await VSBrowser.instance.waitForWorkbench(WORKBENCH_TIMEOUT); await openWorkspaceFile(FIXTURE); @@ -90,7 +88,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' } @@ -107,13 +105,10 @@ 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); + await enterFixturesWorkspace(); await VSBrowser.instance.waitForWorkbench(WORKBENCH_TIMEOUT); await createEnvironment(ENV_NAME); diff --git a/test/e2e/suite/statusBar.e2e.test.ts b/test/e2e/suite/workspace/statusBar.e2e.test.ts similarity index 97% rename from test/e2e/suite/statusBar.e2e.test.ts rename to test/e2e/suite/workspace/statusBar.e2e.test.ts index 89b7b0f8a9..b523ef1bf1 100644 --- a/test/e2e/suite/statusBar.e2e.test.ts +++ b/test/e2e/suite/workspace/statusBar.e2e.test.ts @@ -13,15 +13,14 @@ import { WORKBENCH_TIMEOUT, copyFixtureToTempDir, createScreenshotter, - openFolderViaDialog, + enterFixturesWorkspace, openWorkspaceFile, waitForNotification -} from '../helpers'; +} from '../../helpers'; 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,12 +68,13 @@ 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), ''); await VSBrowser.instance.waitForWorkbench(WORKBENCH_TIMEOUT); - await openFolderViaDialog(copy.tempDir); + await enterFixturesWorkspace(); await VSBrowser.instance.waitForWorkbench(WORKBENCH_TIMEOUT); await openWorkspaceFile(FIXTURE); @@ -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); }); });