diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index a28c014d37..bd26e10b74 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -18,9 +18,13 @@ concurrency: jobs: e2e: - name: E2E (ExTester) + name: E2E (${{ matrix.group }}) runs-on: ubuntu-latest timeout-minutes: 45 + strategy: + fail-fast: false + matrix: + group: [environments, execution, 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 +43,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 +77,94 @@ 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. + # github.run_id in the key because actions/cache only writes on a miss — a bare content hash + # froze the entry at its first save and never picked up newer wheels. 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 managed Deepnote toolkit venv + # Keyed on the Python version too: pyvenv.cfg records the base interpreter, so a patch bump + # would leave a restored venv pointing at a path that is gone. + 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: Warm the managed venv + 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' 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 + 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/.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/package.json b/package.json index 3932b09a94..f68e699a2c 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 -e \"require('./out/e2e/helpers/venv').ensureManagedVenv()\"", + "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.json -e .test-extensions -m ./test/e2e/.mocharc.js -i", + "test:e2e:ci": "extest run-tests \"./out/e2e/suite/$E2E_GROUP/*.e2e.test.js\" -c max -o ./test/e2e/settings.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/src/kernels/deepnote/deepnoteToolkitInstaller.node.ts b/src/kernels/deepnote/deepnoteToolkitInstaller.node.ts index abaa15114a..842dca3301 100644 --- a/src/kernels/deepnote/deepnoteToolkitInstaller.node.ts +++ b/src/kernels/deepnote/deepnoteToolkitInstaller.node.ts @@ -18,7 +18,12 @@ import { } from '../../platform/errors/deepnoteKernelErrors'; import { logger } from '../../platform/logging'; import { PythonEnvironment } from '../../platform/pythonEnvironments/info'; -import { DEEPNOTE_TOOLKIT_VERSION, IDeepnoteToolkitInstaller, VenvAndToolkitInstallation } from './types'; +import { + DEEPNOTE_TOOLKIT_PACKAGES, + DEEPNOTE_TOOLKIT_VERSION, + IDeepnoteToolkitInstaller, + VenvAndToolkitInstallation +} from './types'; /** * Handles installation of the deepnote-toolkit Python package. @@ -367,9 +372,7 @@ export class DeepnoteToolkitInstaller implements IDeepnoteToolkitInstaller { 'install', '--upgrade', `deepnote-toolkit[server]==${DEEPNOTE_TOOLKIT_VERSION}`, - 'ipykernel', - 'python-lsp-server[all]', - 'deepnote-cli' + ...DEEPNOTE_TOOLKIT_PACKAGES ], token ); diff --git a/src/kernels/deepnote/types.ts b/src/kernels/deepnote/types.ts index f33eba3fe3..3881338d10 100644 --- a/src/kernels/deepnote/types.ts +++ b/src/kernels/deepnote/types.ts @@ -414,5 +414,7 @@ export interface IDeepnoteLspClientManager { } export const DEEPNOTE_TOOLKIT_VERSION = '2.1.1'; + +export const DEEPNOTE_TOOLKIT_PACKAGES = ['ipykernel', 'python-lsp-server[all]', 'deepnote-cli']; export const DEEPNOTE_DEFAULT_PORT = 8888; export const DEEPNOTE_NOTEBOOK_TYPE = 'deepnote'; diff --git a/test/e2e/helpers/constants.ts b/test/e2e/helpers/constants.ts index fdaa2eaecb..abcf0d5d01 100644 --- a/test/e2e/helpers/constants.ts +++ b/test/e2e/helpers/constants.ts @@ -47,3 +47,15 @@ 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'; + +// Shared by every suite so only one venv is ever provisioned; the deletion suite is the one +// exception, since it deletes what it creates. +export const SHARED_ENV_NAME = 'E2E Hello Env'; + +// Where the managed venv lives, and the substring that identifies it in the interpreter quick pick. +// The marker matches both the real directory and the `.venv` link the workspace exposes it through. +export const PREBAKED_VENV_DIR_NAME = '.venv-e2e'; +export const PREBAKED_VENV_MARKER = '.venv'; + +// Only elapses when the venv is missing, so it stays well under QUICK_PICK_TIMEOUT. +export const PREBAKED_VENV_FILTER_TIMEOUT = 10_000; diff --git a/test/e2e/helpers/deepnoteEnvironment.ts b/test/e2e/helpers/deepnoteEnvironment.ts index a4f996ce49..166bbae599 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_MARKER, + PREBAKED_VENV_FILTER_TIMEOUT, QUICK_PICK_TIMEOUT } from './constants'; import { dismissAllNotifications, waitForNotification } from './notifications'; @@ -16,6 +18,82 @@ import { tryOpenInputBox } from './quickInput'; const CREATE_ENV_COMMAND = 'Deepnote: Create Environment'; const SELECT_ENV_COMMAND = 'Deepnote: Select Environment for Notebook'; +/** + * Picks the managed venv, which the extension adopts instead of provisioning its own. + * + * `useManagedVenv` picks an interpreter outside it, so the extension creates and owns the venv — + * only the deletion suite needs that, since deleteEnvironment removes the directory for managed + * environments only. + */ +async function selectInterpreter(interpreterPick: InputBox, useManagedVenv: boolean): Promise { + const driver = VSBrowser.instance.driver; + + if (!useManagedVenv) { + await interpreterPick.setText(PREBAKED_VENV_MARKER); + // 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_MARKER) ? picks[0] : undefined; + }, PREBAKED_VENV_FILTER_TIMEOUT) + .catch(() => undefined); + + if (filtered) { + await interpreterPick.confirm(); + + return; + } + + console.warn( + `[deepnote-e2e] no interpreter under ${PREBAKED_VENV_MARKER} 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_MARKER)); + + 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_MARKER); + }, PREBAKED_VENV_FILTER_TIMEOUT) + .catch(() => false); + + if (narrowed) { + await interpreterPick.confirm(); + + return; + } + + await interpreterPick.setText(''); + } + + console.warn( + `[deepnote-e2e] no interpreter outside ${PREBAKED_VENV_MARKER} 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 +101,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 +134,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..a0e021d0a8 100644 --- a/test/e2e/helpers/fixtures.ts +++ b/test/e2e/helpers/fixtures.ts @@ -2,27 +2,77 @@ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; +import { linkManagedVenvInto } from './venv'; + export interface FixtureCopy { /** Removes the throwaway temp directory and its contents. Idempotent; safe to call more than once. */ 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 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'); + +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-')); + // Exposed as `.venv` so the Python extension discovers it with no configuration, which is + // what lets the suites run without a settings file written ahead of the window opening. + linkManagedVenvInto(workspaceRoot); + } + + return workspaceRoot; +} + /** - * 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. + * + * Copies keep their committed ids: what isolates one suite from the next is the window reload in + * enterFixturesWorkspace(), which restarts the extension host, plus this directory being removed in + * `after` — so no two copies of a fixture are ever in the workspace at the same time. */ 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); + fs.copyFileSync(path.join(FIXTURES_DIR, fixtureName), filePath); const cleanup = () => fs.rmSync(tempDir, { recursive: true, force: true }); return { cleanup, filePath, tempDir }; } + +/** + * Copies an additional fixture into an existing copy's directory, which is how a suite assembles a + * multi-file project. Returns the written path. + */ +export function copyFixtureIntoDir(tempDir: string, fixtureName: string): string { + const target = path.join(tempDir, fixtureName); + fs.copyFileSync(path.join(FIXTURES_DIR, fixtureName), target); + + return target; +} + +/** + * Copies a snapshot fixture into `/snapshots/`, the sibling directory buildSnapshotPath + * resolves to. Returns the written path. + */ +export function copySnapshotIntoDir(tempDir: string, snapshotName: string): string { + const snapshotsDir = path.join(tempDir, 'snapshots'); + fs.mkdirSync(snapshotsDir, { recursive: true }); + + const target = path.join(snapshotsDir, snapshotName); + fs.copyFileSync(path.join(FIXTURES_DIR, 'snapshots', snapshotName), target); + + return target; +} diff --git a/test/e2e/helpers/venv.ts b/test/e2e/helpers/venv.ts new file mode 100644 index 0000000000..11eccde73e --- /dev/null +++ b/test/e2e/helpers/venv.ts @@ -0,0 +1,107 @@ +import { execFileSync } from 'child_process'; +import * as fs from 'fs'; +import * as path from 'path'; + +import { PREBAKED_VENV_DIR_NAME } from './constants'; + +const REPO_ROOT = process.cwd(); +const VENV_DIR = path.join(REPO_ROOT, PREBAKED_VENV_DIR_NAME); +const TOOLKIT_SPEC_SOURCE = path.join(REPO_ROOT, 'src', 'kernels', 'deepnote', 'types.ts'); + +function venvPython(): string { + return process.platform === 'win32' + ? path.join(VENV_DIR, 'Scripts', 'python.exe') + : path.join(VENV_DIR, 'bin', 'python'); +} + +/** + * The pip specs deepnoteToolkitInstaller installs, read from source so they cannot drift. The + * installer itself needs `vscode`, which does not resolve in the Mocha process. + */ +function toolkitPipSpecs(): string[] { + const source = fs.readFileSync(TOOLKIT_SPEC_SOURCE, 'utf8'); + + const version = source.match(/DEEPNOTE_TOOLKIT_VERSION\s*=\s*'([^']+)'/)?.[1]; + // Terminated on `];` rather than the first `]`, which sits inside 'python-lsp-server[all]'. + const packages = source.match(/DEEPNOTE_TOOLKIT_PACKAGES\s*=\s*\[([\s\S]*?)\];/)?.[1]; + if (!version || packages === undefined) { + throw new Error(`Could not read the toolkit install spec from ${TOOLKIT_SPEC_SOURCE}`); + } + + return [ + `deepnote-toolkit[server]==${version}`, + ...[...packages.matchAll(/'([^']+)'/g)].map((match) => match[1]) + ]; +} + +/** + * Whether the venv satisfies every spec. Checked via distribution metadata rather than + * `import deepnote_toolkit`, which costs seconds and floods the log on every suite. + */ +function isUsable(specs: string[]): boolean { + if (!fs.existsSync(venvPython())) { + return false; + } + + // 'deepnote-toolkit[server]==2.1.1' -> name 'deepnote-toolkit', version '2.1.1'. + const required = specs.map((spec) => { + const [name, version] = spec.split('=='); + + return { name: name.replace(/\[.*\]$/, ''), version }; + }); + + const check = required + .map(({ name, version }) => + version ? `assert v('${name}') == '${version}'` : `assert v('${name}')` + ) + .join('; '); + + try { + execFileSync(venvPython(), ['-c', `from importlib.metadata import version as v; ${check}`], { + stdio: 'ignore' + }); + + return true; + } catch { + return false; + } +} + +/** + * Guarantees a venv with the toolkit installed and returns its interpreter. The extension adopts it + * rather than provisioning its own, and a venv failing any spec is discarded and rebuilt. + */ +export function ensureManagedVenv(): string { + const specs = toolkitPipSpecs(); + + if (isUsable(specs)) { + return venvPython(); + } + + if (fs.existsSync(VENV_DIR)) { + console.log(`[e2e-venv] discarding unusable venv at ${VENV_DIR}`); + fs.rmSync(VENV_DIR, { recursive: true, force: true }); + } + + console.log(`[e2e-venv] creating ${VENV_DIR} — this only happens once`); + execFileSync(process.env.PYTHON ?? 'python3', ['-m', 'venv', VENV_DIR], { stdio: 'inherit' }); + + // Mirrors deepnoteToolkitInstaller.installVenvAndToolkit. + execFileSync(venvPython(), ['-m', 'pip', 'install', '--upgrade', 'pip'], { stdio: 'inherit' }); + execFileSync(venvPython(), ['-m', 'pip', 'install', '--upgrade', ...specs], { stdio: 'inherit' }); + + return venvPython(); +} + +/** + * Links the venv into a workspace as `.venv`, where the Python extension finds it with no settings, + * while the venv itself stays at a fixed cacheable path. + */ +export function linkManagedVenvInto(workspaceFolder: string): void { + const target = path.join(workspaceFolder, '.venv'); + if (fs.existsSync(target)) { + return; + } + + fs.symlinkSync(ensureManagedVenv().replace(/[/\\]bin[/\\]python$/, ''), target, 'dir'); +} diff --git a/test/e2e/helpers/workspace.ts b/test/e2e/helpers/workspace.ts index 76b8877241..24208e0ec2 100644 --- a/test/e2e/helpers/workspace.ts +++ b/test/e2e/helpers/workspace.ts @@ -5,10 +5,21 @@ import { FOLDER_OK_RETRY_DELAY, FOLDER_OPEN_TIMEOUT, QUICK_PICK_TIMEOUT, - RELOAD_POLL_TIMEOUT + RELOAD_POLL_TIMEOUT, + WORKBENCH_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 RELOAD_WINDOW_COMMAND = 'Developer: Reload Window'; + /** * 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 +49,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 +95,52 @@ 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 and nothing left + * over from the suite before it. Every suite calls this in `before()`. + * + * The folder is opened once; after that each suite gets a window reload. Reloading restarts the + * extension host, which is the only thing that reliably clears everything a previous suite can leave + * behind — open editors, the Deepnote Explorer's cached project groups, cached project data keyed by + * id, and the Python extension's interpreter discovery. Clearing those individually was possible but + * open-ended; every fix uncovered another. + * + * The reload is not what made the old per-suite setup slow. That was `File: Open Folder...`, whose + * simple dialog navigates one level per OK click, so opening a path meant re-clicking for up to + * FOLDER_OPEN_TIMEOUT. Keeping one workspace and reloading inside it drops that cost while keeping + * the isolation. + */ +export async function enterFixturesWorkspace(): Promise { + if (!fixturesRootOpened) { + await openFolderViaDialog(fixturesWorkspaceRoot()); + fixturesRootOpened = true; + + return; + } + + const driver = VSBrowser.instance.driver; + + // Close before reloading: a reload restores whatever was open, so a suite that renamed or deleted + // notebooks would hand its dead tabs straight to the next one. Driven through the palette rather + // than `EditorView.closeAllEditors`, which clicks each tab's close button and fails on notebook + // tabs with ElementNotInteractableError. + await new Workbench().executeCommand(CLOSE_ALL_EDITORS_COMMAND); + + const previousWorkbench = await driver.findElement(By.css('.monaco-workbench')); + await new Workbench().executeCommand(RELOAD_WINDOW_COMMAND); + + // Same staleness probe openFolderViaDialog uses: the old workbench detaching is the signal that + // the reload actually started, rather than a fixed sleep. + await driver.wait(async () => { + try { + await previousWorkbench.getTagName(); + + return false; + } catch { + return true; + } + }, WORKBENCH_TIMEOUT); + + await VSBrowser.instance.waitForWorkbench(WORKBENCH_TIMEOUT); +} 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/environment.e2e.test.ts b/test/e2e/suite/environments/environment.e2e.test.ts similarity index 89% rename from test/e2e/suite/environment.e2e.test.ts rename to test/e2e/suite/environments/environment.e2e.test.ts index 9add95f1fc..27951df03c 100644 --- a/test/e2e/suite/environment.e2e.test.ts +++ b/test/e2e/suite/environments/environment.e2e.test.ts @@ -22,25 +22,27 @@ 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; @@ -97,7 +99,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); @@ -136,14 +138,39 @@ describe('Deepnote — splitting a file migrates its selected environment onto e } await screenshot('split-prompt'); - await assertNotNull(prompt, 'split prompt notification').takeAction(SPLIT_ACTION); + // Re-find the toast on each attempt instead of reusing the reference captured before the + // screenshot above: the notification re-renders while it sits there, which stales it. Same + // locate-and-act-in-one-loop shape as clickRunAll. + assertNotNull(prompt, 'split prompt notification'); + await driver.wait( + async () => { + const current = await waitForNotification(SPLIT_PROMPT, INLINE_PROMPT_TIMEOUT, false); + if (!current) { + return false; + } + + try { + await current.takeAction(SPLIT_ACTION); + + return true; + } catch (error) { + console.warn('[deepnote-e2e] take split action (retrying):', error); + + return false; + } + }, + WORKBENCH_TIMEOUT, + `could not take the "${SPLIT_ACTION}" action on the split prompt` + ); await waitForNotification(SPLIT_DONE, WORKBENCH_TIMEOUT, true); await driver.sleep(2500); await screenshot('split-done'); // 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'); + // a stale pre-split entry). It is written to workspace.workspaceFolders[0] + // (deepnoteExtensionSidecarWriter.node.ts:300), which is the shared fixtures root rather than + // this suite's directory, so the delete also drops whatever earlier suites left in it. + const sidecarPath = path.join(fixturesWorkspaceRoot(), '.vscode', 'deepnote.json'); fs.rmSync(sidecarPath, { force: true }); await new EditorView().closeAllEditors().catch(() => undefined); @@ -172,7 +199,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 +314,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/execution/helloWorld.e2e.test.ts similarity index 96% rename from test/e2e/suite/helloWorld.e2e.test.ts rename to test/e2e/suite/execution/helloWorld.e2e.test.ts index 5090937982..5111e6d36a 100644 --- a/test/e2e/suite/helloWorld.e2e.test.ts +++ b/test/e2e/suite/execution/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/execution/initNotebookRunner.e2e.test.ts similarity index 95% rename from test/e2e/suite/initNotebookRunner.e2e.test.ts rename to test/e2e/suite/execution/initNotebookRunner.e2e.test.ts index c37e8a3d97..1ce55dcd10 100644 --- a/test/e2e/suite/initNotebookRunner.e2e.test.ts +++ b/test/e2e/suite/execution/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/execution/integrationsEnvFileInjection.e2e.test.ts similarity index 97% rename from test/e2e/suite/integrationsEnvFileInjection.e2e.test.ts rename to test/e2e/suite/execution/integrationsEnvFileInjection.e2e.test.ts index 337f61a73a..ff0b54cd36 100644 --- a/test/e2e/suite/integrationsEnvFileInjection.e2e.test.ts +++ b/test/e2e/suite/execution/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/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/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 98% rename from test/e2e/suite/statusBar.e2e.test.ts rename to test/e2e/suite/workspace/statusBar.e2e.test.ts index 89b7b0f8a9..11896835d3 100644 --- a/test/e2e/suite/statusBar.e2e.test.ts +++ b/test/e2e/suite/workspace/statusBar.e2e.test.ts @@ -13,16 +13,16 @@ 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 PROJECT_ID = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc'; const COPIED_TOAST = /Copied Deepnote notebook details to clipboard\./i; /** Finds the Deepnote status-bar item by the notebook name in its text. */ @@ -73,7 +73,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);