diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index b8ea5fbc2c..ef853615ef 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -24,7 +24,7 @@ jobs: strategy: fail-fast: false matrix: - group: [agent, environments, execution, files, workspace] + group: [agent, execution, files, interpreter, 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 @@ -84,13 +84,13 @@ jobs: uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.cache/pip - key: pip-${{ runner.os }}-py${{ steps.python.outputs.python-version }}-${{ hashFiles('src/kernels/deepnote/toolkitSpec.json', 'src/kernels/deepnote/deepnoteToolkitInstaller.node.ts') }} + key: pip-${{ runner.os }}-py${{ steps.python.outputs.python-version }}-${{ hashFiles('src/platform/common/toolkitSpec.json', 'src/kernels/deepnote/deepnoteToolkitInstaller.node.ts') }} - name: Cache the managed Deepnote toolkit venv 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/toolkitSpec.json', 'src/kernels/deepnote/deepnoteToolkitInstaller.node.ts') }} + key: e2e-venv-${{ runner.os }}-py${{ steps.python.outputs.python-version }}-${{ hashFiles('src/platform/common/toolkitSpec.json', 'src/kernels/deepnote/deepnoteToolkitInstaller.node.ts') }} - name: Warm the managed venv run: npm run setup:e2e:venv diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e399753731..44578447f1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -301,10 +301,11 @@ running inside the Extension Development Host. 1. Run the one-time setup. It fetches the test VS Code build, chromedriver, the Python extension and the mock LLM server, then bakes the `deepnote-toolkit` venv into `.venv-e2e` and writes - `test/e2e/settings.generated.json`, without which `test:e2e` fails at launch. That file carries - `python.venvPath`, which is machine-scoped and so only takes effect from user settings — a - workspace `.vscode/settings.json` is silently ignored. `npm run setup:e2e:venv` regenerates it - without re-downloading VS Code. + `test/e2e/settings.generated.json`, without which `test:e2e` fails at launch. That file pins + `python.defaultInterpreterPath` to the baked venv, which is how every suite gets an interpreter + that already carries the toolkit. Both settings it writes only take effect from user settings, + which is where extest puts this file. `npm run setup:e2e:venv` regenerates it without + re-downloading VS Code. 1. Compile the E2E sources — like the unit tests, they run from compiled JS in `out/`. 1. Run the suites. @@ -326,9 +327,12 @@ On a headless machine, wrap the command in `xvfb-run --auto-servernum --server-a A few things worth knowing before you debug a failure: -- **The suites adopt `.venv-e2e` rather than provisioning their own venv**, which is most of the runtime. - If the Python extension does not offer it, the run still passes but takes several minutes longer and - logs `no interpreter under .venv-e2e was offered` — grep for that first when a run is unexpectedly slow. +- **The suites run against `.venv-e2e` rather than provisioning their own venv**, which is most of the + runtime. When it is missing the pinned interpreter does not exist, so the first run stops on the + install-consent modal and the suite times out there — re-run `npm run setup:e2e:venv` first. +- **A suite that needs a different interpreter** writes its own workspace `.vscode/settings.json`; + `python.defaultInterpreterPath` is `machine-overridable`, so the workspace value wins over the pin. + `suite/interpreter/` does this to get an interpreter without the toolkit. - **Screenshots of failures** are written to `/screenshots/` and uploaded as CI artifacts. - **CI shards by directory**, one job per group, so a new group directory must also be added to the matrix in `.github/workflows/e2e.yml` — the `verify-coverage` job fails the build if a group ran nowhere, or if diff --git a/package.json b/package.json index 94aa9f9413..dfdac2c199 100644 --- a/package.json +++ b/package.json @@ -103,41 +103,6 @@ "title": "%deepnote.commands.disableSnapshots.title%", "category": "Deepnote" }, - { - "command": "deepnote.environments.create", - "title": "%deepnote.commands.environments.create.title%", - "category": "Deepnote", - "icon": "$(add)" - }, - { - "command": "deepnote.environments.delete", - "title": "%deepnote.commands.environments.delete.title%", - "category": "Deepnote", - "icon": "$(trash)" - }, - { - "command": "deepnote.environments.managePackages", - "title": "%deepnote.commands.environments.managePackages.title%", - "category": "Deepnote", - "icon": "$(package)" - }, - { - "command": "deepnote.environments.editName", - "title": "%deepnote.commands.environments.editName.title%", - "category": "Deepnote" - }, - { - "command": "deepnote.environments.refresh", - "title": "%deepnote.commands.environments.refresh.title%", - "category": "Deepnote", - "icon": "$(refresh)" - }, - { - "command": "deepnote.environments.selectForNotebook", - "title": "%deepnote.commands.environments.selectForNotebook.title%", - "category": "Deepnote", - "icon": "$(server-environment)" - }, { "command": "deepnote.manageIntegrations", "title": "%deepnote.commands.manageIntegrations.title%", @@ -912,16 +877,6 @@ "command": "deepnote.refreshExplorer", "when": "view == deepnoteExplorer", "group": "navigation@3" - }, - { - "command": "deepnote.environments.create", - "when": "view == deepnoteEnvironments", - "group": "navigation@4" - }, - { - "command": "deepnote.environments.refresh", - "when": "view == deepnoteEnvironments", - "group": "navigation@5" } ], "editor/context": [ @@ -1022,11 +977,6 @@ "group": "navigation@-1", "when": "notebookType == 'deepnote'" }, - { - "command": "deepnote.environments.selectForNotebook", - "group": "navigation@0", - "when": "notebookType == 'deepnote'" - }, { "command": "deepnote.manageIntegrations", "group": "navigation@1", @@ -1601,21 +1551,6 @@ "when": "view == deepnoteExplorer && viewItem != loading", "group": "inline@2" }, - { - "command": "deepnote.environments.managePackages", - "when": "view == deepnoteEnvironments", - "group": "2_manage@1" - }, - { - "command": "deepnote.environments.editName", - "when": "view == deepnoteEnvironments", - "group": "2_manage@2" - }, - { - "command": "deepnote.environments.delete", - "when": "view == deepnoteEnvironments", - "group": "4_danger@1" - }, { "command": "deepnote.addNotebookToProject", "when": "view == deepnoteExplorer && viewItem == projectGroup", @@ -2406,11 +2341,6 @@ "dark": "./resources/dark/deepnote-icon.svg" } }, - { - "id": "deepnoteEnvironments", - "name": "%deepnote.views.environments.name%", - "when": "workspaceFolderCount != 0" - }, { "type": "webview", "id": "deepnoteViewVariables", @@ -2617,18 +2547,6 @@ "completionEvents": [ "onCommand:deepnote.manageIntegrations" ] - }, - { - "id": "deepnote.setupEnvironment", - "title": "%contributes.walkthroughs.deepnoteWelcome.steps.setupEnvironment.title%", - "description": "%contributes.walkthroughs.deepnoteWelcome.steps.setupEnvironment.description%", - "media": { - "image": "resources/walkthroughs/environments.png", - "altText": "%contributes.walkthroughs.deepnoteWelcome.steps.setupEnvironment.media.altText%" - }, - "completionEvents": [ - "onView:deepnoteEnvironments" - ] } ] } diff --git a/package.nls.json b/package.nls.json index 2cf9d50a05..6aa47f1046 100644 --- a/package.nls.json +++ b/package.nls.json @@ -289,17 +289,7 @@ "deepnote.commands.copyNotebookDetails.title": "Copy Active Deepnote Notebook Details", "deepnote.views.explorer.name": "Explorer", "deepnote.views.explorer.welcome": "No Deepnote notebooks found in this workspace.", - "deepnote.views.environments.name": "Environments", "deepnote.command.selectNotebook.title": "Select Notebook", - "deepnote.commands.environments.create.title": "Create Environment", - "deepnote.commands.environments.start.title": "Start Server", - "deepnote.commands.environments.stop.title": "Stop Server", - "deepnote.commands.environments.restart.title": "Restart Server", - "deepnote.commands.environments.delete.title": "Delete Environment", - "deepnote.commands.environments.managePackages.title": "Manage Packages", - "deepnote.commands.environments.editName.title": "Rename Environment", - "deepnote.commands.environments.refresh.title": "Refresh", - "deepnote.commands.environments.selectForNotebook.title": "Select Environment for Notebook", "contributes.walkthroughs.deepnoteWelcome.title": "Get Started with Deepnote", "contributes.walkthroughs.deepnoteWelcome.description": "Your first steps to set up and explore Deepnote notebooks in VS Code.", "contributes.walkthroughs.deepnoteWelcome.steps.exploreProjects.title": "Explore Your Projects", @@ -313,8 +303,5 @@ "contributes.walkthroughs.deepnoteWelcome.steps.notebookBlocks.media.altText": "A Deepnote notebook showing different block types", "contributes.walkthroughs.deepnoteWelcome.steps.connectDataSources.title": "Connect to Your Data Sources", "contributes.walkthroughs.deepnoteWelcome.steps.connectDataSources.description": "Set up integrations to connect your notebooks to databases, data warehouses, and other services. Query your data directly from SQL blocks.", - "contributes.walkthroughs.deepnoteWelcome.steps.connectDataSources.media.altText": "The Manage Integrations panel for connecting data sources", - "contributes.walkthroughs.deepnoteWelcome.steps.setupEnvironment.title": "Set Up a Python Environment", - "contributes.walkthroughs.deepnoteWelcome.steps.setupEnvironment.description": "Create and manage Python environments for your notebooks. Install packages, configure dependencies, and switch between environments.", - "contributes.walkthroughs.deepnoteWelcome.steps.setupEnvironment.media.altText": "The Deepnote Environments panel showing available environments" + "contributes.walkthroughs.deepnoteWelcome.steps.connectDataSources.media.altText": "The Manage Integrations panel for connecting data sources" } diff --git a/resources/walkthroughs/environments.png b/resources/walkthroughs/environments.png deleted file mode 100644 index f37bc38d71..0000000000 Binary files a/resources/walkthroughs/environments.png and /dev/null differ diff --git a/src/kernels/deepnote/deepnoteServerStarter.node.ts b/src/kernels/deepnote/deepnoteServerStarter.node.ts index fb469b694f..6d9c549f37 100644 --- a/src/kernels/deepnote/deepnoteServerStarter.node.ts +++ b/src/kernels/deepnote/deepnoteServerStarter.node.ts @@ -20,13 +20,15 @@ import { IAsyncDisposableRegistry, IDisposable, IOutputChannel } from '../../pla import { sleep } from '../../platform/common/utils/async'; import { generateUuid } from '../../platform/common/uuid'; import { DeepnoteServerStartupError } from '../../platform/errors/deepnoteKernelErrors'; +import { getCachedEnvironment } from '../../platform/interpreter/helpers'; +import { getFilePath } from '../../platform/common/platform/fs-paths.node'; import { logger } from '../../platform/logging'; import { IUserpodApiEndpoints } from '../../platform/notebooks/deepnote/types'; import { PythonEnvironment } from '../../platform/pythonEnvironments/info'; import * as path from '../../platform/vscode-path/path'; import { DeepnoteAgentSkillsManager } from './deepnoteAgentSkillsManager.node'; import { applyIntegrationEndpointEnv } from './deepnoteIntegrationEndpointEnv'; -import { DeepnoteServerInfo, IDeepnoteServerStarter, IDeepnoteToolkitInstaller } from './types'; +import { DeepnoteServerInfo, IDeepnoteServerStarter } from './types'; const MAX_OUTPUT_TRACKING_LENGTH = 5000; const SERVER_STARTUP_TIMEOUT_MS = 120_000; @@ -49,7 +51,7 @@ type PendingOperation = }; interface ProjectContext { - environmentId: string; + interpreterId: string; serverInfo: DeepnoteServerInfo | null; } @@ -72,7 +74,6 @@ export class DeepnoteServerStarter implements IDeepnoteServerStarter, IExtension constructor( @inject(IProcessServiceFactory) private readonly processServiceFactory: IProcessServiceFactory, - @inject(IDeepnoteToolkitInstaller) private readonly toolkitInstaller: IDeepnoteToolkitInstaller, @inject(DeepnoteAgentSkillsManager) private readonly agentSkillsManager: DeepnoteAgentSkillsManager, @inject(IOutputChannel) @named(STANDARD_OUTPUT_CHANNEL) private readonly outputChannel: IOutputChannel, @inject(IAsyncDisposableRegistry) asyncRegistry: IAsyncDisposableRegistry, @@ -98,14 +99,11 @@ export class DeepnoteServerStarter implements IDeepnoteServerStarter, IExtension */ public async startServer( interpreter: PythonEnvironment, - venvPath: Uri, - managedVenv: boolean, - additionalPackages: string[], - environmentId: string, deepnoteFileUri: Uri, token?: CancellationToken ): Promise { const fileKey = deepnoteFileUri.fsPath; + const interpreterId = interpreter.id; let pendingOp = this.pendingOperations.get(fileKey); if (pendingOp) { @@ -119,12 +117,12 @@ export class DeepnoteServerStarter implements IDeepnoteServerStarter, IExtension let existingContext = this.projectContexts.get(fileKey); if (existingContext != null) { - const { environmentId: existingEnvironmentId, serverInfo: existingServerInfo } = existingContext; + const { interpreterId: existingInterpreterId, serverInfo: existingServerInfo } = existingContext; - if (existingEnvironmentId === environmentId) { + if (existingInterpreterId === interpreterId) { if (existingServerInfo != null && (await this.isServerRunning(existingServerInfo))) { logger.info( - `Deepnote server already running at ${existingServerInfo.url} for ${fileKey} (environmentId ${environmentId})` + `Deepnote server already running at ${existingServerInfo.url} for ${fileKey} (interpreter ${interpreterId})` ); return existingServerInfo; } @@ -136,14 +134,15 @@ export class DeepnoteServerStarter implements IDeepnoteServerStarter, IExtension } } else { logger.info( - `Stopping existing server for ${fileKey} with environmentId ${existingEnvironmentId} to start new one with environmentId ${environmentId}...` + `Stopping existing server for ${fileKey} with interpreter ${existingInterpreterId} to start new one with interpreter ${interpreterId}...` ); await this.stopServerForEnvironment(existingContext, deepnoteFileUri, token); - existingContext.environmentId = environmentId; + existingContext = { interpreterId, serverInfo: null }; + this.projectContexts.set(fileKey, existingContext); } } else { const newContext: ProjectContext = { - environmentId, + interpreterId, serverInfo: null }; @@ -153,16 +152,7 @@ export class DeepnoteServerStarter implements IDeepnoteServerStarter, IExtension const operation = { type: 'start' as const, - promise: this.startServerForEnvironment( - existingContext, - interpreter, - venvPath, - managedVenv, - additionalPackages, - environmentId, - deepnoteFileUri, - token - ) + promise: this.startServerForEnvironment(existingContext, interpreter, deepnoteFileUri, token) }; this.pendingOperations.set(fileKey, operation); @@ -223,7 +213,7 @@ export class DeepnoteServerStarter implements IDeepnoteServerStarter, IExtension * Core server start using @deepnote/runtime-core's `startServer`. * * Extension-specific layers: - * - Toolkit/venv installation (before start) + * - The caller guarantees deepnote-toolkit is installed (IDeepnoteToolkitDependencyService) * - Integration endpoint env var injection (via ServerOptions.env) — these point the toolkit at the * extension's loopback `userpod-api` endpoint, which is how it fetches SQL credentials at kernel init * - Lock file creation (after start, using returned PID) @@ -232,34 +222,23 @@ export class DeepnoteServerStarter implements IDeepnoteServerStarter, IExtension private async startServerForEnvironment( projectContext: ProjectContext, interpreter: PythonEnvironment, - venvPath: Uri, - managedVenv: boolean, - additionalPackages: string[], - environmentId: string, deepnoteFileUri: Uri, token?: CancellationToken ): Promise { const fileKey = deepnoteFileUri.fsPath; + const interpreterId = interpreter.id; Cancellation.throwIfCanceled(token); - logger.info(`Ensuring deepnote-toolkit is installed in venv for environment ${environmentId}...`); - const { pythonInterpreter: venvInterpreter } = await this.toolkitInstaller.ensureVenvAndToolkit( - interpreter, - venvPath, - managedVenv, - token - ); - - this.agentSkillsManager.ensureSkillsUpdated(environmentId, venvInterpreter); + // Check if deepnote-toolkit is installed, and install if needed + this.agentSkillsManager.ensureSkillsUpdated(interpreterId, interpreter); Cancellation.throwIfCanceled(token); - await this.toolkitInstaller.installAdditionalPackages(venvPath, additionalPackages, token); - - Cancellation.throwIfCanceled(token); + // Derive the environment path from the interpreter + const envPath = this.deriveEnvPath(interpreter); - logger.info(`Starting deepnote-toolkit server for ${fileKey} (environmentId ${environmentId})`); + logger.info(`Starting deepnote-toolkit server for ${fileKey} (interpreter ${interpreterId})`); this.outputChannel.appendLine(l10n.t('Starting Deepnote server...')); const extraEnv: Record = {}; @@ -276,7 +255,7 @@ export class DeepnoteServerStarter implements IDeepnoteServerStarter, IExtension let serverInfo: DeepnoteServerInfo | undefined; try { serverInfo = await startServer({ - pythonEnv: venvPath.fsPath, + pythonEnv: envPath, workingDirectory: path.dirname(deepnoteFileUri.fsPath), startupTimeoutMs: SERVER_STARTUP_TIMEOUT_MS, env: extraEnv @@ -287,7 +266,7 @@ export class DeepnoteServerStarter implements IDeepnoteServerStarter, IExtension throw new DeepnoteServerStartupError( interpreter.uri.fsPath, - serverInfo?.jupyterPort ?? 0, + 0, 'unknown', capturedOutput?.stdout || '', capturedOutput?.stderr || '', @@ -314,6 +293,28 @@ export class DeepnoteServerStarter implements IDeepnoteServerStarter, IExtension return serverInfo; } + /** + * Derive the environment path from a Python interpreter. + * Uses the cached environment info, or falls back to navigating up from the executable. + */ + private deriveEnvPath(interpreter: PythonEnvironment): string { + const cachedEnv = getCachedEnvironment(interpreter); + const folderPath = getFilePath(cachedEnv?.environment?.folderUri); + + if (folderPath) { + return folderPath; + } + + const sysPrefix = cachedEnv?.executable?.sysPrefix; + + if (sysPrefix) { + return sysPrefix; + } + + // Fallback: go up from bin/python (or Scripts/python.exe on Windows) + return path.dirname(path.dirname(interpreter.uri.fsPath)); + } + /** * Stop the server using @deepnote/runtime-core's `stopServer` (SIGTERM -> wait -> SIGKILL). */ diff --git a/src/kernels/deepnote/deepnoteServerStarter.unit.test.ts b/src/kernels/deepnote/deepnoteServerStarter.unit.test.ts index ab4c6ff245..7703150474 100644 --- a/src/kernels/deepnote/deepnoteServerStarter.unit.test.ts +++ b/src/kernels/deepnote/deepnoteServerStarter.unit.test.ts @@ -7,6 +7,8 @@ import { Uri } from 'vscode'; import { serializeProjectFile } from '../../notebooks/deepnote/deepnoteTestHelpers'; import { IProcessServiceFactory } from '../../platform/common/process/types.node'; import { IAsyncDisposableRegistry, IOutputChannel } from '../../platform/common/types'; +import { PythonExtension } from '@vscode/python-extension'; +import { setPythonApi } from '../../platform/interpreter/helpers'; import { IUserpodApiEndpoints } from '../../platform/notebooks/deepnote/types'; import { PythonEnvironment } from '../../platform/pythonEnvironments/info'; import { @@ -15,10 +17,10 @@ import { __resetRuntimeCoreMock } from '../../test/mocks/deepnoteRuntimeCore'; import { stubReadFile } from '../../test/mocks/vscodeFs'; +import { resolvableInstance } from '../../test/datascience/helpers'; import { resetVSCodeMocks } from '../../test/vscode-mock'; import { DeepnoteAgentSkillsManager } from './deepnoteAgentSkillsManager.node'; import { DeepnoteServerStarter } from './deepnoteServerStarter.node'; -import { IDeepnoteToolkitInstaller } from './types'; /** * Unit tests for DeepnoteServerStarter. @@ -33,14 +35,12 @@ suite('DeepnoteServerStarter', () => { id: '/usr/bin/python3', uri: Uri.file('/usr/bin/python3') }; - const venvPath = Uri.file('/venvs/env1'); // Two notebooks in the SAME project directory but different files (sibling files). const uriA = Uri.file('/workspace/project/notebook-a.deepnote'); const uriB = Uri.file('/workspace/project/notebook-b.deepnote'); let serverStarter: DeepnoteServerStarter; let mockProcessServiceFactory: IProcessServiceFactory; - let mockToolkitInstaller: IDeepnoteToolkitInstaller; let mockAgentSkillsManager: DeepnoteAgentSkillsManager; let mockOutputChannel: IOutputChannel; let mockAsyncRegistry: IAsyncDisposableRegistry; @@ -51,7 +51,6 @@ suite('DeepnoteServerStarter', () => { resetVSCodeMocks(); mockProcessServiceFactory = mock(); - mockToolkitInstaller = mock(); mockAgentSkillsManager = mock(); mockOutputChannel = mock(); mockAsyncRegistry = mock(); @@ -59,22 +58,21 @@ suite('DeepnoteServerStarter', () => { when(mockAsyncRegistry.push(anything())).thenReturn(); when(mockOutputChannel.appendLine(anything())).thenReturn(); - when(mockUserpodApiEndpoints.ready).thenReturn(Promise.resolve()); when(mockUserpodApiEndpoints.baseUrl).thenReturn(undefined); - // The toolkit install step runs before runtime-core's startServer; stub it so the - // start path reaches startServer. (ts-mockito methods that are not stubbed return null.) - when(mockToolkitInstaller.ensureVenvAndToolkit(anything(), anything(), anything(), anything())).thenResolve({ - pythonInterpreter: interpreter, - toolkitVersion: '1.0.0' - }); - when(mockToolkitInstaller.installAdditionalPackages(anything(), anything(), anything())).thenResolve(); when(mockAgentSkillsManager.ensureSkillsUpdated(anything(), anything())).thenReturn(); + // startServer derives the env path via getCachedEnvironment, which needs the Python API. + const mockedApi = mock(); + sinon.stub(PythonExtension, 'api').resolves(resolvableInstance(mockedApi)); + const environments = mock(); + when(mockedApi.environments).thenReturn(instance(environments)); + when(environments.known).thenReturn([]); + setPythonApi(instance(mockedApi)); + serverStarter = new DeepnoteServerStarter( instance(mockProcessServiceFactory), - instance(mockToolkitInstaller), instance(mockAgentSkillsManager), instance(mockOutputChannel), instance(mockAsyncRegistry), @@ -83,6 +81,7 @@ suite('DeepnoteServerStarter', () => { }); teardown(async () => { + setPythonApi(undefined as any); sinon.restore(); await serverStarter.dispose(); }); @@ -100,8 +99,8 @@ suite('DeepnoteServerStarter', () => { when(mockUserpodApiEndpoints.getAuthToken('project-a')).thenReturn('token-a'); when(mockUserpodApiEndpoints.getAuthToken('project-b')).thenReturn('token-b'); - await serverStarter.startServer(interpreter, venvPath, true, [], 'env1', uriA); - await serverStarter.startServer(interpreter, venvPath, true, [], 'env1', uriB); + await serverStarter.startServer(interpreter, uriA); + await serverStarter.startServer(interpreter, uriB); assert.deepStrictEqual( __getStartServerCalls().map((c) => c.env), @@ -128,8 +127,8 @@ suite('DeepnoteServerStarter', () => { suite('per-notebook keying (startServer/stopServer)', () => { test('starts SEPARATE servers for two different notebook URIs in the same dir (catches cross-sibling server reuse)', async () => { - const infoA = await serverStarter.startServer(interpreter, venvPath, true, [], 'env1', uriA); - const infoB = await serverStarter.startServer(interpreter, venvPath, true, [], 'env1', uriB); + const infoA = await serverStarter.startServer(interpreter, uriA); + const infoB = await serverStarter.startServer(interpreter, uriB); // runtime-core startServer must be invoked once per notebook — NOT reused across siblings. const calls = __getStartServerCalls(); @@ -147,8 +146,8 @@ suite('DeepnoteServerStarter', () => { // Simulate a live server: the health probe (GET {url}/api) succeeds. const fetchStub = sinon.stub(globalThis, 'fetch').resolves(new Response()); - const first = await serverStarter.startServer(interpreter, venvPath, true, [], 'env1', uriA); - const second = await serverStarter.startServer(interpreter, venvPath, true, [], 'env1', uriA); + const first = await serverStarter.startServer(interpreter, uriA); + const second = await serverStarter.startServer(interpreter, uriA); assert.strictEqual( __getStartServerCalls().length, @@ -166,16 +165,16 @@ suite('DeepnoteServerStarter', () => { // Health probes report "running" for everything; a stopped notebook must still respawn. sinon.stub(globalThis, 'fetch').resolves(new Response()); - await serverStarter.startServer(interpreter, venvPath, true, [], 'env1', uriA); + await serverStarter.startServer(interpreter, uriA); await serverStarter.stopServer(uriA); - await serverStarter.startServer(interpreter, venvPath, true, [], 'env1', uriA); + await serverStarter.startServer(interpreter, uriA); assert.strictEqual(__getStartServerCalls().length, 2, 'restart after stop must spawn a new server'); }); test('stopServer(uriA) tears down ONLY notebook A; B keeps running (catches cross-notebook teardown)', async () => { - const infoA = await serverStarter.startServer(interpreter, venvPath, true, [], 'env1', uriA); - await serverStarter.startServer(interpreter, venvPath, true, [], 'env1', uriB); + const infoA = await serverStarter.startServer(interpreter, uriA); + await serverStarter.startServer(interpreter, uriB); await serverStarter.stopServer(uriA); @@ -196,8 +195,8 @@ suite('DeepnoteServerStarter', () => { suite('dispose', () => { test('stops every running server; a second dispose is a no-op', async () => { - const infoA = await serverStarter.startServer(interpreter, venvPath, true, [], 'env1', uriA); - const infoB = await serverStarter.startServer(interpreter, venvPath, true, [], 'env1', uriB); + const infoA = await serverStarter.startServer(interpreter, uriA); + const infoB = await serverStarter.startServer(interpreter, uriB); await serverStarter.dispose(); await serverStarter.dispose(); @@ -225,7 +224,7 @@ suite('DeepnoteServerStarter', () => { }) ); - const startPromise = serverStarter.startServer(interpreter, venvPath, true, [], 'env1', uriA); + const startPromise = serverStarter.startServer(interpreter, uriA); let disposeResolved = false; const disposePromise = serverStarter.dispose().then(() => { diff --git a/src/kernels/deepnote/deepnoteSharedToolkitInstaller.node.ts b/src/kernels/deepnote/deepnoteSharedToolkitInstaller.node.ts index 54f37c3dcc..1bc0b959d5 100644 --- a/src/kernels/deepnote/deepnoteSharedToolkitInstaller.node.ts +++ b/src/kernels/deepnote/deepnoteSharedToolkitInstaller.node.ts @@ -10,7 +10,7 @@ import { IOutputChannel, IExtensionContext } from '../../platform/common/types'; import { STANDARD_OUTPUT_CHANNEL } from '../../platform/common/constants'; import { IFileSystem } from '../../platform/common/platform/types'; import { Cancellation } from '../../platform/common/cancellation'; -import { DEEPNOTE_TOOLKIT_VERSION } from './types'; +import { DEEPNOTE_TOOLKIT_VERSION } from '../../platform/common/constants'; /** * Manages a shared installation of deepnote-toolkit in a versioned extension directory. diff --git a/src/kernels/deepnote/deepnoteToolkitDependencyService.node.ts b/src/kernels/deepnote/deepnoteToolkitDependencyService.node.ts new file mode 100644 index 0000000000..11c3985475 --- /dev/null +++ b/src/kernels/deepnote/deepnoteToolkitDependencyService.node.ts @@ -0,0 +1,103 @@ +import { inject, injectable } from 'inversify'; +import { CancellationToken, CancellationTokenSource, commands, window } from 'vscode'; + +import { getDisplayPath } from '../../platform/common/platform/fs-paths.node'; +import { IDisposable, Resource } from '../../platform/common/types'; +import { Common, DataScience } from '../../platform/common/utils/localize'; +import { getPythonEnvDisplayName } from '../../platform/interpreter/helpers'; +import { ProductNames } from '../../platform/interpreter/installer/productNames'; +import { IInstaller, InstallerResponse, Product } from '../../platform/interpreter/installer/types'; +import { logger } from '../../platform/logging'; +import { PythonEnvironment } from '../../platform/pythonEnvironments/info'; +import { DeepnoteToolkitDependencyResponse, IDeepnoteToolkitDependencyService } from './types'; + +const SELECT_INTERPRETER_COMMAND = 'python.setInterpreter'; + +/** + * Asks for consent before installing deepnote-toolkit into the user's interpreter, mirroring + * `KernelDependencyService` — same prompt shape, same "cancel is not a failure" semantics. + * + * It cannot reuse that service directly: `installMissingDependencies` is keyed on a + * `KernelConnectionMetadata`, and a Deepnote connection cannot exist until the toolkit server is + * running and has reported the kernels it offers — which is precisely what this check gates. + */ +@injectable() +export class DeepnoteToolkitDependencyService implements IDeepnoteToolkitDependencyService { + constructor(@inject(IInstaller) private readonly installer: IInstaller) {} + + public async ensureToolkitInstalled( + interpreter: PythonEnvironment, + resource: Resource, + token: CancellationToken + ): Promise { + if (await this.installer.isInstalled(Product.deepnoteToolkit, interpreter)) { + return DeepnoteToolkitDependencyResponse.ok; + } + + if (token.isCancellationRequested) { + return DeepnoteToolkitDependencyResponse.cancel; + } + + const moduleName = ProductNames.get(Product.deepnoteToolkit)!; + const message = DataScience.libraryRequiredToLaunchJupyterKernelNotInstalledInterpreter( + getPythonEnvDisplayName(interpreter) || getDisplayPath(interpreter.uri), + moduleName + ); + const selectInterpreter = DataScience.selectDifferentPythonInterpreter; + + logger.info(`${moduleName} missing for ${getDisplayPath(resource)}, prompting to install`); + + const selection = await window.showInformationMessage( + message, + { modal: true }, + Common.install, + selectInterpreter + ); + + if (selection === selectInterpreter) { + await commands.executeCommand(SELECT_INTERPRETER_COMMAND); + + return DeepnoteToolkitDependencyResponse.selectDifferentInterpreter; + } + + if (selection !== Common.install) { + logger.info(`User declined to install ${moduleName}`); + + return DeepnoteToolkitDependencyResponse.cancel; + } + + return this.install(interpreter, moduleName, token); + } + + private async install( + interpreter: PythonEnvironment, + moduleName: string, + token: CancellationToken + ): Promise { + const cts = new CancellationTokenSource(); + let cancellationListener: IDisposable | undefined; + + try { + cancellationListener = token.onCancellationRequested(() => cts.cancel()); + + const result = await this.installer.install(Product.deepnoteToolkit, interpreter, cts); + + if (result === InstallerResponse.Installed) { + return DeepnoteToolkitDependencyResponse.ok; + } + + if (result === InstallerResponse.Cancelled || token.isCancellationRequested) { + logger.info(`${moduleName} installation cancelled`); + + return DeepnoteToolkitDependencyResponse.cancel; + } + + logger.error(`${moduleName} installation did not complete: ${InstallerResponse[result]}`); + + return DeepnoteToolkitDependencyResponse.failed; + } finally { + cancellationListener?.dispose(); + cts.dispose(); + } + } +} diff --git a/src/kernels/deepnote/deepnoteToolkitDependencyService.unit.test.ts b/src/kernels/deepnote/deepnoteToolkitDependencyService.unit.test.ts new file mode 100644 index 0000000000..8fa7668999 --- /dev/null +++ b/src/kernels/deepnote/deepnoteToolkitDependencyService.unit.test.ts @@ -0,0 +1,116 @@ +import { assert } from 'chai'; +import { PythonExtension } from '@vscode/python-extension'; +import * as sinon from 'sinon'; +import { anything, instance, mock, verify, when } from 'ts-mockito'; +import { Uri } from 'vscode'; + +import { setPythonApi } from '../../platform/interpreter/helpers'; +import { resolvableInstance } from '../../test/datascience/helpers'; + +import { IInstaller, InstallerResponse, Product } from '../../platform/interpreter/installer/types'; +import { PythonEnvironment } from '../../platform/pythonEnvironments/info'; +import { mockedVSCodeNamespaces, resetVSCodeMocks } from '../../test/vscode-mock'; +import { DeepnoteToolkitDependencyService } from './deepnoteToolkitDependencyService.node'; +import { DeepnoteToolkitDependencyResponse } from './types'; + +suite('DeepnoteToolkitDependencyService', () => { + const interpreter: PythonEnvironment = { + id: '/usr/bin/python3', + uri: Uri.file('/usr/bin/python3') + }; + const resource = Uri.file('/workspace/project/notebook.deepnote'); + const notCancelled = { isCancellationRequested: false, onCancellationRequested: () => ({ dispose: () => {} }) }; + + let installer: IInstaller; + let service: DeepnoteToolkitDependencyService; + + /** Makes the consent prompt resolve to `choice` (undefined = the user dismissed it). */ + function answerPrompt(choice: string | undefined) { + when( + mockedVSCodeNamespaces.window.showInformationMessage(anything(), anything(), anything(), anything()) + ).thenResolve(choice as never); + } + + setup(() => { + resetVSCodeMocks(); + installer = mock(); + service = new DeepnoteToolkitDependencyService(instance(installer)); + + // The prompt names the environment via getPythonEnvDisplayName, which reads the Python API. + const mockedApi = mock(); + sinon.stub(PythonExtension, 'api').resolves(resolvableInstance(mockedApi)); + const environments = mock(); + when(mockedApi.environments).thenReturn(instance(environments)); + when(environments.known).thenReturn([]); + setPythonApi(instance(mockedApi)); + }); + + teardown(() => { + setPythonApi(undefined as never); + sinon.restore(); + }); + + test('does not prompt when the toolkit is already installed', async () => { + when(installer.isInstalled(Product.deepnoteToolkit, anything())).thenResolve(true); + + const result = await service.ensureToolkitInstalled(interpreter, resource, notCancelled as never); + + assert.strictEqual(result, DeepnoteToolkitDependencyResponse.ok); + verify( + mockedVSCodeNamespaces.window.showInformationMessage(anything(), anything(), anything(), anything()) + ).never(); + verify(installer.install(anything(), anything(), anything())).never(); + }); + + test('installs only after the user consents', async () => { + when(installer.isInstalled(Product.deepnoteToolkit, anything())).thenResolve(false); + when(installer.install(anything(), anything(), anything())).thenResolve(InstallerResponse.Installed); + answerPrompt('Install'); + + const result = await service.ensureToolkitInstalled(interpreter, resource, notCancelled as never); + + assert.strictEqual(result, DeepnoteToolkitDependencyResponse.ok); + verify(installer.install(Product.deepnoteToolkit, anything(), anything())).once(); + }); + + test('does NOT install when the user dismisses the prompt', async () => { + when(installer.isInstalled(Product.deepnoteToolkit, anything())).thenResolve(false); + answerPrompt(undefined); + + const result = await service.ensureToolkitInstalled(interpreter, resource, notCancelled as never); + + assert.strictEqual(result, DeepnoteToolkitDependencyResponse.cancel); + verify(installer.install(anything(), anything(), anything())).never(); + }); + + test('does NOT install when the user opts to change interpreter, and opens the picker', async () => { + when(installer.isInstalled(Product.deepnoteToolkit, anything())).thenResolve(false); + answerPrompt('Select a different Interpreter'); + + const result = await service.ensureToolkitInstalled(interpreter, resource, notCancelled as never); + + assert.strictEqual(result, DeepnoteToolkitDependencyResponse.selectDifferentInterpreter); + verify(installer.install(anything(), anything(), anything())).never(); + verify(mockedVSCodeNamespaces.commands.executeCommand('python.setInterpreter')).once(); + }); + + test('reports a cancelled install as cancel, not failure', async () => { + when(installer.isInstalled(Product.deepnoteToolkit, anything())).thenResolve(false); + when(installer.install(anything(), anything(), anything())).thenResolve(InstallerResponse.Cancelled); + answerPrompt('Install'); + + const result = await service.ensureToolkitInstalled(interpreter, resource, notCancelled as never); + + assert.strictEqual(result, DeepnoteToolkitDependencyResponse.cancel); + }); + + test('reports an install that did not take as failed', async () => { + when(installer.isInstalled(Product.deepnoteToolkit, anything())).thenResolve(false); + when(installer.install(anything(), anything(), anything())).thenResolve(InstallerResponse.Ignore); + answerPrompt('Install'); + + const result = await service.ensureToolkitInstalled(interpreter, resource, notCancelled as never); + + assert.strictEqual(result, DeepnoteToolkitDependencyResponse.failed); + }); +}); diff --git a/src/kernels/deepnote/deepnoteToolkitInstaller.node.ts b/src/kernels/deepnote/deepnoteToolkitInstaller.node.ts index 842dca3301..0d1096f2a1 100644 --- a/src/kernels/deepnote/deepnoteToolkitInstaller.node.ts +++ b/src/kernels/deepnote/deepnoteToolkitInstaller.node.ts @@ -7,7 +7,11 @@ import { CancellationToken, l10n, Uri, workspace } from 'vscode'; import { resolvePythonExecutable } from '@deepnote/runtime-core'; import { Cancellation, isCancellationError } from '../../platform/common/cancellation'; -import { STANDARD_OUTPUT_CHANNEL } from '../../platform/common/constants'; +import { + DEEPNOTE_TOOLKIT_PACKAGES, + DEEPNOTE_TOOLKIT_VERSION, + STANDARD_OUTPUT_CHANNEL +} from '../../platform/common/constants'; import { IFileSystem } from '../../platform/common/platform/types'; import { ExecutionResult, IProcessServiceFactory } from '../../platform/common/process/types.node'; import { IExtensionContext, IOutputChannel } from '../../platform/common/types'; @@ -18,12 +22,7 @@ import { } from '../../platform/errors/deepnoteKernelErrors'; import { logger } from '../../platform/logging'; import { PythonEnvironment } from '../../platform/pythonEnvironments/info'; -import { - DEEPNOTE_TOOLKIT_PACKAGES, - DEEPNOTE_TOOLKIT_VERSION, - IDeepnoteToolkitInstaller, - VenvAndToolkitInstallation -} from './types'; +import { IDeepnoteToolkitInstaller, VenvAndToolkitInstallation } from './types'; /** * Handles installation of the deepnote-toolkit Python package. diff --git a/src/kernels/deepnote/environments/deepnoteEnvironment.ts b/src/kernels/deepnote/environments/deepnoteEnvironment.ts deleted file mode 100644 index aabff6a693..0000000000 --- a/src/kernels/deepnote/environments/deepnoteEnvironment.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { Uri } from 'vscode'; -import { PythonEnvironment } from '../../../platform/pythonEnvironments/info'; -import { DeepnoteServerInfo } from '../types'; - -/** - * Represents a Deepnote kernel environment. - * This is the runtime model with full objects. - */ -export interface DeepnoteEnvironment { - /** - * Unique identifier for this environment (UUID) - */ - id: string; - - /** - * User-friendly name for the environment - * Example: "Python 3.11 (Data Science)" - */ - name: string; - - /** - * Python interpreter to use for this kernel - */ - pythonInterpreter: PythonEnvironment; - - /** - * Path to the virtual environment for this environment - */ - venvPath: Uri; - - /** - * Whether the venv is managed by this extension (created by us). - * If true, the venv can be deleted when the environment is removed. - * If false, the venv is external and should be preserved. - */ - managedVenv: boolean; - - /** - * Server information (set when server is running) - */ - serverInfo?: DeepnoteServerInfo; - - /** - * Timestamp when this environment was created - */ - createdAt: Date; - - /** - * Timestamp when this environment was last used - */ - lastUsedAt: Date; - - /** - * Optional list of additional packages to install in the venv - */ - packages?: string[]; - - /** - * Version of deepnote-toolkit installed (if known) - */ - toolkitVersion?: string; - - /** - * Optional description for this environment - */ - description?: string; -} - -/** - * Serializable state for storing environments. - * Uses string paths instead of Uri objects for JSON serialization. - */ -export interface DeepnoteEnvironmentState { - id: string; - name: string; - pythonInterpreterPath: { - id: string; - uri: string; - }; - venvPath: string; - managedVenv?: boolean; - createdAt: string; - lastUsedAt: string; - packages?: string[]; - toolkitVersion?: string; - description?: string; -} - -/** - * Options for creating a new kernel environment - */ -export interface CreateDeepnoteEnvironmentOptions { - name: string; - pythonInterpreter: PythonEnvironment; - packages?: string[]; - description?: string; -} diff --git a/src/kernels/deepnote/environments/deepnoteEnvironmentManager.node.ts b/src/kernels/deepnote/environments/deepnoteEnvironmentManager.node.ts deleted file mode 100644 index a89ab3f44e..0000000000 --- a/src/kernels/deepnote/environments/deepnoteEnvironmentManager.node.ts +++ /dev/null @@ -1,298 +0,0 @@ -import { inject, injectable, named } from 'inversify'; -import * as path from '../../../platform/vscode-path/path'; -import { CancellationToken, EventEmitter, l10n, Uri } from 'vscode'; - -import { IExtensionSyncActivationService } from '../../../platform/activation/types'; -import { Cancellation } from '../../../platform/common/cancellation'; -import { STANDARD_OUTPUT_CHANNEL } from '../../../platform/common/constants'; -import { IFileSystem } from '../../../platform/common/platform/types'; -import { IProcessServiceFactory } from '../../../platform/common/process/types.node'; -import { IExtensionContext, IOutputChannel } from '../../../platform/common/types'; -import { generateUuid as uuid } from '../../../platform/common/uuid'; -import { logger } from '../../../platform/logging'; -import { IDeepnoteEnvironmentManager } from '../types'; -import { CreateDeepnoteEnvironmentOptions, DeepnoteEnvironment } from './deepnoteEnvironment'; -import { DeepnoteEnvironmentStorage } from './deepnoteEnvironmentStorage.node'; - -/** - * Manager for Deepnote kernel environments. - * Handles CRUD operations and server lifecycle management. - */ -@injectable() -export class DeepnoteEnvironmentManager implements IExtensionSyncActivationService, IDeepnoteEnvironmentManager { - private environments: Map = new Map(); - private readonly _onDidChangeEnvironments = new EventEmitter(); - public readonly onDidChangeEnvironments = this._onDidChangeEnvironments.event; - private initializationPromise: Promise | undefined; - - constructor( - @inject(IExtensionContext) private readonly context: IExtensionContext, - @inject(DeepnoteEnvironmentStorage) private readonly storage: DeepnoteEnvironmentStorage, - @inject(IOutputChannel) @named(STANDARD_OUTPUT_CHANNEL) private readonly outputChannel: IOutputChannel, - @inject(IFileSystem) private readonly fileSystem: IFileSystem, - @inject(IProcessServiceFactory) private readonly processServiceFactory: IProcessServiceFactory - ) {} - - /** - * Activate the service (called by VS Code on extension activation) - */ - public activate(): void { - // Store the initialization promise so other components can wait for it - this.initializationPromise = this.initialize().catch((error) => { - logger.error('Failed to activate environment manager', error); - const msg = error instanceof Error ? error.message : String(error); - this.outputChannel.appendLine(l10n.t('Failed to activate environment manager: {0}', msg)); - }); - } - - /** - * Initialize the manager by loading environments from storage - */ - public async initialize(): Promise { - try { - const configs = await this.storage.loadEnvironments(); - this.environments.clear(); - - let needsMigration = false; - - for (const config of configs) { - const venvDirName = path.basename(config.venvPath.fsPath); - - // Check if venv path is under current globalStorage - const expectedVenvParent = Uri.joinPath(this.context.globalStorageUri, 'deepnote-venvs').fsPath; - const actualVenvParent = path.dirname(config.venvPath.fsPath); - logger.info(`Actual venv parent: ${actualVenvParent}`); - logger.info(`Expected venv parent: ${expectedVenvParent}`); - const isInCorrectStorage = actualVenvParent === expectedVenvParent; - logger.info(`Is in correct storage: ${isInCorrectStorage}`); - logger.info(`Managed venv: ${config.managedVenv}`); - - // Check if directory name matches the environment ID and is in correct storage - const isExpectedPath = venvDirName === config.id && isInCorrectStorage; - const needsPathMigration = !isExpectedPath && config.managedVenv === true; - - if (needsPathMigration) { - logger.info( - `Migrating environment "${config.name}" from ${config.venvPath.fsPath} to ID-based path` - ); - - config.venvPath = Uri.joinPath(this.context.globalStorageUri, 'deepnote-venvs', config.id); - config.toolkitVersion = undefined; - - logger.info(`New venv path: ${config.venvPath.fsPath} (will be recreated on next use)`); - needsMigration = true; - } - - this.environments.set(config.id, config); - } - - if (needsMigration) { - logger.info('Saving migrated environments to storage'); - await this.persistEnvironments(); - } - - logger.info(`Initialized environment manager with ${this.environments.size} environments`); - - // Fire event to notify tree view of loaded environments - this._onDidChangeEnvironments.fire(); - } catch (error) { - logger.error('Failed to initialize environment manager', error); - } - } - - /** - * Wait for initialization to complete - */ - public async waitForInitialization(): Promise { - if (this.initializationPromise) { - await this.initializationPromise; - } - } - - /** - * Create a new kernel environment - */ - public async createEnvironment( - options: CreateDeepnoteEnvironmentOptions, - token?: CancellationToken - ): Promise { - Cancellation.throwIfCanceled(token); - - const id = uuid(); - - // Check if the Python interpreter is already in a virtual environment - const existingVenvPath = await this.getVenvPathIfInVenv(options.pythonInterpreter.uri); - logger.info(`Existing venv path: ${existingVenvPath?.fsPath}`); - const venvPath = existingVenvPath ?? Uri.joinPath(this.context.globalStorageUri, 'deepnote-venvs', id); - logger.info(`Venv path: ${venvPath.fsPath}`); - - const environment: DeepnoteEnvironment = { - id, - name: options.name, - pythonInterpreter: options.pythonInterpreter, - managedVenv: existingVenvPath == null, - venvPath, - createdAt: new Date(), - lastUsedAt: new Date(), - packages: options.packages, - description: options.description - }; - - Cancellation.throwIfCanceled(token); - - this.environments.set(id, environment); - await this.persistEnvironments(); - this._onDidChangeEnvironments.fire(); - - logger.info(`Created new environment: ${environment.name} (${id})`); - return environment; - } - - /** - * Get all environments - */ - public listEnvironments(): DeepnoteEnvironment[] { - return Array.from(this.environments.values()); - } - - /** - * Get a specific environment by ID - */ - public getEnvironment(id: string): DeepnoteEnvironment | undefined { - return this.environments.get(id); - } - - /** - * Update an environment's metadata - */ - public async updateEnvironment( - id: string, - updates: Partial> - ): Promise { - const config = this.environments.get(id); - if (!config) { - throw new Error(l10n.t('Environment not found: {0}', id)); - } - - if (updates.name !== undefined) { - config.name = updates.name; - } - if (updates.packages !== undefined) { - config.packages = updates.packages; - } - if (updates.description !== undefined) { - config.description = updates.description; - } - - await this.persistEnvironments(); - this._onDidChangeEnvironments.fire(); - - logger.info(`Updated environment: ${config.name} (${id})`); - } - - /** - * Delete an environment - */ - public async deleteEnvironment(id: string, token?: CancellationToken): Promise { - Cancellation.throwIfCanceled(token); - - const config = this.environments.get(id); - if (!config) { - throw new Error(`Environment not found: ${id}`); - } - - Cancellation.throwIfCanceled(token); - - // Only delete the virtual environment directory if it was created by us (managed venv) - // This prevents accidental deletion of user's original virtual environments - if (config.managedVenv) { - try { - await this.fileSystem.delete(config.venvPath); - logger.info(`Deleted virtual environment directory: ${config.venvPath.fsPath}`); - } catch (error) { - // Log but don't fail - the directory might not exist or might already be deleted - logger.warn(`Failed to delete virtual environment directory: ${config.venvPath.fsPath}`, error); - const msg = error instanceof Error ? error.message : String(error); - this.outputChannel.appendLine( - l10n.t('Failed to delete Deepnote virtual environment directory for "{0}": {1}', config.name, msg) - ); - } - } else { - logger.info(`Skipping deletion of external virtual environment: ${config.venvPath.fsPath}`); - } - - Cancellation.throwIfCanceled(token); - - this.environments.delete(id); - await this.persistEnvironments(); - this._onDidChangeEnvironments.fire(); - - logger.info(`Deleted environment: ${config.name} (${id})`); - } - - /** - * Update the last used timestamp for an environment - */ - public async updateLastUsed(id: string): Promise { - const config = this.environments.get(id); - if (!config) { - return; - } - - config.lastUsedAt = new Date(); - await this.persistEnvironments(); - this._onDidChangeEnvironments.fire(); - } - - /** - * Check if a Python binary is inside a virtual environment by executing it. - * Returns the venv root path if it is, otherwise returns undefined. - * - * Detection is based on comparing sys.prefix vs sys.base_prefix: - * - In a virtual environment, sys.prefix points to the venv directory - * - sys.base_prefix points to the original Python installation - * - If they differ, Python is running inside a venv - */ - private async getVenvPathIfInVenv(pythonUri: Uri): Promise { - try { - const processService = await this.processServiceFactory.create(undefined); - - // Execute Python to check if sys.prefix differs from sys.base_prefix - // If they differ, we're in a virtual environment - // Output format: "is_venv|prefix_path" where is_venv is 1 or 0 - const result = await processService.exec( - pythonUri.fsPath, - ['-c', 'import sys; print("1" if sys.prefix != sys.base_prefix else "0", sys.prefix, sep="|")'], - { timeout: 5000 } - ); - - const output = result.stdout.trim(); - const [isVenv, prefixPath] = output.split('|'); - - if (isVenv === '1' && prefixPath) { - logger.info(`Detected existing virtual environment at: ${prefixPath}`); - return Uri.file(prefixPath); - } - - return undefined; - } catch (ex) { - logger.warn('Failed to check if Python is in a virtual environment', ex); - return undefined; - } - } - - /** - * Persist all environments to storage - */ - private async persistEnvironments(): Promise { - const configs = Array.from(this.environments.values()); - await this.storage.saveEnvironments(configs); - } - - /** - * Dispose of all resources - */ - public dispose(): void { - this._onDidChangeEnvironments.dispose(); - } -} diff --git a/src/kernels/deepnote/environments/deepnoteEnvironmentManager.unit.test.ts b/src/kernels/deepnote/environments/deepnoteEnvironmentManager.unit.test.ts deleted file mode 100644 index 381e9c9d9a..0000000000 --- a/src/kernels/deepnote/environments/deepnoteEnvironmentManager.unit.test.ts +++ /dev/null @@ -1,500 +0,0 @@ -import { assert, use } from 'chai'; -import chaiAsPromised from 'chai-as-promised'; -import { anything, instance, mock, when, verify } from 'ts-mockito'; -import { Uri } from 'vscode'; -import * as fs from 'fs'; -import * as os from 'os'; - -import { DeepnoteEnvironmentManager } from './deepnoteEnvironmentManager.node'; -import { DeepnoteEnvironmentStorage } from './deepnoteEnvironmentStorage.node'; -import { IFileSystem } from '../../../platform/common/platform/types'; -import { IProcessService, IProcessServiceFactory } from '../../../platform/common/process/types.node'; -import { IExtensionContext, IOutputChannel } from '../../../platform/common/types'; -import { PythonEnvironment } from '../../../platform/pythonEnvironments/info'; - -use(chaiAsPromised); - -suite('DeepnoteEnvironmentManager', () => { - let manager: DeepnoteEnvironmentManager; - let mockContext: IExtensionContext; - let mockStorage: DeepnoteEnvironmentStorage; - let mockOutputChannel: IOutputChannel; - let mockFileSystem: IFileSystem; - let mockProcessServiceFactory: IProcessServiceFactory; - let mockProcessService: IProcessService; - let testGlobalStoragePath: string; - - const testInterpreter: PythonEnvironment = { - id: 'test-python-id', - uri: Uri.file('/usr/bin/python3'), - version: { major: 3, minor: 11, patch: 0, raw: '3.11.0' } - } as PythonEnvironment; - - setup(() => { - mockContext = mock(); - mockStorage = mock(); - mockOutputChannel = mock(); - mockFileSystem = mock(); - mockProcessServiceFactory = mock(); - mockProcessService = mock(); - - // Create a temporary directory for test storage - testGlobalStoragePath = fs.mkdtempSync(`${os.tmpdir()}/deepnote-test-`); - - when(mockContext.globalStorageUri).thenReturn(Uri.file(testGlobalStoragePath)); - when(mockStorage.loadEnvironments()).thenResolve([]); - when(mockStorage.saveEnvironments(anything())).thenResolve(); - when(mockOutputChannel.appendLine(anything())).thenReturn(); - - // Configure mockFileSystem to actually delete directories for testing - when(mockFileSystem.delete(anything())).thenCall((uri: Uri) => { - const dirPath = uri.fsPath; - if (fs.existsSync(dirPath)) { - fs.rmSync(dirPath, { recursive: true, force: true }); - } - return Promise.resolve(); - }); - - // Configure mock process service to make getVenvPathIfInVenv return undefined - // (stdout starts with '0' means "not in a virtual environment") - when(mockProcessServiceFactory.create(anything(), anything())).thenResolve(instance(mockProcessService)); - when(mockProcessService.exec(anything(), anything(), anything())).thenResolve({ - stdout: '0|/usr/lib/python3', - stderr: '' - }); - - manager = new DeepnoteEnvironmentManager( - instance(mockContext), - instance(mockStorage), - instance(mockOutputChannel), - instance(mockFileSystem), - instance(mockProcessServiceFactory) - ); - }); - - teardown(() => { - // Clean up the temporary directory after each test - if (testGlobalStoragePath && fs.existsSync(testGlobalStoragePath)) { - fs.rmSync(testGlobalStoragePath, { recursive: true, force: true }); - } - }); - - suite('activate', () => { - test('should load environments on activation', async () => { - const existingConfigs = [ - { - id: 'existing-config', - name: 'Existing', - pythonInterpreter: testInterpreter, - venvPath: Uri.file('/path/to/venv'), - managedVenv: true, - createdAt: new Date(), - lastUsedAt: new Date() - } - ]; - - when(mockStorage.loadEnvironments()).thenResolve(existingConfigs); - - manager.activate(); - // Wait for async initialization - await manager.waitForInitialization(); - - const configs = manager.listEnvironments(); - assert.strictEqual(configs.length, 1); - assert.strictEqual(configs[0].id, 'existing-config'); - }); - }); - - suite('createEnvironment', () => { - test('should create a new kernel environment', async () => { - const config = await manager.createEnvironment({ - name: 'Test Config', - pythonInterpreter: testInterpreter, - packages: ['numpy'], - description: 'Test description' - }); - - assert.strictEqual(config.name, 'Test Config'); - assert.strictEqual(config.pythonInterpreter, testInterpreter); - assert.deepStrictEqual(config.packages, ['numpy']); - assert.strictEqual(config.description, 'Test description'); - assert.ok(config.id); - assert.ok(config.venvPath); - assert.ok(config.createdAt); - assert.ok(config.lastUsedAt); - - verify(mockStorage.saveEnvironments(anything())).once(); - }); - - test('should generate unique IDs for each environment', async () => { - const config1 = await manager.createEnvironment({ - name: 'Config 1', - pythonInterpreter: testInterpreter - }); - - const config2 = await manager.createEnvironment({ - name: 'Config 2', - pythonInterpreter: testInterpreter - }); - - assert.notEqual(config1.id, config2.id); - }); - - test('should fire onDidChangeEnvironments event', async () => { - let eventFired = false; - manager.onDidChangeEnvironments(() => { - eventFired = true; - }); - - await manager.createEnvironment({ - name: 'Test', - pythonInterpreter: testInterpreter - }); - - assert.isTrue(eventFired); - }); - }); - - suite('listEnvironments', () => { - test('should return empty array initially', () => { - const configs = manager.listEnvironments(); - assert.deepStrictEqual(configs, []); - }); - - test('should return all created environments', async () => { - await manager.createEnvironment({ name: 'Config 1', pythonInterpreter: testInterpreter }); - await manager.createEnvironment({ name: 'Config 2', pythonInterpreter: testInterpreter }); - - const configs = manager.listEnvironments(); - assert.strictEqual(configs.length, 2); - }); - }); - - suite('getEnvironment', () => { - test('should return undefined for non-existent ID', () => { - const config = manager.getEnvironment('non-existent'); - assert.isUndefined(config); - }); - - test('should return environment by ID', async () => { - const created = await manager.createEnvironment({ - name: 'Test', - pythonInterpreter: testInterpreter - }); - - const found = manager.getEnvironment(created.id); - assert.strictEqual(found?.id, created.id); - assert.strictEqual(found?.name, 'Test'); - }); - }); - - suite('updateEnvironment', () => { - test('should update environment name', async () => { - const config = await manager.createEnvironment({ - name: 'Original Name', - pythonInterpreter: testInterpreter - }); - - await manager.updateEnvironment(config.id, { name: 'Updated Name' }); - - const updated = manager.getEnvironment(config.id); - assert.strictEqual(updated?.name, 'Updated Name'); - verify(mockStorage.saveEnvironments(anything())).atLeast(1); - }); - - test('should update packages', async () => { - const config = await manager.createEnvironment({ - name: 'Test', - pythonInterpreter: testInterpreter, - packages: ['numpy'] - }); - - await manager.updateEnvironment(config.id, { packages: ['numpy', 'pandas'] }); - - const updated = manager.getEnvironment(config.id); - assert.deepStrictEqual(updated?.packages, ['numpy', 'pandas']); - verify(mockStorage.saveEnvironments(anything())).atLeast(1); - }); - - test('should throw error for non-existent environment', async () => { - await assert.isRejected( - manager.updateEnvironment('non-existent', { name: 'Test' }), - 'Environment not found: non-existent' - ); - }); - - test('should fire onDidChangeEnvironments event', async () => { - const config = await manager.createEnvironment({ - name: 'Test', - pythonInterpreter: testInterpreter - }); - - let eventFired = false; - manager.onDidChangeEnvironments(() => { - eventFired = true; - }); - - await manager.updateEnvironment(config.id, { name: 'Updated' }); - - assert.isTrue(eventFired); - }); - }); - - suite('deleteEnvironment', () => { - test('should delete environment', async () => { - const config = await manager.createEnvironment({ - name: 'Test', - pythonInterpreter: testInterpreter - }); - - await manager.deleteEnvironment(config.id); - - const deleted = manager.getEnvironment(config.id); - assert.isUndefined(deleted); - verify(mockStorage.saveEnvironments(anything())).atLeast(1); - }); - - test('should throw error for non-existent environment', async () => { - await assert.isRejected(manager.deleteEnvironment('non-existent'), 'Environment not found: non-existent'); - }); - - test('should delete virtual environment directory from disk', async () => { - const config = await manager.createEnvironment({ - name: 'Test', - pythonInterpreter: testInterpreter - }); - - // Create the virtual environment directory to simulate it existing - const venvDirPath = config.venvPath.fsPath; - fs.mkdirSync(venvDirPath, { recursive: true }); - - // Create a dummy file inside to make it a "real" directory - fs.writeFileSync(`${venvDirPath}/test.txt`, 'test content'); - - // Verify directory and file exist before deletion - assert.isTrue(fs.existsSync(venvDirPath), 'Directory should exist before deletion'); - assert.isTrue(fs.existsSync(`${venvDirPath}/test.txt`), 'File should exist before deletion'); - - // Delete the environment - await manager.deleteEnvironment(config.id); - - // Verify directory no longer exists - assert.isFalse(fs.existsSync(venvDirPath), 'Directory should not exist after deletion'); - }); - }); - - suite('updateLastUsed', () => { - test('should update lastUsedAt timestamp', async () => { - const config = await manager.createEnvironment({ - name: 'Test', - pythonInterpreter: testInterpreter - }); - - const originalLastUsed = config.lastUsedAt; - await new Promise((resolve) => setTimeout(resolve, 10)); - await manager.updateLastUsed(config.id); - - const updated = manager.getEnvironment(config.id); - assert.isTrue(updated!.lastUsedAt > originalLastUsed); - }); - - test('should do nothing for non-existent environment', async () => { - await manager.updateLastUsed('non-existent'); - // Should not throw - }); - }); - - suite('dispose', () => { - test('should dispose event emitter', () => { - manager.dispose(); - // Should not throw - }); - }); - - suite('environment migration', () => { - test('should migrate hash-based venv paths to UUID-based paths', async () => { - const oldHashBasedConfig = { - id: 'abcd1234-5678-90ab-cdef-123456789012', - name: 'Old Hash Config', - pythonInterpreter: testInterpreter, - venvPath: Uri.file('/global/storage/deepnote-venvs/venv_7626587d-1.0.0'), - managedVenv: true, - createdAt: new Date(), - lastUsedAt: new Date() - }; - - when(mockStorage.loadEnvironments()).thenResolve([oldHashBasedConfig]); - when(mockContext.globalStorageUri).thenReturn(Uri.file('/global/storage')); - - manager.activate(); - await manager.waitForInitialization(); - - const configs = manager.listEnvironments(); - assert.strictEqual(configs.length, 1); - - // Should have migrated to UUID-based path - assert.strictEqual( - configs[0].venvPath.fsPath, - '/global/storage/deepnote-venvs/abcd1234-5678-90ab-cdef-123456789012' - ); - - // Should clear toolkit version to force reinstallation - assert.isUndefined(configs[0].toolkitVersion); - - // Should have saved the migration - verify(mockStorage.saveEnvironments(anything())).once(); - }); - - test('should migrate VS Code storage paths to Cursor storage paths', async () => { - const vsCodeConfig = { - id: 'cursor-env-id', - name: 'VS Code Environment', - pythonInterpreter: testInterpreter, - venvPath: Uri.file( - '/Library/Application Support/Code/User/globalStorage/deepnote.vscode-deepnote/deepnote-venvs/cursor-env-id' - ), - managedVenv: true, - createdAt: new Date(), - lastUsedAt: new Date(), - toolkitVersion: '1.0.0' - }; - - when(mockStorage.loadEnvironments()).thenResolve([vsCodeConfig]); - when(mockContext.globalStorageUri).thenReturn( - Uri.file('/Library/Application Support/Cursor/User/globalStorage/deepnote.vscode-deepnote') - ); - - manager.activate(); - await manager.waitForInitialization(); - - const configs = manager.listEnvironments(); - assert.strictEqual(configs.length, 1); - - // Should have migrated to Cursor storage - assert.match(configs[0].venvPath.fsPath, /Cursor.*deepnote-venvs\/cursor-env-id$/); - - // Should clear toolkit version to force reinstallation - assert.isUndefined(configs[0].toolkitVersion); - - verify(mockStorage.saveEnvironments(anything())).once(); - }); - - test('should not migrate environments with correct ID-based paths in correct storage', async () => { - const testDate = new Date(); - const correctConfig = { - id: '12345678-1234-1234-1234-123456789abc', - name: 'Correct Config', - pythonInterpreter: testInterpreter, - venvPath: Uri.file('/global/storage/deepnote-venvs/12345678-1234-1234-1234-123456789abc'), - managedVenv: true, - createdAt: testDate, - lastUsedAt: testDate, - toolkitVersion: '1.0.0', - packages: [] - }; - - when(mockStorage.loadEnvironments()).thenResolve([correctConfig]); - when(mockContext.globalStorageUri).thenReturn(Uri.file('/global/storage')); - - manager.activate(); - await manager.waitForInitialization(); - - const configs = manager.listEnvironments(); - assert.strictEqual(configs.length, 1); - - // Path should remain unchanged - assert.strictEqual( - configs[0].venvPath.fsPath, - '/global/storage/deepnote-venvs/12345678-1234-1234-1234-123456789abc' - ); - - // ID and name should be preserved - assert.strictEqual(configs[0].id, '12345678-1234-1234-1234-123456789abc'); - assert.strictEqual(configs[0].name, 'Correct Config'); - - // Should NOT have saved (no migration needed) - verify(mockStorage.saveEnvironments(anything())).never(); - }); - - test('should not migrate environments with non-UUID IDs when path already matches', async () => { - const testDate = new Date(); - const customIdConfig = { - id: 'my-custom-env-id', - name: 'Custom ID Environment', - pythonInterpreter: testInterpreter, - venvPath: Uri.file('/global/storage/deepnote-venvs/my-custom-env-id'), - managedVenv: true, - createdAt: testDate, - lastUsedAt: testDate, - toolkitVersion: '1.0.0' - }; - - when(mockStorage.loadEnvironments()).thenResolve([customIdConfig]); - when(mockContext.globalStorageUri).thenReturn(Uri.file('/global/storage')); - - manager.activate(); - await manager.waitForInitialization(); - - const configs = manager.listEnvironments(); - assert.strictEqual(configs.length, 1); - - // Path should remain unchanged - assert.strictEqual(configs[0].venvPath.fsPath, '/global/storage/deepnote-venvs/my-custom-env-id'); - - // Toolkit version should NOT be cleared - assert.strictEqual(configs[0].toolkitVersion, '1.0.0'); - - // Should NOT have saved (no migration needed) - verify(mockStorage.saveEnvironments(anything())).never(); - }); - - test('should migrate multiple environments at once', async () => { - const configs = [ - { - id: 'uuid1', - name: 'Hash Config', - pythonInterpreter: testInterpreter, - venvPath: Uri.file('/global/storage/deepnote-venvs/venv_abc123-1.0.0'), - managedVenv: true, - createdAt: new Date(), - lastUsedAt: new Date() - }, - { - id: 'uuid2', - name: 'VS Code Config', - pythonInterpreter: testInterpreter, - venvPath: Uri.file('/Code/globalStorage/deepnote-venvs/uuid2'), - managedVenv: true, - createdAt: new Date(), - lastUsedAt: new Date() - }, - { - id: 'uuid3', - name: 'Correct Config', - pythonInterpreter: testInterpreter, - venvPath: Uri.file('/global/storage/deepnote-venvs/uuid3'), - managedVenv: true, - createdAt: new Date(), - lastUsedAt: new Date() - } - ]; - - when(mockStorage.loadEnvironments()).thenResolve(configs); - when(mockContext.globalStorageUri).thenReturn(Uri.file('/global/storage')); - - manager.activate(); - await manager.waitForInitialization(); - - const loaded = manager.listEnvironments(); - assert.strictEqual(loaded.length, 3); - - // First two should be migrated - assert.strictEqual(loaded[0].venvPath.fsPath, '/global/storage/deepnote-venvs/uuid1'); - assert.strictEqual(loaded[1].venvPath.fsPath, '/global/storage/deepnote-venvs/uuid2'); - // Third should remain unchanged - assert.strictEqual(loaded[2].venvPath.fsPath, '/global/storage/deepnote-venvs/uuid3'); - - verify(mockStorage.saveEnvironments(anything())).once(); - }); - }); -}); diff --git a/src/kernels/deepnote/environments/deepnoteEnvironmentStorage.node.ts b/src/kernels/deepnote/environments/deepnoteEnvironmentStorage.node.ts deleted file mode 100644 index 887604df78..0000000000 --- a/src/kernels/deepnote/environments/deepnoteEnvironmentStorage.node.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { injectable, inject } from 'inversify'; -import { Memento, Uri } from 'vscode'; -import { IExtensionContext } from '../../../platform/common/types'; -import { PythonEnvironment } from '../../../platform/pythonEnvironments/info'; -import { logger } from '../../../platform/logging'; -import { DeepnoteEnvironment, DeepnoteEnvironmentState } from './deepnoteEnvironment'; - -const STORAGE_KEY = 'deepnote.kernelEnvironments'; - -/** - * Service for persisting and loading environments from global storage. - */ -@injectable() -export class DeepnoteEnvironmentStorage { - private readonly globalState: Memento; - - constructor(@inject(IExtensionContext) context: IExtensionContext) { - this.globalState = context.globalState; - } - - /** - * Load all environments from storage - */ - public async loadEnvironments(): Promise { - try { - const states = this.globalState.get(STORAGE_KEY, []); - const environments: DeepnoteEnvironment[] = []; - - for (const state of states) { - const config = this.deserializeEnvironment(state); - if (config) { - environments.push(config); - } else { - logger.error(`Failed to deserialize environment: ${state.id}`); - } - } - - logger.info(`Loaded ${environments.length} environments from storage`); - return environments; - } catch (error) { - logger.error('Failed to load environments', error); - return []; - } - } - - /** - * Save all environments to storage - */ - public async saveEnvironments(environments: DeepnoteEnvironment[]): Promise { - try { - const states = environments.map((config) => this.serializeEnvironment(config)); - await this.globalState.update(STORAGE_KEY, states); - logger.info(`Saved ${environments.length} environments to storage`); - } catch (error) { - logger.error('Failed to save environments', error); - throw error; - } - } - - /** - * Serialize an environment to a storable state - */ - private serializeEnvironment(config: DeepnoteEnvironment): DeepnoteEnvironmentState { - return { - id: config.id, - name: config.name, - pythonInterpreterPath: { - id: config.pythonInterpreter.id, - uri: config.pythonInterpreter.uri.toString(true) - }, - venvPath: config.venvPath.toString(true), - managedVenv: config.managedVenv, - createdAt: config.createdAt.toISOString(), - lastUsedAt: config.lastUsedAt.toISOString(), - packages: config.packages, - toolkitVersion: config.toolkitVersion, - description: config.description - }; - } - - /** - * Deserialize a stored state back to an environment - */ - private deserializeEnvironment(state: DeepnoteEnvironmentState): DeepnoteEnvironment | undefined { - try { - // Create PythonEnvironment directly from stored path - // No need to resolve through interpreter service - we just need the path - const interpreter: PythonEnvironment = { - uri: Uri.parse(state.pythonInterpreterPath.uri), - id: state.pythonInterpreterPath.id - }; - - return { - id: state.id, - name: state.name, - pythonInterpreter: interpreter, - venvPath: Uri.parse(state.venvPath), - managedVenv: state.managedVenv ?? true, - createdAt: new Date(state.createdAt), - lastUsedAt: new Date(state.lastUsedAt), - packages: state.packages, - toolkitVersion: state.toolkitVersion, - description: state.description, - serverInfo: undefined // Don't persist server info across sessions - }; - } catch (error) { - logger.error(`Failed to deserialize environment ${state.id}`, error); - return undefined; - } - } - - /** - * Clear all environments from storage - */ - public async clearEnvironments(): Promise { - try { - await this.globalState.update(STORAGE_KEY, []); - logger.info('Cleared all environments from storage'); - } catch (error) { - logger.error('Failed to clear environments', error); - throw error; - } - } -} diff --git a/src/kernels/deepnote/environments/deepnoteEnvironmentStorage.unit.test.ts b/src/kernels/deepnote/environments/deepnoteEnvironmentStorage.unit.test.ts deleted file mode 100644 index 7230744b0e..0000000000 --- a/src/kernels/deepnote/environments/deepnoteEnvironmentStorage.unit.test.ts +++ /dev/null @@ -1,224 +0,0 @@ -import { assert, use } from 'chai'; -import { anything, instance, mock, when, verify, deepEqual } from 'ts-mockito'; -import { Memento, Uri } from 'vscode'; -import { DeepnoteEnvironmentStorage } from './deepnoteEnvironmentStorage.node'; -import { IExtensionContext } from '../../../platform/common/types'; -import { IInterpreterService } from '../../../platform/interpreter/contracts'; -import { PythonEnvironment } from '../../../platform/pythonEnvironments/info'; -import { DeepnoteEnvironmentState } from './deepnoteEnvironment'; -import chaiAsPromised from 'chai-as-promised'; - -use(chaiAsPromised); - -suite('DeepnoteEnvironmentStorage', () => { - let storage: DeepnoteEnvironmentStorage; - let mockContext: IExtensionContext; - let mockInterpreterService: IInterpreterService; - let mockGlobalState: Memento; - - const testInterpreter: PythonEnvironment = { - id: 'test-python-id', - uri: Uri.file('/usr/bin/python3') - }; - - setup(() => { - mockContext = mock(); - mockInterpreterService = mock(); - mockGlobalState = mock(); - - when(mockGlobalState.update(anything(), anything())).thenResolve(); - when(mockContext.globalState).thenReturn(instance(mockGlobalState) as any); - - storage = new DeepnoteEnvironmentStorage(instance(mockContext)); - }); - - suite('loadEnvironments', () => { - test('should return empty array when no environments are stored', async () => { - when(mockGlobalState.get('deepnote.kernelEnvironments', anything())).thenReturn([]); - - const configs = await storage.loadEnvironments(); - - assert.deepStrictEqual(configs, []); - }); - - test('should load and deserialize stored environments', async () => { - const storedState: DeepnoteEnvironmentState = { - id: 'config-1', - name: 'Test Config', - pythonInterpreterPath: { - id: 'test-python-id', - uri: '/usr/bin/python3' - }, - venvPath: '/path/to/venv', - createdAt: '2025-01-01T00:00:00.000Z', - lastUsedAt: '2025-01-01T00:00:00.000Z', - packages: ['numpy', 'pandas'], - toolkitVersion: '0.2.30', - description: 'Test environment' - }; - - when(mockGlobalState.get('deepnote.kernelEnvironments', anything())).thenReturn([storedState]); - when(mockInterpreterService.getInterpreterDetails(anything())).thenResolve(testInterpreter); - - const configs = await storage.loadEnvironments(); - - assert.strictEqual(configs.length, 1); - assert.strictEqual(configs[0].id, 'config-1'); - assert.strictEqual(configs[0].name, 'Test Config'); - const expectedInterpreterFsPath = Uri.file(storedState.pythonInterpreterPath.uri).fsPath; - const expectedVenvFsPath = Uri.file(storedState.venvPath).fsPath; - assert.strictEqual(configs[0].pythonInterpreter.uri.fsPath, expectedInterpreterFsPath); - assert.strictEqual(configs[0].venvPath.fsPath, expectedVenvFsPath); - assert.deepStrictEqual(configs[0].packages, ['numpy', 'pandas']); - assert.strictEqual(configs[0].toolkitVersion, '0.2.30'); - assert.strictEqual(configs[0].description, 'Test environment'); - }); - - test('should load all environments including those with potentially invalid paths', async () => { - const storedStates: DeepnoteEnvironmentState[] = [ - { - id: 'config-1', - name: 'Valid Config', - pythonInterpreterPath: { - id: 'test-python-id', - uri: '/usr/bin/python3' - }, - venvPath: '/path/to/venv1', - createdAt: '2025-01-01T00:00:00.000Z', - lastUsedAt: '2025-01-01T00:00:00.000Z' - }, - { - id: 'config-2', - name: 'Potentially Invalid Config', - pythonInterpreterPath: { - id: 'test-python-id', - uri: '/invalid/python' - }, - venvPath: '/path/to/venv2', - createdAt: '2025-01-01T00:00:00.000Z', - lastUsedAt: '2025-01-01T00:00:00.000Z' - } - ]; - - when(mockGlobalState.get('deepnote.kernelEnvironments', anything())).thenReturn(storedStates); - - const configs = await storage.loadEnvironments(); - - // All environments should be loaded - interpreter validation happens at usage time, not load time - assert.strictEqual(configs.length, 2); - assert.strictEqual(configs[0].id, 'config-1'); - assert.strictEqual(configs[1].id, 'config-2'); - }); - - test('should handle errors gracefully and return empty array', async () => { - when(mockGlobalState.get('deepnote.kernelEnvironments', anything())).thenThrow(new Error('Storage error')); - - const configs = await storage.loadEnvironments(); - - assert.deepStrictEqual(configs, []); - }); - }); - - suite('saveEnvironments', () => { - test('should serialize and save environments', async () => { - const config = { - id: 'config-1', - name: 'Test Config', - pythonInterpreter: testInterpreter, - venvPath: Uri.file('/path/to/venv'), - managedVenv: true, - createdAt: new Date('2025-01-01T00:00:00.000Z'), - lastUsedAt: new Date('2025-01-01T00:00:00.000Z'), - packages: ['numpy'], - toolkitVersion: '0.2.30', - description: 'Test' - }; - - await storage.saveEnvironments([config]); - - verify( - mockGlobalState.update( - 'deepnote.kernelEnvironments', - deepEqual([ - { - id: 'config-1', - name: 'Test Config', - pythonInterpreterPath: { - id: 'test-python-id', - uri: 'file:///usr/bin/python3' - }, - venvPath: 'file:///path/to/venv', - managedVenv: true, - createdAt: '2025-01-01T00:00:00.000Z', - lastUsedAt: '2025-01-01T00:00:00.000Z', - packages: ['numpy'], - toolkitVersion: '0.2.30', - description: 'Test' - } - ]) - ) - ).once(); - }); - - test('should save multiple environments', async () => { - const configs = [ - { - id: 'config-1', - name: 'Config 1', - pythonInterpreter: testInterpreter, - venvPath: Uri.file('/path/to/venv1'), - managedVenv: true, - createdAt: new Date('2025-01-01T00:00:00.000Z'), - lastUsedAt: new Date('2025-01-01T00:00:00.000Z') - }, - { - id: 'config-2', - name: 'Config 2', - pythonInterpreter: testInterpreter, - venvPath: Uri.file('/path/to/venv2'), - managedVenv: true, - createdAt: new Date('2025-01-02T00:00:00.000Z'), - lastUsedAt: new Date('2025-01-02T00:00:00.000Z') - } - ]; - - when(mockGlobalState.update(anything(), anything())).thenResolve(); - - await storage.saveEnvironments(configs); - - verify(mockGlobalState.update('deepnote.kernelEnvironments', anything())).once(); - }); - - test('should throw error if storage update fails', async () => { - const config = { - id: 'config-1', - name: 'Test Config', - pythonInterpreter: testInterpreter, - venvPath: Uri.file('/path/to/venv'), - managedVenv: true, - createdAt: new Date(), - lastUsedAt: new Date() - }; - - when(mockGlobalState.update(anything(), anything())).thenReject(new Error('Storage error')); - - await assert.isRejected(storage.saveEnvironments([config]), 'Storage error'); - }); - }); - - suite('clearEnvironments', () => { - test('should clear all stored environments', async () => { - when(mockGlobalState.update(anything(), anything())).thenResolve(); - - await storage.clearEnvironments(); - - verify(mockGlobalState.update('deepnote.kernelEnvironments', deepEqual([]))).once(); - }); - - test('should throw error if clear fails', async () => { - when(mockGlobalState.update(anything(), anything())).thenReject(new Error('Storage error')); - - await assert.isRejected(storage.clearEnvironments(), 'Storage error'); - }); - }); -}); diff --git a/src/kernels/deepnote/environments/deepnoteEnvironmentTreeDataProvider.node.ts b/src/kernels/deepnote/environments/deepnoteEnvironmentTreeDataProvider.node.ts deleted file mode 100644 index a63f05c34c..0000000000 --- a/src/kernels/deepnote/environments/deepnoteEnvironmentTreeDataProvider.node.ts +++ /dev/null @@ -1,155 +0,0 @@ -import { Disposable, Event, EventEmitter, TreeDataProvider, TreeItem } from 'vscode'; -import { IDeepnoteEnvironmentManager } from '../types'; -import { EnvironmentTreeItemType, DeepnoteEnvironmentTreeItem } from './deepnoteEnvironmentTreeItem.node'; -import { inject, injectable } from 'inversify'; -import { IExtensionSyncActivationService } from '../../../platform/activation/types'; - -/** - * Tree data provider for the Deepnote kernel environments view - */ -@injectable() -export class DeepnoteEnvironmentTreeDataProvider - implements TreeDataProvider, IExtensionSyncActivationService, Disposable -{ - private readonly _onDidChangeTreeData = new EventEmitter(); - private readonly disposables: Disposable[] = []; - - constructor(@inject(IDeepnoteEnvironmentManager) private readonly environmentManager: IDeepnoteEnvironmentManager) { - // Listen to environment changes and refresh the tree - this.disposables.push( - this.environmentManager.onDidChangeEnvironments(() => { - this.refresh(); - }) - ); - } - - public activate(): void { - this.refresh(); - } - - public get onDidChangeTreeData(): Event { - return this._onDidChangeTreeData.event; - } - - public refresh(): void { - this._onDidChangeTreeData.fire(); - } - - public getTreeItem(element: DeepnoteEnvironmentTreeItem): TreeItem { - return element; - } - - public async getChildren(element?: DeepnoteEnvironmentTreeItem): Promise { - if (!element) { - // Root level: show all environments + create action - return this.getRootItems(); - } - - // Expanded environment: show info items - if (element.type === EnvironmentTreeItemType.Environment && element.environment) { - return this.getEnvironmentInfoItems(element); - } - - return []; - } - - private async getRootItems(): Promise { - const environments = this.environmentManager.listEnvironments(); - const items: DeepnoteEnvironmentTreeItem[] = []; - - // Add environment items - for (const config of environments) { - const item = new DeepnoteEnvironmentTreeItem(EnvironmentTreeItemType.Environment, config); - - items.push(item); - } - - // Add create action at the end - items.push(new DeepnoteEnvironmentTreeItem(EnvironmentTreeItemType.CreateAction)); - - return items; - } - - private getEnvironmentInfoItems(element: DeepnoteEnvironmentTreeItem): DeepnoteEnvironmentTreeItem[] { - const config = element.environment; - if (!config) { - return []; - } - - const items: DeepnoteEnvironmentTreeItem[] = []; - - // Python interpreter - items.push( - DeepnoteEnvironmentTreeItem.createInfoItem( - 'python', - config.id, - `Python: ${config.pythonInterpreter.uri.fsPath}`, - 'symbol-namespace' - ) - ); - - // Venv path - items.push( - DeepnoteEnvironmentTreeItem.createInfoItem('venv', config.id, `Venv: ${config.venvPath.fsPath}`, 'folder') - ); - - // Managed status - const managedLabel = config.managedVenv ? 'Type: Managed' : 'Type: External'; - const managedIcon = config.managedVenv ? 'shield' : 'link-external'; - items.push(DeepnoteEnvironmentTreeItem.createInfoItem('managed', config.id, managedLabel, managedIcon)); - - // Packages - if (config.packages && config.packages.length > 0) { - items.push( - DeepnoteEnvironmentTreeItem.createInfoItem( - 'packages', - config.id, - `Packages: ${config.packages.join(', ')}`, - 'package' - ) - ); - } else { - items.push( - DeepnoteEnvironmentTreeItem.createInfoItem('packages', config.id, 'Packages: (none)', 'package') - ); - } - - // Toolkit version - if (config.toolkitVersion) { - items.push( - DeepnoteEnvironmentTreeItem.createInfoItem( - 'toolkit', - config.id, - `Toolkit: ${config.toolkitVersion}`, - 'versions' - ) - ); - } - - // Timestamps - items.push( - DeepnoteEnvironmentTreeItem.createInfoItem( - 'created', - config.id, - `Created: ${config.createdAt.toLocaleString()}`, - 'history' - ) - ); - - items.push( - DeepnoteEnvironmentTreeItem.createInfoItem( - 'lastUsed', - config.id, - `Last used: ${config.lastUsedAt.toLocaleString()}`, - 'clock' - ) - ); - - return items; - } - - public dispose(): void { - this._onDidChangeTreeData.dispose(); - this.disposables.forEach((d) => d.dispose()); - } -} diff --git a/src/kernels/deepnote/environments/deepnoteEnvironmentTreeDataProvider.unit.test.ts b/src/kernels/deepnote/environments/deepnoteEnvironmentTreeDataProvider.unit.test.ts deleted file mode 100644 index 05b690f3b7..0000000000 --- a/src/kernels/deepnote/environments/deepnoteEnvironmentTreeDataProvider.unit.test.ts +++ /dev/null @@ -1,161 +0,0 @@ -import { assert } from 'chai'; -import { instance, mock, when } from 'ts-mockito'; -import { Uri, EventEmitter } from 'vscode'; -import { createMockChildProcess } from '../deepnoteTestHelpers.node'; -import { DeepnoteEnvironmentTreeDataProvider } from './deepnoteEnvironmentTreeDataProvider.node'; -import { IDeepnoteEnvironmentManager } from '../types'; -import { DeepnoteEnvironment } from './deepnoteEnvironment'; -import { PythonEnvironment } from '../../../platform/pythonEnvironments/info'; -import { EnvironmentTreeItemType } from './deepnoteEnvironmentTreeItem.node'; - -suite('DeepnoteEnvironmentTreeDataProvider', () => { - let provider: DeepnoteEnvironmentTreeDataProvider; - let mockConfigManager: IDeepnoteEnvironmentManager; - let configChangeEmitter: EventEmitter; - - const testInterpreter: PythonEnvironment = { - id: 'test-python-id', - uri: Uri.file('/usr/bin/python3') - }; - - const testConfig1: DeepnoteEnvironment = { - id: 'config-1', - name: 'Config 1', - pythonInterpreter: testInterpreter, - venvPath: Uri.file('/path/to/venv1'), - managedVenv: true, - createdAt: new Date(), - lastUsedAt: new Date() - }; - - const testConfig2: DeepnoteEnvironment = { - id: 'config-2', - name: 'Config 2', - pythonInterpreter: testInterpreter, - venvPath: Uri.file('/path/to/venv2'), - managedVenv: true, - createdAt: new Date(), - lastUsedAt: new Date(), - packages: ['numpy'], - serverInfo: { - url: 'http://localhost:8888', - jupyterPort: 8888, - lspPort: 8889, - token: 'test-token', - process: createMockChildProcess() - } - }; - - setup(() => { - mockConfigManager = mock(); - configChangeEmitter = new EventEmitter(); - - when(mockConfigManager.onDidChangeEnvironments).thenReturn(configChangeEmitter.event); - when(mockConfigManager.listEnvironments()).thenReturn([]); - - provider = new DeepnoteEnvironmentTreeDataProvider(instance(mockConfigManager)); - }); - - suite('getChildren - Root Level', () => { - test('should return create action when no environments exist', async () => { - when(mockConfigManager.listEnvironments()).thenReturn([]); - - const children = await provider.getChildren(); - - assert.strictEqual(children.length, 1); - assert.strictEqual(children[0].type, EnvironmentTreeItemType.CreateAction); - }); - - test('should return environments and create action', async () => { - when(mockConfigManager.listEnvironments()).thenReturn([testConfig1, testConfig2]); - when(mockConfigManager.getEnvironment('config-1')).thenReturn(testConfig1); - when(mockConfigManager.getEnvironment('config-2')).thenReturn(testConfig2); - - const children = await provider.getChildren(); - - assert.strictEqual(children.length, 3); // 2 configs + create action - assert.strictEqual(children[0].type, EnvironmentTreeItemType.Environment); - assert.strictEqual(children[1].type, EnvironmentTreeItemType.Environment); - assert.strictEqual(children[2].type, EnvironmentTreeItemType.CreateAction); - }); - }); - - suite('getChildren - Environment Children', () => { - test('should include packages when present', async () => { - when(mockConfigManager.listEnvironments()).thenReturn([testConfig2]); - when(mockConfigManager.getEnvironment('config-2')).thenReturn(testConfig2); - - const rootChildren = await provider.getChildren(); - const configItem = rootChildren[0]; - const infoItems = await provider.getChildren(configItem); - - const labels = infoItems.map((item) => item.label as string); - const hasPackages = labels.some((label) => label.includes('Packages:') && label.includes('numpy')); - - assert.isTrue(hasPackages); - }); - - test('should return empty array for non-environment items', async () => { - when(mockConfigManager.listEnvironments()).thenReturn([]); - - const rootChildren = await provider.getChildren(); - const createAction = rootChildren[0]; - const children = await provider.getChildren(createAction); - - assert.deepStrictEqual(children, []); - }); - - test('should return empty array for info items', async () => { - when(mockConfigManager.listEnvironments()).thenReturn([testConfig1]); - when(mockConfigManager.getEnvironment('config-1')).thenReturn(testConfig1); - - const rootChildren = await provider.getChildren(); - const configItem = rootChildren[0]; - const infoItems = await provider.getChildren(configItem); - const children = await provider.getChildren(infoItems[0]); - - assert.deepStrictEqual(children, []); - }); - }); - - suite('getTreeItem', () => { - test('should return the same tree item', async () => { - when(mockConfigManager.listEnvironments()).thenReturn([testConfig1]); - when(mockConfigManager.getEnvironment('config-1')).thenReturn(testConfig1); - - const children = await provider.getChildren(); - const item = children[0]; - const treeItem = provider.getTreeItem(item); - - assert.strictEqual(treeItem, item); - }); - }); - - suite('refresh', () => { - test('should fire onDidChangeTreeData event', (done) => { - provider.onDidChangeTreeData(() => { - done(); - }); - - provider.refresh(); - }); - }); - - suite('Auto-refresh on environment changes', () => { - test('should refresh when environments change', (done) => { - provider.onDidChangeTreeData(() => { - done(); - }); - - // Simulate environment change - configChangeEmitter.fire(); - }); - }); - - suite('dispose', () => { - test('should dispose without errors', () => { - provider.dispose(); - // Should not throw - }); - }); -}); diff --git a/src/kernels/deepnote/environments/deepnoteEnvironmentTreeItem.node.ts b/src/kernels/deepnote/environments/deepnoteEnvironmentTreeItem.node.ts deleted file mode 100644 index dcd24756ee..0000000000 --- a/src/kernels/deepnote/environments/deepnoteEnvironmentTreeItem.node.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { l10n, ThemeIcon, TreeItem, TreeItemCollapsibleState } from 'vscode'; - -import { DeepnoteEnvironment } from './deepnoteEnvironment'; - -/** - * Type of tree item in the environments view - */ -export enum EnvironmentTreeItemType { - Environment = 'environment', - InfoItem = 'info', - CreateAction = 'create' -} - -export type DeepnoteEnvironmentTreeInfoItemId = - | 'python' - | 'venv' - | 'managed' - | 'packages' - | 'toolkit' - | 'created' - | 'lastUsed'; - -/** - * Tree item for displaying environments and related info - */ -export class DeepnoteEnvironmentTreeItem extends TreeItem { - constructor( - public readonly type: EnvironmentTreeItemType, - public readonly environment?: DeepnoteEnvironment, - label?: string, - collapsibleState?: TreeItemCollapsibleState - ) { - super(label || '', collapsibleState); - - // Setup inline to avoid method binding issues with ES modules and TreeItem proxy - if (type === EnvironmentTreeItemType.Environment && environment) { - // setupEnvironmentItem inline - this.id = environment.id; - this.label = environment.name; - this.collapsibleState = TreeItemCollapsibleState.Collapsed; - - // getRelativeTime inline - const now = new Date(); - const diff = now.getTime() - environment.lastUsedAt.getTime(); - const seconds = Math.floor(diff / 1000); - const minutes = Math.floor(seconds / 60); - const hours = Math.floor(minutes / 60); - const days = Math.floor(hours / 24); - - let lastUsed: string; - if (seconds < 60) { - lastUsed = l10n.t('just now'); - } else if (minutes < 60) { - lastUsed = minutes === 1 ? l10n.t('1 minute ago') : l10n.t('{0} minutes ago', minutes); - } else if (hours < 24) { - lastUsed = hours === 1 ? l10n.t('1 hour ago') : l10n.t('{0} hours ago', hours); - } else if (days < 7) { - lastUsed = days === 1 ? l10n.t('1 day ago') : l10n.t('{0} days ago', days); - } else { - lastUsed = environment.lastUsedAt.toLocaleDateString(); - } - - this.description = l10n.t('Last used: {0}', lastUsed); - - // buildTooltip inline - const lines: string[] = []; - lines.push(`**${environment.name}**`); - lines.push(''); - lines.push(l10n.t('Python: {0}', environment.pythonInterpreter.uri.toString(true))); - lines.push(l10n.t('Venv: {0}', environment.venvPath.toString(true))); - lines.push( - environment.managedVenv - ? l10n.t('Type: Managed (created by extension)') - : l10n.t('Type: External (user-provided)') - ); - - if (environment.packages && environment.packages.length > 0) { - lines.push(l10n.t('Packages: {0}', environment.packages.join(', '))); - } - - if (environment.toolkitVersion) { - lines.push(l10n.t('Toolkit: {0}', environment.toolkitVersion)); - } - - lines.push(''); - lines.push(l10n.t('Created: {0}', environment.createdAt.toLocaleString())); - lines.push(l10n.t('Last used: {0}', environment.lastUsedAt.toLocaleString())); - - this.tooltip = lines.join('\n'); - } else if (type === EnvironmentTreeItemType.InfoItem) { - // setupInfoItem inline - this.contextValue = 'deepnoteEnvironment.info'; - this.collapsibleState = TreeItemCollapsibleState.None; - } else if (type === EnvironmentTreeItemType.CreateAction) { - // setupCreateAction inline - this.id = 'create'; - this.label = l10n.t('Create New Environment'); - this.iconPath = new ThemeIcon('add'); - this.contextValue = 'deepnoteEnvironment.create'; - this.collapsibleState = TreeItemCollapsibleState.None; - this.command = { - command: 'deepnote.environments.create', - title: l10n.t('Create Environment') - }; - } - } - - /** - * Create an info item to display under an environment - */ - public static createInfoItem( - id: DeepnoteEnvironmentTreeInfoItemId, - environmentId: string, - label: string, - icon?: string - ): DeepnoteEnvironmentTreeItem { - const item = new DeepnoteEnvironmentTreeItem(EnvironmentTreeItemType.InfoItem, undefined, label); - item.id = `info-${environmentId}-${id}`; - - if (icon) { - item.iconPath = new ThemeIcon(icon); - } - - return item; - } -} diff --git a/src/kernels/deepnote/environments/deepnoteEnvironmentTreeItem.unit.test.ts b/src/kernels/deepnote/environments/deepnoteEnvironmentTreeItem.unit.test.ts deleted file mode 100644 index a7f0a03de7..0000000000 --- a/src/kernels/deepnote/environments/deepnoteEnvironmentTreeItem.unit.test.ts +++ /dev/null @@ -1,177 +0,0 @@ -import { assert } from 'chai'; -import { ThemeIcon, TreeItemCollapsibleState, Uri } from 'vscode'; - -import { DeepnoteEnvironmentTreeItem, EnvironmentTreeItemType } from './deepnoteEnvironmentTreeItem.node'; -import { DeepnoteEnvironment } from './deepnoteEnvironment'; -import { PythonEnvironment } from '../../../platform/pythonEnvironments/info'; - -suite('DeepnoteEnvironmentTreeItem', () => { - const testInterpreter: PythonEnvironment = { - id: 'test-python-id', - uri: Uri.file('/usr/bin/python3') - }; - - const testEnvironment: DeepnoteEnvironment = { - id: 'test-config-id', - name: 'Test Environment', - pythonInterpreter: testInterpreter, - venvPath: Uri.file('/path/to/venv'), - managedVenv: true, - createdAt: new Date('2024-01-01T10:00:00Z'), - lastUsedAt: new Date('2024-01-01T12:00:00Z') - }; - - suite('Environment Item', () => { - test('should create environment item', () => { - const item = new DeepnoteEnvironmentTreeItem(EnvironmentTreeItemType.Environment, testEnvironment); - - assert.strictEqual(item.type, EnvironmentTreeItemType.Environment); - assert.strictEqual(item.environment, testEnvironment); - assert.strictEqual(item.label, 'Test Environment'); - assert.strictEqual(item.collapsibleState, TreeItemCollapsibleState.Collapsed); - }); - - test('should include last used time in description', () => { - const item = new DeepnoteEnvironmentTreeItem(EnvironmentTreeItemType.Environment, testEnvironment); - - assert.include(item.description as string, 'Last used:'); - }); - - test('should have tooltip with environment details', () => { - const item = new DeepnoteEnvironmentTreeItem(EnvironmentTreeItemType.Environment, testEnvironment); - - const tooltip = `${item.tooltip}`; - assert.include(tooltip, 'Test Environment'); - assert.include(tooltip, testInterpreter.uri.toString(true)); - }); - - test('should include packages in tooltip when present', () => { - const configWithPackages: DeepnoteEnvironment = { - ...testEnvironment, - packages: ['numpy', 'pandas'] - }; - - const item = new DeepnoteEnvironmentTreeItem(EnvironmentTreeItemType.Environment, configWithPackages); - - const tooltip = item.tooltip as string; - assert.include(tooltip, 'numpy'); - assert.include(tooltip, 'pandas'); - }); - }); - - suite('Info Item', () => { - test('should create info item', () => { - const item = new DeepnoteEnvironmentTreeItem(EnvironmentTreeItemType.InfoItem, undefined, 'Info Label'); - - assert.strictEqual(item.type, EnvironmentTreeItemType.InfoItem); - assert.strictEqual(item.label, 'Info Label'); - assert.strictEqual(item.contextValue, 'deepnoteEnvironment.info'); - assert.strictEqual(item.collapsibleState, TreeItemCollapsibleState.None); - }); - - test('should create info item with icon', () => { - const item = DeepnoteEnvironmentTreeItem.createInfoItem( - 'python', - 'test-config-id', - 'Python: /usr/bin/python3', - 'circle-filled' - ); - - assert.strictEqual(item.label, 'Python: /usr/bin/python3'); - assert.instanceOf(item.iconPath, ThemeIcon); - assert.strictEqual((item.iconPath as ThemeIcon).id, 'circle-filled'); - }); - - test('should create info item without icon', () => { - const item = DeepnoteEnvironmentTreeItem.createInfoItem('venv', 'test-config-id', 'No icon'); - - assert.strictEqual(item.label, 'No icon'); - assert.isUndefined(item.iconPath); - }); - }); - - suite('Create Action Item', () => { - test('should create action item', () => { - const item = new DeepnoteEnvironmentTreeItem(EnvironmentTreeItemType.CreateAction); - - assert.strictEqual(item.type, EnvironmentTreeItemType.CreateAction); - assert.strictEqual(item.label, 'Create New Environment'); - assert.strictEqual(item.contextValue, 'deepnoteEnvironment.create'); - assert.strictEqual(item.collapsibleState, TreeItemCollapsibleState.None); - assert.instanceOf(item.iconPath, ThemeIcon); - assert.strictEqual((item.iconPath as ThemeIcon).id, 'add'); - }); - - test('should have command', () => { - const item = new DeepnoteEnvironmentTreeItem(EnvironmentTreeItemType.CreateAction); - - assert.ok(item.command); - assert.strictEqual(item.command?.command, 'deepnote.environments.create'); - assert.strictEqual(item.command?.title, 'Create Environment'); - }); - }); - - suite('Relative Time Formatting', () => { - test('should show "just now" for recent times', () => { - const recentConfig: DeepnoteEnvironment = { - ...testEnvironment, - lastUsedAt: new Date() - }; - - const item = new DeepnoteEnvironmentTreeItem(EnvironmentTreeItemType.Environment, recentConfig); - - assert.include(item.description as string, 'just now'); - }); - - test('should handle negative time (few seconds in the past)', () => { - const fewSecondsAgo = new Date(Date.now() - 5 * 1000); - const config: DeepnoteEnvironment = { - ...testEnvironment, - lastUsedAt: fewSecondsAgo - }; - - const item = new DeepnoteEnvironmentTreeItem(EnvironmentTreeItemType.Environment, config); - - assert.include(item.description as string, 'just now'); - }); - - test('should show minutes ago', () => { - const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000); - const config: DeepnoteEnvironment = { - ...testEnvironment, - lastUsedAt: fiveMinutesAgo - }; - - const item = new DeepnoteEnvironmentTreeItem(EnvironmentTreeItemType.Environment, config); - - assert.include(item.description as string, 'minute'); - assert.include(item.description as string, 'ago'); - }); - - test('should show hours ago', () => { - const twoHoursAgo = new Date(Date.now() - 2 * 60 * 60 * 1000); - const config: DeepnoteEnvironment = { - ...testEnvironment, - lastUsedAt: twoHoursAgo - }; - - const item = new DeepnoteEnvironmentTreeItem(EnvironmentTreeItemType.Environment, config); - - assert.include(item.description as string, 'hour'); - assert.include(item.description as string, 'ago'); - }); - - test('should show days ago', () => { - const threeDaysAgo = new Date(Date.now() - 3 * 24 * 60 * 60 * 1000); - const config: DeepnoteEnvironment = { - ...testEnvironment, - lastUsedAt: threeDaysAgo - }; - - const item = new DeepnoteEnvironmentTreeItem(EnvironmentTreeItemType.Environment, config); - - assert.include(item.description as string, 'day'); - assert.include(item.description as string, 'ago'); - }); - }); -}); diff --git a/src/kernels/deepnote/environments/deepnoteEnvironmentsActivationService.ts b/src/kernels/deepnote/environments/deepnoteEnvironmentsActivationService.ts deleted file mode 100644 index 4ccf8534e9..0000000000 --- a/src/kernels/deepnote/environments/deepnoteEnvironmentsActivationService.ts +++ /dev/null @@ -1,45 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import { inject, injectable, named } from 'inversify'; -import { IExtensionSyncActivationService } from '../../../platform/activation/types'; -import { IDeepnoteEnvironmentManager } from '../types'; -import { DeepnoteEnvironmentsView } from './deepnoteEnvironmentsView.node'; -import { logger } from '../../../platform/logging'; -import { IOutputChannel } from '../../../platform/common/types'; -import { STANDARD_OUTPUT_CHANNEL } from '../../../platform/common/constants'; -import { l10n } from 'vscode'; - -/** - * Activation service for the Deepnote kernel environments view. - * Initializes the environment manager and registers the tree view. - */ -@injectable() -export class DeepnoteEnvironmentsActivationService implements IExtensionSyncActivationService { - constructor( - @inject(IDeepnoteEnvironmentManager) - private readonly environmentManager: IDeepnoteEnvironmentManager, - @inject(IOutputChannel) @named(STANDARD_OUTPUT_CHANNEL) private readonly outputChannel: IOutputChannel, - @inject(DeepnoteEnvironmentsView) - _environmentsView: DeepnoteEnvironmentsView - ) { - // _environmentsView is injected to ensure the view is created, - // but we don't need to store a reference to it - } - - public activate(): void { - logger.info('Activating Deepnote kernel environments view'); - - // Initialize the environment manager (loads environments from storage) - this.environmentManager.initialize().then( - () => { - logger.info('Deepnote kernel environments initialized'); - }, - (error: unknown) => { - logger.error('Failed to initialize Deepnote kernel environments', error); - const msg = error instanceof Error ? error.message : String(error); - this.outputChannel.appendLine(l10n.t('Failed to initialize Deepnote kernel environments: {0}', msg)); - } - ); - } -} diff --git a/src/kernels/deepnote/environments/deepnoteEnvironmentsActivationService.unit.test.ts b/src/kernels/deepnote/environments/deepnoteEnvironmentsActivationService.unit.test.ts deleted file mode 100644 index 03c2be0902..0000000000 --- a/src/kernels/deepnote/environments/deepnoteEnvironmentsActivationService.unit.test.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { assert } from 'chai'; -import { instance, mock, when, verify } from 'ts-mockito'; -import { DeepnoteEnvironmentsActivationService } from './deepnoteEnvironmentsActivationService'; -import { IDeepnoteEnvironmentManager } from '../types'; -import { DeepnoteEnvironmentsView } from './deepnoteEnvironmentsView.node'; -import { IOutputChannel } from '../../../platform/common/types'; - -suite('DeepnoteEnvironmentsActivationService', () => { - let activationService: DeepnoteEnvironmentsActivationService; - let mockConfigManager: IDeepnoteEnvironmentManager; - let mockEnvironmentsView: DeepnoteEnvironmentsView; - let mockOutputChannel: IOutputChannel; - - setup(() => { - mockConfigManager = mock(); - mockEnvironmentsView = mock(); - mockOutputChannel = mock(); - - activationService = new DeepnoteEnvironmentsActivationService( - instance(mockConfigManager), - instance(mockOutputChannel), - instance(mockEnvironmentsView) - ); - }); - - suite('activate', () => { - test('should call initialize on environment manager', async () => { - when(mockConfigManager.initialize()).thenResolve(); - - activationService.activate(); - - // Wait for async initialization - await new Promise((resolve) => setTimeout(resolve, 100)); - - verify(mockConfigManager.initialize()).once(); - }); - - test('should handle initialization errors gracefully', async () => { - when(mockConfigManager.initialize()).thenReject(new Error('Initialization failed')); - - // Should not throw - activationService.activate(); - - // Wait for async initialization - await new Promise((resolve) => setTimeout(resolve, 100)); - - verify(mockConfigManager.initialize()).once(); - }); - - test('should not throw when activate is called', () => { - when(mockConfigManager.initialize()).thenResolve(); - - assert.doesNotThrow(() => { - activationService.activate(); - }); - }); - }); - - suite('constructor', () => { - test('should create service with dependencies', () => { - assert.ok(activationService); - }); - - test('should accept dependencies', () => { - const service = new DeepnoteEnvironmentsActivationService( - instance(mockConfigManager), - instance(mockOutputChannel), - instance(mockEnvironmentsView) - ); - - assert.ok(service); - }); - }); -}); diff --git a/src/kernels/deepnote/environments/deepnoteEnvironmentsView.node.ts b/src/kernels/deepnote/environments/deepnoteEnvironmentsView.node.ts deleted file mode 100644 index e04c9d4d59..0000000000 --- a/src/kernels/deepnote/environments/deepnoteEnvironmentsView.node.ts +++ /dev/null @@ -1,631 +0,0 @@ -import { inject, injectable, named } from 'inversify'; -import { - commands, - Disposable, - l10n, - NotebookDocument, - ProgressLocation, - QuickPickItem, - TreeView, - window, - workspace -} from 'vscode'; -import { IPythonApiProvider } from '../../../platform/api/types'; -import { STANDARD_OUTPUT_CHANNEL } from '../../../platform/common/constants'; -import { getDisplayPath } from '../../../platform/common/platform/fs-paths.node'; -import { ITelemetryService } from '../../../platform/analytics/types'; -import { IDisposableRegistry, IOutputChannel } from '../../../platform/common/types'; -import { createDeepnoteServerConfigHandle } from '../../../platform/deepnote/deepnoteServerUtils.node'; -import { DeepnoteToolkitMissingError } from '../../../platform/errors/deepnoteKernelErrors'; -import { - getCachedEnvironment, - getPythonEnvironmentName, - resolvedPythonEnvToJupyterEnv -} from '../../../platform/interpreter/helpers'; -import { logger } from '../../../platform/logging'; -import { IKernelProvider } from '../../types'; -import { - DeepnoteKernelConnectionMetadata, - IDeepnoteEnvironmentManager, - IDeepnoteKernelAutoSelector, - IDeepnoteNotebookEnvironmentMapper, - IDeepnoteServerStarter -} from '../types'; -import { CreateDeepnoteEnvironmentOptions, DeepnoteEnvironment } from './deepnoteEnvironment'; -import { DeepnoteEnvironmentTreeDataProvider } from './deepnoteEnvironmentTreeDataProvider.node'; -import { DeepnoteEnvironmentTreeItem } from './deepnoteEnvironmentTreeItem.node'; - -/** - * View controller for the Deepnote kernel environments tree view. - * Manages the tree view and handles all environment-related commands. - */ -@injectable() -export class DeepnoteEnvironmentsView implements Disposable { - private readonly treeView: TreeView; - private readonly disposables: Disposable[] = []; - - constructor( - @inject(IDeepnoteEnvironmentManager) private readonly environmentManager: IDeepnoteEnvironmentManager, - @inject(DeepnoteEnvironmentTreeDataProvider) - private readonly treeDataProvider: DeepnoteEnvironmentTreeDataProvider, - @inject(IPythonApiProvider) private readonly pythonApiProvider: IPythonApiProvider, - @inject(IDisposableRegistry) disposableRegistry: IDisposableRegistry, - @inject(IDeepnoteKernelAutoSelector) private readonly kernelAutoSelector: IDeepnoteKernelAutoSelector, - @inject(IDeepnoteNotebookEnvironmentMapper) - private readonly notebookEnvironmentMapper: IDeepnoteNotebookEnvironmentMapper, - @inject(IKernelProvider) private readonly kernelProvider: IKernelProvider, - @inject(IOutputChannel) @named(STANDARD_OUTPUT_CHANNEL) private readonly outputChannel: IOutputChannel, - @inject(IDeepnoteServerStarter) private readonly serverStarter: IDeepnoteServerStarter, - @inject(ITelemetryService) private readonly analytics: ITelemetryService - ) { - // Create tree data provider - - // Create tree view - this.treeView = window.createTreeView('deepnoteEnvironments', { - treeDataProvider: this.treeDataProvider, - showCollapseAll: true - }); - - this.disposables.push(this.treeView); - - // Register commands - this.registerCommands(); - - // Register for disposal - disposableRegistry.push(this); - } - - public async createEnvironmentCommand(): Promise { - try { - // Step 1: Select Python interpreter - const api = await this.pythonApiProvider.getNewApi(); - if (!api || !api.environments.known || api.environments.known.length === 0) { - void window.showErrorMessage(l10n.t('No Python interpreters found. Please install Python first.')); - return; - } - - const interpreterItems = api.environments.known - .map((env) => { - const interpreter = resolvedPythonEnvToJupyterEnv(getCachedEnvironment(env)); - if (!interpreter) { - return undefined; - } - return { - label: getPythonEnvironmentName(interpreter) || getDisplayPath(interpreter.uri), - description: getDisplayPath(interpreter.uri), - interpreter - }; - }) - .filter( - ( - item - ): item is { - label: string; - description: string; - interpreter: import('../../../platform/pythonEnvironments/info').PythonEnvironment; - } => item !== undefined - ); - - const selectedInterpreter = await window.showQuickPick(interpreterItems, { - placeHolder: l10n.t('Select a Python interpreter for this environment'), - matchOnDescription: true - }); - - if (!selectedInterpreter) { - return; - } - - // Step 2: Enter environment name - const name = await window.showInputBox({ - prompt: l10n.t('Enter a name for this environment'), - placeHolder: l10n.t('e.g., Python 3.11 (Data Science)'), - validateInput: (value: string) => { - if (!value || value.trim().length === 0) { - return l10n.t('Name cannot be empty'); - } - return undefined; - } - }); - - if (!name) { - return; - } - - // Check if name is already in use - const existingEnvironments = this.environmentManager.listEnvironments(); - if (existingEnvironments.some((env) => env.name === name)) { - void window.showErrorMessage(l10n.t('An environment with this name already exists')); - return; - } - - // Step 3: Enter packages (optional) - const packagesInput = await window.showInputBox({ - prompt: l10n.t('Enter additional packages to install (comma-separated, optional)'), - placeHolder: l10n.t('e.g., matplotlib, tensorflow'), - validateInput: (value: string) => { - if (!value || value.trim().length === 0) { - return undefined; // Empty is OK - } - // Basic validation: check for valid package names - const packages = value.split(',').map((p: string) => p.trim()); - for (const pkg of packages) { - const isValid = - /^[A-Za-z0-9._\-]+(\[[A-Za-z0-9_,.\-]+\])?(\s*(==|>=|<=|~=|>|<)\s*[A-Za-z0-9.*+!\-_.]+)?(?:\s*;.+)?$/.test( - pkg - ); - if (!isValid) { - return l10n.t('Invalid package name: {0}', pkg); - } - } - return undefined; - } - }); - - // Parse packages - const packages = - packagesInput && packagesInput.trim().length > 0 - ? packagesInput - .split(',') - .map((p: string) => p.trim()) - .filter((p: string) => p.length > 0) - : undefined; - - // Step 4: Enter description (optional) - const description = await window.showInputBox({ - prompt: l10n.t('Enter a description for this environment (optional)'), - placeHolder: l10n.t('e.g., Environment for data science projects') - }); - - // Create environment with progress - return await window.withProgress( - { - location: ProgressLocation.Notification, - title: l10n.t('Creating environment "{0}"...', name), - cancellable: true - }, - async (progress: { report: (value: { message?: string; increment?: number }) => void }, token) => { - progress.report({ message: l10n.t('Setting up virtual environment...') }); - - const options: CreateDeepnoteEnvironmentOptions = { - name: name.trim(), - pythonInterpreter: selectedInterpreter.interpreter, - packages, - description: description?.trim() - }; - - try { - const config = await this.environmentManager.createEnvironment(options, token); - logger.info(`Created environment: ${config.id} (${config.name})`); - - this.analytics.trackEvent({ - eventName: 'create_environment', - properties: { - hasDescription: !!options.description, - packageCount: options.packages?.length ?? 0 - } - }); - - void window.showInformationMessage( - l10n.t('Environment "{0}" created successfully!', config.name) - ); - - return config; - } catch (error) { - logger.error('Failed to create environment', error); - throw error; - } - } - ); - } catch (error) { - void window.showErrorMessage(l10n.t('Failed to create environment. See output for details.')); - } - } - - private registerCommands(): void { - // Refresh command - this.disposables.push( - commands.registerCommand('deepnote.environments.refresh', () => { - this.treeDataProvider.refresh(); - }) - ); - - // Create environment command - this.disposables.push( - commands.registerCommand('deepnote.environments.create', async () => { - await this.createEnvironmentCommand(); - }) - ); - - // Delete environment command - this.disposables.push( - commands.registerCommand('deepnote.environments.delete', async (item: DeepnoteEnvironmentTreeItem) => { - if (item?.environment) { - await this.deleteEnvironmentCommand(item.environment.id); - } - }) - ); - - // Edit name command - this.disposables.push( - commands.registerCommand('deepnote.environments.editName', async (item: DeepnoteEnvironmentTreeItem) => { - if (item?.environment) { - await this.editEnvironmentName(item.environment.id); - } - }) - ); - - // Manage packages command - this.disposables.push( - commands.registerCommand( - 'deepnote.environments.managePackages', - async (item: DeepnoteEnvironmentTreeItem) => { - if (item?.environment) { - await this.managePackages(item.environment.id); - } - } - ) - ); - - // Switch environment for notebook command - this.disposables.push( - commands.registerCommand( - 'deepnote.environments.selectForNotebook', - async (options?: { notebook?: NotebookDocument }) => { - // Get the active notebook - const activeNotebook = options?.notebook ?? window.activeNotebookEditor?.notebook; - if (!activeNotebook || activeNotebook.notebookType !== 'deepnote') { - void window.showWarningMessage(l10n.t('No active Deepnote notebook found')); - return; - } - - await this.selectEnvironmentForNotebook({ notebook: activeNotebook }); - } - ) - ); - } - - public async deleteEnvironmentCommand(environmentId: string): Promise { - const config = this.environmentManager.getEnvironment(environmentId); - if (!config) { - return; - } - - // Confirm deletion - const confirmation = await window.showWarningMessage( - l10n.t( - 'Are you sure you want to delete "{0}"? This will remove the virtual environment and cannot be undone.', - config.name - ), - { modal: true }, - l10n.t('Delete') - ); - - if (confirmation !== l10n.t('Delete')) { - return; - } - - try { - await window.withProgress( - { - location: ProgressLocation.Notification, - title: l10n.t('Deleting environment "{0}"...', config.name), - cancellable: true - }, - async (_progress, token) => { - // Resolve every notebook that uses this environment from the persisted - // mapper state BEFORE any entries are removed, so the list is complete. - const uris = this.notebookEnvironmentMapper.getNotebooksUsingEnvironment(environmentId); - - // Stop each notebook's server (per-notebook keying reaches closed-but-running ones too). - // stopServer is a safe no-op when a notebook has no running server. - for (const uri of uris) { - try { - await this.serverStarter.stopServer(uri, token); - } catch (error) { - logger.error(`Failed to stop server for ${getDisplayPath(uri)}`, error); - } - } - - // Dispose kernels from any open notebooks using this environment - await this.disposeKernelsUsingEnvironment(environmentId); - - for (const uri of uris) { - await this.notebookEnvironmentMapper.removeEnvironmentForNotebook(uri); - } - - await this.environmentManager.deleteEnvironment(environmentId, token); - logger.info(`Deleted environment: ${environmentId}`); - } - ); - - this.analytics.trackEvent({ eventName: 'delete_environment' }); - void window.showInformationMessage(l10n.t('Environment "{0}" deleted', config.name)); - } catch (error) { - logger.error('Failed to delete environment', error); - void window.showErrorMessage(l10n.t('Failed to delete environment. See output for details.')); - } - } - - /** - * Dispose kernels from any open notebooks that are using the specified environment. - * This ensures the UI reflects that the kernel is no longer available. - */ - private async disposeKernelsUsingEnvironment(environmentId: string): Promise { - const openNotebooks = workspace.notebookDocuments; - - for (const notebook of openNotebooks) { - // Only check Deepnote notebooks - if (notebook.notebookType !== 'deepnote') { - continue; - } - - // Get the kernel for this notebook - const kernel = this.kernelProvider.get(notebook); - if (!kernel) { - continue; - } - - // Check if this kernel is using the environment being deleted - const connectionMetadata = kernel.kernelConnectionMetadata; - if (connectionMetadata.kind === 'startUsingDeepnoteKernel') { - const deepnoteMetadata = connectionMetadata as DeepnoteKernelConnectionMetadata; - const expectedHandle = createDeepnoteServerConfigHandle(environmentId, notebook.uri); - - if (deepnoteMetadata.serverProviderHandle.handle === expectedHandle) { - logger.info( - `Disposing kernel for notebook ${getDisplayPath( - notebook.uri - )} as it uses deleted environment ${environmentId}` - ); - - try { - // First, unselect the controller from the notebook UI - this.kernelAutoSelector.clearControllerForEnvironment(notebook, environmentId); - - // Then dispose the kernel - await kernel.dispose(); - } catch (error) { - logger.error(`Failed to dispose kernel for ${getDisplayPath(notebook.uri)}`, error); - } - } - } - } - } - - public async selectEnvironmentForNotebook({ notebook }: { notebook: NotebookDocument }): Promise { - logger.info('Selecting environment for notebook:', notebook); - - // Get current environment selection - const currentEnvironmentId = this.notebookEnvironmentMapper.getEnvironmentForNotebook(notebook.uri); - const currentEnvironment = currentEnvironmentId - ? this.environmentManager.getEnvironment(currentEnvironmentId) - : undefined; - - // Get all environments - const environments = this.environmentManager.listEnvironments(); - - // Build quick pick items - const items: (QuickPickItem & { environmentId?: string })[] = environments.map((env) => { - const isCurrent = currentEnvironment?.id === env.id; - - return { - label: `${env.name} ${isCurrent ? ' $(check)' : ''}`, - description: getDisplayPath(env.pythonInterpreter.uri), - detail: env.packages?.length - ? l10n.t('Packages: {0}', env.packages.join(', ')) - : l10n.t('No additional packages'), - environmentId: env.id - }; - }); - - const createNewLabel = l10n.t('$(add) Create New Environment'); - - // Add "Create new" option at the end - items.push({ - label: createNewLabel, - description: l10n.t('Set up a new kernel environment'), - alwaysShow: true - }); - - const selected = await window.showQuickPick(items, { - placeHolder: l10n.t('Select an environment for this notebook'), - matchOnDescription: true, - matchOnDetail: true - }); - - if (!selected) { - return; // User cancelled - } - - let selectedEnvironmentId: string | undefined; - - if (selected.label === createNewLabel) { - const newEnvironment = await this.createEnvironmentCommand(); - if (newEnvironment == null) { - return; - } - // return; - selectedEnvironmentId = newEnvironment.id; - } else { - selectedEnvironmentId = selected.environmentId; - } - - // Check if user selected the same environment - if (selectedEnvironmentId === currentEnvironmentId) { - logger.info(`User selected the same environment - no changes needed`); - return; - } else if (selectedEnvironmentId == null) { - logger.info('User cancelled environment selection'); - return; - } - - // Check if any cells are currently executing using the kernel execution state - // This is more reliable than checking executionSummary - const kernel = this.kernelProvider.get(notebook); - const hasExecutingCells = kernel - ? this.kernelProvider.getKernelExecution(kernel).pendingCells.length > 0 - : false; - - if (hasExecutingCells) { - const proceed = await window.showWarningMessage( - l10n.t( - 'Some cells are currently executing. Switching environments now may cause errors. Do you want to continue?' - ), - { modal: true }, - l10n.t('Yes, Switch Anyway'), - l10n.t('Cancel') - ); - - if (proceed !== l10n.t('Yes, Switch Anyway')) { - logger.info('User cancelled environment switch due to executing cells'); - return; - } - } - - // User selected a different environment - switch to it - logger.info(`Switching notebook ${getDisplayPath(notebook.uri)} to environment ${selectedEnvironmentId}`); - - try { - await window.withProgress( - { - location: ProgressLocation.Notification, - title: l10n.t('Switching to environment...'), - cancellable: true - }, - async (progress, token) => { - // Update the notebook-to-environment mapping - await this.notebookEnvironmentMapper.setEnvironmentForNotebook(notebook.uri, selectedEnvironmentId); - - // Force rebuild the controller with the new environment - // This clears cached metadata and creates a fresh controller. - // await this.kernelAutoSelector.ensureKernelSelected(activeNotebook); - await this.kernelAutoSelector.rebuildController(notebook, progress, token); - - logger.info(`Successfully switched to environment ${selectedEnvironmentId}`); - } - ); - - this.analytics.trackEvent({ eventName: 'select_environment' }); - void window.showInformationMessage(l10n.t('Environment switched successfully')); - } catch (error) { - if (error instanceof DeepnoteToolkitMissingError) { - await this.kernelAutoSelector.handleKernelSelectionError(error, notebook); - return; - } - - logger.error('Failed to switch environment', error); - const showOutputAction = l10n.t('Show Output'); - const errorMessage = error instanceof Error ? error.message : String(error); - const selectedAction = await window.showErrorMessage( - l10n.t('Failed to switch environment: {0}', errorMessage), - { modal: false }, - showOutputAction - ); - - if (selectedAction === showOutputAction) { - this.outputChannel.show(); - } - } - } - - public async editEnvironmentName(environmentId: string): Promise { - const config = this.environmentManager.getEnvironment(environmentId); - if (!config) { - return; - } - - const newName = await window.showInputBox({ - prompt: l10n.t('Enter a new name for this environment'), - value: config.name, - validateInput: (value: string) => { - if (!value || value.trim().length === 0) { - return l10n.t('Name cannot be empty'); - } - return undefined; - } - }); - - if (!newName || newName === config.name) { - return; - } - - try { - await this.environmentManager.updateEnvironment(environmentId, { - name: newName.trim() - }); - - logger.info(`Renamed environment ${environmentId} to "${newName}"`); - void window.showInformationMessage(l10n.t('Environment renamed to "{0}"', newName)); - this.analytics.trackEvent({ eventName: 'update_environment', properties: { field: 'name' } }); - } catch (error) { - logger.error('Failed to rename environment', error); - void window.showErrorMessage(l10n.t('Failed to rename environment. See output for details.')); - } - } - - private async managePackages(environmentId: string): Promise { - const config = this.environmentManager.getEnvironment(environmentId); - if (!config) { - return; - } - - // Show input box for package names - const packagesInput = await window.showInputBox({ - prompt: l10n.t('Enter packages to install (comma-separated)'), - placeHolder: l10n.t('e.g., pandas, numpy, matplotlib'), - value: config.packages?.join(', ') || '', - validateInput: (value: string) => { - if (!value || value.trim().length === 0) { - return l10n.t('Please enter at least one package'); - } - const packages = value.split(',').map((p: string) => p.trim()); - for (const pkg of packages) { - const isValid = - /^[A-Za-z0-9._\-]+(\[[A-Za-z0-9_,.\-]+\])?(\s*(==|>=|<=|~=|>|<)\s*[A-Za-z0-9.*+!\-_.]+)?(?:\s*;.+)?$/.test( - pkg - ); - if (!isValid) { - return l10n.t('Invalid package name: {0}', pkg); - } - } - return undefined; - } - }); - - if (!packagesInput) { - return; - } - - const packages = packagesInput - .split(',') - .map((p: string) => p.trim()) - .filter((p: string) => p.length > 0); - - try { - await window.withProgress( - { - location: ProgressLocation.Notification, - title: l10n.t('Updating packages for "{0}"...', config.name), - cancellable: false - }, - async () => { - await this.environmentManager.updateEnvironment(environmentId, { packages }); - logger.info(`Updated packages for environment ${environmentId}`); - } - ); - - void window.showInformationMessage(l10n.t('Packages updated for "{0}"', config.name)); - this.analytics.trackEvent({ - eventName: 'update_environment', - properties: { field: 'packages', packageCount: packages.length } - }); - } catch (error) { - logger.error('Failed to update packages', error); - void window.showErrorMessage(l10n.t('Failed to update packages. See output for details.')); - } - } - - public dispose(): void { - this.disposables.forEach((d) => d?.dispose()); - } -} diff --git a/src/kernels/deepnote/environments/deepnoteEnvironmentsView.unit.test.ts b/src/kernels/deepnote/environments/deepnoteEnvironmentsView.unit.test.ts deleted file mode 100644 index 9d59edc279..0000000000 --- a/src/kernels/deepnote/environments/deepnoteEnvironmentsView.unit.test.ts +++ /dev/null @@ -1,1269 +0,0 @@ -import { assert } from 'chai'; -import * as sinon from 'sinon'; -import { anything, capture, instance, mock, when, verify, deepEqual, resetCalls } from 'ts-mockito'; -import { CancellationToken, Disposable, NotebookDocument, ProgressOptions, Uri } from 'vscode'; -import { DeepnoteEnvironmentsView } from './deepnoteEnvironmentsView.node'; -import { - IDeepnoteEnvironmentManager, - IDeepnoteKernelAutoSelector, - IDeepnoteNotebookEnvironmentMapper, - IDeepnoteServerStarter -} from '../types'; -import { IPythonApiProvider } from '../../../platform/api/types'; -import { ITelemetryService } from '../../../platform/analytics/types'; -import { IDisposableRegistry, IOutputChannel } from '../../../platform/common/types'; -import { IKernelProvider } from '../../../kernels/types'; -import { DeepnoteEnvironment } from './deepnoteEnvironment'; -import { PythonEnvironment } from '../../../platform/pythonEnvironments/info'; -import { mockedVSCodeNamespaces, resetVSCodeMocks } from '../../../test/vscode-mock'; -import { DeepnoteEnvironmentTreeDataProvider } from './deepnoteEnvironmentTreeDataProvider.node'; -import { crateMockedPythonApi, whenKnownEnvironments } from '../../helpers.unit.test'; -import type { PythonExtension } from '@vscode/python-extension'; -import { createDeepnoteServerConfigHandle } from '../../../platform/deepnote/deepnoteServerUtils.node'; - -suite('DeepnoteEnvironmentsView', () => { - let view: DeepnoteEnvironmentsView; - let mockConfigManager: IDeepnoteEnvironmentManager; - let mockTreeDataProvider: DeepnoteEnvironmentTreeDataProvider; - let mockPythonApiProvider: IPythonApiProvider; - let mockDisposableRegistry: IDisposableRegistry; - let mockKernelAutoSelector: IDeepnoteKernelAutoSelector; - let mockNotebookEnvironmentMapper: IDeepnoteNotebookEnvironmentMapper; - let mockKernelProvider: IKernelProvider; - let mockOutputChannel: IOutputChannel; - let mockTelemetryService: ITelemetryService; - let mockServerStarter: IDeepnoteServerStarter; - let disposables: Disposable[] = []; - let pythonEnvironments: PythonExtension['environments']; - - setup(() => { - resetVSCodeMocks(); - disposables.push(new Disposable(() => resetVSCodeMocks())); - - // Initialize Python API for helper functions - pythonEnvironments = crateMockedPythonApi(disposables).environments; - - mockConfigManager = mock(); - mockTreeDataProvider = mock(); - mockPythonApiProvider = mock(); - mockDisposableRegistry = mock(); - mockKernelAutoSelector = mock(); - mockNotebookEnvironmentMapper = mock(); - mockKernelProvider = mock(); - mockOutputChannel = mock(); - mockTelemetryService = mock(); - mockServerStarter = mock(); - - // stopServer is a safe no-op when a notebook has no running server - when(mockServerStarter.stopServer(anything(), anything())).thenResolve(); - - // Mock onDidChangeEnvironments to return a disposable event - when(mockConfigManager.onDidChangeEnvironments).thenReturn((_listener: () => void) => { - return { - dispose: () => { - /* noop */ - } - }; - }); - - view = new DeepnoteEnvironmentsView( - instance(mockConfigManager), - instance(mockTreeDataProvider), - instance(mockPythonApiProvider), - instance(mockDisposableRegistry), - instance(mockKernelAutoSelector), - instance(mockNotebookEnvironmentMapper), - instance(mockKernelProvider), - instance(mockOutputChannel), - instance(mockServerStarter), - instance(mockTelemetryService) - ); - }); - - teardown(() => { - if (view) { - view.dispose(); - } - disposables.forEach((d) => d.dispose()); - disposables = []; - }); - - suite('constructor', () => { - test('should create tree view', () => { - // View should be created without errors - assert.ok(view); - }); - - test('should register with disposable registry', () => { - verify(mockDisposableRegistry.push(anything())).atLeast(1); - }); - }); - - suite('dispose', () => { - test('should dispose all resources', () => { - view.dispose(); - // Should not throw - }); - - test('should dispose tree view', () => { - view.dispose(); - // Tree view should be disposed - // In a real test, we would verify the tree view's dispose was called - }); - }); - - suite('editEnvironmentName', () => { - const testEnvironmentId = 'test-env-id'; - const testInterpreter: PythonEnvironment = { - id: 'test-python-id', - uri: Uri.file('/usr/bin/python3'), - version: { major: 3, minor: 11, patch: 0, raw: '3.11.0' } - } as PythonEnvironment; - - const testEnvironment: DeepnoteEnvironment = { - id: testEnvironmentId, - name: 'Original Name', - pythonInterpreter: testInterpreter, - venvPath: Uri.file('/path/to/venv'), - managedVenv: true, - createdAt: new Date(), - lastUsedAt: new Date() - }; - - const testEnvironmentExternal: DeepnoteEnvironment = { - id: testEnvironmentId, - name: 'Original Name', - pythonInterpreter: testInterpreter, - venvPath: Uri.file('/path/to/external/venv'), - managedVenv: false, - createdAt: new Date(), - lastUsedAt: new Date() - }; - - setup(() => { - // Reset mocks between tests - resetCalls(mockConfigManager); - resetCalls(mockedVSCodeNamespaces.window); - }); - - test('should return early if environment not found', async () => { - when(mockConfigManager.getEnvironment(testEnvironmentId)).thenReturn(undefined); - - await view.editEnvironmentName(testEnvironmentId); - - // Should not call showInputBox or updateEnvironment - verify(mockedVSCodeNamespaces.window.showInputBox(anything())).never(); - verify(mockConfigManager.updateEnvironment(anything(), anything())).never(); - }); - - test('should return early if user cancels input', async () => { - when(mockConfigManager.getEnvironment(testEnvironmentId)).thenReturn(testEnvironment); - when(mockedVSCodeNamespaces.window.showInputBox(anything())).thenReturn(Promise.resolve(undefined)); - - await view.editEnvironmentName(testEnvironmentId); - - verify(mockedVSCodeNamespaces.window.showInputBox(anything())).once(); - verify(mockConfigManager.updateEnvironment(anything(), anything())).never(); - }); - - test('should return early if user provides same name', async () => { - when(mockConfigManager.getEnvironment(testEnvironmentId)).thenReturn(testEnvironment); - when(mockedVSCodeNamespaces.window.showInputBox(anything())).thenReturn(Promise.resolve('Original Name')); - - await view.editEnvironmentName(testEnvironmentId); - - verify(mockedVSCodeNamespaces.window.showInputBox(anything())).once(); - verify(mockConfigManager.updateEnvironment(anything(), anything())).never(); - }); - - test('should validate that name cannot be empty', async () => { - when(mockConfigManager.getEnvironment(testEnvironmentId)).thenReturn(testEnvironment); - - // Capture the validator function - let validatorFn: ((value: string) => string | undefined) | undefined; - when(mockedVSCodeNamespaces.window.showInputBox(anything())).thenCall((options) => { - validatorFn = options.validateInput; - return Promise.resolve(undefined); - }); - - await view.editEnvironmentName(testEnvironmentId); - - assert.ok(validatorFn, 'Validator function should be provided'); - assert.strictEqual(validatorFn!(''), 'Name cannot be empty'); - assert.strictEqual(validatorFn!(' '), 'Name cannot be empty'); - assert.strictEqual(validatorFn!('Valid Name'), undefined); - }); - - test('should successfully rename environment with trimmed name', async () => { - when(mockConfigManager.getEnvironment(testEnvironmentId)).thenReturn(testEnvironment); - when(mockedVSCodeNamespaces.window.showInputBox(anything())).thenReturn(Promise.resolve(' New Name ')); - when(mockConfigManager.updateEnvironment(anything(), anything())).thenResolve(); - when(mockedVSCodeNamespaces.window.showInformationMessage(anything())).thenResolve(); - - await view.editEnvironmentName(testEnvironmentId); - - verify(mockConfigManager.updateEnvironment(testEnvironmentId, deepEqual({ name: 'New Name' }))).once(); - verify(mockedVSCodeNamespaces.window.showInformationMessage(anything())).once(); - }); - - test('should show error message if update fails', async () => { - when(mockConfigManager.getEnvironment(testEnvironmentId)).thenReturn(testEnvironment); - when(mockedVSCodeNamespaces.window.showInputBox(anything())).thenReturn(Promise.resolve('New Name')); - when(mockConfigManager.updateEnvironment(anything(), anything())).thenReject(new Error('Update failed')); - when(mockedVSCodeNamespaces.window.showErrorMessage(anything())).thenResolve(); - - await view.editEnvironmentName(testEnvironmentId); - - verify(mockConfigManager.updateEnvironment(anything(), anything())).once(); - verify(mockedVSCodeNamespaces.window.showErrorMessage(anything())).once(); - }); - - test('should call updateEnvironment with correct parameters', async () => { - const newName = 'Updated Environment Name'; - when(mockConfigManager.getEnvironment(testEnvironmentId)).thenReturn(testEnvironment); - when(mockedVSCodeNamespaces.window.showInputBox(anything())).thenReturn(Promise.resolve(newName)); - when(mockConfigManager.updateEnvironment(anything(), anything())).thenResolve(); - when(mockedVSCodeNamespaces.window.showInformationMessage(anything())).thenResolve(); - - await view.editEnvironmentName(testEnvironmentId); - - verify(mockConfigManager.updateEnvironment(testEnvironmentId, deepEqual({ name: newName }))).once(); - }); - - test('should preserve existing environment configuration except name', async () => { - const envWithPackages: DeepnoteEnvironment = { - ...testEnvironment, - packages: ['numpy', 'pandas'], - description: 'Test description' - }; - - when(mockConfigManager.getEnvironment(testEnvironmentId)).thenReturn(envWithPackages); - when(mockedVSCodeNamespaces.window.showInputBox(anything())).thenReturn(Promise.resolve('New Name')); - when(mockConfigManager.updateEnvironment(anything(), anything())).thenResolve(); - when(mockedVSCodeNamespaces.window.showInformationMessage(anything())).thenResolve(); - - await view.editEnvironmentName(testEnvironmentId); - - // Should only update the name, not other properties - verify(mockConfigManager.updateEnvironment(testEnvironmentId, deepEqual({ name: 'New Name' }))).once(); - }); - - test('should show input box with current name as default value', async () => { - when(mockConfigManager.getEnvironment(testEnvironmentId)).thenReturn(testEnvironment); - - let capturedOptions: any; - when(mockedVSCodeNamespaces.window.showInputBox(anything())).thenCall((options) => { - capturedOptions = options; - return Promise.resolve(undefined); - }); - - await view.editEnvironmentName(testEnvironmentId); - - assert.ok(capturedOptions, 'Options should be provided'); - assert.strictEqual(capturedOptions.value, 'Original Name'); - }); - - test('should successfully rename external environment (managedVenv: false)', async () => { - when(mockConfigManager.getEnvironment(testEnvironmentId)).thenReturn(testEnvironmentExternal); - when(mockedVSCodeNamespaces.window.showInputBox(anything())).thenReturn( - Promise.resolve('New External Name') - ); - when(mockConfigManager.updateEnvironment(anything(), anything())).thenResolve(); - when(mockedVSCodeNamespaces.window.showInformationMessage(anything())).thenResolve(); - - await view.editEnvironmentName(testEnvironmentId); - - verify( - mockConfigManager.updateEnvironment(testEnvironmentId, deepEqual({ name: 'New External Name' })) - ).once(); - verify(mockedVSCodeNamespaces.window.showInformationMessage(anything())).once(); - }); - }); - - suite('createEnvironmentCommand', () => { - const testInterpreter: PythonEnvironment = { - id: 'test-python-id', - uri: Uri.file('/usr/bin/python3.11'), - version: { major: 3, minor: 11, patch: 0, raw: '3.11.0' } - } as PythonEnvironment; - - const createdEnvironment: DeepnoteEnvironment = { - id: 'new-env-id', - name: 'My Data Science Environment', - pythonInterpreter: testInterpreter, - venvPath: Uri.file('/path/to/new/venv'), - managedVenv: true, - packages: ['pandas', 'numpy', 'matplotlib'], - description: 'Environment for data science work', - createdAt: new Date(), - lastUsedAt: new Date() - }; - - setup(() => { - resetCalls(mockConfigManager); - resetCalls(mockPythonApiProvider); - resetCalls(mockedVSCodeNamespaces.window); - }); - - test('should successfully create environment with all inputs', async () => { - // Set up Python environments for helper functions to use - const mockResolvedEnvironment = { - id: testInterpreter.id, - path: testInterpreter.uri.fsPath, - version: { - major: 3, - minor: 11, - micro: 0 - }, - environment: { - name: 'test-env', - folderUri: testInterpreter.uri - }, - tools: [], - executable: { - uri: testInterpreter.uri - } - }; - - // Configure the Python API that was initialized in setup() - whenKnownEnvironments(pythonEnvironments).thenReturn([mockResolvedEnvironment]); - - // Mock the Python API provider to return the same environments - const mockPythonApi = { - environments: { - known: [mockResolvedEnvironment] - } - }; - when(mockPythonApiProvider.getNewApi()).thenResolve(mockPythonApi as any); - - // Mock interpreter selection - return the first item - when(mockedVSCodeNamespaces.window.showQuickPick(anything(), anything())).thenCall((items: any[]) => { - return Promise.resolve(items[0]); - }); - - // Mock input boxes for name, packages, and description - let inputBoxCallCount = 0; - when(mockedVSCodeNamespaces.window.showInputBox(anything())).thenCall(() => { - inputBoxCallCount++; - if (inputBoxCallCount === 1) { - // First call: environment name - return Promise.resolve('My Data Science Environment'); - } else if (inputBoxCallCount === 2) { - // Second call: packages - return Promise.resolve('pandas, numpy, matplotlib'); - } else { - // Third call: description - return Promise.resolve('Environment for data science work'); - } - }); - - // Mock list environments to return empty (no duplicates) - when(mockConfigManager.listEnvironments()).thenReturn([]); - - // Mock window.withProgress to execute the callback - when(mockedVSCodeNamespaces.window.withProgress(anything(), anything())).thenCall( - (_options: ProgressOptions, callback: Function) => { - const mockProgress = { - report: (_value: { message?: string; increment?: number }) => { - // Mock progress reporting - } - }; - const mockToken = { - isCancellationRequested: false, - onCancellationRequested: (_listener: any) => { - return { - dispose: () => { - // Mock disposable - } - }; - } - }; - return callback(mockProgress, mockToken); - } - ); - - // Mock environment creation - when(mockConfigManager.createEnvironment(anything(), anything())).thenResolve(createdEnvironment); - - // Mock success message - when(mockedVSCodeNamespaces.window.showInformationMessage(anything())).thenResolve(undefined); - - // Execute the command - await view.createEnvironmentCommand(); - - // Verify API calls - verify(mockPythonApiProvider.getNewApi()).once(); - verify(mockedVSCodeNamespaces.window.showQuickPick(anything(), anything())).once(); - verify(mockedVSCodeNamespaces.window.showInputBox(anything())).times(3); - verify(mockConfigManager.listEnvironments()).once(); - verify(mockedVSCodeNamespaces.window.withProgress(anything(), anything())).once(); - - // Verify createEnvironment was called with correct options - verify(mockConfigManager.createEnvironment(anything(), anything())).once(); - const [capturedOptions, capturedToken] = capture(mockConfigManager.createEnvironment).last(); - assert.strictEqual(capturedOptions.name, 'My Data Science Environment'); - assert.deepStrictEqual(capturedOptions.packages, ['pandas', 'numpy', 'matplotlib']); - assert.strictEqual(capturedOptions.description, 'Environment for data science work'); - // Don't assert on pythonInterpreter.id as the helper functions transform it - assert.ok(capturedOptions.pythonInterpreter, 'Python interpreter should be provided'); - assert.ok(capturedOptions.pythonInterpreter.uri, 'Python interpreter uri should be present'); - assert.ok(capturedOptions.pythonInterpreter.id, 'Python interpreter id should be present'); - assert.ok(capturedToken, 'Cancellation token should be provided'); - - // Verify success message was shown - verify(mockedVSCodeNamespaces.window.showInformationMessage(anything())).once(); - }); - }); - - suite('deleteEnvironmentCommand', () => { - const testEnvironmentId = 'test-env-id-to-delete'; - const testExternalEnvironmentId = 'test-external-env-id-to-delete'; - const testInterpreter: PythonEnvironment = { - id: 'test-python-id', - uri: Uri.file('/usr/bin/python3.11'), - version: { major: 3, minor: 11, patch: 0, raw: '3.11.0' } - } as PythonEnvironment; - - const testEnvironment: DeepnoteEnvironment = { - id: testEnvironmentId, - name: 'Environment to Delete', - pythonInterpreter: testInterpreter, - venvPath: Uri.file('/path/to/venv'), - managedVenv: true, - createdAt: new Date(), - lastUsedAt: new Date() - }; - - const testExternalEnvironment: DeepnoteEnvironment = { - id: testExternalEnvironmentId, - name: 'External Environment to Delete', - pythonInterpreter: testInterpreter, - venvPath: Uri.file('/path/to/external/venv'), - managedVenv: false, - createdAt: new Date(), - lastUsedAt: new Date() - }; - - setup(() => { - resetCalls(mockConfigManager); - resetCalls(mockNotebookEnvironmentMapper); - resetCalls(mockedVSCodeNamespaces.window); - }); - - test('should successfully delete environment with notebooks using it', async () => { - // Mock environment exists - when(mockConfigManager.getEnvironment(testEnvironmentId)).thenReturn(testEnvironment); - - // Mock user confirmation - user clicks "Delete" button - when(mockedVSCodeNamespaces.window.showWarningMessage(anything(), anything(), anything())).thenReturn( - Promise.resolve('Delete') - ); - - // Mock notebooks using this environment - const notebook1Uri = Uri.file('/workspace/notebook1.deepnote'); - const notebook2Uri = Uri.file('/workspace/notebook2.deepnote'); - when(mockNotebookEnvironmentMapper.getNotebooksUsingEnvironment(testEnvironmentId)).thenReturn([ - notebook1Uri, - notebook2Uri - ]); - - // Mock removing environment mappings - when(mockNotebookEnvironmentMapper.removeEnvironmentForNotebook(anything())).thenResolve(); - - // Mock window.withProgress to execute the callback - when(mockedVSCodeNamespaces.window.withProgress(anything(), anything())).thenCall( - (_options: ProgressOptions, callback: Function) => { - const mockProgress = { - report: (_value: { message?: string; increment?: number }) => { - // Mock progress reporting - } - }; - const mockToken: CancellationToken = { - isCancellationRequested: false, - onCancellationRequested: (_listener: any) => { - return { - dispose: () => { - // Mock disposable - } - }; - } - }; - return callback(mockProgress, mockToken); - } - ); - - // Mock environment deletion - when(mockConfigManager.deleteEnvironment(testEnvironmentId, anything())).thenResolve(); - - // Mock success message - when(mockedVSCodeNamespaces.window.showInformationMessage(anything())).thenResolve(undefined); - - // Execute the command - await view.deleteEnvironmentCommand(testEnvironmentId); - - // Verify API calls - verify(mockConfigManager.getEnvironment(testEnvironmentId)).once(); - verify(mockedVSCodeNamespaces.window.showWarningMessage(anything(), anything(), anything())).once(); - verify(mockNotebookEnvironmentMapper.getNotebooksUsingEnvironment(testEnvironmentId)).once(); - - // Verify environment mappings were removed for both notebooks - verify(mockNotebookEnvironmentMapper.removeEnvironmentForNotebook(notebook1Uri)).once(); - verify(mockNotebookEnvironmentMapper.removeEnvironmentForNotebook(notebook2Uri)).once(); - - // Verify environment deletion - verify(mockConfigManager.deleteEnvironment(testEnvironmentId, anything())).once(); - - // Verify success message was shown - verify(mockedVSCodeNamespaces.window.showInformationMessage(anything())).once(); - }); - - test('should dispose kernels from open notebooks using the deleted environment', async () => { - // Mock environment exists - when(mockConfigManager.getEnvironment(testEnvironmentId)).thenReturn(testEnvironment); - - // Mock user confirmation - when(mockedVSCodeNamespaces.window.showWarningMessage(anything(), anything(), anything())).thenReturn( - Promise.resolve('Delete') - ); - - // Mock notebooks using this environment - when(mockNotebookEnvironmentMapper.getNotebooksUsingEnvironment(testEnvironmentId)).thenReturn([]); - when(mockNotebookEnvironmentMapper.removeEnvironmentForNotebook(anything())).thenResolve(); - - // Mock open notebooks with kernels - const openNotebook1 = { - uri: Uri.file('/workspace/open-notebook1.deepnote'), - notebookType: 'deepnote', - isClosed: false - } as any; - - const openNotebook2 = { - uri: Uri.file('/workspace/open-notebook2.deepnote'), - notebookType: 'jupyter-notebook', - isClosed: false - } as any; - - const openNotebook3 = { - uri: Uri.file('/workspace/open-notebook3.deepnote'), - notebookType: 'deepnote', - isClosed: false - } as any; - - // Mock workspace.notebookDocuments - when(mockedVSCodeNamespaces.workspace.notebookDocuments).thenReturn([ - openNotebook1, - openNotebook2, - openNotebook3 - ]); - - // Mock kernels - const mockKernel1 = { - kernelConnectionMetadata: { - kind: 'startUsingDeepnoteKernel', - serverProviderHandle: { - handle: createDeepnoteServerConfigHandle(testEnvironmentId, openNotebook1.uri) - } - }, - dispose: sinon.stub().resolves() - }; - - const mockKernel3 = { - kernelConnectionMetadata: { - kind: 'startUsingDeepnoteKernel', - serverProviderHandle: { - handle: createDeepnoteServerConfigHandle('different-env-id', openNotebook3.uri) - } - }, - dispose: sinon.stub().resolves() - }; - - // Mock kernelProvider.get() - when(mockKernelProvider.get(openNotebook1)).thenReturn(mockKernel1 as any); - when(mockKernelProvider.get(openNotebook2)).thenReturn(undefined); // No kernel for jupyter notebook - when(mockKernelProvider.get(openNotebook3)).thenReturn(mockKernel3 as any); - - // Mock window.withProgress - when(mockedVSCodeNamespaces.window.withProgress(anything(), anything())).thenCall( - (_options: ProgressOptions, callback: Function) => { - const mockProgress = { - report: () => { - // Mock progress reporting - } - }; - const mockToken: CancellationToken = { - isCancellationRequested: false, - onCancellationRequested: () => ({ - dispose: () => { - // Mock disposable - } - }) - }; - return callback(mockProgress, mockToken); - } - ); - - // Mock environment deletion - when(mockConfigManager.deleteEnvironment(testEnvironmentId, anything())).thenResolve(); - when(mockedVSCodeNamespaces.window.showInformationMessage(anything())).thenResolve(undefined); - - // Execute the command - await view.deleteEnvironmentCommand(testEnvironmentId); - - // Verify that only kernel1 (using the deleted environment) was disposed - assert.strictEqual(mockKernel1.dispose.callCount, 1, 'Kernel using deleted environment should be disposed'); - assert.strictEqual( - mockKernel3.dispose.callCount, - 0, - 'Kernel using different environment should not be disposed' - ); - }); - - test('should successfully delete external environment (managedVenv: false) with same side effects', async () => { - // Mock environment exists - external environment - when(mockConfigManager.getEnvironment(testExternalEnvironmentId)).thenReturn(testExternalEnvironment); - - // Mock user confirmation - user clicks "Delete" button - when(mockedVSCodeNamespaces.window.showWarningMessage(anything(), anything(), anything())).thenReturn( - Promise.resolve('Delete') - ); - - // Mock notebooks using this environment - const notebook1Uri = Uri.file('/workspace/notebook1.deepnote'); - const notebook2Uri = Uri.file('/workspace/notebook2.deepnote'); - when(mockNotebookEnvironmentMapper.getNotebooksUsingEnvironment(testExternalEnvironmentId)).thenReturn([ - notebook1Uri, - notebook2Uri - ]); - - // Mock removing environment mappings - when(mockNotebookEnvironmentMapper.removeEnvironmentForNotebook(anything())).thenResolve(); - - // Mock open notebooks - when(mockedVSCodeNamespaces.workspace.notebookDocuments).thenReturn([]); - - // Mock window.withProgress to execute the callback - when(mockedVSCodeNamespaces.window.withProgress(anything(), anything())).thenCall( - (_options: ProgressOptions, callback: Function) => { - const mockProgress = { - report: (_value: { message?: string; increment?: number }) => { - // Mock progress reporting - } - }; - const mockToken: CancellationToken = { - isCancellationRequested: false, - onCancellationRequested: (_listener: any) => { - return { - dispose: () => { - // Mock disposable - } - }; - } - }; - return callback(mockProgress, mockToken); - } - ); - - // Mock environment deletion - the manager handles managedVenv check internally - when(mockConfigManager.deleteEnvironment(testExternalEnvironmentId, anything())).thenResolve(); - - // Mock success message - when(mockedVSCodeNamespaces.window.showInformationMessage(anything())).thenResolve(undefined); - - // Execute the command - await view.deleteEnvironmentCommand(testExternalEnvironmentId); - - // Verify API calls - same as for managed venv - verify(mockConfigManager.getEnvironment(testExternalEnvironmentId)).once(); - verify(mockedVSCodeNamespaces.window.showWarningMessage(anything(), anything(), anything())).once(); - verify(mockNotebookEnvironmentMapper.getNotebooksUsingEnvironment(testExternalEnvironmentId)).once(); - - // Verify environment mappings were removed for both notebooks - verify(mockNotebookEnvironmentMapper.removeEnvironmentForNotebook(notebook1Uri)).once(); - verify(mockNotebookEnvironmentMapper.removeEnvironmentForNotebook(notebook2Uri)).once(); - - // Verify environment deletion - the manager is responsible for checking managedVenv - verify(mockConfigManager.deleteEnvironment(testExternalEnvironmentId, anything())).once(); - - // Verify success message was shown - verify(mockedVSCodeNamespaces.window.showInformationMessage(anything())).once(); - }); - - test('should dispose kernels from open notebooks using deleted external environment (managedVenv: false)', async () => { - // Mock environment exists - external environment - when(mockConfigManager.getEnvironment(testExternalEnvironmentId)).thenReturn(testExternalEnvironment); - - // Mock user confirmation - when(mockedVSCodeNamespaces.window.showWarningMessage(anything(), anything(), anything())).thenReturn( - Promise.resolve('Delete') - ); - - // Mock notebooks using this environment - when(mockNotebookEnvironmentMapper.getNotebooksUsingEnvironment(testExternalEnvironmentId)).thenReturn([]); - when(mockNotebookEnvironmentMapper.removeEnvironmentForNotebook(anything())).thenResolve(); - - // Mock open notebooks with kernels - const openNotebook1 = { - uri: Uri.file('/workspace/open-notebook1.deepnote'), - notebookType: 'deepnote', - isClosed: false - } as any; - - when(mockedVSCodeNamespaces.workspace.notebookDocuments).thenReturn([openNotebook1]); - - // Mock kernel using the external environment - const mockKernel1 = { - kernelConnectionMetadata: { - kind: 'startUsingDeepnoteKernel', - serverProviderHandle: { - handle: createDeepnoteServerConfigHandle(testExternalEnvironmentId, openNotebook1.uri) - } - }, - dispose: sinon.stub().resolves() - }; - - when(mockKernelProvider.get(openNotebook1)).thenReturn(mockKernel1 as any); - - // Mock window.withProgress - when(mockedVSCodeNamespaces.window.withProgress(anything(), anything())).thenCall( - (_options: ProgressOptions, callback: Function) => { - const mockProgress = { - report: () => { - // Mock progress reporting - } - }; - const mockToken: CancellationToken = { - isCancellationRequested: false, - onCancellationRequested: () => ({ - dispose: () => { - // Mock disposable - } - }) - }; - return callback(mockProgress, mockToken); - } - ); - - // Mock environment deletion - when(mockConfigManager.deleteEnvironment(testExternalEnvironmentId, anything())).thenResolve(); - when(mockedVSCodeNamespaces.window.showInformationMessage(anything())).thenResolve(undefined); - - // Execute the command - await view.deleteEnvironmentCommand(testExternalEnvironmentId); - - // Verify that kernel was disposed even for external environment - assert.strictEqual( - mockKernel1.dispose.callCount, - 1, - 'Kernel using deleted external environment should be disposed' - ); - }); - }); - - suite('deleteEnvironmentCommand - stop servers before removing mappings (the load-bearing fix)', () => { - const envId = 'env-to-delete'; - const testInterpreter: PythonEnvironment = { - id: 'test-python-id', - uri: Uri.file('/usr/bin/python3.11') - }; - - const environment: DeepnoteEnvironment = { - id: envId, - name: 'Environment to Delete', - pythonInterpreter: testInterpreter, - venvPath: Uri.file('/path/to/venv'), - managedVenv: true, - createdAt: new Date(), - lastUsedAt: new Date() - }; - - // One notebook is OPEN; the other is CLOSED but its server is still running. - const openNotebookUri = Uri.file('/workspace/open.deepnote'); - const closedNotebookUri = Uri.file('/workspace/closed-but-running.deepnote'); - - setup(() => { - resetCalls(mockConfigManager); - resetCalls(mockNotebookEnvironmentMapper); - resetCalls(mockServerStarter); - resetCalls(mockedVSCodeNamespaces.window); - resetCalls(mockedVSCodeNamespaces.workspace); - }); - - // Wire a shared, ordered call log so we can assert that every stopServer happens BEFORE - // any removeEnvironmentForNotebook, and both happen before deleteEnvironment. - const wireOrderedDeletion = (callLog: string[], options?: { stopRejectsFor?: Uri }): void => { - when(mockConfigManager.getEnvironment(envId)).thenReturn(environment); - when(mockedVSCodeNamespaces.window.showWarningMessage(anything(), anything(), anything())).thenReturn( - Promise.resolve('Delete') - ); - when(mockNotebookEnvironmentMapper.getNotebooksUsingEnvironment(envId)).thenReturn([ - openNotebookUri, - closedNotebookUri - ]); - - // Only the OPEN notebook is present in workspace.notebookDocuments — the other is closed. - const mockOpenNotebookDoc = mock(); - when(mockOpenNotebookDoc.uri).thenReturn(openNotebookUri); - when(mockOpenNotebookDoc.notebookType).thenReturn('deepnote'); - when(mockOpenNotebookDoc.isClosed).thenReturn(false); - const openNotebookDoc = instance(mockOpenNotebookDoc); - when(mockedVSCodeNamespaces.workspace.notebookDocuments).thenReturn([openNotebookDoc]); - when(mockKernelProvider.get(openNotebookDoc)).thenReturn(undefined); - - when(mockServerStarter.stopServer(anything(), anything())).thenCall((uri: Uri) => { - callLog.push(`stop:${uri.toString()}`); - if (options?.stopRejectsFor && uri.toString() === options.stopRejectsFor.toString()) { - return Promise.reject(new Error('stop failed')); - } - return Promise.resolve(); - }); - when(mockNotebookEnvironmentMapper.removeEnvironmentForNotebook(anything())).thenCall((uri: Uri) => { - callLog.push(`remove:${uri.toString()}`); - return Promise.resolve(); - }); - when(mockConfigManager.deleteEnvironment(envId, anything())).thenCall(() => { - callLog.push('deleteEnvironment'); - return Promise.resolve(); - }); - - when(mockedVSCodeNamespaces.window.withProgress(anything(), anything())).thenCall( - (_options: ProgressOptions, callback: Function) => { - const mockProgress = { report: () => undefined }; - const mockToken: CancellationToken = { - isCancellationRequested: false, - onCancellationRequested: () => ({ dispose: () => undefined }) - }; - return callback(mockProgress, mockToken); - } - ); - when(mockedVSCodeNamespaces.window.showInformationMessage(anything())).thenResolve(undefined); - }; - - test('every stopServer precedes every removeEnvironmentForNotebook, and both precede deleteEnvironment (catches inverted order)', async () => { - const callLog: string[] = []; - wireOrderedDeletion(callLog); - - await view.deleteEnvironmentCommand(envId); - - const stopIndexes = callLog.map((entry, i) => (entry.startsWith('stop:') ? i : -1)).filter((i) => i >= 0); - const removeIndexes = callLog - .map((entry, i) => (entry.startsWith('remove:') ? i : -1)) - .filter((i) => i >= 0); - const deleteIndex = callLog.indexOf('deleteEnvironment'); - - assert.strictEqual(stopIndexes.length, 2, 'both servers stopped'); - assert.strictEqual(removeIndexes.length, 2, 'both mappings removed'); - assert.isAtLeast(deleteIndex, 0, 'environment deleted'); - - const lastStop = Math.max(...stopIndexes); - const firstRemove = Math.min(...removeIndexes); - const lastRemove = Math.max(...removeIndexes); - - assert.isBelow( - lastStop, - firstRemove, - 'all stopServer calls must come BEFORE any removeEnvironmentForNotebook (else the mapper list would already be empty when stopping)' - ); - assert.isBelow(lastRemove, deleteIndex, 'mappings must be removed before the environment is deleted'); - }); - - test('a stopServer rejection for one URI does NOT abort the deletion — the other still stops and deletion proceeds (per-iteration try/catch)', async () => { - const callLog: string[] = []; - wireOrderedDeletion(callLog, { stopRejectsFor: openNotebookUri }); - - await view.deleteEnvironmentCommand(envId); - - // The failing stop was attempted, the other still ran, and deletion completed. - verify(mockServerStarter.stopServer(openNotebookUri, anything())).once(); - verify(mockServerStarter.stopServer(closedNotebookUri, anything())).once(); - assert.include( - callLog, - `stop:${closedNotebookUri.toString()}`, - 'second server still stopped after first threw' - ); - verify(mockNotebookEnvironmentMapper.removeEnvironmentForNotebook(openNotebookUri)).once(); - verify(mockNotebookEnvironmentMapper.removeEnvironmentForNotebook(closedNotebookUri)).once(); - verify(mockConfigManager.deleteEnvironment(envId, anything())).once(); - assert.include(callLog, 'deleteEnvironment', 'environment deletion still proceeds after a stop failure'); - }); - }); - - suite('selectEnvironmentForNotebook', () => { - const testInterpreter1: PythonEnvironment = { - id: 'python-1', - uri: Uri.file('/usr/bin/python3.11'), - version: { major: 3, minor: 11, patch: 0, raw: '3.11.0' } - } as PythonEnvironment; - - const testInterpreter2: PythonEnvironment = { - id: 'python-2', - uri: Uri.file('/usr/bin/python3.12'), - version: { major: 3, minor: 12, patch: 0, raw: '3.12.0' } - } as PythonEnvironment; - - const currentEnvironment: DeepnoteEnvironment = { - id: 'current-env-id', - name: 'Current Environment', - pythonInterpreter: testInterpreter1, - venvPath: Uri.file('/path/to/current/venv'), - managedVenv: true, - createdAt: new Date(), - lastUsedAt: new Date() - }; - - const currentExternalEnvironment: DeepnoteEnvironment = { - id: 'current-external-env-id', - name: 'Current External Environment', - pythonInterpreter: testInterpreter1, - venvPath: Uri.file('/path/to/external/current/venv'), - managedVenv: false, - createdAt: new Date(), - lastUsedAt: new Date() - }; - - const newEnvironment: DeepnoteEnvironment = { - id: 'new-env-id', - name: 'New Environment', - pythonInterpreter: testInterpreter2, - venvPath: Uri.file('/path/to/new/venv'), - managedVenv: true, - packages: ['pandas', 'numpy'], - createdAt: new Date(), - lastUsedAt: new Date() - }; - - const newExternalEnvironment: DeepnoteEnvironment = { - id: 'new-external-env-id', - name: 'New External Environment', - pythonInterpreter: testInterpreter2, - venvPath: Uri.file('/path/to/external/new/venv'), - managedVenv: false, - packages: ['requests'], - createdAt: new Date(), - lastUsedAt: new Date() - }; - - setup(() => { - resetCalls(mockConfigManager); - resetCalls(mockNotebookEnvironmentMapper); - resetCalls(mockKernelAutoSelector); - resetCalls(mockKernelProvider); - resetCalls(mockedVSCodeNamespaces.window); - }); - - test('should successfully switch to a different environment', async () => { - // Mock active notebook - const notebookUri = Uri.file('/workspace/notebook.deepnote'); - const mockNotebook = { - uri: notebookUri, - notebookType: 'deepnote', - cellCount: 5 - }; - const mockNotebookEditor = { - notebook: mockNotebook - }; - - when(mockedVSCodeNamespaces.window.activeNotebookEditor).thenReturn(mockNotebookEditor as any); - - // Mock current environment mapping - const baseFileUri = notebookUri.with({ query: '', fragment: '' }); - when(mockNotebookEnvironmentMapper.getEnvironmentForNotebook(baseFileUri)).thenReturn( - currentEnvironment.id - ); - when(mockConfigManager.getEnvironment(currentEnvironment.id)).thenReturn(currentEnvironment); - - // Mock available environments - when(mockConfigManager.listEnvironments()).thenReturn([currentEnvironment, newEnvironment]); - - // Mock environment status - when(mockConfigManager.getEnvironment(currentEnvironment.id)).thenReturn(currentEnvironment); - when(mockConfigManager.getEnvironment(newEnvironment.id)).thenReturn(newEnvironment); - - // Mock user selecting the new environment - when(mockedVSCodeNamespaces.window.showQuickPick(anything(), anything())).thenCall((items: any[]) => { - // Find the item for the new environment - const selectedItem = items.find((item) => item.environmentId === newEnvironment.id); - return Promise.resolve(selectedItem); - }); - - // Mock no executing cells - const mockKernel = { id: 'test-kernel' }; - const mockKernelExecution = { - pendingCells: [] - }; - when(mockKernelProvider.get(mockNotebook as any)).thenReturn(mockKernel as any); - when(mockKernelProvider.getKernelExecution(mockKernel as any)).thenReturn(mockKernelExecution as any); - - // Mock window.withProgress to execute the callback - when(mockedVSCodeNamespaces.window.withProgress(anything(), anything())).thenCall( - (_options: ProgressOptions, callback: Function) => { - return callback(); - } - ); - - // Mock environment mapping update - when(mockNotebookEnvironmentMapper.setEnvironmentForNotebook(baseFileUri, newEnvironment.id)).thenResolve(); - - // Mock controller rebuild - when(mockKernelAutoSelector.rebuildController(mockNotebook as any, anything(), anything())).thenResolve(); - - // Mock success message - when(mockedVSCodeNamespaces.window.showInformationMessage(anything())).thenResolve(undefined); - - // Execute the command - await view.selectEnvironmentForNotebook({ notebook: mockNotebook as NotebookDocument }); - - // Verify API calls - verify(mockNotebookEnvironmentMapper.getEnvironmentForNotebook(baseFileUri)).once(); - verify(mockConfigManager.getEnvironment(currentEnvironment.id)).once(); - verify(mockConfigManager.listEnvironments()).once(); - verify(mockConfigManager.getEnvironment(currentEnvironment.id)).once(); - verify(mockedVSCodeNamespaces.window.showQuickPick(anything(), anything())).once(); - verify(mockKernelProvider.get(mockNotebook as any)).once(); - verify(mockKernelProvider.getKernelExecution(mockKernel as any)).once(); - - // Verify environment switch - verify(mockedVSCodeNamespaces.window.withProgress(anything(), anything())).once(); - verify(mockNotebookEnvironmentMapper.setEnvironmentForNotebook(baseFileUri, newEnvironment.id)).once(); - verify(mockKernelAutoSelector.rebuildController(mockNotebook as any, anything(), anything())).once(); - - // Verify success message was shown - verify(mockedVSCodeNamespaces.window.showInformationMessage(anything())).once(); - }); - - test('should successfully switch from managed to external environment (managedVenv: false)', async () => { - // Mock active notebook - const notebookUri = Uri.file('/workspace/notebook.deepnote'); - const mockNotebook = { - uri: notebookUri, - notebookType: 'deepnote', - cellCount: 5 - }; - const mockNotebookEditor = { - notebook: mockNotebook - }; - - when(mockedVSCodeNamespaces.window.activeNotebookEditor).thenReturn(mockNotebookEditor as any); - - // Mock current environment mapping (managed) - const baseFileUri = notebookUri.with({ query: '', fragment: '' }); - when(mockNotebookEnvironmentMapper.getEnvironmentForNotebook(baseFileUri)).thenReturn( - currentEnvironment.id - ); - when(mockConfigManager.getEnvironment(currentEnvironment.id)).thenReturn(currentEnvironment); - - // Mock available environments (mix of managed and external) - when(mockConfigManager.listEnvironments()).thenReturn([ - currentEnvironment, - newEnvironment, - newExternalEnvironment - ]); - - // Mock environment status - when(mockConfigManager.getEnvironment(newExternalEnvironment.id)).thenReturn(newExternalEnvironment); - - // Mock user selecting the new external environment - when(mockedVSCodeNamespaces.window.showQuickPick(anything(), anything())).thenCall((items: any[]) => { - // Find the item for the new external environment - const selectedItem = items.find((item) => item.environmentId === newExternalEnvironment.id); - return Promise.resolve(selectedItem); - }); - - // Mock no executing cells - const mockKernel = { id: 'test-kernel' }; - const mockKernelExecution = { - pendingCells: [] - }; - when(mockKernelProvider.get(mockNotebook as any)).thenReturn(mockKernel as any); - when(mockKernelProvider.getKernelExecution(mockKernel as any)).thenReturn(mockKernelExecution as any); - - // Mock window.withProgress to execute the callback - when(mockedVSCodeNamespaces.window.withProgress(anything(), anything())).thenCall( - (_options: ProgressOptions, callback: Function) => { - return callback(); - } - ); - - // Mock environment mapping update - when( - mockNotebookEnvironmentMapper.setEnvironmentForNotebook(baseFileUri, newExternalEnvironment.id) - ).thenResolve(); - - // Mock controller rebuild - when(mockKernelAutoSelector.rebuildController(mockNotebook as any, anything(), anything())).thenResolve(); - - // Mock success message - when(mockedVSCodeNamespaces.window.showInformationMessage(anything())).thenResolve(undefined); - - // Execute the command - await view.selectEnvironmentForNotebook({ notebook: mockNotebook as NotebookDocument }); - - // Verify environment switch to external environment - verify(mockedVSCodeNamespaces.window.withProgress(anything(), anything())).once(); - verify( - mockNotebookEnvironmentMapper.setEnvironmentForNotebook(baseFileUri, newExternalEnvironment.id) - ).once(); - verify(mockKernelAutoSelector.rebuildController(mockNotebook as any, anything(), anything())).once(); - - // Verify success message was shown - verify(mockedVSCodeNamespaces.window.showInformationMessage(anything())).once(); - }); - - test('should successfully switch from external to managed environment', async () => { - // Mock active notebook - const notebookUri = Uri.file('/workspace/notebook.deepnote'); - const mockNotebook = { - uri: notebookUri, - notebookType: 'deepnote', - cellCount: 5 - }; - const mockNotebookEditor = { - notebook: mockNotebook - }; - - when(mockedVSCodeNamespaces.window.activeNotebookEditor).thenReturn(mockNotebookEditor as any); - - // Mock current environment mapping (external) - const baseFileUri = notebookUri.with({ query: '', fragment: '' }); - when(mockNotebookEnvironmentMapper.getEnvironmentForNotebook(baseFileUri)).thenReturn( - currentExternalEnvironment.id - ); - when(mockConfigManager.getEnvironment(currentExternalEnvironment.id)).thenReturn( - currentExternalEnvironment - ); - - // Mock available environments - when(mockConfigManager.listEnvironments()).thenReturn([currentExternalEnvironment, newEnvironment]); - - // Mock environment status - when(mockConfigManager.getEnvironment(newEnvironment.id)).thenReturn(newEnvironment); - - // Mock user selecting the new managed environment - when(mockedVSCodeNamespaces.window.showQuickPick(anything(), anything())).thenCall((items: any[]) => { - const selectedItem = items.find((item) => item.environmentId === newEnvironment.id); - return Promise.resolve(selectedItem); - }); - - // Mock no executing cells - const mockKernel = { id: 'test-kernel' }; - const mockKernelExecution = { - pendingCells: [] - }; - when(mockKernelProvider.get(mockNotebook as any)).thenReturn(mockKernel as any); - when(mockKernelProvider.getKernelExecution(mockKernel as any)).thenReturn(mockKernelExecution as any); - - // Mock window.withProgress to execute the callback - when(mockedVSCodeNamespaces.window.withProgress(anything(), anything())).thenCall( - (_options: ProgressOptions, callback: Function) => { - return callback(); - } - ); - - // Mock environment mapping update - when(mockNotebookEnvironmentMapper.setEnvironmentForNotebook(baseFileUri, newEnvironment.id)).thenResolve(); - - // Mock controller rebuild - when(mockKernelAutoSelector.rebuildController(mockNotebook as any, anything(), anything())).thenResolve(); - - // Mock success message - when(mockedVSCodeNamespaces.window.showInformationMessage(anything())).thenResolve(undefined); - - // Execute the command - await view.selectEnvironmentForNotebook({ notebook: mockNotebook as NotebookDocument }); - - // Verify environment switch from external to managed - verify(mockedVSCodeNamespaces.window.withProgress(anything(), anything())).once(); - verify(mockNotebookEnvironmentMapper.setEnvironmentForNotebook(baseFileUri, newEnvironment.id)).once(); - verify(mockKernelAutoSelector.rebuildController(mockNotebook as any, anything(), anything())).once(); - - // Verify success message was shown - verify(mockedVSCodeNamespaces.window.showInformationMessage(anything())).once(); - }); - }); - - suite('managePackages', () => { - const testEnvironmentId = 'test-env-id'; - const testExternalEnvironmentId = 'test-external-env-id'; - const testInterpreter: PythonEnvironment = { - id: 'test-python-id', - uri: Uri.file('/usr/bin/python3'), - version: { major: 3, minor: 11, patch: 0, raw: '3.11.0' } - } as PythonEnvironment; - - const testEnvironment: DeepnoteEnvironment = { - id: testEnvironmentId, - name: 'Test Environment', - pythonInterpreter: testInterpreter, - venvPath: Uri.file('/path/to/venv'), - managedVenv: true, - packages: ['numpy', 'pandas'], - createdAt: new Date(), - lastUsedAt: new Date() - }; - - const testExternalEnvironment: DeepnoteEnvironment = { - id: testExternalEnvironmentId, - name: 'Test External Environment', - pythonInterpreter: testInterpreter, - venvPath: Uri.file('/path/to/external/venv'), - managedVenv: false, - packages: ['requests'], - createdAt: new Date(), - lastUsedAt: new Date() - }; - - setup(() => { - resetCalls(mockConfigManager); - resetCalls(mockedVSCodeNamespaces.window); - }); - - test('should call environmentManager.updateEnvironment with parsed packages', async () => { - when(mockConfigManager.getEnvironment(testEnvironmentId)).thenReturn(testEnvironment); - when(mockedVSCodeNamespaces.window.showInputBox(anything())).thenReturn( - Promise.resolve('matplotlib, scipy, sklearn') - ); - when(mockConfigManager.updateEnvironment(anything(), anything())).thenResolve(); - - when(mockedVSCodeNamespaces.window.withProgress(anything(), anything())).thenCall( - (_options: ProgressOptions, callback: Function) => { - return callback(); - } - ); - - when(mockedVSCodeNamespaces.window.showInformationMessage(anything())).thenResolve(undefined); - - await (view as any).managePackages(testEnvironmentId); - - verify( - mockConfigManager.updateEnvironment( - testEnvironmentId, - deepEqual({ packages: ['matplotlib', 'scipy', 'sklearn'] }) - ) - ).once(); - }); - - test('should update packages for external environment (managedVenv: false)', async () => { - when(mockConfigManager.getEnvironment(testExternalEnvironmentId)).thenReturn(testExternalEnvironment); - when(mockedVSCodeNamespaces.window.showInputBox(anything())).thenReturn( - Promise.resolve('flask, sqlalchemy') - ); - when(mockConfigManager.updateEnvironment(anything(), anything())).thenResolve(); - - when(mockedVSCodeNamespaces.window.withProgress(anything(), anything())).thenCall( - (_options: ProgressOptions, callback: Function) => { - return callback(); - } - ); - - when(mockedVSCodeNamespaces.window.showInformationMessage(anything())).thenResolve(undefined); - - await (view as any).managePackages(testExternalEnvironmentId); - - verify( - mockConfigManager.updateEnvironment( - testExternalEnvironmentId, - deepEqual({ packages: ['flask', 'sqlalchemy'] }) - ) - ).once(); - }); - }); -}); diff --git a/src/kernels/deepnote/environments/deepnoteExtensionSidecarWriter.node.ts b/src/kernels/deepnote/environments/deepnoteExtensionSidecarWriter.node.ts deleted file mode 100644 index 4f2038d8e1..0000000000 --- a/src/kernels/deepnote/environments/deepnoteExtensionSidecarWriter.node.ts +++ /dev/null @@ -1,356 +0,0 @@ -import { inject, injectable } from 'inversify'; -import * as YAML from 'yaml'; -import { env, NotebookDocument, Uri, workspace } from 'vscode'; - -import { IExtensionSyncActivationService } from '../../../platform/activation/types'; -import { IDisposableRegistry } from '../../../platform/common/types'; -import { logger } from '../../../platform/logging'; -import { IDeepnoteEnvironmentManager, IDeepnoteNotebookEnvironmentMapper } from '../types'; - -const SIDECAR_FILENAME = 'deepnote.json'; - -/** - * Returns the editor-specific settings folder name based on the current app. - * - VS Code → `.vscode` - * - Cursor → `.cursor` - * - Antigravity → `.antigravity` - * - Unknown → `.vscode` (safe default) - */ -function getEditorSettingsFolder(): string { - const appName = env.appName.toLowerCase(); - if (appName.includes('cursor')) { - return '.cursor'; - } - if (appName.includes('antigravity')) { - return '.antigravity'; - } - return '.vscode'; -} - -interface SidecarEntry { - environmentId: string; - venvPath: string; - pythonInterpreter: string; -} - -interface SidecarFile { - mappings: Record; -} - -/** - * Writes a `deepnote.json` sidecar file in the editor settings - * folder (e.g. `.vscode/`, `.cursor/`, `.antigravity/`) so that external - * tools (e.g. the Deepnote CLI) can discover the selected venv path for - * each project without reading VS Code workspace state. - */ -@injectable() -export class DeepnoteExtensionSidecarWriter implements IExtensionSyncActivationService { - /** Reverse map: notebookUri.fsPath → projectId (populated from sidecar + set calls). */ - private readonly fsPathToProjectId = new Map(); - /** Serializes sidecar writes to avoid read-modify-write races. */ - private writeQueue: Promise = Promise.resolve(); - - constructor( - @inject(IDeepnoteNotebookEnvironmentMapper) private readonly mapper: IDeepnoteNotebookEnvironmentMapper, - @inject(IDeepnoteEnvironmentManager) private readonly environmentManager: IDeepnoteEnvironmentManager, - @inject(IDisposableRegistry) private readonly disposables: IDisposableRegistry - ) {} - - public activate(): void { - this.disposables.push( - this.mapper.onDidSetEnvironment((e) => this.handleSetEnvironment(e)), - this.mapper.onDidRemoveEnvironment((e) => this.handleRemoveEnvironment(e)), - this.environmentManager.onDidChangeEnvironments(() => this.handleEnvironmentsChanged()), - workspace.onDidOpenNotebookDocument((doc) => this.handleNotebookOpened(doc)) - ); - - // Sync existing mappings so the sidecar is up-to-date for existing users - // who already have workspace-state mappings but no sidecar file yet. - void this.syncExistingMappings(); - } - - private async handleEnvironmentsChanged(): Promise { - try { - await this.enqueueWrite(async (sidecar) => { - if (Object.keys(sidecar.mappings).length === 0) { - return false; - } - - let changed = false; - for (const [projectId, entry] of Object.entries(sidecar.mappings)) { - try { - const environment = this.environmentManager.getEnvironment(entry.environmentId); - if (!environment) { - delete sidecar.mappings[projectId]; - changed = true; - } else if ( - environment.venvPath.fsPath !== entry.venvPath || - environment.pythonInterpreter.uri.fsPath !== entry.pythonInterpreter - ) { - sidecar.mappings[projectId] = { - environmentId: entry.environmentId, - venvPath: environment.venvPath.fsPath, - pythonInterpreter: environment.pythonInterpreter.uri.fsPath - }; - changed = true; - } - } catch (entryError) { - logger.warn(`[SidecarWriter] Failed to process mapping for project ${projectId}`, entryError); - } - } - - return changed; - }); - } catch (error) { - logger.warn('[SidecarWriter] Failed to handle environments changed', error); - } - } - - /** - * When a notebook is opened after activation, check if it already has - * a mapping and add it to the sidecar. - */ - private async handleNotebookOpened(doc: NotebookDocument): Promise { - if (doc.notebookType !== 'deepnote') { - return; - } - - try { - const notebookUri = doc.uri; - const projectId = doc.metadata?.deepnoteProjectId as string | undefined; - if (!projectId) { - return; - } - - const environmentId = this.mapper.getEnvironmentForNotebook(notebookUri); - if (!environmentId) { - return; - } - - const environment = this.environmentManager.getEnvironment(environmentId); - if (!environment) { - return; - } - - this.fsPathToProjectId.set(notebookUri.fsPath, projectId); - - await this.enqueueWrite(async (sidecar) => { - const existing = sidecar.mappings[projectId]; - if ( - existing?.environmentId === environmentId && - existing?.venvPath === environment.venvPath.fsPath && - existing?.pythonInterpreter === environment.pythonInterpreter.uri.fsPath - ) { - return false; - } - sidecar.mappings[projectId] = { - environmentId, - venvPath: environment.venvPath.fsPath, - pythonInterpreter: environment.pythonInterpreter.uri.fsPath - }; - return true; - }); - } catch (error) { - logger.warn('[SidecarWriter] Failed to handle notebook opened', error); - } - } - - private async handleRemoveEnvironment({ notebookUri }: { notebookUri: Uri }): Promise { - try { - const projectId = this.fsPathToProjectId.get(notebookUri.fsPath) ?? this.resolveProjectId(notebookUri); - if (!projectId) { - return; - } - - this.fsPathToProjectId.delete(notebookUri.fsPath); - - await this.enqueueWrite(async (sidecar) => { - if (!(projectId in sidecar.mappings)) { - return false; - } - delete sidecar.mappings[projectId]; - return true; - }); - } catch (error) { - logger.warn('[SidecarWriter] Failed to handle remove environment', error); - } - } - - private async handleSetEnvironment({ - notebookUri, - environmentId - }: { - notebookUri: Uri; - environmentId: string; - }): Promise { - try { - const projectId = this.resolveProjectId(notebookUri); - if (!projectId) { - return; - } - - const environment = this.environmentManager.getEnvironment(environmentId); - if (!environment) { - return; - } - - this.fsPathToProjectId.set(notebookUri.fsPath, projectId); - - await this.enqueueWrite(async (sidecar) => { - sidecar.mappings[projectId] = { - environmentId, - venvPath: environment.venvPath.fsPath, - pythonInterpreter: environment.pythonInterpreter.uri.fsPath - }; - return true; - }); - } catch (error) { - logger.warn('[SidecarWriter] Failed to handle set environment', error); - } - } - - /** - * On activation, iterate all persisted mapper entries (not just open - * notebooks) and write their mappings to the sidecar. For each entry - * we read the `.deepnote` file to extract the project ID. - */ - private async syncExistingMappings(): Promise { - try { - await this.environmentManager.waitForInitialization(); - - const allMappings = this.mapper.getAllMappings(); - if (allMappings.size === 0) { - return; - } - - // Collect all project IDs outside the write queue to avoid holding the lock during I/O. - const entries: Array<{ - fsPath: string; - projectId: string; - environmentId: string; - venvPath: string; - pythonInterpreter: string; - }> = []; - for (const [fsPath, environmentId] of allMappings) { - try { - const environment = this.environmentManager.getEnvironment(environmentId); - if (!environment) { - continue; - } - - const projectId = await this.readProjectIdFromFile(Uri.file(fsPath)); - if (!projectId) { - continue; - } - - this.fsPathToProjectId.set(fsPath, projectId); - entries.push({ - fsPath, - projectId, - environmentId, - venvPath: environment.venvPath.fsPath, - pythonInterpreter: environment.pythonInterpreter.uri.fsPath - }); - } catch (entryError) { - logger.warn(`[SidecarWriter] Failed to process mapping for ${fsPath}`, entryError); - } - } - - if (entries.length === 0) { - return; - } - - await this.enqueueWrite(async (sidecar) => { - for (const entry of entries) { - sidecar.mappings[entry.projectId] = { - environmentId: entry.environmentId, - venvPath: entry.venvPath, - pythonInterpreter: entry.pythonInterpreter - }; - } - return true; - }); - } catch (error) { - logger.warn('[SidecarWriter] Failed to sync existing mappings', error); - } - } - - // ── Helpers ────────────────────────────────────────────────────────── - - /** - * Serializes all sidecar mutations through a queue so that concurrent - * read-modify-write cycles don't clobber each other. The `mutate` callback - * receives the current sidecar contents and returns `true` if it modified - * the object (i.e. should be written back). - */ - private enqueueWrite(mutate: (sidecar: SidecarFile) => Promise): Promise { - const op = this.writeQueue.then(async () => { - const sidecar = await this.readSidecar(); - const changed = await mutate(sidecar); - if (changed) { - await this.writeSidecar(sidecar); - } - }); - // eslint-disable-next-line @typescript-eslint/no-empty-function -- keep queue chain alive after rejection - this.writeQueue = op.catch(() => {}); - return op; - } - - private getSidecarUri(): Uri | undefined { - const folder = workspace.workspaceFolders?.[0]; - if (!folder) { - return undefined; - } - return Uri.joinPath(folder.uri, getEditorSettingsFolder(), SIDECAR_FILENAME); - } - - private async readProjectIdFromFile(fileUri: Uri): Promise { - try { - const raw = await workspace.fs.readFile(fileUri); - const parsed = YAML.parse(Buffer.from(raw).toString('utf-8')) as { project?: { id?: string } } | undefined; - return parsed?.project?.id; - } catch { - return undefined; - } - } - - private async readSidecar(): Promise { - const uri = this.getSidecarUri(); - if (!uri) { - return { mappings: {} }; - } - - try { - const raw = await workspace.fs.readFile(uri); - const parsed = JSON.parse(Buffer.from(raw).toString('utf-8')) as SidecarFile; - if (parsed?.mappings && typeof parsed.mappings === 'object') { - return parsed; - } - } catch { - // File doesn't exist or is invalid — start fresh. - } - - return { mappings: {} }; - } - - private resolveProjectId(notebookUri: Uri): string | undefined { - const doc = workspace.notebookDocuments.find( - (d) => d.notebookType === 'deepnote' && d.uri.fsPath === notebookUri.fsPath - ); - return doc?.metadata?.deepnoteProjectId as string | undefined; - } - - private async writeSidecar(sidecar: SidecarFile): Promise { - const uri = this.getSidecarUri(); - if (!uri) { - return; - } - - // Ensure the editor settings folder exists (e.g. .vscode/). - const folderUri = Uri.joinPath(uri, '..'); - await workspace.fs.createDirectory(folderUri); - - const content = JSON.stringify(sidecar, undefined, 2) + '\n'; - await workspace.fs.writeFile(uri, Buffer.from(content, 'utf-8')); - } -} diff --git a/src/kernels/deepnote/environments/deepnoteExtensionSidecarWriter.unit.test.ts b/src/kernels/deepnote/environments/deepnoteExtensionSidecarWriter.unit.test.ts deleted file mode 100644 index 5db130e25d..0000000000 --- a/src/kernels/deepnote/environments/deepnoteExtensionSidecarWriter.unit.test.ts +++ /dev/null @@ -1,519 +0,0 @@ -import { assert } from 'chai'; -import * as sinon from 'sinon'; -import { anything, instance, mock, when } from 'ts-mockito'; -import { EventEmitter, NotebookDocument, Uri, WorkspaceFolder } from 'vscode'; - -import type { IDisposableRegistry } from '../../../platform/common/types'; -import { mockedVSCodeNamespaces, resetVSCodeMocks } from '../../../test/vscode-mock'; -import { IDeepnoteEnvironmentManager, IDeepnoteNotebookEnvironmentMapper } from '../types'; -import { DeepnoteEnvironment } from './deepnoteEnvironment'; -import { DeepnoteExtensionSidecarWriter } from './deepnoteExtensionSidecarWriter.node'; - -const waitForTimeoutMs = 5000; -const waitForIntervalMs = 50; - -async function waitFor( - condition: () => boolean, - timeoutMs = waitForTimeoutMs, - intervalMs = waitForIntervalMs -): Promise { - const start = Date.now(); - while (!condition()) { - if (Date.now() - start > timeoutMs) { - throw new Error(`waitFor timed out after ${timeoutMs}ms`); - } - await new Promise((resolve) => setTimeout(resolve, intervalMs)); - } -} - -function makeEnvironment(overrides: Partial & { id: string }): DeepnoteEnvironment { - return { - name: 'Test Env', - pythonInterpreter: { id: 'python3', uri: Uri.file('/usr/bin/python3') } as any, - venvPath: Uri.file('/home/user/.venvs/test'), - managedVenv: true, - createdAt: new Date(), - lastUsedAt: new Date(), - ...overrides - }; -} - -function makeDeepnoteYaml(projectId: string): string { - return `version: '1.0'\nproject:\n id: ${projectId}\n name: Test\n notebooks: []\n`; -} - -function createMockNotebook(opts: { uri: Uri; projectId?: string; notebookType?: string }): NotebookDocument { - return { - uri: opts.uri, - notebookType: opts.notebookType ?? 'deepnote', - metadata: { deepnoteProjectId: opts.projectId ?? 'project-1' }, - isDirty: false, - isUntitled: false, - isClosed: false, - version: 1, - cellCount: 0, - cellAt: () => { - throw new Error('Not implemented'); - }, - getCells: () => [], - save: async () => true - } as unknown as NotebookDocument; -} - -suite('DeepnoteExtensionSidecarWriter', () => { - let writer: DeepnoteExtensionSidecarWriter; - let disposables: IDisposableRegistry; - let mockMapper: IDeepnoteNotebookEnvironmentMapper; - let mockEnvironmentManager: IDeepnoteEnvironmentManager; - - let onDidSetEnvironment: EventEmitter<{ notebookUri: Uri; environmentId: string }>; - let onDidRemoveEnvironment: EventEmitter<{ notebookUri: Uri }>; - let onDidChangeEnvironments: EventEmitter; - let onDidOpenNotebookDocument: EventEmitter; - - let writtenContent: string | undefined; - let writeFileCallCount: number; - let createDirectoryUris: Uri[]; - let readFileContent: string; - /** Per-file read responses keyed by fsPath — used for .deepnote YAML files. */ - let fileContents: Map; - - const workspaceUri = Uri.file('/workspace'); - - setup(() => { - resetVSCodeMocks(); - writtenContent = undefined; - writeFileCallCount = 0; - createDirectoryUris = []; - readFileContent = ''; - fileContents = new Map(); - - disposables = []; - - // Set up event emitters - onDidSetEnvironment = new EventEmitter<{ notebookUri: Uri; environmentId: string }>(); - onDidRemoveEnvironment = new EventEmitter<{ notebookUri: Uri }>(); - onDidChangeEnvironments = new EventEmitter(); - onDidOpenNotebookDocument = new EventEmitter(); - disposables.push( - onDidSetEnvironment, - onDidRemoveEnvironment, - onDidChangeEnvironments, - onDidOpenNotebookDocument - ); - - // Set up mapper mock - mockMapper = mock(); - when(mockMapper.onDidSetEnvironment).thenReturn(onDidSetEnvironment.event); - when(mockMapper.onDidRemoveEnvironment).thenReturn(onDidRemoveEnvironment.event); - when(mockMapper.getAllMappings()).thenReturn(new Map()); - - // Set up environment manager mock - mockEnvironmentManager = mock(); - when(mockEnvironmentManager.onDidChangeEnvironments).thenReturn(onDidChangeEnvironments.event); - when(mockEnvironmentManager.waitForInitialization()).thenResolve(); - - // Set up workspace folder and onDidOpenNotebookDocument - const workspaceFolder = { uri: workspaceUri, name: 'workspace', index: 0 } as WorkspaceFolder; - when(mockedVSCodeNamespaces.workspace.workspaceFolders).thenReturn([workspaceFolder]); - when(mockedVSCodeNamespaces.workspace.onDidOpenNotebookDocument).thenReturn(onDidOpenNotebookDocument.event); - - // Set up workspace.fs mock - setupMockFs(); - - writer = new DeepnoteExtensionSidecarWriter( - instance(mockMapper), - instance(mockEnvironmentManager), - disposables - ); - }); - - teardown(() => { - sinon.restore(); - for (const d of disposables) { - d.dispose(); - } - }); - - function setupMockFs() { - const mockFs = mock(); - when(mockFs.readFile(anything())).thenCall((uri: Uri) => { - // Check per-file map first (for .deepnote YAML files) - const perFile = fileContents.get(uri.fsPath); - if (perFile !== undefined) { - return Promise.resolve(Buffer.from(perFile, 'utf-8')); - } - // Fall back to global sidecar content - if (!readFileContent) { - return Promise.reject(new Error('File not found')); - } - return Promise.resolve(Buffer.from(readFileContent, 'utf-8')); - }); - when(mockFs.createDirectory(anything())).thenCall((uri: Uri) => { - createDirectoryUris.push(uri); - return Promise.resolve(); - }); - when(mockFs.writeFile(anything(), anything())).thenCall((_uri: Uri, content: Uint8Array) => { - writtenContent = Buffer.from(content).toString('utf-8'); - writeFileCallCount++; - return Promise.resolve(); - }); - when(mockedVSCodeNamespaces.workspace.fs).thenReturn(instance(mockFs)); - } - - function parseSidecar(): { - mappings: Record; - } { - assert.isDefined(writtenContent, 'Expected sidecar to be written'); - return JSON.parse(writtenContent!); - } - - test('set mapping writes sidecar with correct projectId, environmentId, and venvPath', async () => { - const notebookUri = Uri.file('/workspace/project.deepnote'); - const notebook = createMockNotebook({ uri: notebookUri, projectId: 'proj-abc' }); - when(mockedVSCodeNamespaces.workspace.notebookDocuments).thenReturn([notebook]); - - const env = makeEnvironment({ - id: 'env-1', - venvPath: Uri.file('/home/user/.venvs/my-env') - }); - when(mockEnvironmentManager.getEnvironment('env-1')).thenReturn(env); - - writer.activate(); - - onDidSetEnvironment.fire({ notebookUri, environmentId: 'env-1' }); - - await waitFor(() => writeFileCallCount > 0); - - const sidecar = parseSidecar(); - assert.deepStrictEqual(sidecar.mappings['proj-abc'], { - environmentId: 'env-1', - venvPath: '/home/user/.venvs/my-env', - pythonInterpreter: '/usr/bin/python3' - }); - }); - - test('remove mapping removes entry from sidecar', async () => { - const notebookUri = Uri.file('/workspace/project.deepnote'); - const notebook = createMockNotebook({ uri: notebookUri, projectId: 'proj-abc' }); - when(mockedVSCodeNamespaces.workspace.notebookDocuments).thenReturn([notebook]); - - const env = makeEnvironment({ id: 'env-1' }); - when(mockEnvironmentManager.getEnvironment('env-1')).thenReturn(env); - - writer.activate(); - - // First set, then remove - onDidSetEnvironment.fire({ notebookUri, environmentId: 'env-1' }); - await waitFor(() => writeFileCallCount >= 1); - - // Set the sidecar content so the next read picks it up - readFileContent = writtenContent!; - - onDidRemoveEnvironment.fire({ notebookUri }); - await waitFor(() => writeFileCallCount >= 2); - - const sidecar = parseSidecar(); - assert.isUndefined(sidecar.mappings['proj-abc']); - assert.deepStrictEqual(sidecar.mappings, {}); - }); - - test('multiple projects accumulate entries in a single sidecar', async () => { - const uri1 = Uri.file('/workspace/project1.deepnote'); - const uri2 = Uri.file('/workspace/project2.deepnote'); - const nb1 = createMockNotebook({ uri: uri1, projectId: 'proj-1' }); - const nb2 = createMockNotebook({ uri: uri2, projectId: 'proj-2' }); - when(mockedVSCodeNamespaces.workspace.notebookDocuments).thenReturn([nb1, nb2]); - - const env1 = makeEnvironment({ id: 'env-1', venvPath: Uri.file('/venvs/env1') }); - const env2 = makeEnvironment({ id: 'env-2', venvPath: Uri.file('/venvs/env2') }); - when(mockEnvironmentManager.getEnvironment('env-1')).thenReturn(env1); - when(mockEnvironmentManager.getEnvironment('env-2')).thenReturn(env2); - - writer.activate(); - - onDidSetEnvironment.fire({ notebookUri: uri1, environmentId: 'env-1' }); - await waitFor(() => writeFileCallCount >= 1); - readFileContent = writtenContent!; - - onDidSetEnvironment.fire({ notebookUri: uri2, environmentId: 'env-2' }); - await waitFor(() => writeFileCallCount >= 2); - - const sidecar = parseSidecar(); - assert.deepStrictEqual(sidecar.mappings, { - 'proj-1': { environmentId: 'env-1', venvPath: '/venvs/env1', pythonInterpreter: '/usr/bin/python3' }, - 'proj-2': { environmentId: 'env-2', venvPath: '/venvs/env2', pythonInterpreter: '/usr/bin/python3' } - }); - }); - - test('error reading sidecar does not throw', async () => { - const notebookUri = Uri.file('/workspace/project.deepnote'); - const notebook = createMockNotebook({ uri: notebookUri, projectId: 'proj-abc' }); - when(mockedVSCodeNamespaces.workspace.notebookDocuments).thenReturn([notebook]); - - const env = makeEnvironment({ id: 'env-1' }); - when(mockEnvironmentManager.getEnvironment('env-1')).thenReturn(env); - - // readFile will reject (default behavior when readFileContent is empty) - writer.activate(); - - // Should not throw even though readFile fails - onDidSetEnvironment.fire({ notebookUri, environmentId: 'env-1' }); - await waitFor(() => writeFileCallCount >= 1); - - // Still writes successfully with a fresh sidecar - const sidecar = parseSidecar(); - assert.isDefined(sidecar.mappings['proj-abc']); - }); - - test('error writing sidecar does not throw', async () => { - const notebookUri = Uri.file('/workspace/project.deepnote'); - const notebook = createMockNotebook({ uri: notebookUri, projectId: 'proj-abc' }); - when(mockedVSCodeNamespaces.workspace.notebookDocuments).thenReturn([notebook]); - - const env = makeEnvironment({ id: 'env-1' }); - when(mockEnvironmentManager.getEnvironment('env-1')).thenReturn(env); - - // Make writeFile throw - const mockFs = mock(); - when(mockFs.readFile(anything())).thenReject(new Error('File not found')); - when(mockFs.writeFile(anything(), anything())).thenReject(new Error('Permission denied')); - when(mockedVSCodeNamespaces.workspace.fs).thenReturn(instance(mockFs)); - - writer.activate(); - - // Should not throw - onDidSetEnvironment.fire({ notebookUri, environmentId: 'env-1' }); - - // Give time for the async operation to complete - await new Promise((resolve) => setTimeout(resolve, 200)); - - // No crash — test passes if we get here - }); - - test('environment changed refreshes sidecar with updated venvPath', async () => { - const notebookUri = Uri.file('/workspace/project.deepnote'); - const notebook = createMockNotebook({ uri: notebookUri, projectId: 'proj-abc' }); - when(mockedVSCodeNamespaces.workspace.notebookDocuments).thenReturn([notebook]); - - const env = makeEnvironment({ id: 'env-1', venvPath: Uri.file('/venvs/old-path') }); - when(mockEnvironmentManager.getEnvironment('env-1')).thenReturn(env); - - writer.activate(); - - // Set initial mapping - onDidSetEnvironment.fire({ notebookUri, environmentId: 'env-1' }); - await waitFor(() => writeFileCallCount >= 1); - readFileContent = writtenContent!; - - // Now change the env venvPath - const updatedEnv = makeEnvironment({ id: 'env-1', venvPath: Uri.file('/venvs/new-path') }); - when(mockEnvironmentManager.getEnvironment('env-1')).thenReturn(updatedEnv); - - onDidChangeEnvironments.fire(); - await waitFor(() => writeFileCallCount >= 2); - - const sidecar = parseSidecar(); - assert.strictEqual(sidecar.mappings['proj-abc'].venvPath, '/venvs/new-path'); - }); - - test('environment deleted removes entry on environments changed', async () => { - const notebookUri = Uri.file('/workspace/project.deepnote'); - const notebook = createMockNotebook({ uri: notebookUri, projectId: 'proj-abc' }); - when(mockedVSCodeNamespaces.workspace.notebookDocuments).thenReturn([notebook]); - - const env = makeEnvironment({ id: 'env-1' }); - when(mockEnvironmentManager.getEnvironment('env-1')).thenReturn(env); - - writer.activate(); - - onDidSetEnvironment.fire({ notebookUri, environmentId: 'env-1' }); - await waitFor(() => writeFileCallCount >= 1); - readFileContent = writtenContent!; - - // Now the environment no longer exists - when(mockEnvironmentManager.getEnvironment('env-1')).thenReturn(undefined); - - onDidChangeEnvironments.fire(); - await waitFor(() => writeFileCallCount >= 2); - - const sidecar = parseSidecar(); - assert.isUndefined(sidecar.mappings['proj-abc']); - }); - - test('no-op when no workspace folder is open', async () => { - when(mockedVSCodeNamespaces.workspace.workspaceFolders).thenReturn(undefined); - - const notebookUri = Uri.file('/workspace/project.deepnote'); - const notebook = createMockNotebook({ uri: notebookUri, projectId: 'proj-abc' }); - when(mockedVSCodeNamespaces.workspace.notebookDocuments).thenReturn([notebook]); - - const env = makeEnvironment({ id: 'env-1' }); - when(mockEnvironmentManager.getEnvironment('env-1')).thenReturn(env); - - writer.activate(); - - onDidSetEnvironment.fire({ notebookUri, environmentId: 'env-1' }); - - await new Promise((resolve) => setTimeout(resolve, 200)); - - assert.strictEqual(writeFileCallCount, 0, 'Should not write when no workspace folder'); - }); - - test('activation syncs existing mappings to sidecar by reading .deepnote files', async () => { - const fsPath = '/workspace/project.deepnote'; - - // Mapper has a persisted entry — notebook is NOT open - when(mockMapper.getAllMappings()).thenReturn(new Map([[fsPath, 'env-existing']])); - fileContents.set(fsPath, makeDeepnoteYaml('proj-existing')); - - const env = makeEnvironment({ id: 'env-existing', venvPath: Uri.file('/venvs/existing') }); - when(mockEnvironmentManager.getEnvironment('env-existing')).thenReturn(env); - - writer.activate(); - - await waitFor(() => writeFileCallCount >= 1); - - const sidecar = parseSidecar(); - assert.deepStrictEqual(sidecar.mappings['proj-existing'], { - environmentId: 'env-existing', - venvPath: '/venvs/existing', - pythonInterpreter: '/usr/bin/python3' - }); - }); - - test('activation syncs multiple projects including closed notebooks', async () => { - const path1 = '/workspace/proj1.deepnote'; - const path2 = '/workspace/proj2.deepnote'; - - when(mockMapper.getAllMappings()).thenReturn( - new Map([ - [path1, 'env-1'], - [path2, 'env-2'] - ]) - ); - fileContents.set(path1, makeDeepnoteYaml('proj-1')); - fileContents.set(path2, makeDeepnoteYaml('proj-2')); - - const env1 = makeEnvironment({ id: 'env-1', venvPath: Uri.file('/venvs/env1') }); - const env2 = makeEnvironment({ id: 'env-2', venvPath: Uri.file('/venvs/env2') }); - when(mockEnvironmentManager.getEnvironment('env-1')).thenReturn(env1); - when(mockEnvironmentManager.getEnvironment('env-2')).thenReturn(env2); - - writer.activate(); - - await waitFor(() => writeFileCallCount >= 1); - - const sidecar = parseSidecar(); - assert.deepStrictEqual(sidecar.mappings, { - 'proj-1': { environmentId: 'env-1', venvPath: '/venvs/env1', pythonInterpreter: '/usr/bin/python3' }, - 'proj-2': { environmentId: 'env-2', venvPath: '/venvs/env2', pythonInterpreter: '/usr/bin/python3' } - }); - }); - - test('activation skips entries whose .deepnote file cannot be read', async () => { - const goodPath = '/workspace/good.deepnote'; - const badPath = '/workspace/missing.deepnote'; - - when(mockMapper.getAllMappings()).thenReturn( - new Map([ - [goodPath, 'env-1'], - [badPath, 'env-2'] - ]) - ); - fileContents.set(goodPath, makeDeepnoteYaml('proj-good')); - // badPath not in fileContents → readFile will reject - - const env1 = makeEnvironment({ id: 'env-1', venvPath: Uri.file('/venvs/env1') }); - const env2 = makeEnvironment({ id: 'env-2', venvPath: Uri.file('/venvs/env2') }); - when(mockEnvironmentManager.getEnvironment('env-1')).thenReturn(env1); - when(mockEnvironmentManager.getEnvironment('env-2')).thenReturn(env2); - - writer.activate(); - - await waitFor(() => writeFileCallCount >= 1); - - const sidecar = parseSidecar(); - assert.strictEqual(Object.keys(sidecar.mappings).length, 1); - assert.isDefined(sidecar.mappings['proj-good']); - assert.isUndefined(sidecar.mappings['proj-missing']); - }); - - test('opening a notebook with existing mapping writes to sidecar', async () => { - when(mockedVSCodeNamespaces.workspace.notebookDocuments).thenReturn([]); - - writer.activate(); - - // Wait for initial sync (no-op since no notebooks open) - await new Promise((resolve) => setTimeout(resolve, 100)); - - const notebookUri = Uri.file('/workspace/project.deepnote'); - const notebook = createMockNotebook({ uri: notebookUri, projectId: 'proj-opened' }); - - const env = makeEnvironment({ id: 'env-1', venvPath: Uri.file('/venvs/env1') }); - when(mockEnvironmentManager.getEnvironment('env-1')).thenReturn(env); - when(mockMapper.getEnvironmentForNotebook(anything())).thenReturn('env-1'); - - onDidOpenNotebookDocument.fire(notebook); - - await waitFor(() => writeFileCallCount >= 1); - - const sidecar = parseSidecar(); - assert.deepStrictEqual(sidecar.mappings['proj-opened'], { - environmentId: 'env-1', - venvPath: '/venvs/env1', - pythonInterpreter: '/usr/bin/python3' - }); - }); - - test('no-op when notebook has no projectId in metadata', async () => { - const notebookUri = Uri.file('/workspace/project.deepnote'); - // Notebook without projectId - const notebook = { - uri: notebookUri, - notebookType: 'deepnote', - metadata: {}, - isDirty: false, - isUntitled: false, - isClosed: false, - version: 1, - cellCount: 0, - cellAt: () => { - throw new Error('Not implemented'); - }, - getCells: () => [], - save: async () => true - } as unknown as NotebookDocument; - - when(mockedVSCodeNamespaces.workspace.notebookDocuments).thenReturn([notebook]); - - const env = makeEnvironment({ id: 'env-1' }); - when(mockEnvironmentManager.getEnvironment('env-1')).thenReturn(env); - - writer.activate(); - - onDidSetEnvironment.fire({ notebookUri, environmentId: 'env-1' }); - - await new Promise((resolve) => setTimeout(resolve, 200)); - - assert.strictEqual(writeFileCallCount, 0, 'Should not write when no projectId'); - }); - - test('creates the editor settings folder before writing', async () => { - const notebookUri = Uri.file('/workspace/project.deepnote'); - const notebook = createMockNotebook({ uri: notebookUri, projectId: 'proj-abc' }); - when(mockedVSCodeNamespaces.workspace.notebookDocuments).thenReturn([notebook]); - - const env = makeEnvironment({ id: 'env-1' }); - when(mockEnvironmentManager.getEnvironment('env-1')).thenReturn(env); - - writer.activate(); - - onDidSetEnvironment.fire({ notebookUri, environmentId: 'env-1' }); - - await waitFor(() => writeFileCallCount > 0); - - assert.strictEqual(createDirectoryUris.length, 1); - assert.strictEqual(createDirectoryUris[0].fsPath, Uri.file('/workspace/.vscode').fsPath); - }); -}); diff --git a/src/kernels/deepnote/environments/deepnoteNotebookEnvironmentMapper.node.ts b/src/kernels/deepnote/environments/deepnoteNotebookEnvironmentMapper.node.ts deleted file mode 100644 index 61070f8217..0000000000 --- a/src/kernels/deepnote/environments/deepnoteNotebookEnvironmentMapper.node.ts +++ /dev/null @@ -1,110 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import { injectable, inject } from 'inversify'; -import { EventEmitter, Uri, Memento } from 'vscode'; - -import { IDisposableRegistry, IExtensionContext } from '../../../platform/common/types'; -import { logger } from '../../../platform/logging'; - -/** - * Manages the mapping between notebooks and their selected environments - * Stores selections in workspace state for persistence across sessions - */ -@injectable() -export class DeepnoteNotebookEnvironmentMapper { - private static readonly STORAGE_KEY = 'deepnote.notebookEnvironmentMappings'; - private readonly workspaceState: Memento; - private mappings: Map; // notebookUri.fsPath -> environmentId - - private readonly _onDidRemoveEnvironment = new EventEmitter<{ notebookUri: Uri }>(); - private readonly _onDidSetEnvironment = new EventEmitter<{ notebookUri: Uri; environmentId: string }>(); - public readonly onDidRemoveEnvironment = this._onDidRemoveEnvironment.event; - public readonly onDidSetEnvironment = this._onDidSetEnvironment.event; - - constructor( - @inject(IExtensionContext) context: IExtensionContext, - @inject(IDisposableRegistry) disposables: IDisposableRegistry - ) { - this.workspaceState = context.workspaceState; - this.mappings = new Map(); - this.loadMappings(); - disposables.push(this._onDidSetEnvironment, this._onDidRemoveEnvironment); - } - - /** - * Get the environment ID selected for a notebook - * @param notebookUri The notebook URI (without query/fragment) - * @returns Environment ID, or undefined if not set - */ - public getEnvironmentForNotebook(notebookUri: Uri): string | undefined { - const key = notebookUri.fsPath; - return this.mappings.get(key); - } - - /** - * Set the environment for a notebook - * @param notebookUri The notebook URI (without query/fragment) - * @param environmentId The environment ID - */ - public async setEnvironmentForNotebook(notebookUri: Uri, environmentId: string): Promise { - const key = notebookUri.fsPath; - this.mappings.set(key, environmentId); - await this.saveMappings(); - logger.info(`Mapped notebook ${notebookUri.fsPath} to environment ${environmentId}`); - this._onDidSetEnvironment.fire({ notebookUri, environmentId }); - } - - /** - * Remove the environment mapping for a notebook - * @param notebookUri The notebook URI (without query/fragment) - */ - public async removeEnvironmentForNotebook(notebookUri: Uri): Promise { - const key = notebookUri.fsPath; - this.mappings.delete(key); - await this.saveMappings(); - logger.info(`Removed environment mapping for notebook ${notebookUri.fsPath}`); - this._onDidRemoveEnvironment.fire({ notebookUri }); - } - - /** - * Get all notebooks using a specific environment - * @param environmentId The environment ID - * @returns Array of notebook URIs - */ - public getNotebooksUsingEnvironment(environmentId: string): Uri[] { - const notebooks: Uri[] = []; - for (const [notebookPath, configId] of this.mappings.entries()) { - if (configId === environmentId) { - notebooks.push(Uri.file(notebookPath)); - } - } - return notebooks; - } - - /** - * Get all notebook-to-environment mappings - */ - public getAllMappings(): ReadonlyMap { - return new Map(this.mappings); - } - - /** - * Load mappings from workspace state - */ - private loadMappings(): void { - const stored = this.workspaceState.get>(DeepnoteNotebookEnvironmentMapper.STORAGE_KEY); - if (stored) { - this.mappings = new Map(Object.entries(stored)); - logger.info(`Loaded ${this.mappings.size} notebook-environment mappings`); - } - } - - /** - * Save mappings to workspace state - */ - private async saveMappings(): Promise { - const obj = Object.fromEntries(this.mappings.entries()); - await this.workspaceState.update(DeepnoteNotebookEnvironmentMapper.STORAGE_KEY, obj); - } -} diff --git a/src/kernels/deepnote/types.ts b/src/kernels/deepnote/types.ts index 0195097139..dcf1e65bc4 100644 --- a/src/kernels/deepnote/types.ts +++ b/src/kernels/deepnote/types.ts @@ -9,9 +9,6 @@ import { PythonEnvironment } from '../../platform/pythonEnvironments/info'; import { getTelemetrySafeHashedString } from '../../platform/telemetry/helpers'; import { JupyterServerProviderHandle } from '../jupyter/types'; import { IJupyterKernelSpec } from '../types'; -import { CreateDeepnoteEnvironmentOptions, DeepnoteEnvironment } from './environments/deepnoteEnvironment'; -import toolkitSpec from './toolkitSpec.json'; -import { AssertNonAnyString, AssertNonAnyStringArray } from '../../platform/common/utils/typescript'; export interface VenvAndToolkitInstallation { pythonInterpreter: PythonEnvironment; @@ -33,7 +30,6 @@ export class DeepnoteKernelConnectionMetadata { public readonly serverProviderHandle: JupyterServerProviderHandle; public readonly serverInfo?: DeepnoteServerInfo; // Store server info for connection public readonly environmentName?: string; // Name of the Deepnote environment for display purposes - public readonly projectName?: string; // Name of the project for display purposes public readonly notebookName?: string; // Name of the notebook for display purposes private constructor(options: { @@ -45,7 +41,6 @@ export class DeepnoteKernelConnectionMetadata { serverProviderHandle: JupyterServerProviderHandle; serverInfo?: DeepnoteServerInfo; environmentName?: string; - projectName?: string; notebookName?: string; }) { this.interpreter = options.interpreter; @@ -56,7 +51,6 @@ export class DeepnoteKernelConnectionMetadata { this.serverProviderHandle = options.serverProviderHandle; this.serverInfo = options.serverInfo; this.environmentName = options.environmentName; - this.projectName = options.projectName; this.notebookName = options.notebookName; } @@ -69,7 +63,6 @@ export class DeepnoteKernelConnectionMetadata { serverProviderHandle: JupyterServerProviderHandle; serverInfo?: DeepnoteServerInfo; environmentName?: string; - projectName?: string; notebookName?: string; }) { return new DeepnoteKernelConnectionMetadata(options); @@ -150,35 +143,53 @@ export interface IDeepnoteToolkitInstaller { getVenvHash(deepnoteFileUri: vscode.Uri): string; } +export enum DeepnoteToolkitDependencyResponse { + /** The toolkit is present, or the user approved the install and it succeeded. */ + ok, + /** The user declined or cancelled. Not a failure — nothing should be reported as an error. */ + cancel, + /** The user chose to point the workspace at a different interpreter instead. */ + selectDifferentInterpreter, + /** The install ran and did not succeed. */ + failed +} + +export const IDeepnoteToolkitDependencyService = Symbol('IDeepnoteToolkitDependencyService'); +export interface IDeepnoteToolkitDependencyService { + /** + * Ensures deepnote-toolkit is available in the interpreter, prompting for consent first. + * @param interpreter The interpreter the kernel will run in + * @param resource The notebook the check is running for, used for logging + * @param token Cancellation token to cancel the check or the install + */ + ensureToolkitInstalled( + interpreter: PythonEnvironment, + resource: vscode.Uri | undefined, + token: vscode.CancellationToken + ): Promise; +} + export const IDeepnoteServerStarter = Symbol('IDeepnoteServerStarter'); export interface IDeepnoteServerStarter { /** - * Starts a deepnote-toolkit Jupyter server for a kernel environment. - * Environment-based method. + * Starts a deepnote-toolkit Jupyter server using the active Python interpreter. + * Handles checking/installing deepnote-toolkit via the IInstaller infrastructure. * @param interpreter The Python interpreter to use - * @param venvPath The path to the venv - * @param managedVenv Whether the venv is managed by this extension (created by us) - * @param environmentId The environment ID (for server management) * @param deepnoteFileUri The URI of the .deepnote file * @param token Cancellation token to cancel the operation * @returns Connection information (URL, port, etc.) */ startServer( interpreter: PythonEnvironment, - venvPath: vscode.Uri, - managedVenv: boolean, - additionalPackages: string[], - environmentId: string, deepnoteFileUri: vscode.Uri, token?: vscode.CancellationToken ): Promise; /** - * Stops the deepnote-toolkit server for a kernel environment. - * @param environmentId The environment ID + * Stops the deepnote-toolkit server for a .deepnote file. + * @param deepnoteFileUri The URI of the .deepnote file * @param token Cancellation token to cancel the operation */ - // stopServer(environmentId: string, token?: vscode.CancellationToken): Promise; stopServer(deepnoteFileUri: vscode.Uri, token?: vscode.CancellationToken): Promise; /** @@ -227,14 +238,6 @@ export interface IServerHandleRegistry { export const IDeepnoteKernelAutoSelector = Symbol('IDeepnoteKernelAutoSelector'); export interface IDeepnoteKernelAutoSelector { - /** - * Clear the controller selection for a notebook using a specific environment. - * This is used when deleting an environment to unselect its controller from any open notebooks. - * @param notebook The notebook document - * @param environmentId The environment ID - */ - clearControllerForEnvironment(notebook: vscode.NotebookDocument, environmentId: string): void; - /** * Ensure an environment is configured for the notebook before execution. * If not configured, shows picker and sets up the kernel. @@ -276,115 +279,6 @@ export interface IDeepnoteKernelAutoSelector { ): Promise; } -export const IDeepnoteEnvironmentManager = Symbol('IDeepnoteEnvironmentManager'); -export interface IDeepnoteEnvironmentManager { - /** - * Initialize the manager by loading environments from storage - */ - initialize(): Promise; - - /** - * Wait for initialization to complete - */ - waitForInitialization(): Promise; - - /** - * Create a new kernel environment - * @param options Environment creation options - * @param token Cancellation token to cancel the operation - */ - createEnvironment( - options: CreateDeepnoteEnvironmentOptions, - token?: vscode.CancellationToken - ): Promise; - - /** - * Get all environments - */ - listEnvironments(): DeepnoteEnvironment[]; - - /** - * Get a specific environment by ID - */ - getEnvironment(id: string): DeepnoteEnvironment | undefined; - - /** - * Update an environment's metadata - */ - updateEnvironment( - id: string, - updates: Partial> - ): Promise; - - /** - * Delete an environment - * @param id The environment ID - * @param token Cancellation token to cancel the operation - */ - deleteEnvironment(id: string, token?: vscode.CancellationToken): Promise; - - /** - * Update the last used timestamp for an environment - */ - updateLastUsed(id: string): Promise; - - /** - * Event fired when environments change - */ - onDidChangeEnvironments: vscode.Event; - - /** - * Dispose of all resources - */ - dispose(): void; -} - -export const IDeepnoteNotebookEnvironmentMapper = Symbol('IDeepnoteNotebookEnvironmentMapper'); -export interface IDeepnoteNotebookEnvironmentMapper { - /** - * Get the environment ID selected for a notebook - * @param notebookUri The notebook URI (without query/fragment) - * @returns Environment ID, or undefined if not set - */ - getEnvironmentForNotebook(notebookUri: vscode.Uri): string | undefined; - - /** - * Set the environment for a notebook - * @param notebookUri The notebook URI (without query/fragment) - * @param environmentId The environment ID - */ - setEnvironmentForNotebook(notebookUri: vscode.Uri, environmentId: string): Promise; - - /** - * Remove the environment mapping for a notebook - * @param notebookUri The notebook URI (without query/fragment) - */ - removeEnvironmentForNotebook(notebookUri: vscode.Uri): Promise; - - /** - * Get all notebooks using a specific environment - * @param environmentId The environment ID - * @returns Array of notebook URIs - */ - getNotebooksUsingEnvironment(environmentId: string): vscode.Uri[]; - - /** - * Get all notebook-to-environment mappings - * @returns Map of notebookUri.fsPath → environmentId - */ - getAllMappings(): ReadonlyMap; - - /** - * Event fired when an environment mapping is removed for a notebook - */ - onDidRemoveEnvironment: vscode.Event<{ notebookUri: vscode.Uri }>; - - /** - * Event fired when an environment is set for a notebook - */ - onDidSetEnvironment: vscode.Event<{ notebookUri: vscode.Uri; environmentId: string }>; -} - export const IDeepnoteLspClientManager = Symbol('IDeepnoteLspClientManager'); export interface IDeepnoteLspClientManager { /** @@ -415,11 +309,5 @@ export interface IDeepnoteLspClientManager { stopAllClients(token?: vscode.CancellationToken): Promise; } -true satisfies AssertNonAnyString; -true satisfies AssertNonAnyStringArray; - -export const DEEPNOTE_TOOLKIT_VERSION: string = toolkitSpec.version; - -export const DEEPNOTE_TOOLKIT_PACKAGES: string[] = toolkitSpec.packages; export const DEEPNOTE_DEFAULT_PORT = 8888; export const DEEPNOTE_NOTEBOOK_TYPE = 'deepnote'; diff --git a/src/kernels/helpers.ts b/src/kernels/helpers.ts index 11c88baa21..938bad5880 100644 --- a/src/kernels/helpers.ts +++ b/src/kernels/helpers.ts @@ -302,16 +302,22 @@ export function getDisplayNameOrNameOfKernelConnection(kernelConnection: KernelC return `Python ${pythonVersion}`.trim(); } case 'startUsingDeepnoteKernel': { - // Display as "Project Title" - if (kernelConnection.projectName) { - return kernelConnection.projectName; + // Named after the environment the code runs in, exactly as 'startUsingPythonInterpreter' + // does: the kernel picker answers "which interpreter?", and the project title is already + // carried by the editor tab and the Deepnote status bar. + if (!kernelConnection.interpreter) { + return oldDisplayName; } - // For Deepnote kernels, use the environment name if available - if (kernelConnection.environmentName) { - return `Deepnote: ${kernelConnection.environmentName}`; + + if (getEnvironmentType(kernelConnection.interpreter) !== EnvironmentType.Unknown) { + return getDisplayNameOrNameOfPythonKernelConnection(kernelConnection.interpreter); } - // Fallback to kernelspec display name - return oldDisplayName; + + const deepnotePythonVersion = ( + getTelemetrySafeVersion(getCachedVersion(kernelConnection.interpreter)) || '' + ).trim(); + + return `Python ${deepnotePythonVersion}`.trim(); } } return oldDisplayName; @@ -375,7 +381,8 @@ export function getKernelDisplayPathFromKernelConnection(kernelConnection?: Kern if ( kernelConnection.kind === 'startUsingPythonInterpreter' || ((kernelConnection.kind === 'startUsingRemoteKernelSpec' || - kernelConnection.kind === 'startUsingLocalKernelSpec') && + kernelConnection.kind === 'startUsingLocalKernelSpec' || + kernelConnection.kind === 'startUsingDeepnoteKernel') && kernelConnection.kernelSpec.language === PYTHON_LANGUAGE) ) { const pathValue = diff --git a/src/kernels/helpers.unit.test.ts b/src/kernels/helpers.unit.test.ts index 0d6d163881..49f410b37f 100644 --- a/src/kernels/helpers.unit.test.ts +++ b/src/kernels/helpers.unit.test.ts @@ -5,7 +5,8 @@ import * as sinon from 'sinon'; import { assert } from 'chai'; import { when, instance, mock, anything } from 'ts-mockito'; import { Uri } from 'vscode'; -import { getDisplayNameOrNameOfKernelConnection } from './helpers'; +import { getDisplayNameOrNameOfKernelConnection, getKernelDisplayPathFromKernelConnection } from './helpers'; +import { DeepnoteKernelConnectionMetadata } from './deepnote/types'; import { IJupyterKernelSpec, LiveRemoteKernelConnectionMetadata, @@ -261,6 +262,63 @@ suite('Kernel Connection Helpers', () => { assert.strictEqual(name, 'kspecname (.env)'); }); }); + suite('Deepnote kernels', () => { + const venv = { uri: Uri.file('/work/.venv/bin/python'), id: '/work/.venv/bin/python' }; + + function deepnoteConnection(interpreter?: PythonEnvironment) { + return DeepnoteKernelConnectionMetadata.create({ + id: 'deepnote-notebook-1', + baseUrl: 'http://127.0.0.1:8888', + kernelSpec: { + argv: [], + display_name: 'deepnote-kernelspec-name', + name: 'python3', + executable: 'python', + language: 'python' + }, + serverProviderHandle: { extensionId: 'ext', id: 'deepnote-server', handle: 'handle' }, + interpreter + }); + } + + test('is named after the environment, not the Deepnote project', () => { + whenKnownEnvironments(environments).thenReturn([ + { + id: venv.id, + path: venv.uri.fsPath, + environment: { name: '.venv', type: 'VirtualEnvironment' }, + version: { major: 3, minor: 12, micro: 13, sysVersion: '3.12.13' } + } + ]); + + const name = getDisplayNameOrNameOfKernelConnection(deepnoteConnection(venv as PythonEnvironment)); + + assert.strictEqual(name, '.venv (Python 3.12.13)'); + }); + + test('describes the interpreter, resolving a relative kernelspec executable via the environment', () => { + whenKnownEnvironments(environments).thenReturn([ + { + id: venv.id, + path: venv.uri.fsPath, + environment: { name: '.venv', type: 'VirtualEnvironment', folderUri: Uri.file('/work/.venv') }, + version: { major: 3, minor: 12, micro: 13, sysVersion: '3.12.13' } + } + ]); + + // The toolkit server serves the stock ipykernel spec, whose executable is a bare "python". + const displayPath = getKernelDisplayPathFromKernelConnection(deepnoteConnection(venv as PythonEnvironment)); + + assert.strictEqual(displayPath?.fsPath, Uri.file('/work/.venv').fsPath); + }); + + test('falls back to the kernelspec name when there is no interpreter', () => { + const name = getDisplayNameOrNameOfKernelConnection(deepnoteConnection(undefined)); + + assert.strictEqual(name, 'deepnote-kernelspec-name'); + }); + }); + suite('Python kernels (started using kernelspec)', () => { test('Display name if language is python', () => { const name = getDisplayNameOrNameOfKernelConnection( diff --git a/src/notebooks/deepnote/deepnoteActivationService.ts b/src/notebooks/deepnote/deepnoteActivationService.ts index cb2144b9fd..3ad5534d9b 100644 --- a/src/notebooks/deepnote/deepnoteActivationService.ts +++ b/src/notebooks/deepnote/deepnoteActivationService.ts @@ -5,7 +5,6 @@ import { IExtensionSyncActivationService } from '../../platform/activation/types import { ITelemetryService } from '../../platform/analytics/types'; import { IExtensionContext } from '../../platform/common/types'; import { ILogger } from '../../platform/logging/types'; -import { IDeepnoteNotebookEnvironmentMapper } from '../../kernels/deepnote/types'; import { IDeepnoteNotebookManager } from '../types'; import { DeepnoteNotebookSerializer } from './deepnoteSerializer'; import { DeepnoteExplorerView } from './deepnoteExplorerView'; @@ -42,10 +41,7 @@ export class DeepnoteActivationService implements IExtensionSyncActivationServic @inject(IIntegrationManager) integrationManager: IIntegrationManager, @inject(ILogger) private readonly logger: ILogger, @inject(ITelemetryService) private readonly analytics: ITelemetryService, - @inject(SnapshotService) @optional() private readonly snapshotService?: SnapshotService, - @inject(IDeepnoteNotebookEnvironmentMapper) - @optional() - private readonly environmentMapper?: IDeepnoteNotebookEnvironmentMapper + @inject(SnapshotService) @optional() private readonly snapshotService?: SnapshotService ) { this.integrationManager = integrationManager; } @@ -86,7 +82,6 @@ export class DeepnoteActivationService implements IExtensionSyncActivationServic this.integrationManager.activate(); this.multiNotebookSplitter = new DeepnoteMultiNotebookSplitter( - this.environmentMapper, () => this.explorerView.refresh(), this.logger, deepnoteFileExists, diff --git a/src/notebooks/deepnote/deepnoteKernelAutoSelector.node.ts b/src/notebooks/deepnote/deepnoteKernelAutoSelector.node.ts index e09236d914..41414d882a 100644 --- a/src/notebooks/deepnote/deepnoteKernelAutoSelector.node.ts +++ b/src/notebooks/deepnote/deepnoteKernelAutoSelector.node.ts @@ -7,32 +7,26 @@ import { inject, injectable, named, optional } from 'inversify'; import { CancellationToken, CancellationTokenSource, - NotebookController, NotebookControllerAffinity, NotebookDocument, NotebookEditor, ProgressLocation, - QuickPickItem, Uri, commands, env, l10n, - notebooks, window, workspace } from 'vscode'; -import { DeepnoteEnvironment } from '../../kernels/deepnote/environments/deepnoteEnvironment'; import { DEEPNOTE_NOTEBOOK_TYPE, - DEEPNOTE_TOOLKIT_VERSION, DeepnoteKernelConnectionMetadata, - IDeepnoteEnvironmentManager, + DeepnoteToolkitDependencyResponse, IDeepnoteKernelAutoSelector, IDeepnoteLspClientManager, - IDeepnoteNotebookEnvironmentMapper, IDeepnoteServerProvider, IDeepnoteServerStarter, - IDeepnoteToolkitInstaller, + IDeepnoteToolkitDependencyService, IServerHandleRegistry } from '../../kernels/deepnote/types'; import { createJupyterConnectionInfo } from '../../kernels/jupyter/jupyterUtils'; @@ -44,24 +38,35 @@ import { } from '../../kernels/jupyter/types'; import { IJupyterKernelSpec, IKernelProvider } from '../../kernels/types'; import { IExtensionSyncActivationService } from '../../platform/activation/types'; -import { ITelemetryService } from '../../platform/analytics/types'; import { IPythonExtensionChecker } from '../../platform/api/types'; import { Cancellation, isCancellationError } from '../../platform/common/cancellation'; import { JVSC_EXTENSION_ID, STANDARD_OUTPUT_CHANNEL } from '../../platform/common/constants'; import { getDisplayPath } from '../../platform/common/platform/fs-paths.node'; import { IConfigurationService, IDisposableRegistry, IOutputChannel } from '../../platform/common/types'; import { disposeAsync } from '../../platform/common/utils'; +import { getNotebookKey } from '../../platform/deepnote/deepnoteProjectUtils'; import { createDeepnoteServerConfigHandle } from '../../platform/deepnote/deepnoteServerUtils.node'; -import { DeepnoteKernelError, DeepnoteToolkitMissingError } from '../../platform/errors/deepnoteKernelErrors'; +import { DeepnoteKernelError } from '../../platform/errors/deepnoteKernelErrors'; +import { IInterpreterService } from '../../platform/interpreter/contracts'; import { logger } from '../../platform/logging'; import { PythonEnvironment } from '../../platform/pythonEnvironments/info'; import { IControllerRegistration, IVSCodeNotebookController } from '../controllers/types'; import { IDeepnoteNotebookManager } from '../types'; -import { getNotebookKey } from '../../platform/deepnote/deepnoteProjectUtils'; import { computeRequirementsHash } from './deepnoteProjectUtils'; import { IDeepnoteRequirementsHelper } from './deepnoteRequirementsHelper.node'; -// Constants for NotebookEditor retry logic +/** + * Stand-in for the spec the toolkit server will report. The server's own spec replaces it once the + * kernel is set up; until then it only has to describe the environment for the kernel picker. + */ +const PENDING_KERNEL_SPEC: IJupyterKernelSpec = { + argv: [], + display_name: 'Python 3 (ipykernel)', + executable: 'python', + language: 'python', + name: 'python3' +}; + const NOTEBOOK_EDITOR_RETRY_COUNT = 10; const NOTEBOOK_EDITOR_RETRY_DELAY_MS = 100; @@ -74,10 +79,8 @@ export class DeepnoteKernelAutoSelector implements IDeepnoteKernelAutoSelector, private readonly notebookConnectionMetadata = new Map(); // Track registered controllers per NOTEBOOK (full URI with query) - one controller per notebook private readonly notebookControllers = new Map(); - // Track environment for each notebook - private readonly notebookEnvironmentsIds = new Map(); - // Track per-notebook placeholder controllers for notebooks without configured environments - private readonly placeholderControllers = new Map(); + // Track interpreter ID for each notebook + private readonly notebookInterpreterIds = new Map(); constructor( @inject(IDisposableRegistry) private readonly disposables: IDisposableRegistry, @@ -93,14 +96,12 @@ export class DeepnoteKernelAutoSelector implements IDeepnoteKernelAutoSelector, @inject(IDeepnoteNotebookManager) private readonly notebookManager: IDeepnoteNotebookManager, @inject(IKernelProvider) private readonly kernelProvider: IKernelProvider, @inject(IDeepnoteRequirementsHelper) private readonly requirementsHelper: IDeepnoteRequirementsHelper, - @inject(IDeepnoteEnvironmentManager) private readonly environmentManager: IDeepnoteEnvironmentManager, @inject(IDeepnoteServerStarter) private readonly serverStarter: IDeepnoteServerStarter, - @inject(IDeepnoteNotebookEnvironmentMapper) - private readonly notebookEnvironmentMapper: IDeepnoteNotebookEnvironmentMapper, @inject(IOutputChannel) @named(STANDARD_OUTPUT_CHANNEL) private readonly outputChannel: IOutputChannel, - @inject(IDeepnoteToolkitInstaller) private readonly toolkitInstaller: IDeepnoteToolkitInstaller, + @inject(IInterpreterService) private readonly interpreterService: IInterpreterService, @inject(IServerHandleRegistry) private readonly serverHandleRegistry: IServerHandleRegistry, - @inject(ITelemetryService) private readonly analytics: ITelemetryService + @inject(IDeepnoteToolkitDependencyService) + private readonly toolkitDependencyService: IDeepnoteToolkitDependencyService ) {} public activate() { @@ -134,125 +135,13 @@ export class DeepnoteKernelAutoSelector implements IDeepnoteKernelAutoSelector, logger.info(`Deepnote notebook opened: ${getDisplayPath(notebook.uri)}`); - // Always try to ensure kernel is selected (this will reuse existing controllers) - // Don't await - let it happen in background so notebook opens quickly - window - .withProgress( - { - location: ProgressLocation.Notification, - title: l10n.t('Auto-selecting Deepnote kernel... {0}', getDisplayPath(notebook.uri)), - cancellable: true - }, - async (progress, token) => { - try { - const result = await this.ensureKernelSelected(notebook, progress, token); - return result; - } catch (error) { - logger.error( - `Failed to auto-select Deepnote kernel for ${getDisplayPath(notebook.uri)}`, - error - ); - void this.handleKernelSelectionError(error, notebook); - return true; - } - } - ) - .then( - (result) => { - logger.info(`Auto-selecting Deepnote kernel for ${getDisplayPath(notebook.uri)} result: ${result}`); - if (!result) { - logger.info(`No environment configured for ${getDisplayPath(notebook.uri)}, showing warning`); - this.showNoEnvironmentWarning(notebook).catch((error) => { - logger.error( - `Error showing no environment warning for ${getDisplayPath(notebook.uri)}`, - error - ); - void this.handleKernelSelectionError(error, notebook); - }); - } - }, - (error) => { - logger.error(`Error auto-selecting Deepnote kernel for ${getDisplayPath(notebook.uri)}`, error); - void this.handleKernelSelectionError(error, notebook); - } - ); - } - - private async showNoEnvironmentWarning(notebook: NotebookDocument): Promise { - logger.info(`Showing no environment warning for ${getDisplayPath(notebook.uri)}`); - const selectEnvironmentAction = l10n.t('Select Environment'); - const cancelAction = l10n.t('Cancel'); - - const selectedAction = await window.showWarningMessage( - l10n.t('No environment configured for this notebook. Please select an environment to continue.'), - { modal: false }, - selectEnvironmentAction, - cancelAction - ); - - logger.info(`Selected action: ${selectedAction}`); - if (selectedAction === selectEnvironmentAction) { - logger.info(`Executing command to pick environment for ${getDisplayPath(notebook.uri)}`); - void commands.executeCommand('deepnote.environments.selectForNotebook', { notebook }); - } - } - - public async pickEnvironment(notebookUri: Uri): Promise { - logger.info(`Picking environment for notebook ${getDisplayPath(notebookUri)}`); - - // Wait for environment manager to finish loading environments from storage - await this.environmentManager.waitForInitialization(); - - const environments = this.environmentManager.listEnvironments(); - const items: (QuickPickItem & { environment?: DeepnoteEnvironment })[] = environments.map((env) => { - return { - label: env.name, - description: getDisplayPath(env.pythonInterpreter.uri), - detail: env.packages?.length - ? l10n.t('Packages: {0}', env.packages.join(', ')) - : l10n.t('No additional packages'), - environment: env - }; - }); - - items.push({ - label: '$(add) Create New Environment', - description: 'Set up a new kernel environment', - alwaysShow: true - }); - - const selected = await window.showQuickPick(items, { - placeHolder: `Select an environment for ${getDisplayPath(notebookUri)}`, - matchOnDescription: true, - matchOnDetail: true - }); - - if (!selected) { - logger.info('User cancelled environment selection'); - return; // User cancelled - } - - if (!selected.environment) { - logger.info('User chose to create new environment - triggering create command'); - - await commands.executeCommand('deepnote.environments.create'); - - const newEnvironments = this.environmentManager.listEnvironments(); - - if (newEnvironments.length > environments.length) { - logger.info('Environment created, showing picker again'); - - return this.pickEnvironment(notebookUri); - } - - logger.info('No new environment created'); - - return; + // Like the Jupyter extension, opening a notebook only registers a controller for the + // environment; nothing is installed and no server starts until the first execution. + try { + await this.registerControllerForNotebook(notebook); + } catch (error) { + logger.error(`Failed to offer a Deepnote kernel for ${getDisplayPath(notebook.uri)}`, error); } - - logger.info(`Selected environment "${selected.environment.name}" for notebook ${getDisplayPath(notebookUri)}`); - - return selected.environment; } private onControllerSelectionChanged(event: { @@ -273,24 +162,16 @@ export class DeepnoteKernelAutoSelector implements IDeepnoteKernelAutoSelector, } private onDidCloseNotebook(notebook: NotebookDocument) { - logger.info(`Notebook closed: ${getDisplayPath(notebook.uri)}, with type: ${notebook.notebookType}`); - - // Only handle deepnote notebooks if (notebook.notebookType !== DEEPNOTE_NOTEBOOK_TYPE) { return; } - logger.info(`Deepnote notebook closed: ${getDisplayPath(notebook.uri)}`); - - // Clean up placeholder controller if it exists const notebookKey = getNotebookKey(notebook.uri); - const placeholder = this.placeholderControllers.get(notebookKey); + this.notebookConnectionMetadata.delete(notebookKey); + this.notebookInterpreterIds.delete(notebookKey); + this.notebookControllers.delete(notebookKey); - if (placeholder) { - logger.info(`Disposing placeholder controller for closed notebook: ${getDisplayPath(notebook.uri)}`); - placeholder.dispose(); - this.placeholderControllers.delete(notebookKey); - } + logger.info(`Deepnote notebook closed, cleaned up: ${getDisplayPath(notebook.uri)}`); } /** @@ -332,19 +213,23 @@ export class DeepnoteKernelAutoSelector implements IDeepnoteKernelAutoSelector, // cause "command already exists" errors when trying to start new clients await this.lspClientManager.stopLspClients(notebook.uri, token); - // Update the controller with new environment's metadata - // Because we use notebook-based controller IDs, addOrUpdate will call updateConnection() - // on the existing controller instead of creating a new one - const environmentId = this.notebookEnvironmentMapper.getEnvironmentForNotebook(notebook.uri); - const environment = environmentId ? this.environmentManager.getEnvironment(environmentId) : undefined; + // Get the active interpreter and re-setup the kernel + const interpreter = await this.interpreterService.getActiveInterpreter(notebook.uri); + + if (!interpreter) { + logger.error(`No active Python interpreter found for ${getDisplayPath(notebook.uri)}`); + return; + } + + const dependency = await this.toolkitDependencyService.ensureToolkitInstalled(interpreter, notebook.uri, token); + + if (dependency !== DeepnoteToolkitDependencyResponse.ok) { + logger.info(`deepnote-toolkit unavailable, controller not rebuilt for ${getDisplayPath(notebook.uri)}`); - if (environment == null) { - await this.notebookEnvironmentMapper.removeEnvironmentForNotebook(notebook.uri); - logger.error(`No environment found for notebook ${getDisplayPath(notebook.uri)}`); return; } - await this.ensureKernelSelectedWithConfiguration(notebook, environment, notebookKey, progress, token); + await this.ensureKernelSelectedWithInterpreter(notebook, interpreter, notebookKey, progress, token); // Setup succeeded. If it registered a new server handle (full setup path), drop the old one. // The verified-controller early return reuses the existing handle, so nothing to clear then. @@ -366,47 +251,28 @@ export class DeepnoteKernelAutoSelector implements IDeepnoteKernelAutoSelector, // notebookKey uniquely identifies THIS NOTEBOOK - the same identity the controller/server use const notebookKey = getNotebookKey(notebook.uri); - const environmentId = this.notebookEnvironmentMapper.getEnvironmentForNotebook(notebook.uri); - - if (environmentId == null) { - await this.selectPlaceholderController(notebook); + // Get the active Python interpreter + const interpreter = await this.interpreterService.getActiveInterpreter(notebook.uri); + if (!interpreter) { + logger.warn(`No active Python interpreter found for ${getDisplayPath(notebook.uri)}`); return false; } - const environment = environmentId ? this.environmentManager.getEnvironment(environmentId) : undefined; - - if (environment == null) { - logger.info(`No environment found for notebook ${getDisplayPath(notebook.uri)}`); - await this.notebookEnvironmentMapper.removeEnvironmentForNotebook(notebook.uri); - await this.selectPlaceholderController(notebook); - - return false; - } - - await this.ensureKernelSelectedWithConfiguration(notebook, environment, notebookKey, progress, token); + await this.ensureKernelSelectedWithInterpreter(notebook, interpreter, notebookKey, progress, token); return true; } - public async ensureKernelSelectedWithConfiguration( + public async ensureKernelSelectedWithInterpreter( notebook: NotebookDocument, - configuration: DeepnoteEnvironment, + interpreter: PythonEnvironment, notebookKey: string, progress: { report(value: { message?: string; increment?: number }): void }, progressToken: CancellationToken ): Promise { - // Dispose placeholder controller if it exists (real controller is taking over) - const placeholder = this.placeholderControllers.get(notebookKey); - - if (placeholder) { - logger.info(`Disposing placeholder controller for ${getDisplayPath(notebook.uri)}`); - placeholder.dispose(); - this.placeholderControllers.delete(notebookKey); - } - - logger.info(`Setting up kernel using configuration: ${configuration.name} (${configuration.id})`); - progress.report({ message: `Using ${configuration.name}...` }); + logger.info(`Setting up kernel using interpreter: ${interpreter.id}`); + progress.report({ message: `Using interpreter ${getDisplayPath(interpreter.uri)}...` }); // Check if Python extension is installed if (!this.pythonExtensionChecker.isPythonExtensionInstalled) { @@ -415,71 +281,41 @@ export class DeepnoteKernelAutoSelector implements IDeepnoteKernelAutoSelector, return; } - const existingController = this.notebookControllers.get(notebookKey); - const existingEnvironmentId = this.notebookEnvironmentsIds.get(notebookKey); - - if (existingEnvironmentId != null && existingController != null && existingEnvironmentId === configuration.id) { - logger.info(`Existing controller found for notebook ${getDisplayPath(notebook.uri)}, verifying connection`); - - // Verify the controller's interpreter path matches the expected venv path - // This handles cases where notebooks were used in VS Code and now opened in Cursor - if (this.isControllerInterpreterValid(existingController, configuration.venvPath)) { - logger.info(`Controller verified, selecting it`); - await this.ensureControllerSelectedForNotebook(notebook, existingController, progressToken); - - return; - } - - const expectedInterpreter = this.getVenvInterpreterUri(configuration.venvPath); - logger.warn( - `Controller interpreter path mismatch! Expected: ${expectedInterpreter.fsPath}, Got: ${existingController.connection.interpreter?.uri.fsPath}. Recreating controller.` + if (this.isKernelReady(notebookKey, interpreter.id)) { + logger.info(`Existing controller found for notebook ${getDisplayPath(notebook.uri)}, reusing`); + await this.ensureControllerSelectedForNotebook( + notebook, + this.notebookControllers.get(notebookKey)!, + progressToken ); - // Dispose old controller and recreate it - existingController.dispose(); - this.notebookControllers.delete(notebookKey); + return; } // Ensure server is running (startServer is idempotent - returns early if already running) - // Note: startServer() will create the venv if it doesn't exist - logger.info(`Ensuring server is running for configuration ${configuration.id}`); + // Server starter handles toolkit check/install via IInstaller internally + logger.info(`Ensuring server is running for interpreter ${interpreter.id}`); progress.report({ message: 'Starting Deepnote server...' }); - const serverInfo = await this.serverStarter.startServer( - configuration.pythonInterpreter, - configuration.venvPath, - configuration.managedVenv, - configuration.packages ?? [], - configuration.id, - notebook.uri, - progressToken - ); + const serverInfo = await this.serverStarter.startServer(interpreter, notebook.uri, progressToken); - this.notebookEnvironmentsIds.set(notebookKey, configuration.id); + this.notebookInterpreterIds.set(notebookKey, interpreter.id); logger.info(`Server running at ${serverInfo.url}`); - // Update last used timestamp - await this.environmentManager.updateLastUsed(configuration.id); - - // Create server provider handle + // Create server provider handle using interpreter ID const serverProviderHandle: JupyterServerProviderHandle = { extensionId: JVSC_EXTENSION_ID, id: 'deepnote-server', - handle: createDeepnoteServerConfigHandle(configuration.id, notebook.uri) + handle: createDeepnoteServerConfigHandle(interpreter.id, notebook.uri) }; + // Register the server with the provider (one server per PROJECT) this.serverProvider.registerServer(serverProviderHandle.handle, serverInfo); this.serverHandleRegistry.set(notebookKey, serverProviderHandle.handle); - const lspInterpreterUri = this.getVenvInterpreterUri(configuration.venvPath); - - const lspInterpreter: PythonEnvironment = { - uri: lspInterpreterUri, - id: lspInterpreterUri.fsPath - } as PythonEnvironment; - + // Use the active interpreter directly for LSP (it already has deepnote-toolkit installed) try { - await this.lspClientManager.startLspClients(serverInfo, notebook.uri, lspInterpreter, progressToken); + await this.lspClientManager.startLspClients(serverInfo, notebook.uri, interpreter, progressToken); logger.info(`✓ LSP clients started for ${notebookKey}`); } catch (error) { @@ -488,12 +324,14 @@ export class DeepnoteKernelAutoSelector implements IDeepnoteKernelAutoSelector, progress.report({ message: 'Connecting to kernel...' }); + const displayName = `Deepnote: ${getDisplayPath(interpreter.uri)} (${notebookKey})`; + const connectionInfo = createJupyterConnectionInfo( serverProviderHandle, { baseUrl: serverInfo.url, token: serverInfo.token || '', - displayName: `Deepnote: ${configuration.name} (${notebookKey})`, + displayName, authorizationHeader: {} }, this.requestCreator, @@ -508,8 +346,8 @@ export class DeepnoteKernelAutoSelector implements IDeepnoteKernelAutoSelector, const kernelSpecs = await sessionManager.getKernelSpecs(); logger.info(`Available kernel specs on Deepnote server: ${kernelSpecs.map((s) => s.name).join(', ')}`); - // Use the extracted kernel selection logic - kernelSpec = this.selectKernelSpec(kernelSpecs, configuration.id); + // Select the default Python kernel (ipykernel-provided python3 spec) + kernelSpec = this.selectKernelSpec(kernelSpecs); logger.info(`✓ Using kernel spec: ${kernelSpec.name} (${kernelSpec.display_name})`); } finally { @@ -518,10 +356,7 @@ export class DeepnoteKernelAutoSelector implements IDeepnoteKernelAutoSelector, progress.report({ message: 'Finalizing kernel setup...' }); - const venvInterpreter = this.getVenvInterpreterUri(configuration.venvPath); - - logger.info(`Using venv path: ${configuration.venvPath.fsPath}`); - logger.info(`Venv interpreter path: ${venvInterpreter.fsPath}`); + logger.info(`Using interpreter: ${interpreter.uri.fsPath}`); // CRITICAL: Use unique notebook-based ID (includes query with notebook ID) // This ensures each notebook gets its own controller/kernel, even within the same project. @@ -529,19 +364,15 @@ export class DeepnoteKernelAutoSelector implements IDeepnoteKernelAutoSelector, // controller instead of creating a new one, avoiding the DISPOSED error. const controllerId = `deepnote-notebook-${notebookKey}`; - // Extract project and notebook titles from metadata for display - const projectTitle = notebook.metadata?.deepnoteProjectName || 'Untitled Project'; - const newConnectionMetadata = DeepnoteKernelConnectionMetadata.create({ - interpreter: { uri: venvInterpreter, id: venvInterpreter.fsPath }, + interpreter, kernelSpec, baseUrl: serverInfo.url, id: controllerId, projectFilePath: getNotebookKey(notebook.uri), serverProviderHandle, serverInfo, - environmentName: configuration.name, - projectName: projectTitle, + environmentName: getDisplayPath(interpreter.uri), notebookName: notebookKey }); @@ -564,7 +395,6 @@ export class DeepnoteKernelAutoSelector implements IDeepnoteKernelAutoSelector, // Store the controller for reuse this.notebookControllers.set(notebookKey, controller); - // Prepare init notebook execution const projectId = notebook.metadata?.deepnoteProjectId; const notebookId = notebook.metadata?.deepnoteNotebookId; const project = @@ -610,7 +440,7 @@ export class DeepnoteKernelAutoSelector implements IDeepnoteKernelAutoSelector, // Auto-select the controller await this.ensureControllerSelectedForNotebook(notebook, controller, progressToken); - logger.info(`Successfully set up kernel with configuration: ${configuration.name}`); + logger.info(`Successfully set up kernel with interpreter: ${interpreter.id}`); progress.report({ message: 'Kernel ready!' }); } @@ -653,35 +483,20 @@ export class DeepnoteKernelAutoSelector implements IDeepnoteKernelAutoSelector, } /** - * Select the appropriate kernel spec for an environment. + * Select the default Python kernel spec from the server. * Extracted for testability. * @param kernelSpecs Available kernel specs from the server - * @param environmentId The environment ID to find a kernel for * @returns The selected kernel spec * @throws Error if no suitable kernel spec is found */ - public selectKernelSpec(kernelSpecs: IJupyterKernelSpec[], environmentId: string): IJupyterKernelSpec { - // Look for environment-specific kernel first - const expectedKernelName = `deepnote-${environmentId}`; - logger.info(`Looking for environment-specific kernel: ${expectedKernelName}`); - - const kernelSpec = kernelSpecs.find((s) => s.name === expectedKernelName); + public selectKernelSpec(kernelSpecs: IJupyterKernelSpec[]): IJupyterKernelSpec { + const kernelSpec = + kernelSpecs.find((s) => s.language === 'python') || + kernelSpecs.find((s) => s.name === 'python3') || + kernelSpecs[0]; if (!kernelSpec) { - logger.warn( - `Environment-specific kernel '${expectedKernelName}' not found! Falling back to generic Python kernel.` - ); - // Fallback to any Python kernel - const fallbackKernel = - kernelSpecs.find((s) => s.language === 'python') || - kernelSpecs.find((s) => s.name === 'python3') || - kernelSpecs[0]; - - if (!fallbackKernel) { - throw new Error('No kernel specs available on Deepnote server'); - } - - return fallbackKernel; + throw new Error('No kernel specs available on Deepnote server'); } return kernelSpec; @@ -689,7 +504,7 @@ export class DeepnoteKernelAutoSelector implements IDeepnoteKernelAutoSelector, /** * Ensure an environment is configured for the notebook before execution. - * If not configured, shows picker and sets up the kernel. + * Uses the active Python interpreter and the IInstaller infrastructure. * @returns true if environment is ready, false if user cancelled */ public async ensureEnvironmentConfiguredBeforeExecution( @@ -700,96 +515,56 @@ export class DeepnoteKernelAutoSelector implements IDeepnoteKernelAutoSelector, const notebookKey = getNotebookKey(notebook.uri); - const existingEnvironmentId = this.notebookEnvironmentMapper.getEnvironmentForNotebook(notebook.uri); + const interpreter = await this.interpreterService.getActiveInterpreter(notebook.uri); - // No environment configured - need to pick one - if (!existingEnvironmentId) { - return this.pickAndSetupEnvironment(notebook, notebookKey, token); + if (!interpreter) { + logger.warn(`No active Python interpreter found for ${getDisplayPath(notebook.uri)}`); + return false; } - const environment = this.environmentManager.getEnvironment(existingEnvironmentId); + if (this.isKernelReady(notebookKey, interpreter.id)) { + logger.info(`Controller already configured for ${getDisplayPath(notebook.uri)}`); - // Environment no longer exists - remove stale mapping and pick a new one - if (!environment) { - logger.info(`Removing stale environment mapping for ${getDisplayPath(notebook.uri)}`); - await this.notebookEnvironmentMapper.removeEnvironmentForNotebook(notebook.uri); - - return this.pickAndSetupEnvironment(notebook, notebookKey, token); + return true; } - const existingController = this.notebookControllers.get(notebookKey); + // Consent before installing into the user's interpreter. Declining or cancelling is not a + // failure: execution simply does not proceed and no error UI is raised. + let dependency: DeepnoteToolkitDependencyResponse; - // Environment and controller already configured - but verify interpreter path still matches - if (existingController) { - if (!this.isControllerInterpreterValid(existingController, environment.venvPath)) { - const expectedInterpreter = this.getVenvInterpreterUri(environment.venvPath); - logger.warn( - `Controller interpreter path mismatch! Expected: ${expectedInterpreter.fsPath}, Got: ${existingController.connection.interpreter?.uri.fsPath}. Recreating controller.` - ); - - existingController.dispose(); - this.notebookControllers.delete(notebookKey); + try { + dependency = await this.toolkitDependencyService.ensureToolkitInstalled(interpreter, notebook.uri, token); + } catch (error) { + if (token.isCancellationRequested || isCancellationError(error as Error)) { + logger.info(`deepnote-toolkit install cancelled for ${getDisplayPath(notebook.uri)}`); - return this.setupKernelForEnvironment(notebook, environment, notebookKey, token); + return false; } - logger.info(`Environment "${environment.name}" already configured for ${getDisplayPath(notebook.uri)}`); + await this.handleKernelSelectionError(error, notebook); - return true; + return false; } - // Environment exists but controller is missing - set it up - logger.info( - `Environment "${environment.name}" configured but controller missing for ${getDisplayPath( - notebook.uri - )}, triggering setup` - ); - - return this.setupKernelForEnvironment(notebook, environment, notebookKey, token); - } - - /** - * Pick an environment and set up the kernel for a notebook. - */ - private async pickAndSetupEnvironment( - notebook: NotebookDocument, - notebookKey: string, - token: CancellationToken - ): Promise { - Cancellation.throwIfCanceled(token); - - logger.info(`No environment configured for ${getDisplayPath(notebook.uri)}, showing picker`); - const selectedEnvironment = await this.pickEnvironment(notebook.uri); - - if (!selectedEnvironment) { - logger.info(`User cancelled environment selection for ${getDisplayPath(notebook.uri)}`); + if (dependency === DeepnoteToolkitDependencyResponse.failed) { + await this.handleKernelSelectionError( + new Error(l10n.t('Failed to install {0}.', 'deepnote-toolkit')), + notebook + ); return false; } - Cancellation.throwIfCanceled(token); - - await this.notebookEnvironmentMapper.setEnvironmentForNotebook(notebook.uri, selectedEnvironment.id); - - const result = await this.setupKernelForEnvironment(notebook, selectedEnvironment, notebookKey, token); + if (dependency !== DeepnoteToolkitDependencyResponse.ok) { + logger.info( + `deepnote-toolkit unavailable for ${getDisplayPath(notebook.uri)} (${ + DeepnoteToolkitDependencyResponse[dependency] + }), kernel not started` + ); - if (result) { - this.analytics.trackEvent({ eventName: 'select_environment' }); - logger.info(`Environment "${selectedEnvironment.name}" configured for ${getDisplayPath(notebook.uri)}`); + return false; } - return result; - } - - /** - * Set up the kernel for a given environment. - */ - private async setupKernelForEnvironment( - notebook: NotebookDocument, - environment: DeepnoteEnvironment, - notebookKey: string, - token: CancellationToken - ): Promise { try { await window.withProgress( { @@ -798,9 +573,9 @@ export class DeepnoteKernelAutoSelector implements IDeepnoteKernelAutoSelector, cancellable: true }, async (progress, progressToken) => { - await this.ensureKernelSelectedWithConfiguration( + await this.ensureKernelSelectedWithInterpreter( notebook, - environment, + interpreter, notebookKey, progress, progressToken @@ -810,69 +585,76 @@ export class DeepnoteKernelAutoSelector implements IDeepnoteKernelAutoSelector, } catch (error) { if (token.isCancellationRequested || isCancellationError(error as Error)) { logger.info(`Kernel setup cancelled for ${getDisplayPath(notebook.uri)}`); - return false; } throw error; } - const createdController = this.notebookControllers.get(notebookKey); - - if (!createdController) { - logger.warn( - `Controller not created for "${environment.name}" on ${getDisplayPath(notebook.uri)} after setup` - ); - - return false; - } + return !!this.notebookControllers.get(notebookKey); + } - return true; + /** + * True when the notebook's controller is bound to a running server for this interpreter. A + * controller registered at open time carries no baseUrl and is deliberately not "ready". + */ + private isKernelReady(notebookKey: string, interpreterId: string): boolean { + return ( + this.notebookControllers.has(notebookKey) && + this.notebookInterpreterIds.get(notebookKey) === interpreterId && + !!this.notebookConnectionMetadata.get(notebookKey)?.baseUrl + ); } /** - * Clear the controller selection for a notebook using a specific environment. - * This is used when deleting an environment to unselect its controller from any open notebooks. + * Registers a controller for the notebook's active interpreter without starting anything, so the + * notebook has a real, correctly named kernel to select the moment it opens. Its connection + * carries no baseUrl; the first execution starts the server and updates it in place. */ - public clearControllerForEnvironment(notebook: NotebookDocument, environmentId: string): void { - const selectedController = this.controllerRegistration.getSelected(notebook); - if (!selectedController || selectedController.connection.kind !== 'startUsingDeepnoteKernel') { - return; - } + private async registerControllerForNotebook(notebook: NotebookDocument): Promise { + const interpreter = await this.interpreterService.getActiveInterpreter(notebook.uri); - const expectedHandle = createDeepnoteServerConfigHandle(environmentId, notebook.uri); + if (!interpreter) { + logger.warn(`No active Python interpreter for ${getDisplayPath(notebook.uri)}, no kernel offered`); - if (selectedController.connection.serverProviderHandle.handle === expectedHandle) { - // Unselect the controller by setting affinity to Default - selectedController.controller.updateNotebookAffinity(notebook, NotebookControllerAffinity.Default); - logger.info( - `Cleared controller for notebook ${getDisplayPath(notebook.uri)} (environment ${environmentId})` - ); + return; } - } - private getVenvInterpreterUri(venvPath: Uri): Uri { - return process.platform === 'win32' - ? Uri.joinPath(venvPath, 'Scripts', 'python.exe') - : Uri.joinPath(venvPath, 'bin', 'python'); - } + const notebookKey = getNotebookKey(notebook.uri); + const connection = DeepnoteKernelConnectionMetadata.create({ + interpreter, + kernelSpec: PENDING_KERNEL_SPEC, + baseUrl: '', + id: `deepnote-notebook-${notebookKey}`, + projectFilePath: notebookKey, + serverProviderHandle: { + extensionId: JVSC_EXTENSION_ID, + id: 'deepnote-server', + handle: createDeepnoteServerConfigHandle(interpreter.id, notebook.uri) + }, + environmentName: getDisplayPath(interpreter.uri), + notebookName: notebookKey + }); - /** - * Check if a controller's interpreter path matches the expected venv path. - * Returns true when no interpreter is present (nothing to validate) or when paths match. - */ - private isControllerInterpreterValid( - controller: { connection: { interpreter?: { uri: Uri } } }, - venvPath: Uri - ): boolean { - const existingInterpreter = controller.connection.interpreter; + const [controller] = this.controllerRegistration.addOrUpdate(connection, [DEEPNOTE_NOTEBOOK_TYPE]); - if (!existingInterpreter) { - return true; + if (!controller) { + logger.error(`Failed to register a Deepnote controller for ${getDisplayPath(notebook.uri)}`); + + return; } - const expectedInterpreter = this.getVenvInterpreterUri(venvPath); + this.notebookConnectionMetadata.set(notebookKey, connection); + this.notebookInterpreterIds.set(notebookKey, interpreter.id); + this.notebookControllers.set(notebookKey, controller); + this.controllerRegistration.trackActiveInterpreterControllers([controller]); + + const cts = new CancellationTokenSource(); - return existingInterpreter.uri.fsPath === expectedInterpreter.fsPath; + try { + await this.ensureControllerSelectedForNotebook(notebook, controller, cts.token); + } finally { + cts.dispose(); + } } /** @@ -881,7 +663,6 @@ export class DeepnoteKernelAutoSelector implements IDeepnoteKernelAutoSelector, * Includes retry logic since the editor might not be visible immediately when the document opens. */ private async findNotebookEditor(notebook: NotebookDocument): Promise { - // Try to find immediately let editor = window.visibleNotebookEditors.find( (e) => getNotebookKey(e.notebook.uri) === getNotebookKey(notebook.uri) ); @@ -890,7 +671,6 @@ export class DeepnoteKernelAutoSelector implements IDeepnoteKernelAutoSelector, return editor; } - // If not found, wait briefly and retry (editor might not be visible yet) for (let i = 0; i < NOTEBOOK_EDITOR_RETRY_COUNT; i++) { await new Promise((resolve) => setTimeout(resolve, NOTEBOOK_EDITOR_RETRY_DELAY_MS)); @@ -906,28 +686,6 @@ export class DeepnoteKernelAutoSelector implements IDeepnoteKernelAutoSelector, return; } - /** - * Create and select a placeholder controller for a notebook without a configured environment. - */ - private async selectPlaceholderController(notebook: NotebookDocument): Promise { - const placeholder = this.createPlaceholderController(notebook); - placeholder.updateNotebookAffinity(notebook, NotebookControllerAffinity.Preferred); - - const notebookEditor = await this.findNotebookEditor(notebook); - - if (notebookEditor) { - await commands.executeCommand('notebook.selectKernel', { - notebookEditor: notebookEditor, - id: placeholder.id, - extension: JVSC_EXTENSION_ID - }); - } else { - logger.warn( - `Could not find NotebookEditor for ${getDisplayPath(notebook.uri)}, kernel may not be selected` - ); - } - } - /** * Handle kernel selection errors with user-friendly messages and actions */ @@ -939,28 +697,6 @@ export class DeepnoteKernelAutoSelector implements IDeepnoteKernelAutoSelector, return; } - if (error instanceof DeepnoteToolkitMissingError) { - const installAction = l10n.t('Install'); - const changeEnvironmentAction = l10n.t('Change Environment'); - const selectedAction = await window.showWarningMessage( - l10n.t( - 'Running Deepnote projects requires deepnote-toolkit[server]=={0} to be installed in the selected environment', - DEEPNOTE_TOOLKIT_VERSION - ), - { modal: true }, - installAction, - changeEnvironmentAction - ); - - if (selectedAction === installAction) { - await this.installToolkitAndNotify(error.venvPath, notebook); - } else if (selectedAction === changeEnvironmentAction) { - void commands.executeCommand('deepnote.environments.selectForNotebook', { notebook }); - } - - return; - } - // Handle DeepnoteKernelError types with specific guidance if (error instanceof DeepnoteKernelError) { // Log the technical details @@ -1014,41 +750,6 @@ export class DeepnoteKernelAutoSelector implements IDeepnoteKernelAutoSelector, } } - /** - * Install deepnote-toolkit in an existing venv and rebuild the controller. - */ - private async installToolkitAndNotify(venvPath: string, notebook: NotebookDocument): Promise { - try { - await window.withProgress( - { - location: ProgressLocation.Notification, - title: l10n.t('Installing deepnote-toolkit...'), - cancellable: true - }, - async (progress, token) => { - await this.toolkitInstaller.installToolkitInExistingVenv(Uri.file(venvPath), token); - - // After successful installation, rebuild the controller to use the new environment - progress.report({ message: l10n.t('Starting kernel...') }); - await this.rebuildController(notebook, progress, token); - } - ); - - void window.showInformationMessage(l10n.t('deepnote-toolkit installed successfully')); - } catch (installError) { - if (installError instanceof Error && isCancellationError(installError)) { - logger.info('deepnote-toolkit installation cancelled'); - - return; - } - - logger.error('Failed to install deepnote-toolkit', installError); - const errorMessage = installError instanceof Error ? installError.message : String(installError); - - void window.showErrorMessage(l10n.t('Failed to install deepnote-toolkit: {0}', errorMessage)); - } - } - /** * Read and hash the existing requirements.txt file if it exists. * Returns the same hash format as computeRequirementsHash for comparison. @@ -1084,78 +785,4 @@ export class DeepnoteKernelAutoSelector implements IDeepnoteKernelAutoSelector, return ''; } } - - /** - * Create a placeholder controller for a notebook without a configured environment. - * Each notebook gets its own placeholder with a unique ID. - * The placeholder's executeHandler shows the environment picker when user tries to run cells. - */ - private createPlaceholderController(notebook: NotebookDocument): NotebookController { - const notebookKey = getNotebookKey(notebook.uri); - - // Check if we already have one - const existing = this.placeholderControllers.get(notebookKey); - - if (existing) { - return existing; - } - - const controller = notebooks.createNotebookController( - `deepnote-placeholder-${notebookKey}`, - DEEPNOTE_NOTEBOOK_TYPE, - l10n.t('Deepnote: Select Environment') - ); - - controller.supportsExecutionOrder = true; - controller.supportedLanguages = ['python', 'sql', 'markdown', 'plaintext']; - - // Environment picker only; execution goes through the real controller on retry. - controller.executeHandler = async (cells, doc) => { - logger.info( - `Placeholder controller execute handler called for ${getDisplayPath(doc.uri)} with ${ - cells.length - } cells` - ); - - if (!workspace.isTrusted) { - logger.info(`Workspace is not trusted, skipping environment setup for ${getDisplayPath(doc.uri)}`); - - return; - } - - // Create a cancellation token that cancels when the notebook is closed - const cts = new CancellationTokenSource(); - const closeListener = workspace.onDidCloseNotebookDocument((closedDoc) => { - if (getNotebookKey(closedDoc.uri) === getNotebookKey(doc.uri)) { - logger.info(`Notebook closed during environment setup, cancelling operation`); - cts.cancel(); - } - }); - - try { - const hasEnvironment = await this.ensureEnvironmentConfiguredBeforeExecution(doc, cts.token); - - if (!hasEnvironment) { - logger.info(`User cancelled environment selection, not executing cells`); - - return; - } - - void window.showInformationMessage(l10n.t('Environment ready. Run the cells again to execute them.')); - } catch (error) { - if (isCancellationError(error)) { - logger.info(`Environment setup cancelled for ${getDisplayPath(doc.uri)}`); - } else { - logger.error(`Error in placeholder controller execute handler`, error); - } - } finally { - closeListener.dispose(); - cts.dispose(); - } - }; - - this.placeholderControllers.set(notebookKey, controller); - - return controller; - } } diff --git a/src/notebooks/deepnote/deepnoteKernelAutoSelector.node.unit.test.ts b/src/notebooks/deepnote/deepnoteKernelAutoSelector.node.unit.test.ts index 7ae8452120..b06b9681b9 100644 --- a/src/notebooks/deepnote/deepnoteKernelAutoSelector.node.unit.test.ts +++ b/src/notebooks/deepnote/deepnoteKernelAutoSelector.node.unit.test.ts @@ -1,20 +1,15 @@ import { assert } from 'chai'; import * as sinon from 'sinon'; -import { anything, deepEqual, instance, mock, verify, when } from 'ts-mockito'; +import { anything, instance, mock, verify, when } from 'ts-mockito'; import { DeepnoteKernelAutoSelector } from './deepnoteKernelAutoSelector.node'; -import { createMockChildProcess } from '../../kernels/deepnote/deepnoteTestHelpers.node'; -import { createMockCell } from './deepnoteTestHelpers'; import { ServerHandleRegistry } from '../../kernels/deepnote/deepnoteServerHandleRegistry.node'; +import { DeepnoteToolkitDependencyResponse, IDeepnoteToolkitDependencyService } from '../../kernels/deepnote/types'; import { - IDeepnoteEnvironmentManager, IDeepnoteLspClientManager, - IDeepnoteNotebookEnvironmentMapper, IDeepnoteServerProvider, - IDeepnoteServerStarter, - IDeepnoteToolkitInstaller + IDeepnoteServerStarter } from '../../kernels/deepnote/types'; import { IControllerRegistration, IVSCodeNotebookController } from '../controllers/types'; -import { ITelemetryService } from '../../platform/analytics/types'; import { IDisposableRegistry, IOutputChannel } from '../../platform/common/types'; import { IPythonExtensionChecker } from '../../platform/api/types'; import { IJupyterRequestCreator } from '../../kernels/jupyter/types'; @@ -24,23 +19,20 @@ import { IKernelProvider, IKernel, IJupyterKernelSpec, KernelConnectionMetadata import { IDeepnoteRequirementsHelper } from './deepnoteRequirementsHelper.node'; import { CancellationError, - EventEmitter, NotebookDocument, NotebookEditor, Uri, NotebookController, CancellationToken } from 'vscode'; -import { DeepnoteToolkitMissingError } from '../../platform/errors/deepnoteKernelErrors'; -import { DeepnoteEnvironment } from '../../kernels/deepnote/environments/deepnoteEnvironment'; import { PythonEnvironment } from '../../platform/pythonEnvironments/info'; +import { IInterpreterService } from '../../platform/interpreter/contracts'; import { getNotebookKey } from '../../platform/deepnote/deepnoteProjectUtils'; import { computeRequirementsHash } from './deepnoteProjectUtils'; import { mockedVSCodeNamespaces, resetVSCodeMocks } from '../../test/vscode-mock'; suite('DeepnoteKernelAutoSelector - rebuildController', () => { let selector: DeepnoteKernelAutoSelector; - let registry: ServerHandleRegistry; let mockDisposableRegistry: IDisposableRegistry; let mockControllerRegistration: IControllerRegistration; let mockPythonExtensionChecker: IPythonExtensionChecker; @@ -51,12 +43,11 @@ suite('DeepnoteKernelAutoSelector - rebuildController', () => { let mockNotebookManager: IDeepnoteNotebookManager; let mockKernelProvider: IKernelProvider; let mockRequirementsHelper: IDeepnoteRequirementsHelper; - let mockEnvironmentManager: IDeepnoteEnvironmentManager; let mockServerStarter: IDeepnoteServerStarter; - let mockNotebookEnvironmentMapper: IDeepnoteNotebookEnvironmentMapper; let mockOutputChannel: IOutputChannel; - let mockToolkitInstaller: IDeepnoteToolkitInstaller; - let mockTelemetryService: ITelemetryService; + let mockInterpreterService: IInterpreterService; + let registry: ServerHandleRegistry; + let mockToolkitDependencyService: IDeepnoteToolkitDependencyService; let mockProgress: { report(value: { message?: string; increment?: number }): void }; let mockCancellationToken: CancellationToken; @@ -81,12 +72,14 @@ suite('DeepnoteKernelAutoSelector - rebuildController', () => { mockNotebookManager = mock(); mockKernelProvider = mock(); mockRequirementsHelper = mock(); - mockEnvironmentManager = mock(); mockServerStarter = mock(); - mockToolkitInstaller = mock(); - mockNotebookEnvironmentMapper = mock(); mockOutputChannel = mock(); - mockTelemetryService = mock(); + mockInterpreterService = mock(); + registry = new ServerHandleRegistry(); + mockToolkitDependencyService = mock(); + when(mockToolkitDependencyService.ensureToolkitInstalled(anything(), anything(), anything())).thenResolve( + DeepnoteToolkitDependencyResponse.ok + ); mockProgress = { report: sandbox.stub() }; mockCancellationToken = mock(); @@ -121,21 +114,6 @@ suite('DeepnoteKernelAutoSelector - rebuildController', () => { // Mock disposable registry - push returns the index when(mockDisposableRegistry.push(anything())).thenReturn(0); - // Mock notebooks.createNotebookController to return a mock controller for the loading kernel - const mockLoadingController = { - id: 'deepnote-loading-kernel', - supportsExecutionOrder: false, - supportedLanguages: ['python'], - executeHandler: undefined as unknown, - updateNotebookAffinity: sandbox.stub(), - dispose: sandbox.stub() - } as unknown as NotebookController; - when(mockedVSCodeNamespaces.notebooks!.createNotebookController(anything(), anything(), anything())).thenReturn( - mockLoadingController - ); - - registry = new ServerHandleRegistry(); - // Create selector instance selector = new DeepnoteKernelAutoSelector( instance(mockDisposableRegistry), @@ -149,13 +127,11 @@ suite('DeepnoteKernelAutoSelector - rebuildController', () => { instance(mockNotebookManager), instance(mockKernelProvider), instance(mockRequirementsHelper), - instance(mockEnvironmentManager), instance(mockServerStarter), - instance(mockNotebookEnvironmentMapper), instance(mockOutputChannel), - instance(mockToolkitInstaller), + instance(mockInterpreterService), registry, - instance(mockTelemetryService) + instance(mockToolkitDependencyService) ); }); @@ -174,31 +150,31 @@ suite('DeepnoteKernelAutoSelector - rebuildController', () => { pendingCells: [{ index: 0 }, { index: 1 }] // 2 cells pending }; - // Create mock environment - const mockEnvironment = createMockEnvironment('test-env-id', 'Test Environment'); - - // Mock environment mapper and manager - when(mockNotebookEnvironmentMapper.getEnvironmentForNotebook(anything())).thenReturn('test-env-id'); - when(mockEnvironmentManager.getEnvironment('test-env-id')).thenReturn(mockEnvironment); + // Mock interpreter service to return an active interpreter + const mockInterpreter: PythonEnvironment = { + id: '/usr/bin/python3', + uri: Uri.parse('/usr/bin/python3') + }; + when(mockInterpreterService.getActiveInterpreter(anything())).thenResolve(mockInterpreter); when(mockKernelProvider.get(mockNotebook)).thenReturn(instance(mockKernel)); when(mockKernelProvider.getKernelExecution(instance(mockKernel))).thenReturn(mockExecution as any); - // Stub ensureKernelSelectedWithConfiguration to verify it's still called despite pending cells - const ensureKernelSelectedWithConfigurationStub = sandbox - .stub(selector, 'ensureKernelSelectedWithConfiguration') + // Stub ensureKernelSelectedWithInterpreter to verify it's still called despite pending cells + const ensureKernelSelectedWithInterpreterStub = sandbox + .stub(selector, 'ensureKernelSelectedWithInterpreter') .resolves(); // Act await selector.rebuildController(mockNotebook, mockProgress, instance(mockCancellationToken)); // Assert - should proceed despite pending cells assert.strictEqual( - ensureKernelSelectedWithConfigurationStub.calledOnce, + ensureKernelSelectedWithInterpreterStub.calledOnce, true, 'ensureKernelSelected should be called even with pending cells' ); assert.strictEqual( - ensureKernelSelectedWithConfigurationStub.firstCall.args[0], + ensureKernelSelectedWithInterpreterStub.firstCall.args[0], mockNotebook, 'ensureKernelSelected should be called with the notebook' ); @@ -211,16 +187,16 @@ suite('DeepnoteKernelAutoSelector - rebuildController', () => { // Arrange when(mockKernelProvider.get(mockNotebook)).thenReturn(undefined); - // Create mock environment - const mockEnvironment = createMockEnvironment('test-env-id', 'Test Environment'); - - // Mock environment mapper and manager - when(mockNotebookEnvironmentMapper.getEnvironmentForNotebook(anything())).thenReturn('test-env-id'); - when(mockEnvironmentManager.getEnvironment('test-env-id')).thenReturn(mockEnvironment); + // Mock interpreter service to return an active interpreter + const mockInterpreter: PythonEnvironment = { + id: '/usr/bin/python3', + uri: Uri.parse('/usr/bin/python3') + }; + when(mockInterpreterService.getActiveInterpreter(anything())).thenResolve(mockInterpreter); - // Stub ensureKernelSelectedWithConfiguration to verify it's called - const ensureKernelSelectedWithConfigurationStub = sandbox - .stub(selector, 'ensureKernelSelectedWithConfiguration') + // Stub ensureKernelSelectedWithInterpreter to verify it's called + const ensureKernelSelectedWithInterpreterStub = sandbox + .stub(selector, 'ensureKernelSelectedWithInterpreter') .resolves(); // Act @@ -228,34 +204,34 @@ suite('DeepnoteKernelAutoSelector - rebuildController', () => { // Assert - should proceed normally without a kernel assert.strictEqual( - ensureKernelSelectedWithConfigurationStub.calledOnce, + ensureKernelSelectedWithInterpreterStub.calledOnce, true, 'ensureKernelSelected should be called even when no kernel exists' ); assert.strictEqual( - ensureKernelSelectedWithConfigurationStub.firstCall.args[0], + ensureKernelSelectedWithInterpreterStub.firstCall.args[0], mockNotebook, 'ensureKernelSelected should be called with the notebook' ); }); - test('should complete successfully and delegate to ensureKernelSelectedWithConfiguration', async () => { - // This test verifies that ensureKernelSelectedWithConfiguration completes successfully + test('should complete successfully and delegate to ensureKernelSelectedWithInterpreter', async () => { + // This test verifies that ensureKernelSelectedWithInterpreter completes successfully // and delegates kernel setup to ensureKernelSelected // Arrange when(mockKernelProvider.get(mockNotebook)).thenReturn(undefined); - // Create mock environment - const mockEnvironment = createMockEnvironment('test-env-id', 'Test Environment'); - - // Mock environment mapper and manager - when(mockNotebookEnvironmentMapper.getEnvironmentForNotebook(anything())).thenReturn('test-env-id'); - when(mockEnvironmentManager.getEnvironment('test-env-id')).thenReturn(mockEnvironment); + // Mock interpreter service to return an active interpreter + const mockInterpreter: PythonEnvironment = { + id: '/usr/bin/python3', + uri: Uri.parse('/usr/bin/python3') + }; + when(mockInterpreterService.getActiveInterpreter(anything())).thenResolve(mockInterpreter); - // Stub ensureKernelSelectedWithConfiguration to verify delegation - const ensureKernelSelectedWithConfigurationStub = sandbox - .stub(selector, 'ensureKernelSelectedWithConfiguration') + // Stub ensureKernelSelectedWithInterpreter to verify delegation + const ensureKernelSelectedWithInterpreterStub = sandbox + .stub(selector, 'ensureKernelSelectedWithInterpreter') .resolves(); // Act @@ -263,30 +239,30 @@ suite('DeepnoteKernelAutoSelector - rebuildController', () => { // Assert - method should complete without errors assert.strictEqual( - ensureKernelSelectedWithConfigurationStub.calledOnce, + ensureKernelSelectedWithInterpreterStub.calledOnce, true, - 'ensureKernelSelectedWithConfiguration should be called to set up the new environment' + 'ensureKernelSelectedWithInterpreter should be called to set up the new environment' ); }); - test('should pass cancellation token to ensureKernelSelectedWithConfiguration', async () => { + test('should pass cancellation token to ensureKernelSelectedWithInterpreter', async () => { // This test verifies that rebuildController correctly passes the cancellation token - // to ensureKernelSelectedWithConfiguration, allowing the operation to be cancelled during execution + // to ensureKernelSelectedWithInterpreter, allowing the operation to be cancelled during execution // Arrange when(mockCancellationToken.isCancellationRequested).thenReturn(true); when(mockKernelProvider.get(mockNotebook)).thenReturn(undefined); - // Create mock environment - const mockEnvironment = createMockEnvironment('test-env-id', 'Test Environment'); - - // Mock environment mapper and manager - when(mockNotebookEnvironmentMapper.getEnvironmentForNotebook(anything())).thenReturn('test-env-id'); - when(mockEnvironmentManager.getEnvironment('test-env-id')).thenReturn(mockEnvironment); + // Mock interpreter service to return an active interpreter + const mockInterpreter: PythonEnvironment = { + id: '/usr/bin/python3', + uri: Uri.parse('/usr/bin/python3') + }; + when(mockInterpreterService.getActiveInterpreter(anything())).thenResolve(mockInterpreter); - // Stub ensureKernelSelectedWithConfiguration to verify it receives the token - const ensureKernelSelectedWithConfigurationStub = sandbox - .stub(selector, 'ensureKernelSelectedWithConfiguration') + // Stub ensureKernelSelectedWithInterpreter to verify it receives the token + const ensureKernelSelectedWithInterpreterStub = sandbox + .stub(selector, 'ensureKernelSelectedWithInterpreter') .resolves(); // Act @@ -294,67 +270,65 @@ suite('DeepnoteKernelAutoSelector - rebuildController', () => { // Assert assert.strictEqual( - ensureKernelSelectedWithConfigurationStub.calledOnce, + ensureKernelSelectedWithInterpreterStub.calledOnce, true, - 'ensureKernelSelectedWithConfiguration should be called once' + 'ensureKernelSelectedWithInterpreter should be called once' ); assert.strictEqual( - ensureKernelSelectedWithConfigurationStub.firstCall.args[0], + ensureKernelSelectedWithInterpreterStub.firstCall.args[0], mockNotebook, 'ensureKernelSelected should be called with the notebook' ); assert.strictEqual( - ensureKernelSelectedWithConfigurationStub.firstCall.args[4], + ensureKernelSelectedWithInterpreterStub.firstCall.args[4], instance(mockCancellationToken), 'ensureKernelSelected should be called with the cancellation token' ); }); - test('should keep the old server handle registered when the environment switch fails', async () => { - // Arrange - old handle already tracked, no new handle registered because setup fails + test('should keep the old server handle registered when the interpreter switch fails', async () => { + // Old handle already tracked; setup fails so no new handle is ever registered. const notebookKey = getNotebookKey(mockNotebook.uri); const oldServerHandle = 'old-server-handle'; registry.set(notebookKey, oldServerHandle); - const mockEnvironment = createMockEnvironment('test-env-id', 'Test Environment'); - when(mockNotebookEnvironmentMapper.getEnvironmentForNotebook(anything())).thenReturn('test-env-id'); - when(mockEnvironmentManager.getEnvironment('test-env-id')).thenReturn(mockEnvironment); - - sandbox.stub(selector, 'ensureKernelSelectedWithConfiguration').rejects(new Error('startServer failed')); + const mockInterpreter: PythonEnvironment = { + id: '/usr/bin/python3', + uri: Uri.parse('/usr/bin/python3') + }; + when(mockKernelProvider.get(mockNotebook)).thenReturn(undefined); + when(mockInterpreterService.getActiveInterpreter(anything())).thenResolve(mockInterpreter); + sandbox.stub(selector, 'ensureKernelSelectedWithInterpreter').rejects(new Error('startServer failed')); - // Act await assert.isRejected( selector.rebuildController(mockNotebook, mockProgress, instance(mockCancellationToken)) ); - // Assert - the old handle must remain registered so the selected controller keeps resolving + // The old handle must remain registered so the selected controller keeps resolving. verify(mockServerProvider.unregisterServer(oldServerHandle)).never(); }); - test('should unregister the old server handle after switching to a different environment', async () => { - // Arrange - old handle tracked; successful setup registers a different new handle + test('should unregister the old server handle after switching to a different interpreter', async () => { const notebookKey = getNotebookKey(mockNotebook.uri); const oldServerHandle = 'old-server-handle'; const newServerHandle = 'new-server-handle'; registry.set(notebookKey, oldServerHandle); - const mockEnvironment = createMockEnvironment('test-env-id', 'Test Environment'); - when(mockNotebookEnvironmentMapper.getEnvironmentForNotebook(anything())).thenReturn('test-env-id'); - when(mockEnvironmentManager.getEnvironment('test-env-id')).thenReturn(mockEnvironment); - - // Real setup registers a new handle for the notebook - emulate that side effect - sandbox.stub(selector, 'ensureKernelSelectedWithConfiguration').callsFake(async () => { + const mockInterpreter: PythonEnvironment = { + id: '/usr/bin/python3', + uri: Uri.parse('/usr/bin/python3') + }; + when(mockKernelProvider.get(mockNotebook)).thenReturn(undefined); + when(mockInterpreterService.getActiveInterpreter(anything())).thenResolve(mockInterpreter); + // Real setup registers a new handle for the notebook - emulate that side effect. + sandbox.stub(selector, 'ensureKernelSelectedWithInterpreter').callsFake(async () => { registry.set(notebookKey, newServerHandle); }); - // Act await selector.rebuildController(mockNotebook, mockProgress, instance(mockCancellationToken)); - // Assert - only the stale old handle is unregistered; the new one stays verify(mockServerProvider.unregisterServer(oldServerHandle)).once(); verify(mockServerProvider.unregisterServer(newServerHandle)).never(); - - // Assert - the registry ends up tracking the new handle, not the stale one assert.strictEqual( registry.get(notebookKey), newServerHandle, @@ -363,133 +337,64 @@ suite('DeepnoteKernelAutoSelector - rebuildController', () => { }); }); - suite('pickEnvironment', () => { - test('should return selected environment when user picks one', async () => { - // Arrange - const notebookUri = Uri.parse('file:///test/notebook.deepnote'); - const mockEnv1 = createMockEnvironment('env-1', 'Environment 1'); - const mockEnv2 = createMockEnvironment('env-2', 'Environment 2'); - const environments = [mockEnv1, mockEnv2]; - - // Mock environment manager - when(mockEnvironmentManager.waitForInitialization()).thenResolve(); - when(mockEnvironmentManager.listEnvironments()).thenReturn(environments); - - // Mock window.showQuickPick to simulate user selecting the first environment - when(mockedVSCodeNamespaces.window.showQuickPick(anything(), anything())).thenResolve({ - label: mockEnv1.name, - description: mockEnv1.pythonInterpreter.uri.fsPath, - environment: mockEnv1 - } as any); + suite('ensureControllerSelectedForNotebook', () => { + // Every Deepnote controller for one notebook carries the same id, derived from the notebook URI. + const CONTROLLER_ID = 'deepnote-notebook-/test/notebook.deepnote'; - // Act - const result = await selector.pickEnvironment(notebookUri); + function createController(): IVSCodeNotebookController { + const controller = mock(); + when(controller.id).thenReturn(CONTROLLER_ID); + when(controller.connection).thenReturn({ id: CONTROLLER_ID } as KernelConnectionMetadata); + when(controller.controller).thenReturn({ + updateNotebookAffinity: sandbox.stub() + } as unknown as NotebookController); - // Assert - assert.strictEqual(result, mockEnv1, 'Should return the selected environment'); - }); - }); + return instance(controller); + } - suite('ensureEnvironmentConfiguredBeforeExecution', () => { - // The first-run picker is a second environment-selection path alongside the environments view; - // both must report select_environment or the metric only counts users who switch. - function arrangeFirstRunPicker(picked: DeepnoteEnvironment | undefined) { + setup(() => { when(mockCancellationToken.isCancellationRequested).thenReturn(false); - when(mockNotebookEnvironmentMapper.getEnvironmentForNotebook(anything())).thenReturn(undefined); - when(mockNotebookEnvironmentMapper.setEnvironmentForNotebook(anything(), anything())).thenResolve(); - when(mockEnvironmentManager.waitForInitialization()).thenResolve(); - when(mockEnvironmentManager.listEnvironments()).thenReturn(picked ? [picked] : []); - when(mockedVSCodeNamespaces.window.showQuickPick(anything(), anything())).thenResolve( - picked ? ({ label: picked.name, environment: picked } as any) : undefined - ); - } + when(mockedVSCodeNamespaces.window.visibleNotebookEditors).thenReturn([ + { notebook: mockNotebook } as NotebookEditor + ]); + when(mockedVSCodeNamespaces.commands.executeCommand('notebook.selectKernel', anything())).thenResolve(); + }); - test('should track select_environment after the execution picker configures an environment', async () => { - const mockEnvironment = createMockEnvironment('env-1', 'Environment 1'); - arrangeFirstRunPicker(mockEnvironment); - const setupStub = sandbox.stub(selector as any, 'setupKernelForEnvironment').resolves(true); + test('selects the kernel when the recorded controller was replaced by one with the same id', async () => { + // The recorded controller was disposed and rebuilt; only object identity tells the two apart, + // and skipping selectKernel here leaves the notebook bound to the dead one. + when(mockControllerRegistration.getSelected(anything())).thenReturn(createController()); - const result = await selector.ensureEnvironmentConfiguredBeforeExecution( + await selector.ensureControllerSelectedForNotebook( mockNotebook, + createController(), instance(mockCancellationToken) ); - assert.isTrue(result, 'Environment should be reported as configured'); - assert.isTrue(setupStub.calledOnce, 'Kernel setup should have run'); - verify(mockTelemetryService.trackEvent(deepEqual({ eventName: 'select_environment' }))).once(); - }); - - test('should not track select_environment when the picker is cancelled or setup fails', async () => { - arrangeFirstRunPicker(undefined); - - assert.isFalse( - await selector.ensureEnvironmentConfiguredBeforeExecution( - mockNotebook, - instance(mockCancellationToken) - ), - 'Cancelling the picker should not configure an environment' - ); - - arrangeFirstRunPicker(createMockEnvironment('env-1', 'Environment 1')); - sandbox.stub(selector as any, 'setupKernelForEnvironment').resolves(false); - - assert.isFalse( - await selector.ensureEnvironmentConfiguredBeforeExecution( - mockNotebook, - instance(mockCancellationToken) - ), - 'Failed setup should not report success' - ); - verify(mockTelemetryService.trackEvent(anything())).never(); + verify(mockedVSCodeNamespaces.commands.executeCommand('notebook.selectKernel', anything())).once(); }); - }); - - suite('ensureKernelSelected', () => { - test('should return false when no environment ID is assigned to the notebook', async () => { - // Mock environment mapper to return null (no environment assigned) - when(mockNotebookEnvironmentMapper.getEnvironmentForNotebook(anything())).thenReturn(undefined); - // Stub ensureKernelSelectedWithConfiguration to track if it gets called - const ensureKernelSelectedStub = sandbox.stub(selector, 'ensureKernelSelectedWithConfiguration').resolves(); - - // Mock commands.executeCommand - when(mockedVSCodeNamespaces.commands.executeCommand(anything(), anything())).thenResolve(); + test('skips the kernel picker when the recorded controller is the one being selected', async () => { + const controller = createController(); + when(mockControllerRegistration.getSelected(anything())).thenReturn(controller); - // Act - const result = await selector.ensureKernelSelected( + await selector.ensureControllerSelectedForNotebook( mockNotebook, - mockProgress, + controller, instance(mockCancellationToken) ); - // Assert - assert.strictEqual(result, false, 'Should return false when no environment is assigned'); - assert.strictEqual( - ensureKernelSelectedStub.called, - false, - 'ensureKernelSelectedWithConfiguration should not be called' - ); - verify(mockNotebookEnvironmentMapper.getEnvironmentForNotebook(anything())).once(); + verify(mockedVSCodeNamespaces.commands.executeCommand('notebook.selectKernel', anything())).never(); }); + }); - test('should return false and remove mapping when environment is not found', async () => { - // Arrange - const environmentId = 'missing-env-id'; - - // Mock environment mapper to return an ID - when(mockNotebookEnvironmentMapper.getEnvironmentForNotebook(anything())).thenReturn(environmentId); - - // Mock environment manager to return null (environment not found) - when(mockEnvironmentManager.getEnvironment(environmentId)).thenReturn(undefined); - - // Mock remove environment mapping - when(mockNotebookEnvironmentMapper.removeEnvironmentForNotebook(anything())).thenResolve(); - - // Stub ensureKernelSelectedWithConfiguration to track if it gets called - const ensureKernelSelectedStub = sandbox.stub(selector, 'ensureKernelSelectedWithConfiguration').resolves(); + suite('ensureKernelSelected', () => { + test('should return false when no active interpreter is found', async () => { + // Mock interpreter service to return undefined (no active interpreter) + when(mockInterpreterService.getActiveInterpreter(anything())).thenResolve(undefined); - // Mock commands.executeCommand - when(mockedVSCodeNamespaces.commands.executeCommand(anything(), anything())).thenResolve(); + // Stub ensureKernelSelectedWithInterpreter to track if it gets called + const ensureKernelSelectedStub = sandbox.stub(selector, 'ensureKernelSelectedWithInterpreter').resolves(); // Act const result = await selector.ensureKernelSelected( @@ -499,34 +404,27 @@ suite('DeepnoteKernelAutoSelector - rebuildController', () => { ); // Assert - assert.strictEqual(result, false, 'Should return false when environment is not found'); + assert.strictEqual(result, false, 'Should return false when no active interpreter is found'); assert.strictEqual( ensureKernelSelectedStub.called, false, - 'ensureKernelSelectedWithConfiguration should not be called' + 'ensureKernelSelectedWithInterpreter should not be called' ); - verify(mockNotebookEnvironmentMapper.getEnvironmentForNotebook(anything())).once(); - verify(mockEnvironmentManager.getEnvironment(environmentId)).once(); - verify(mockNotebookEnvironmentMapper.removeEnvironmentForNotebook(anything())).once(); }); - test('should return true and call ensureKernelSelectedWithConfiguration when environment is found', async () => { + test('should return true and call ensureKernelSelectedWithInterpreter when interpreter is found', async () => { // Arrange const notebookKey = getNotebookKey(mockNotebook.uri); - const environmentId = 'test-env-id'; - const mockEnvironment = createMockEnvironment(environmentId, 'Test Environment'); - - // Mock environment mapper to return an ID - when(mockNotebookEnvironmentMapper.getEnvironmentForNotebook(anything())).thenReturn(environmentId); - - // Mock environment manager to return the environment - when(mockEnvironmentManager.getEnvironment(environmentId)).thenReturn(mockEnvironment); + const mockInterpreter: PythonEnvironment = { + id: '/usr/bin/python3', + uri: Uri.parse('/usr/bin/python3') + }; - // Stub ensureKernelSelectedWithConfiguration to track calls - const ensureKernelSelectedStub = sandbox.stub(selector, 'ensureKernelSelectedWithConfiguration').resolves(); + // Mock interpreter service to return an active interpreter + when(mockInterpreterService.getActiveInterpreter(anything())).thenResolve(mockInterpreter); - // Mock commands.executeCommand - when(mockedVSCodeNamespaces.commands.executeCommand(anything(), anything())).thenResolve(); + // Stub ensureKernelSelectedWithInterpreter to track calls + const ensureKernelSelectedStub = sandbox.stub(selector, 'ensureKernelSelectedWithInterpreter').resolves(); // Act const result = await selector.ensureKernelSelected( @@ -536,74 +434,120 @@ suite('DeepnoteKernelAutoSelector - rebuildController', () => { ); // Assert - assert.strictEqual(result, true, 'Should return true when environment is found'); + assert.strictEqual(result, true, 'Should return true when interpreter is found'); assert.strictEqual( ensureKernelSelectedStub.calledOnce, true, - 'ensureKernelSelectedWithConfiguration should be called once' + 'ensureKernelSelectedWithInterpreter should be called once' ); // Verify it was called with correct arguments const callArgs = ensureKernelSelectedStub.firstCall.args; assert.strictEqual(callArgs[0], mockNotebook, 'First arg should be notebook'); - assert.strictEqual(callArgs[1], mockEnvironment, 'Second arg should be environment'); + assert.deepStrictEqual(callArgs[1], mockInterpreter, 'Second arg should be interpreter'); assert.strictEqual(callArgs[2], notebookKey, 'Third arg should be notebookKey'); assert.strictEqual(callArgs[3], mockProgress, 'Fourth arg should be progress'); assert.strictEqual(callArgs[4], instance(mockCancellationToken), 'Fifth arg should be token'); - - verify(mockNotebookEnvironmentMapper.getEnvironmentForNotebook(anything())).once(); - verify(mockEnvironmentManager.getEnvironment(environmentId)).once(); }); }); - suite('ensureControllerSelectedForNotebook', () => { - // Every Deepnote controller for one notebook carries the same id, derived from the notebook URI. - const CONTROLLER_ID = 'deepnote-notebook-/test/notebook.deepnote'; + suite('ensureEnvironmentConfiguredBeforeExecution', () => { + const nonCancelledToken: CancellationToken = { + isCancellationRequested: false, + onCancellationRequested: () => ({ dispose: () => {} }) as any + }; + + test('should reconfigure when active interpreter differs from cached interpreter', async () => { + const notebookKey = mockNotebook.uri.toString(); + const interpreterA: PythonEnvironment = { + id: '/usr/bin/python3.10', + uri: Uri.parse('/usr/bin/python3.10') + }; + const interpreterB: PythonEnvironment = { + id: '/usr/bin/python3.12', + uri: Uri.parse('/usr/bin/python3.12') + }; - function createController(): IVSCodeNotebookController { - const controller = mock(); - when(controller.id).thenReturn(CONTROLLER_ID); - when(controller.connection).thenReturn({ id: CONTROLLER_ID } as KernelConnectionMetadata); - when(controller.controller).thenReturn({ - updateNotebookAffinity: sandbox.stub() - } as unknown as NotebookController); + // Prime the internal maps: controller exists for interpreter A + const selectorAny = selector as any; + selectorAny.notebookControllers.set(notebookKey, instance(mockController)); + selectorAny.notebookInterpreterIds.set(notebookKey, interpreterA.id); - return instance(controller); - } + // Active interpreter is now B + when(mockInterpreterService.getActiveInterpreter(anything())).thenResolve(interpreterB); - setup(() => { - when(mockCancellationToken.isCancellationRequested).thenReturn(false); - when(mockedVSCodeNamespaces.window.visibleNotebookEditors).thenReturn([ - { notebook: mockNotebook } as NotebookEditor - ]); - when(mockedVSCodeNamespaces.commands.executeCommand('notebook.selectKernel', anything())).thenResolve(); - }); + // Stub ensureKernelSelectedWithInterpreter to track calls + const ensureStub = sandbox.stub(selector, 'ensureKernelSelectedWithInterpreter').resolves(); - test('selects the kernel when the recorded controller was replaced by one with the same id', async () => { - // The recorded controller was disposed and rebuilt; only object identity tells the two apart, - // and skipping selectKernel here leaves the notebook bound to the dead one. - when(mockControllerRegistration.getSelected(anything())).thenReturn(createController()); + // withProgress must call through to the task callback + when(mockedVSCodeNamespaces.window.withProgress(anything(), anything())).thenCall( + (_opts: any, task: any) => { + return task({ report: sandbox.stub() }, nonCancelledToken); + } + ); - await selector.ensureControllerSelectedForNotebook( - mockNotebook, - createController(), - instance(mockCancellationToken) + // Put a controller in the map so the final check returns true + ensureStub.callsFake(async () => { + selectorAny.notebookControllers.set(notebookKey, instance(mockNewController)); + }); + + const result = await selector.ensureEnvironmentConfiguredBeforeExecution(mockNotebook, nonCancelledToken); + + assert.strictEqual(result, true, 'Should return true after reconfiguring'); + assert.strictEqual(ensureStub.calledOnce, true, 'Should call ensureKernelSelectedWithInterpreter'); + assert.deepStrictEqual( + ensureStub.firstCall.args[1], + interpreterB, + 'Should reconfigure with the new interpreter' ); + }); - verify(mockedVSCodeNamespaces.commands.executeCommand('notebook.selectKernel', anything())).once(); + test('should return true immediately when controller exists for the same interpreter', async () => { + const notebookKey = mockNotebook.uri.toString(); + const interpreterA: PythonEnvironment = { + id: '/usr/bin/python3.10', + uri: Uri.parse('/usr/bin/python3.10') + }; + + // Prime the internal maps: controller exists for interpreter A + const selectorAny = selector as any; + selectorAny.notebookControllers.set(notebookKey, instance(mockController)); + selectorAny.notebookInterpreterIds.set(notebookKey, interpreterA.id); + selectorAny.notebookConnectionMetadata.set(notebookKey, { baseUrl: 'http://127.0.0.1:8888' }); + + // Active interpreter is still A + when(mockInterpreterService.getActiveInterpreter(anything())).thenResolve(interpreterA); + + // Stub ensureKernelSelectedWithInterpreter — should NOT be called + const ensureStub = sandbox.stub(selector, 'ensureKernelSelectedWithInterpreter').resolves(); + + const result = await selector.ensureEnvironmentConfiguredBeforeExecution(mockNotebook, nonCancelledToken); + + assert.strictEqual(result, true, 'Should return true (fast path)'); + assert.strictEqual(ensureStub.called, false, 'Should NOT call ensureKernelSelectedWithInterpreter'); }); - test('skips the kernel picker when the recorded controller is the one being selected', async () => { - const controller = createController(); - when(mockControllerRegistration.getSelected(anything())).thenReturn(controller); + test('does NOT treat the controller registered at open as ready (it has no server yet)', async () => { + const notebookKey = mockNotebook.uri.toString(); + const interpreterA: PythonEnvironment = { + id: '/usr/bin/python3.10', + uri: Uri.parse('/usr/bin/python3.10') + }; - await selector.ensureControllerSelectedForNotebook( - mockNotebook, - controller, - instance(mockCancellationToken) - ); + // What registerControllerForNotebook leaves behind: a controller whose connection has no + // baseUrl. Short-circuiting here would run cells against a server that was never started. + const selectorAny = selector as any; + selectorAny.notebookControllers.set(notebookKey, instance(mockController)); + selectorAny.notebookInterpreterIds.set(notebookKey, interpreterA.id); + selectorAny.notebookConnectionMetadata.set(notebookKey, { baseUrl: '' }); - verify(mockedVSCodeNamespaces.commands.executeCommand('notebook.selectKernel', anything())).never(); + when(mockInterpreterService.getActiveInterpreter(anything())).thenResolve(interpreterA); + + const ensureStub = sandbox.stub(selector, 'ensureKernelSelectedWithInterpreter').resolves(); + + await selector.ensureEnvironmentConfiguredBeforeExecution(mockNotebook, nonCancelledToken); + + assert.strictEqual(ensureStub.called, true, 'must set the kernel up before executing'); }); }); @@ -736,8 +680,21 @@ suite('DeepnoteKernelAutoSelector - rebuildController', () => { // - Validates server cleanup during rebuild // - Ensures old server is unregistered from provider // - // rebuildController drops the old server handle only AFTER setup registers a - // replacement, so a failed/cancelled switch never strands the controller on a dead handle. + // THE ACTUAL IMPLEMENTATION at deepnoteKernelAutoSelector.node.ts:269-291: + // + // // Clear cached state + // this.notebookControllers.delete(notebookKey); + // this.notebookConnectionMetadata.delete(notebookKey); + // + // // Unregister old server + // const oldServerHandle = this.notebookServerHandles.get(notebookKey); + // if (oldServerHandle) { + // this.serverProvider.unregisterServer(oldServerHandle); + // this.notebookServerHandles.delete(notebookKey); + // } + // + // These operations happen BEFORE calling ensureKernelSelected() to create the new controller, + // ensuring clean state for the environment switch. assert.ok(true, 'UT-2 is validated by existing tests and implementation (INV-9)'); }); @@ -838,7 +795,7 @@ suite('DeepnoteKernelAutoSelector - rebuildController', () => { // // CURRENT IMPLEMENTATION BEHAVIOR: // - // 1. If startServer() fails, the error propagates from ensureKernelSelectedWithConfiguration() + // 1. If startServer() fails, the error propagates from ensureKernelSelectedWithInterpreter() // (deepnoteKernelAutoSelector.node.ts:450-467) // // 2. The error is caught and shown to user in the UI layer @@ -874,72 +831,53 @@ suite('DeepnoteKernelAutoSelector - rebuildController', () => { // REAL TDD Tests - These should FAIL if bugs exist suite('Bug Detection: Kernel Selection', () => { - test('BUG-1: Should prefer environment-specific kernel over .env kernel', () => { - // REAL TEST: This will FAIL if the wrong kernel is selected - // - // The selectKernelSpec method is now extracted and testable! + test('Should select the first Python kernel from available specs', () => { + // The selectKernelSpec method selects the first Python kernel available - const envId = 'env123'; const kernelSpecs: IJupyterKernelSpec[] = [ createMockKernelSpec('.env', '.env Python', 'python'), - createMockKernelSpec(`deepnote-${envId}`, 'Deepnote Environment', 'python'), createMockKernelSpec('python3', 'Python 3', 'python') ]; - const selected = selector.selectKernelSpec(kernelSpecs, envId); + const selected = selector.selectKernelSpec(kernelSpecs); - // CRITICAL ASSERTION: Should select environment-specific kernel, NOT .env - assert.strictEqual( - selected?.name, - `deepnote-${envId}`, - `BUG DETECTED: Selected "${selected?.name}" instead of "deepnote-${envId}"! This would use wrong environment.` - ); + // Should select the first Python kernel + assert.strictEqual(selected.language, 'python', 'Should select a Python kernel'); + assert.strictEqual(selected.name, '.env', 'Should select the first Python kernel'); }); - test('BUG-1b: Current implementation falls back to Python kernel (documents expected behavior)', () => { - // This test documents that the current implementation DOES have fallback logic - // - // EXPECTED BEHAVIOR (current): Fall back to generic Python kernel when env-specific kernel not found - // This is a design decision - we don't want to block users if the environment-specific kernel isn't ready yet + test('Should fall back to python3 named kernel when no python language kernel exists first', () => { + // Documents fallback behavior - finds python3 by name if no python language match - const envId = 'env123'; const kernelSpecs: IJupyterKernelSpec[] = [ - createMockKernelSpec('.env', '.env Python', 'python'), + createMockKernelSpec('javascript', 'JavaScript', 'javascript'), createMockKernelSpec('python3', 'Python 3', 'python') ]; - // Should fall back to a Python kernel (this is the current behavior) - const selected = selector.selectKernelSpec(kernelSpecs, envId); + const selected = selector.selectKernelSpec(kernelSpecs); - // Should have selected a fallback kernel (either .env or python3) - assert.ok(selected, 'Should select a fallback kernel'); - assert.strictEqual(selected.language, 'python', 'Fallback should be a Python kernel'); + assert.strictEqual(selected.name, 'python3', 'Should find python3 kernel'); }); - test('Kernel selection: Should find environment-specific kernel when it exists', () => { - const envId = 'my-env'; + test('Kernel selection: Should fall back to first available kernel when no Python kernel exists', () => { const kernelSpecs: IJupyterKernelSpec[] = [ - createMockKernelSpec('python3', 'Python 3', 'python'), - createMockKernelSpec(`deepnote-${envId}`, 'My Environment', 'python') + createMockKernelSpec('javascript', 'JavaScript', 'javascript'), + createMockKernelSpec('r', 'R', 'r') ]; - const selected = selector.selectKernelSpec(kernelSpecs, envId); + const selected = selector.selectKernelSpec(kernelSpecs); - assert.strictEqual(selected?.name, `deepnote-${envId}`); + assert.strictEqual(selected.name, 'javascript', 'Should fall back to first available kernel'); }); - test('Kernel selection: Should fall back to python3 when env kernel missing', () => { - // Documents current fallback behavior - falls back to python3 when env kernel missing - const envId = 'my-env'; - const kernelSpecs: IJupyterKernelSpec[] = [ - createMockKernelSpec('python3', 'Python 3', 'python'), - createMockKernelSpec('javascript', 'JavaScript', 'javascript') - ]; - - // Should fall back to python3 (current behavior) - const selected = selector.selectKernelSpec(kernelSpecs, envId); + test('Kernel selection: Should throw when no kernel specs are available', () => { + const kernelSpecs: IJupyterKernelSpec[] = []; - assert.strictEqual(selected.name, 'python3', 'Should fall back to python3'); + assert.throws( + () => selector.selectKernelSpec(kernelSpecs), + /No kernel specs available/, + 'Should throw when no kernel specs are available' + ); }); }); @@ -970,9 +908,14 @@ suite('DeepnoteKernelAutoSelector - rebuildController', () => { // REAL TEST: This will FAIL if disposal happens too early // // Setup: Create a scenario where we have an old controller and create a new one - const baseFileUri = mockNotebook.uri.with({ query: '', fragment: '' }); + // const baseFileUri = mockNotebook.uri.with({ query: '', fragment: '' }); // const notebookKey = baseFileUri.fsPath; - const newEnv = createMockEnvironment('env-new', 'New Environment', true); + // Mock interpreter service to return an active interpreter + const mockInterpreter: PythonEnvironment = { + id: '/usr/bin/python3', + uri: Uri.parse('/usr/bin/python3') + }; + when(mockInterpreterService.getActiveInterpreter(anything())).thenResolve(mockInterpreter); // Track call order const callOrder: string[] = []; @@ -996,8 +939,6 @@ suite('DeepnoteKernelAutoSelector - rebuildController', () => { when(newController.controller).thenReturn({} as any); // Setup mocks - when(mockNotebookEnvironmentMapper.getEnvironmentForNotebook(baseFileUri)).thenReturn('env-new'); - when(mockEnvironmentManager.getEnvironment('env-new')).thenReturn(newEnv); when(mockPythonExtensionChecker.isPythonExtensionInstalled).thenReturn(true); // Mock controller registration to track when new controller is added @@ -1154,103 +1095,7 @@ suite('DeepnoteKernelAutoSelector - rebuildController', () => { }); }); - suite('Placeholder controller execution', () => { - function createPlaceholder() { - const placeholder = { - supportsExecutionOrder: false, - supportedLanguages: [] as string[], - updateNotebookAffinity: sandbox.stub(), - dispose: sandbox.stub(), - createNotebookCellExecution: sandbox.stub() - } as unknown as NotebookController; - - when( - mockedVSCodeNamespaces.notebooks!.createNotebookController(anything(), anything(), anything()) - ).thenReturn(placeholder); - - const onDidCloseNotebookDocument = new EventEmitter(); - when(mockedVSCodeNamespaces.workspace.onDidCloseNotebookDocument).thenReturn( - onDidCloseNotebookDocument.event - ); - - const internals = selector as unknown as { - createPlaceholderController(notebook: NotebookDocument): NotebookController; - }; - - internals.createPlaceholderController(mockNotebook); - - return placeholder; - } - - const agentCell = createMockCell({ index: 0, metadata: { __deepnotePocket: { type: 'agent' } } }); - const codeCell = createMockCell({ index: 1 }); - - test('configures the environment and executes nothing', async () => { - when(mockedVSCodeNamespaces.workspace.isTrusted).thenReturn(true); - const placeholder = createPlaceholder(); - const ensureEnvironment = sandbox - .stub(selector, 'ensureEnvironmentConfiguredBeforeExecution') - .resolves(true); - - await placeholder.executeHandler!([agentCell, codeCell], mockNotebook, placeholder); - - assert.isTrue(ensureEnvironment.calledOnce, 'should prompt for an environment'); - assert.isTrue( - (placeholder.createNotebookCellExecution as sinon.SinonStub).notCalled, - 'placeholder must not create executions' - ); - verify(mockKernelProvider.getOrCreate(anything(), anything())).never(); - }); - - test('does nothing at all in an untrusted workspace', async () => { - when(mockedVSCodeNamespaces.workspace.isTrusted).thenReturn(false); - const placeholder = createPlaceholder(); - const ensureEnvironment = sandbox - .stub(selector, 'ensureEnvironmentConfiguredBeforeExecution') - .resolves(true); - - await placeholder.executeHandler!([agentCell, codeCell], mockNotebook, placeholder); - - assert.isTrue(ensureEnvironment.notCalled, 'should not prompt in an untrusted workspace'); - }); - }); - - /** - * Every exec in the installer can now be killed, so cancellation surfaces here immediately - * instead of after pip has finished anyway. A user-initiated Stop is not a failure and - * must not raise the error UI - but a genuine failure still has to. - */ suite('cancellation is not reported as a failure', () => { - const toolkitMissing = () => new DeepnoteToolkitMissingError('/usr/bin/python3', '/fake/venv'); - - function chooseInstall(): void { - when( - mockedVSCodeNamespaces.window.showWarningMessage(anything(), anything(), anything(), anything()) - ).thenResolve('Install' as never); - } - - test('cancelling the toolkit install does not show an error message', async () => { - chooseInstall(); - when(mockToolkitInstaller.installToolkitInExistingVenv(anything(), anything())).thenReject( - new CancellationError() - ); - - await selector.handleKernelSelectionError(toolkitMissing(), mockNotebook); - - verify(mockedVSCodeNamespaces.window.showErrorMessage(anything())).never(); - }); - - test('a failed toolkit install still shows an error message', async () => { - chooseInstall(); - when(mockToolkitInstaller.installToolkitInExistingVenv(anything(), anything())).thenReject( - new Error('pip exited with code 1') - ); - - await selector.handleKernelSelectionError(toolkitMissing(), mockNotebook); - - verify(mockedVSCodeNamespaces.window.showErrorMessage(anything())).once(); - }); - test('a cancelled kernel selection does not show an error message', async () => { await selector.handleKernelSelectionError(new CancellationError(), mockNotebook); @@ -1265,37 +1110,6 @@ suite('DeepnoteKernelAutoSelector - rebuildController', () => { }); }); -/** - * Helper function to create mock environments - */ -function createMockEnvironment(id: string, name: string, hasServer: boolean = false): DeepnoteEnvironment { - const mockPythonInterpreter: PythonEnvironment = { - id: `/usr/bin/python3`, - uri: Uri.parse(`/usr/bin/python3`) - }; - - return { - id, - name, - description: `Test environment ${name}`, - pythonInterpreter: mockPythonInterpreter, - venvPath: Uri.file(`/test/venvs/${id}`), - managedVenv: true, - packages: [], - createdAt: new Date(), - lastUsedAt: new Date(), - serverInfo: hasServer - ? { - url: `http://localhost:8888`, - jupyterPort: 8888, - lspPort: 8889, - token: 'test-token', - process: createMockChildProcess() - } - : undefined - }; -} - /** * Helper function to create mock kernel specs */ diff --git a/src/notebooks/deepnote/deepnoteMultiNotebookSplitter.ts b/src/notebooks/deepnote/deepnoteMultiNotebookSplitter.ts index d80b20ae3f..bc1e2cc64c 100644 --- a/src/notebooks/deepnote/deepnoteMultiNotebookSplitter.ts +++ b/src/notebooks/deepnote/deepnoteMultiNotebookSplitter.ts @@ -4,7 +4,6 @@ import { isSingleNotebookDeepnoteFile, splitByNotebooks } from '@deepnote/conver import { ITelemetryService } from '../../platform/analytics/types'; import { ILogger } from '../../platform/logging/types'; -import type { IDeepnoteNotebookEnvironmentMapper } from '../../kernels/deepnote/types'; import { DEEPNOTE_NOTEBOOK_TYPE } from '../../kernels/deepnote/types'; import { readDeepnoteProjectFile } from '../../platform/deepnote/deepnoteProjectFileReader'; import { allocateSiblingUri } from './deepnoteSiblingFileAllocator'; @@ -28,8 +27,6 @@ export class DeepnoteMultiNotebookSplitter { private readonly disposables: Disposable[] = []; - private readonly envMapper: IDeepnoteNotebookEnvironmentMapper | undefined; - private readonly exists: (uri: Uri) => Promise; private readonly logger: ILogger; @@ -39,13 +36,11 @@ export class DeepnoteMultiNotebookSplitter { private readonly refreshTree: () => void; constructor( - envMapper: IDeepnoteNotebookEnvironmentMapper | undefined, refreshTree: () => void, logger: ILogger, exists: (uri: Uri) => Promise, analytics: ITelemetryService ) { - this.envMapper = envMapper; this.refreshTree = refreshTree; this.logger = logger; this.exists = exists; @@ -158,8 +153,6 @@ export class DeepnoteMultiNotebookSplitter { const deepnoteFile = await readDeepnoteProjectFile(fileUri); const parentDir = Uri.joinPath(fileUri, '..'); - const envMapper = this.envMapper; - const originalEnv = envMapper?.getEnvironmentForNotebook(fileUri); // Write all children before retiring the original (see step below). const entries = splitByNotebooks(deepnoteFile, getFileStem(fileUri)); @@ -187,15 +180,6 @@ export class DeepnoteMultiNotebookSplitter { newUris.push(targetUri); } - // Migrate the environment selection onto each new file (desktop-only). - if (envMapper && originalEnv) { - for (const newUri of newUris) { - // Register the revert BEFORE the set: the mapper mutates memory before the persist that can reject. - rollbacks.push(() => envMapper.removeEnvironmentForNotebook(newUri)); - await envMapper.setEnvironmentForNotebook(newUri, originalEnv); - } - } - // Abort before retiring the original if its tab won't close, else a later save recreates it. if (!(await this.closeNotebookTab(fileUri))) { throw new Error(l10n.t('The file is still open in an editor and could not be closed.')); @@ -206,15 +190,6 @@ export class DeepnoteMultiNotebookSplitter { renamed = true; rollbacks.push(() => workspace.fs.rename(legacyUri, fileUri, { overwrite: false })); - if (envMapper) { - // Restore the original mapping on rollback before removing it here. - if (originalEnv) { - rollbacks.push(() => envMapper.setEnvironmentForNotebook(fileUri, originalEnv)); - } - - await envMapper.removeEnvironmentForNotebook(fileUri); - } - this.refreshTree(); await window.showInformationMessage(l10n.t('Split into {0} files.', newUris.length)); diff --git a/src/notebooks/deepnote/deepnoteMultiNotebookSplitter.unit.test.ts b/src/notebooks/deepnote/deepnoteMultiNotebookSplitter.unit.test.ts index 460fa01e6c..74ec00cd96 100644 --- a/src/notebooks/deepnote/deepnoteMultiNotebookSplitter.unit.test.ts +++ b/src/notebooks/deepnote/deepnoteMultiNotebookSplitter.unit.test.ts @@ -4,7 +4,6 @@ import { anything, deepEqual, instance, mock, verify, when } from 'ts-mockito'; import { EventEmitter, FileType, NotebookDocument, TabGroups, TabInputNotebook, Uri } from 'vscode'; import { ITelemetryService } from '../../platform/analytics/types'; -import type { IDeepnoteNotebookEnvironmentMapper } from '../../kernels/deepnote/types'; import type { ILogger } from '../../platform/logging/types'; import { mockedVSCodeNamespaces, resetVSCodeMocks } from '../../test/vscode-mock'; import { DeepnoteMultiNotebookSplitter } from './deepnoteMultiNotebookSplitter'; @@ -49,7 +48,6 @@ suite('DeepnoteMultiNotebookSplitter', () => { let splitter: DeepnoteMultiNotebookSplitter; let onDidOpen: EventEmitter; let refreshTreeCount: number; - let envMapper: IDeepnoteNotebookEnvironmentMapper; // Ordered log of side-effecting fs operations, so we can assert write-before-rename ORDER. let callLog: Array<{ op: 'write' | 'rename'; name: string }>; @@ -192,15 +190,7 @@ suite('DeepnoteMultiNotebookSplitter', () => { return Promise.resolve(undefined); }); - // Environment mapper: per-notebook env, recorded via real-ish maps. - const envMock = mock(); - when(envMock.getEnvironmentForNotebook(anything())).thenReturn(undefined); - when(envMock.setEnvironmentForNotebook(anything(), anything())).thenResolve(); - when(envMock.removeEnvironmentForNotebook(anything())).thenResolve(); - envMapper = instance(envMock); - splitter = new DeepnoteMultiNotebookSplitter( - envMapper, () => { refreshTreeCount++; }, @@ -388,56 +378,6 @@ suite('DeepnoteMultiNotebookSplitter', () => { ); }); - test('copies the original env mapping onto each new file and removes the original mapping (regression: split-time env migration)', async () => { - const file = makeFile([makeNotebook('n1', 'Alpha', 'a'), makeNotebook('n2', 'Beta', 'b')]); - stubReadFile(file); - acceptSplit(); - - const setCalls: string[] = []; - const removeCalls: string[] = []; - const envMock = mock(); - when(envMock.getEnvironmentForNotebook(anything())).thenReturn('env-xyz'); - when(envMock.setEnvironmentForNotebook(anything(), anything())).thenCall((uri: Uri, env: string) => { - setCalls.push(`${basename(uri)}=${env}`); - return Promise.resolve(); - }); - when(envMock.removeEnvironmentForNotebook(anything())).thenCall((uri: Uri) => { - removeCalls.push(basename(uri)); - return Promise.resolve(); - }); - - // Point the open event at a fresh local emitter BEFORE constructing/activating the - // env-returning splitter, so the new splitter subscribes to the emitter we fire below. - const localEmitter = new EventEmitter(); - when(mockedVSCodeNamespaces.workspace.onDidOpenNotebookDocument).thenReturn(localEmitter.event); - - const splitterWithEnv = new DeepnoteMultiNotebookSplitter( - instance(envMock), - () => { - refreshTreeCount++; - }, - logger, - (uri: Uri) => Promise.resolve(existingOnDisk.has(basename(uri))), - instance(mock()) - ); - splitterWithEnv.activate(); - - localEmitter.fire(notebookDoc(Uri.file('/ws/multi.deepnote'))); - - await waitFor(() => removeCalls.length >= 1); - await settle(); - - assert.deepStrictEqual( - setCalls.sort(), - ['multi-alpha.deepnote=env-xyz', 'multi-beta.deepnote=env-xyz'], - 'the original env must be copied onto every new sibling' - ); - assert.deepStrictEqual(removeCalls, ['multi.deepnote'], 'the original mapping must be removed'); - - splitterWithEnv.dispose(); - localEmitter.dispose(); - }); - test('refreshes the tree after a successful split', async () => { const file = makeFile([makeNotebook('n1', 'Alpha', 'a'), makeNotebook('n2', 'Beta', 'b')]); stubReadFile(file); @@ -558,51 +498,28 @@ suite('DeepnoteMultiNotebookSplitter', () => { } /** - * Build and activate a splitter wired to a fresh env mapper (so a test can inject env-set/remove - * failures) and a private open-event emitter (so the fire below targets exactly this splitter). + * Build and activate a splitter whose refreshTree throws, plus a private open-event emitter (so + * the fire below targets exactly this splitter). refreshTree is the last step after the rename, + * which makes it the injection point for "the split failed once the original was already gone". */ - function makeEnvSplitter(opts: { env: string | undefined; failSetFor?: string; failRemoveFor?: string }): { + function makeFailingSplitter(): { emitter: EventEmitter; splitter: DeepnoteMultiNotebookSplitter; - setCalls: string[]; - removeCalls: string[]; } { - const setCalls: string[] = []; - const removeCalls: string[] = []; - const envMock = mock(); - when(envMock.getEnvironmentForNotebook(anything())).thenReturn(opts.env); - when(envMock.setEnvironmentForNotebook(anything(), anything())).thenCall((uri: Uri, env: string) => { - const name = basename(uri); - if (opts.failSetFor && name === opts.failSetFor) { - return Promise.reject(new Error(`set env failed for ${name}`)); - } - setCalls.push(`${name}=${env}`); - return Promise.resolve(); - }); - when(envMock.removeEnvironmentForNotebook(anything())).thenCall((uri: Uri) => { - const name = basename(uri); - if (opts.failRemoveFor && name === opts.failRemoveFor) { - return Promise.reject(new Error(`remove env failed for ${name}`)); - } - removeCalls.push(name); - return Promise.resolve(); - }); - const emitter = new EventEmitter(); when(mockedVSCodeNamespaces.workspace.onDidOpenNotebookDocument).thenReturn(emitter.event); - const envSplitter = new DeepnoteMultiNotebookSplitter( - instance(envMock), + const failingSplitter = new DeepnoteMultiNotebookSplitter( () => { - refreshTreeCount++; + throw new Error('refresh failed after the rename'); }, logger, (uri: Uri) => Promise.resolve(existingOnDisk.has(basename(uri))), instance(mock()) ); - envSplitter.activate(); + failingSplitter.activate(); - return { emitter, splitter: envSplitter, setCalls, removeCalls }; + return { emitter, splitter: failingSplitter }; } test('a failure after the rename rolls the original back into place and reports it restored', async () => { @@ -611,16 +528,8 @@ suite('DeepnoteMultiNotebookSplitter', () => { acceptSplit(); const errors = captureErrorMessage(); - // The rename succeeds, then removing the original's env mapping (the post-rename step) fails. - const { - emitter, - splitter: envSplitter, - setCalls, - removeCalls - } = makeEnvSplitter({ - env: 'env-xyz', - failRemoveFor: 'multi.deepnote' - }); + // The rename succeeds, then the post-rename refresh fails. + const { emitter, splitter: failingSplitter } = makeFailingSplitter(); emitter.fire(notebookDoc(Uri.file('/ws/multi.deepnote'))); @@ -637,19 +546,12 @@ suite('DeepnoteMultiNotebookSplitter', () => { ['multi-alpha.deepnote', 'multi-beta.deepnote'], 'the new siblings are still cleaned up' ); - // The original mapping is restored via a compensating set (its forward removal had failed). - assert.include(setCalls, 'multi.deepnote=env-xyz', 'the original env mapping must be restored on rollback'); - assert.deepStrictEqual( - removeCalls.slice().sort(), - ['multi-alpha.deepnote', 'multi-beta.deepnote'], - 'the sibling env sets are reverted' - ); assert.strictEqual( errors.get(), - 'Failed to split file: remove env failed for multi.deepnote. The original file was restored.' + 'Failed to split file: refresh failed after the rename. The original file was restored.' ); - envSplitter.dispose(); + failingSplitter.dispose(); emitter.dispose(); }); }); diff --git a/src/notebooks/deepnote/snapshots/environmentCapture.node.ts b/src/notebooks/deepnote/snapshots/environmentCapture.node.ts index dcd3c923cb..ab3f96def0 100644 --- a/src/notebooks/deepnote/snapshots/environmentCapture.node.ts +++ b/src/notebooks/deepnote/snapshots/environmentCapture.node.ts @@ -8,12 +8,12 @@ import type { Environment } from '@deepnote/blocks'; import { PythonEnvironment } from '../../../platform/pythonEnvironments/info'; import { computeHash } from '../../../platform/common/crypto'; import { raceTimeout } from '../../../platform/common/utils/async'; +import { IInterpreterService } from '../../../platform/interpreter/contracts'; +import { getEnvironmentType } from '../../../platform/interpreter/helpers'; +import { EnvironmentType } from '../../../platform/pythonEnvironments/info'; import { logger } from '../../../platform/logging'; import { parsePipFreezeFile } from './pipFileParser'; -import { IDeepnoteEnvironmentManager, IDeepnoteNotebookEnvironmentMapper } from '../../../kernels/deepnote/types'; import { Uri } from 'vscode'; -import { DeepnoteEnvironment } from '../../../kernels/deepnote/environments/deepnoteEnvironment'; -import * as path from '../../../platform/vscode-path/path'; const captureTimeoutInMilliseconds = 5_000; @@ -31,36 +31,37 @@ export interface IEnvironmentCapture { type PythonEnvironmentType = 'uv' | 'conda' | 'venv' | 'poetry' | 'system'; +// The snapshot schema names a narrower set than the Python extension discovers; anything without a +// counterpart is reported as the plain interpreter it effectively is. +const ENVIRONMENT_TYPES: Partial> = { + [EnvironmentType.Conda]: 'conda', + [EnvironmentType.Poetry]: 'poetry', + [EnvironmentType.Venv]: 'venv', + [EnvironmentType.VirtualEnv]: 'venv', + [EnvironmentType.VirtualEnvWrapper]: 'venv' +}; + @injectable() export class EnvironmentCapture implements IEnvironmentCapture { - constructor( - @inject(IDeepnoteNotebookEnvironmentMapper) - private readonly environmentMapper: IDeepnoteNotebookEnvironmentMapper, - @inject(IDeepnoteEnvironmentManager) private readonly environmentManager: IDeepnoteEnvironmentManager - ) {} + constructor(@inject(IInterpreterService) private readonly interpreterService: IInterpreterService) {} async captureEnvironment(notebookUri: Uri): Promise { - const deepnoteEnvironment = this.getEnvironmentForNotebook(notebookUri); + const interpreter = await this.interpreterService.getActiveInterpreter(notebookUri); - if (!deepnoteEnvironment) { - logger.warn('[EnvironmentCapture] No Deepnote environment found for the given notebook'); + if (!interpreter) { + logger.warn('[EnvironmentCapture] No active Python interpreter for the given notebook'); return undefined; } - const interpreterPath = deepnoteEnvironment.pythonInterpreter.uri.fsPath; - const pipDir = os.platform() === 'win32' ? 'Scripts' : 'bin'; - const pipBinary = os.platform() === 'win32' ? 'pip.exe' : 'pip'; - const pipBinaryPath = path.resolve(deepnoteEnvironment.venvPath.fsPath, pipDir, pipBinary); - - logger.info(`[EnvironmentCapture] Capturing environment for interpreter ${interpreterPath}`); + logger.info(`[EnvironmentCapture] Capturing environment for interpreter ${interpreter.uri.fsPath}`); const platform = `${os.platform()}-${os.arch()}`; const [pythonVersion, pythonEnvironment, packages] = await Promise.all([ - this.determinePythonVersion(deepnoteEnvironment.pythonInterpreter), - this.determinePythonEnvironment(), - this.listPackageVersions(pipBinaryPath) + this.determinePythonVersion(interpreter), + this.determinePythonEnvironment(interpreter), + this.listPackageVersions(interpreter) ]); if (!pythonVersion) { @@ -107,13 +108,11 @@ export class EnvironmentCapture implements IEnvironmentCapture { return `sha256:${hash}`; } - private async determinePythonEnvironment(): Promise { - // We manage the venv for the Environment that we use to run the Deepnote Kernel so this will always be executed in a - // venv environment. Once we support other environment types, we can expand this logic. - return 'venv'; + protected determinePythonEnvironment(interpreter: PythonEnvironment): PythonEnvironmentType { + return ENVIRONMENT_TYPES[getEnvironmentType(interpreter)] ?? 'system'; } - private async determinePythonVersion(interpreter: PythonEnvironment): Promise { + protected async determinePythonVersion(interpreter: PythonEnvironment): Promise { const pythonVersionFromInterpreter = await this.determinePythonVersionFromRunningTheInterpreter(interpreter); if (pythonVersionFromInterpreter) { @@ -184,22 +183,14 @@ export class EnvironmentCapture implements IEnvironmentCapture { return raceTimeout(captureTimeoutInMilliseconds, undefined, getVersion()); } - private getEnvironmentForNotebook(notebookUri: Uri): DeepnoteEnvironment | undefined { - const environmentId = this.environmentMapper.getEnvironmentForNotebook(notebookUri); - - if (!environmentId) { - return undefined; - } - - return this.environmentManager.getEnvironment(environmentId); - } - - private async listPackageVersions(pipBinaryPath: string): Promise> { + // `-m pip` rather than a `/bin/pip` path: the active interpreter may be conda, system or + // poetry, where no such binary exists next to it. + protected async listPackageVersions(interpreter: PythonEnvironment): Promise> { const execFileAsync = promisify(execFile); const getPackages = async (): Promise> => { try { - const output = await execFileAsync(pipBinaryPath, ['freeze', '--local']); + const output = await execFileAsync(interpreter.uri.fsPath, ['-m', 'pip', 'freeze', '--local']); if (output.stderr) { logger.warn('pip freeze returned error output', { stderr: output.stderr }); diff --git a/src/notebooks/deepnote/snapshots/environmentCapture.unit.test.ts b/src/notebooks/deepnote/snapshots/environmentCapture.unit.test.ts index 4daa3d2cae..1c162017c1 100644 --- a/src/notebooks/deepnote/snapshots/environmentCapture.unit.test.ts +++ b/src/notebooks/deepnote/snapshots/environmentCapture.unit.test.ts @@ -1,6 +1,55 @@ import { assert } from 'chai'; - -import { parsePipFreeze } from './environmentCapture.node'; +import { instance, mock, when } from 'ts-mockito'; +import { Uri } from 'vscode'; + +import { IInterpreterService } from '../../../platform/interpreter/contracts'; +import { PythonEnvironment } from '../../../platform/pythonEnvironments/info'; +import { EnvironmentCapture, parsePipFreeze } from './environmentCapture.node'; + +const NOTEBOOK = Uri.file('/w/project.deepnote'); +const INTERPRETER: PythonEnvironment = { id: 'py-312', uri: Uri.file('/w/.venv/bin/python') }; + +/** + * Overrides the three members that shell out. `execFile` is imported as a module binding, which + * cannot be stubbed under ESM, so the seam has to be on the instance. + */ +interface Stub { + version?: string; + environment?: 'uv' | 'conda' | 'venv' | 'poetry' | 'system'; + packages?: Record; +} + +class StubbedCapture extends EnvironmentCapture { + public seenPackagesInterpreter: PythonEnvironment | undefined; + + constructor( + interpreterService: IInterpreterService, + private readonly stub: Stub + ) { + super(interpreterService); + } + + protected override async determinePythonVersion(): Promise { + return this.stub.version; + } + + protected override determinePythonEnvironment(): 'uv' | 'conda' | 'venv' | 'poetry' | 'system' { + return this.stub.environment ?? 'venv'; + } + + protected override async listPackageVersions(interpreter: PythonEnvironment): Promise> { + this.seenPackagesInterpreter = interpreter; + + return this.stub.packages ?? {}; + } +} + +function captureWith(interpreter: PythonEnvironment | undefined, stub: Stub): StubbedCapture { + const interpreterService = mock(); + when(interpreterService.getActiveInterpreter(NOTEBOOK)).thenResolve(interpreter); + + return new StubbedCapture(instance(interpreterService), stub); +} suite('EnvironmentCapture', () => { suite('parsePipFreeze', () => { @@ -125,8 +174,53 @@ package4==4.0.0.dev1+local`; }); }); - // Note: The captureEnvironment tests have been removed because the implementation - // uses node:child_process.execFile directly which cannot be stubbed in ES modules. - // Integration tests should be used to verify captureEnvironment behavior. - // The core parsing logic is tested via parsePipFreeze above. + suite('captureEnvironment', () => { + test('captures from the active interpreter, with no Deepnote environment involved', async () => { + const capture = captureWith(INTERPRETER, { + version: '3.12.13', + environment: 'venv', + packages: { numpy: '1.26.0', pandas: '2.1.0' } + }); + + const environment = await capture.captureEnvironment(NOTEBOOK); + + assert.deepStrictEqual(environment?.packages, { numpy: '1.26.0', pandas: '2.1.0' }); + assert.deepStrictEqual(environment?.python, { environment: 'venv', version: '3.12.13' }); + assert.strictEqual(capture.seenPackagesInterpreter, INTERPRETER); + }); + + test('reports the interpreter type it was given rather than assuming a venv', async () => { + const capture = captureWith(INTERPRETER, { version: '3.12.13', environment: 'conda' }); + + const environment = await capture.captureEnvironment(NOTEBOOK); + + assert.strictEqual(environment?.python?.environment, 'conda'); + }); + + test('returns undefined when the notebook has no active interpreter', async () => { + const capture = captureWith(undefined, { version: '3.12.13' }); + + assert.isUndefined(await capture.captureEnvironment(NOTEBOOK)); + }); + + test('returns undefined when the Python version cannot be determined', async () => { + const capture = captureWith(INTERPRETER, { version: undefined, packages: { numpy: '1.26.0' } }); + + assert.isUndefined(await capture.captureEnvironment(NOTEBOOK)); + }); + + test('hashes the package set, so a changed package produces a different hash', async () => { + const before = await captureWith(INTERPRETER, { + version: '3.12.13', + packages: { numpy: '1.26.0' } + }).captureEnvironment(NOTEBOOK); + const after = await captureWith(INTERPRETER, { + version: '3.12.13', + packages: { numpy: '1.26.1' } + }).captureEnvironment(NOTEBOOK); + + assert.match(before?.hash ?? '', /^sha256:/); + assert.notStrictEqual(before?.hash, after?.hash); + }); + }); }); diff --git a/src/notebooks/serviceRegistry.node.ts b/src/notebooks/serviceRegistry.node.ts index 7beb5c8916..b470dc3b00 100644 --- a/src/notebooks/serviceRegistry.node.ts +++ b/src/notebooks/serviceRegistry.node.ts @@ -75,9 +75,8 @@ import { IDeepnoteServerStarter, IDeepnoteKernelAutoSelector, IDeepnoteServerProvider, - IDeepnoteEnvironmentManager, - IDeepnoteNotebookEnvironmentMapper, IDeepnoteLspClientManager, + IDeepnoteToolkitDependencyService, IServerHandleRegistry } from '../kernels/deepnote/types'; import { DeepnoteAgentSkillsManager } from '../kernels/deepnote/deepnoteAgentSkillsManager.node'; @@ -86,15 +85,10 @@ import { DeepnoteServerStarter } from '../kernels/deepnote/deepnoteServerStarter import { DeepnoteKernelAutoSelector } from './deepnote/deepnoteKernelAutoSelector.node'; import { DeepnoteServerProvider } from '../kernels/deepnote/deepnoteServerProvider.node'; import { ServerHandleRegistry } from '../kernels/deepnote/deepnoteServerHandleRegistry.node'; +import { DeepnoteToolkitDependencyService } from '../kernels/deepnote/deepnoteToolkitDependencyService.node'; import { DeepnoteLspClientManager } from '../kernels/deepnote/deepnoteLspClientManager.node'; import { DeepnoteInitNotebookRunner } from './deepnote/deepnoteInitNotebookRunner.node'; import { DeepnoteRequirementsHelper, IDeepnoteRequirementsHelper } from './deepnote/deepnoteRequirementsHelper.node'; -import { DeepnoteEnvironmentManager } from '../kernels/deepnote/environments/deepnoteEnvironmentManager.node'; -import { DeepnoteEnvironmentStorage } from '../kernels/deepnote/environments/deepnoteEnvironmentStorage.node'; -import { DeepnoteEnvironmentsView } from '../kernels/deepnote/environments/deepnoteEnvironmentsView.node'; -import { DeepnoteEnvironmentsActivationService } from '../kernels/deepnote/environments/deepnoteEnvironmentsActivationService'; -import { DeepnoteExtensionSidecarWriter } from '../kernels/deepnote/environments/deepnoteExtensionSidecarWriter.node'; -import { DeepnoteNotebookEnvironmentMapper } from '../kernels/deepnote/environments/deepnoteNotebookEnvironmentMapper.node'; import { DeepnoteCellExecutionAnalytics } from './deepnote/deepnoteCellExecutionAnalytics'; import { DeepnoteNotebookCommandListener } from './deepnote/deepnoteNotebookCommandListener'; import { DeepnoteInputBlockCellStatusBarItemProvider } from './deepnote/deepnoteInputBlockCellStatusBarProvider'; @@ -105,7 +99,6 @@ import { EphemeralCellStatusBarProvider } from './deepnote/ephemeralCellStatusBa import { OrphanedEphemeralCellCleaner } from './deepnote/orphanedEphemeralCellCleaner'; import { DeepnoteNewCellLanguageService } from './deepnote/deepnoteNewCellLanguageService'; import { DeepnoteCellCopyHandler } from './deepnote/deepnoteCellCopyHandler'; -import { DeepnoteEnvironmentTreeDataProvider } from '../kernels/deepnote/environments/deepnoteEnvironmentTreeDataProvider.node'; import { OpenInDeepnoteHandler } from './deepnote/openInDeepnoteHandler.node'; import { IntegrationEnvRefreshHandler } from './deepnote/integrations/integrationEnvRefreshHandler'; import { IntegrationsEnvFileWatcher } from './deepnote/integrations/integrationsEnvFileWatcher.node'; @@ -264,6 +257,10 @@ export function registerTypes(serviceManager: IServiceManager, isDevMode: boolea serviceManager.addSingleton(IDeepnoteServerProvider, DeepnoteServerProvider); serviceManager.addBinding(IDeepnoteServerProvider, IExtensionSyncActivationService); serviceManager.addSingleton(IServerHandleRegistry, ServerHandleRegistry); + serviceManager.addSingleton( + IDeepnoteToolkitDependencyService, + DeepnoteToolkitDependencyService + ); serviceManager.addSingleton(IDeepnoteKernelAutoSelector, DeepnoteKernelAutoSelector); serviceManager.addBinding(IDeepnoteKernelAutoSelector, IExtensionSyncActivationService); serviceManager.addSingleton(IDeepnoteLspClientManager, DeepnoteLspClientManager); @@ -306,33 +303,6 @@ export function registerTypes(serviceManager: IServiceManager, isDevMode: boolea DeepnoteNotebookInfoStatusBar ); - // Deepnote configuration services - serviceManager.addSingleton(DeepnoteEnvironmentStorage, DeepnoteEnvironmentStorage); - serviceManager.addSingleton(IDeepnoteEnvironmentManager, DeepnoteEnvironmentManager); - serviceManager.addSingleton( - DeepnoteEnvironmentTreeDataProvider, - DeepnoteEnvironmentTreeDataProvider - ); - - // Deepnote configuration view - serviceManager.addSingleton(DeepnoteEnvironmentsView, DeepnoteEnvironmentsView); - serviceManager.addSingleton( - IExtensionSyncActivationService, - DeepnoteEnvironmentsActivationService - ); - - // Deepnote configuration selection - serviceManager.addSingleton( - IDeepnoteNotebookEnvironmentMapper, - DeepnoteNotebookEnvironmentMapper - ); - - // Sidecar file writer (exposes env mappings for external tools) - serviceManager.addSingleton( - IExtensionSyncActivationService, - DeepnoteExtensionSidecarWriter - ); - // Snapshot service serviceManager.addSingleton(IEnvironmentCapture, EnvironmentCapture); serviceManager.addSingleton(SnapshotService, SnapshotService); diff --git a/src/platform/api/pythonApi.ts b/src/platform/api/pythonApi.ts index 3e68cc60f6..18e620063f 100644 --- a/src/platform/api/pythonApi.ts +++ b/src/platform/api/pythonApi.ts @@ -49,6 +49,10 @@ import { trackInterpreterDiscovery, trackPythonExtensionActivation } from '../.. import { findPythonEnvBelongingToFolder } from '../../notebooks/controllers/preferredKernelConnectionService.node'; import { DisposableMap } from '../common/utils/lifecycle'; +// The Python extension completes its Jupyter handshake asynchronously; if the callback has not +// landed by then it never will (see failApiIfHandshakeNeverLands). +const PYTHON_API_HANDSHAKE_TIMEOUT = 5_000; + export function deserializePythonEnvironment( pythonVersion: Partial | undefined, pythonEnvId: string @@ -161,6 +165,25 @@ export class OldPythonApiProvider implements IPythonApiProvider { return extension?.exports; } + /** + * The Python extension ends the handshake by calling `registerPythonApi` on the extension it + * knows as `ms-toolsai.jupyter`. This fork ships under a different id, so that callback never + * arrives and every `getApi()` awaiter would hang forever. Fail the promise instead — callers + * already degrade to unactivated execution when activation variables are unavailable. + */ + private failApiIfHandshakeNeverLands() { + const timer = setTimeout(() => { + if (!this.api.resolved && !this.api.rejected) { + logger.warn('Python extension did not complete the Jupyter API handshake; continuing without it'); + this.api.reject(new PythonExtensionApiNotExportedError()); + } + }, PYTHON_API_HANDSHAKE_TIMEOUT); + + // Nothing may be awaiting the promise at rejection time. + this.api.promise.catch(noop); + this.disposables.push({ dispose: () => clearTimeout(timer) }); + } + public setApi(api: PythonApi): void { // Never allow accessing python API (we don't want to ever use the API and run code in untrusted API). // Don't assume Python API will always be disabled in untrusted workspaces. @@ -219,6 +242,7 @@ export class OldPythonApiProvider implements IPythonApiProvider { this.api.reject(new PythonExtensionApiNotExportedError()); } else { pythonExtension.exports.jupyter.registerHooks(); + this.failApiIfHandshakeNeverLands(); } this._pythonExtensionHooked.resolve(); } diff --git a/src/platform/common/constants.ts b/src/platform/common/constants.ts index f840ff1aa5..fa53e7e08c 100644 --- a/src/platform/common/constants.ts +++ b/src/platform/common/constants.ts @@ -1,6 +1,9 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +import toolkitSpec from './toolkitSpec.json'; +import { AssertNonAnyString, AssertNonAnyStringArray } from './utils/typescript'; + export const PYTHON_LANGUAGE = 'python'; export const MARKDOWN_LANGUAGE = 'markdown'; export const NotebookCellScheme = 'vscode-notebook-cell'; @@ -22,6 +25,16 @@ export const NOTEBOOK_SELECTOR = [ export const CodespaceExtensionId = 'GitHub.codespaces'; export const JVSC_EXTENSION_ID = 'Deepnote.vscode-deepnote'; + +// The spec lives here rather than with the Deepnote kernel types so the pip installer, which is +// platform code, can pin the package without importing across the layer boundary. +true satisfies AssertNonAnyString; +true satisfies AssertNonAnyStringArray; + +export const DEEPNOTE_TOOLKIT_VERSION: string = toolkitSpec.version; + +export const DEEPNOTE_TOOLKIT_PACKAGES: string[] = toolkitSpec.packages; + export const DATA_WRANGLER_EXTENSION_ID = 'ms-toolsai.datawrangler'; export const PROPOSED_API_ALLOWED_PUBLISHERS = ['donjayamanne']; export const POWER_TOYS_EXTENSION_ID = 'ms-toolsai.vscode-jupyter-powertoys'; diff --git a/src/kernels/deepnote/toolkitSpec.json b/src/platform/common/toolkitSpec.json similarity index 100% rename from src/kernels/deepnote/toolkitSpec.json rename to src/platform/common/toolkitSpec.json diff --git a/src/platform/common/utils/localize.ts b/src/platform/common/utils/localize.ts index 30d282fda7..7bf68e84fe 100644 --- a/src/platform/common/utils/localize.ts +++ b/src/platform/common/utils/localize.ts @@ -466,6 +466,7 @@ export namespace DataScience { l10n.t('Failure during variable extraction: \r\n{0}', errorMessage); export const selectKernel = l10n.t('Change Kernel'); export const selectDifferentKernel = l10n.t('Select a different Kernel'); + export const selectDifferentPythonInterpreter = l10n.t('Select a different Interpreter'); export const kernelFilterPlaceholder = l10n.t('Choose the kernels that are available in the kernel picker.'); export const recommendedItemCategoryInQuickPick = l10n.t('Recommended'); export const selectedKernelCategoryInQuickPick = l10n.t('Selected'); diff --git a/src/platform/interpreter/installer/pipInstaller.node.ts b/src/platform/interpreter/installer/pipInstaller.node.ts index ae53dde627..39f4e01a35 100644 --- a/src/platform/interpreter/installer/pipInstaller.node.ts +++ b/src/platform/interpreter/installer/pipInstaller.node.ts @@ -15,6 +15,8 @@ import { Environment } from '@vscode/python-extension'; import { getEnvironmentType } from '../helpers'; import { workspace } from 'vscode'; +import { DEEPNOTE_TOOLKIT_VERSION } from '../../common/constants'; + /** * Installer for pip. Default installer for most everything. */ @@ -85,8 +87,14 @@ export class PipInstaller extends ModuleInstaller { if (getEnvironmentType(interpreter) === EnvironmentType.Unknown) { args.push('--user'); } + // deepnote_toolkit's import name differs from the pip package name (deepnote-toolkit[server]) + const pipPackageName = + moduleName === translateProductToModule(Product.deepnoteToolkit) + ? `deepnote-toolkit[server]==${DEEPNOTE_TOOLKIT_VERSION}` + : moduleName; + return { - args: ['-m', 'pip', ...args, moduleName].concat(getPinnedPackages('pip', moduleName)) + args: ['-m', 'pip', ...args, pipPackageName].concat(getPinnedPackages('pip', moduleName)) }; } private isPipAvailable(interpreter: PythonEnvironment | Environment): Promise { diff --git a/src/platform/interpreter/installer/productInstaller.node.ts b/src/platform/interpreter/installer/productInstaller.node.ts index bf59cc22f3..e9e8dac97a 100644 --- a/src/platform/interpreter/installer/productInstaller.node.ts +++ b/src/platform/interpreter/installer/productInstaller.node.ts @@ -7,10 +7,12 @@ import { ProductNames } from './productNames'; import { IInstallationChannelManager, IInstaller, + IModuleInstaller, InstallerResponse, IProductPathService, IProductService, ModuleInstallFlags, + ModuleInstallerType, Product, ProductType } from './types'; @@ -93,8 +95,20 @@ export class DataScienceInstaller { installPipIfRequired?: boolean, silent?: boolean ): Promise { - const channels = this.serviceContainer.get(IInstallationChannelManager); - const installer = await channels.getInstallationChannel(product, interpreter); + let installer: IModuleInstaller | undefined; + + // deepnote-toolkit is PyPI-only with pip-specific [server] extras syntax, + // so always use PipInstaller regardless of environment type. + // We bypass getInstallationChannels() because it filters by isSupported(), + // and PipInstaller.isSupported() rejects Conda/Pipenv/Poetry interpreters. + if (product === Product.deepnoteToolkit) { + const allInstallers = this.serviceContainer.getAll(IModuleInstaller); + installer = allInstallers.find((i) => i.type === ModuleInstallerType.Pip); + } else { + const channels = this.serviceContainer.get(IInstallationChannelManager); + installer = await channels.getInstallationChannel(product, interpreter); + } + if (!installer) { return InstallerResponse.Ignore; } diff --git a/src/platform/interpreter/installer/productInstaller.unit.test.ts b/src/platform/interpreter/installer/productInstaller.unit.test.ts index dbe9dba1db..27daf48151 100644 --- a/src/platform/interpreter/installer/productInstaller.unit.test.ts +++ b/src/platform/interpreter/installer/productInstaller.unit.test.ts @@ -206,4 +206,63 @@ suite('DataScienceInstaller install', async () => { const result = await dataScienceInstaller.install(Product.ipykernel, testEnvironment, tokenSource); expect(result).to.equal(InstallerResponse.Installed, 'Should be Installed'); }); + + test('Will use pip for deepnoteToolkit even on Conda interpreter (bypasses isSupported filter)', async () => { + const testEnvironment: PythonEnvironment = { + id: interpreterPath.fsPath, + uri: interpreterPath + }; + + // Create a pip installer mock + const pipInstaller = TypeMoq.Mock.ofType(); + pipInstaller.setup((c) => c.type).returns(() => ModuleInstallerType.Pip); + pipInstaller + .setup((c) => + c.installModule( + TypeMoq.It.isValue(Product.deepnoteToolkit), + TypeMoq.It.isValue(testEnvironment), + TypeMoq.It.isAny(), + TypeMoq.It.isAny(), + TypeMoq.It.isAny() + ) + ) + .returns(() => Promise.resolve()); + pipInstaller.setup((p) => (p as any).then).returns(() => undefined); + + // Create a conda installer mock (would normally be selected for Conda envs) + const condaInstaller = TypeMoq.Mock.ofType(); + condaInstaller.setup((c) => c.type).returns(() => ModuleInstallerType.Conda); + + // serviceContainer.getAll returns both installers — the code must pick pip + serviceContainer + .setup((c) => c.getAll(TypeMoq.It.isValue(IModuleInstaller))) + .returns(() => [condaInstaller.object, pipInstaller.object]); + + const result = await dataScienceInstaller.install(Product.deepnoteToolkit, testEnvironment, tokenSource); + expect(result).to.equal(InstallerResponse.Installed, 'Should be Installed via pip'); + + // Verify pip was called, not conda + pipInstaller.verify( + (c) => + c.installModule( + TypeMoq.It.isValue(Product.deepnoteToolkit), + TypeMoq.It.isValue(testEnvironment), + TypeMoq.It.isAny(), + TypeMoq.It.isAny(), + TypeMoq.It.isAny() + ), + TypeMoq.Times.once() + ); + condaInstaller.verify( + (c) => + c.installModule( + TypeMoq.It.isAny(), + TypeMoq.It.isAny(), + TypeMoq.It.isAny(), + TypeMoq.It.isAny(), + TypeMoq.It.isAny() + ), + TypeMoq.Times.never() + ); + }); }); diff --git a/src/platform/interpreter/installer/productNames.ts b/src/platform/interpreter/installer/productNames.ts index 5c9eaa559b..246432faa1 100644 --- a/src/platform/interpreter/installer/productNames.ts +++ b/src/platform/interpreter/installer/productNames.ts @@ -12,3 +12,4 @@ ProductNames.set(Product.kernelspec, 'kernelspec'); ProductNames.set(Product.pandas, 'pandas'); ProductNames.set(Product.pip, 'pip'); ProductNames.set(Product.ensurepip, 'ensurepip'); +ProductNames.set(Product.deepnoteToolkit, 'deepnote-toolkit'); diff --git a/src/platform/interpreter/installer/productService.node.ts b/src/platform/interpreter/installer/productService.node.ts index 5059e2b2a0..8af4dfb549 100644 --- a/src/platform/interpreter/installer/productService.node.ts +++ b/src/platform/interpreter/installer/productService.node.ts @@ -20,6 +20,7 @@ export class ProductService implements IProductService { this.ProductTypes.set(Product.pandas, ProductType.DataScience); this.ProductTypes.set(Product.pip, ProductType.DataScience); this.ProductTypes.set(Product.ensurepip, ProductType.DataScience); + this.ProductTypes.set(Product.deepnoteToolkit, ProductType.DataScience); } public getProductType(product: Product): ProductType { return this.ProductTypes.get(product)!; diff --git a/src/platform/interpreter/installer/types.ts b/src/platform/interpreter/installer/types.ts index bdb5bfee39..550886232c 100644 --- a/src/platform/interpreter/installer/types.ts +++ b/src/platform/interpreter/installer/types.ts @@ -21,7 +21,8 @@ export enum Product { nbconvert = 22, pandas = 23, pip = 27, - ensurepip = 28 + ensurepip = 28, + deepnoteToolkit = 29 } export enum ProductInstallStatus { diff --git a/src/platform/interpreter/installer/utils.ts b/src/platform/interpreter/installer/utils.ts index b85bd38d76..2e80f6d481 100644 --- a/src/platform/interpreter/installer/utils.ts +++ b/src/platform/interpreter/installer/utils.ts @@ -23,6 +23,8 @@ export function translateProductToModule(product: Product): string { return 'pip'; case Product.ensurepip: return 'ensurepip'; + case Product.deepnoteToolkit: + return 'deepnote_toolkit'; default: { throw new WrappedError( `Product ${product} cannot be installed as a Python Module.`, diff --git a/test/e2e/fixtures/interpreter-kernel.deepnote b/test/e2e/fixtures/interpreter-kernel.deepnote new file mode 100644 index 0000000000..ecb1a47e88 --- /dev/null +++ b/test/e2e/fixtures/interpreter-kernel.deepnote @@ -0,0 +1,23 @@ +version: '1.0.0' +metadata: + createdAt: '2025-01-01T00:00:00.000Z' + modifiedAt: '2025-01-01T00:00:00.000Z' +project: + id: e2e-interpreter-kernel-project + name: E2E Interpreter Kernel + notebooks: + - id: e2e-interpreter-kernel-notebook + name: Interpreter Kernel + blocks: + - id: e2e-interpreter-kernel-block + blockGroup: e2e-interpreter-kernel-group + type: code + content: |- + import sys + print("interpreter-kernel-ok") + print(sys.prefix) + sortingKey: a0 + metadata: {} + executionMode: block + isModule: false + settings: {} diff --git a/test/e2e/helpers/constants.ts b/test/e2e/helpers/constants.ts index ddf9bc852c..e36e88690d 100644 --- a/test/e2e/helpers/constants.ts +++ b/test/e2e/helpers/constants.ts @@ -3,7 +3,6 @@ export const WORKBENCH_TIMEOUT = 60_000; export const QUICK_PICK_TIMEOUT = 30_000; -export const ENV_CREATED_TIMEOUT = 120_000; export const KERNEL_CONNECT_TIMEOUT = 300_000; // Mocha per-test timeout applied to the whole suite (overrides the .mocharc default). Stays just @@ -11,10 +10,10 @@ export const KERNEL_CONNECT_TIMEOUT = 300_000; // Deepnote toolkit provisioning). export const SUITE_TIMEOUT = 1_320_000; // 22 min -// A single "Run All" against an already-selected kernel must render output within this window. It -// sits well above a healthy first run (the kernel is bound before the click — see -// selectEnvironmentForNotebook) and below the multi-minute stall a dropped first run would cause, -// so the kernel-binding regression fails here instead of being masked by re-runs. +// A single "Run All" must render output within this window. It sits well above a healthy first run +// (the interpreter already carries the toolkit, so nothing is provisioned) and below the multi-minute +// stall a dropped first run would cause, so the kernel-binding regression fails here instead of +// being masked by re-runs. export const FIRST_RUN_OUTPUT_TIMEOUT = 120_000; // How often to poll the output webview for the expected text. @@ -23,19 +22,6 @@ export const OUTPUT_POLL_INTERVAL = 1_500; // How long to wait for the notebook output iframe (`#active-frame`) to become switchable. export const OUTPUT_FRAME_SWITCH_TIMEOUT = 5_000; -export const INTERPRETER_RETRY_DELAY = 5_000; -export const MAX_CREATE_ATTEMPTS = 6; - -// How long to wait for the interpreter quick pick to open after issuing the create-environment -// command. When no interpreter has been discovered yet the command shows a "No Python interpreters -// found" notification and returns instead, so this wait elapses and the attempt is retried. -export const INTERPRETER_PROMPT_TIMEOUT = 5_000; - -// How long to wait for an optional input box (packages/description) to appear after confirming the -// environment name. When the name already exists the create command short-circuits with an "already -// exists" notification and opens no further inputs, so this wait elapses and the prompts are skipped. -export const OPTIONAL_PROMPT_TIMEOUT = 5_000; - // The in-window simple file/folder dialog needs a beat to resolve a typed path before it accepts. export const DIALOG_RESOLVE_DELAY = 1_500; // "Open Folder" navigates one level toward the typed path per OK, so we re-click OK up to @@ -51,11 +37,6 @@ export const EDITOR_ACTIVE_TIMEOUT = 15_000; // so reading them cannot accidentally match the cell's source in the editor. export const OUTPUT_SELECTOR = '.output_container, .output, .rendered-output'; -// Where the managed venv lives. The directory name is what identifies it in the interpreter quick -// pick; the bare '.venv' marker can only tell venv-shaped interpreters apart from the rest, since it -// is also a substring of '.venv-e2e'. +// Where the managed venv lives. Suites run against its interpreter, pinned in the generated user +// settings (helpers/venv.ts). export const PREBAKED_VENV_DIR_NAME = '.venv-e2e'; -export const ANY_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 deleted file mode 100644 index 6c39ed7d37..0000000000 --- a/test/e2e/helpers/deepnoteEnvironment.ts +++ /dev/null @@ -1,221 +0,0 @@ -import { EditorView, InputBox, QuickPickItem, VSBrowser, Workbench } from 'vscode-extension-tester'; - -import { - ANY_VENV_MARKER, - ENV_CREATED_TIMEOUT, - INTERPRETER_PROMPT_TIMEOUT, - INTERPRETER_RETRY_DELAY, - 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'; -import { tryOpenInputBox } from './quickInput'; - -// Command palette labels (category + title) the way `Workbench.executeCommand` matches them. -const CREATE_ENV_COMMAND = 'Deepnote: Create Environment'; -const SELECT_ENV_COMMAND = 'Deepnote: Select Environment for Notebook'; - -/** - * Reads a quick-pick row as the label to filter by plus the label-and-description text to match - * against: the Python extension puts the interpreter path in either field depending on the entry, - * so matching one alone misses venvs described in the other. - */ -async function readPick(pick: QuickPickItem): Promise<{ label: string; text: string }> { - const label = await pick.getLabel(); - - return { label, text: `${label} ${(await pick.getDescription()) ?? ''}` }; -} - -/** - * Picks the pre-baked venv, which the extension adopts instead of provisioning its own. - * - * `useExistingVenv: false` 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 - * extension-managed environments only. - */ -async function selectInterpreter(interpreterPick: InputBox, useExistingVenv: boolean): Promise { - const driver = VSBrowser.instance.driver; - - if (useExistingVenv) { - 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 readPick(picks[0]); - - return first.text.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 entries = await Promise.all(picks.map(readPick)); - // Any venv, not just the baked one: the extension adopts whatever venv it is pointed at - // (managedVenv: false), leaving the deletion suite nothing to delete. In CI this resolves to the - // interpreter actions/setup-python installed. - const wanted = entries.find((entry) => !entry.text.includes(ANY_VENV_MARKER))?.label; - - 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(); - if (filtered.length === 0) { - return false; - } - - return !(await readPick(filtered[0])).text.includes(ANY_VENV_MARKER); - }, PREBAKED_VENV_FILTER_TIMEOUT) - .catch(() => false); - - if (narrowed) { - await interpreterPick.confirm(); - - return; - } - - await interpreterPick.setText(''); - } - - console.warn( - '[deepnote-e2e] no interpreter outside a venv could be filtered to; ' + - `accepting the first entry. Offered: ${JSON.stringify(entries.map((entry) => entry.text))}` - ); - await interpreterPick.confirm(); -} - -/** - * Drives `deepnote.environments.create`: pick interpreter -> name -> skip packages -> skip - * description. Retries while the Python extension is still discovering interpreters, and treats - * "already exists" as success so a retry reuses the environment instead of colliding with it. - * - * Each suite passes its own name, so no suite inherits an environment another one set up. - */ -export async function createEnvironment(name: string, options: { useExistingVenv?: boolean } = {}): Promise { - const driver = VSBrowser.instance.driver; - let lastError: unknown; - - for (let attempt = 1; attempt <= MAX_CREATE_ATTEMPTS; attempt++) { - await new Workbench().executeCommand(CREATE_ENV_COMMAND); - - // Either the interpreter quick pick opens, or (no interpreter discovered yet) the command - // shows a "No Python interpreters found" notification and returns. - const interpreterPick = await tryOpenInputBox(INTERPRETER_PROMPT_TIMEOUT); - if (!interpreterPick) { - await dismissAllNotifications(); - await driver.sleep(INTERPRETER_RETRY_DELAY); - lastError = new Error('interpreter quick pick did not appear (interpreter discovery not ready?)'); - continue; - } - - // Every step below drives a quick pick the workbench can tear down under us, so they share - // one recovery: escape whatever input is open and spend another attempt. Retrying is safe - // because the create command treats an existing name as success. - try { - await driver.wait( - async () => (await interpreterPick.getQuickPicks()).length > 0, - QUICK_PICK_TIMEOUT, - 'no Python interpreters were listed' - ); - - await selectInterpreter(interpreterPick, options.useExistingVenv !== false); - - const nameBox = await InputBox.create(); - await nameBox.setText(name); - await nameBox.confirm(); - - // On an existing name the create command short-circuits after the name prompt with an - // "already exists" notification and opens no further inputs, so only drive the optional - // prompts when the packages box actually appears. This keeps the documented idempotent - // retry path working: a leftover environment is reused rather than failing the test on a - // timed-out InputBox that never opens. - const packagesBox = await tryOpenInputBox(OPTIONAL_PROMPT_TIMEOUT); - if (packagesBox) { - // Packages (optional) — leave empty. - await packagesBox.confirm(); - - // Description (optional) — leave empty. - await (await InputBox.create()).confirm(); - } - - // Treat both the success toast and the "already exists" guard as success: a leftover - // environment from a previous/retried run is fine — it will be selected next. - await waitForNotification(/created successfully|already exists/i, ENV_CREATED_TIMEOUT, false); - - return; - } catch (error) { - await interpreterPick.cancel().catch((cancelError) => { - console.warn('[deepnote-e2e] cancel interpreter quick pick:', cancelError); - }); - await dismissAllNotifications(); - await driver.sleep(INTERPRETER_RETRY_DELAY); - lastError = error; - continue; - } - } - - throw new Error( - `Failed to create a Deepnote environment after ${MAX_CREATE_ATTEMPTS} attempts. ` + - `Ensure the Python extension is installed and an interpreter is discoverable. ` + - `Last error: ${String(lastError)}` - ); -} - -/** - * Drives `deepnote.environments.selectForNotebook`. Selecting the environment rebuilds and - * explicitly selects the notebook's kernel controller (provisioning the venv + toolkit), which is - * what "wait for the kernel to connect" means in this extension. - */ -export async function selectEnvironmentForNotebook(name: string, notebookFileName: string): Promise { - const driver = VSBrowser.instance.driver; - - // The command requires an active `deepnote` notebook — make sure it's focused. - await new EditorView().openEditor(notebookFileName); - - // Clear the "select an environment" prompt and any other toasts; they can overlap the quick pick - // and intercept clicks. - await dismissAllNotifications(); - - await new Workbench().executeCommand(SELECT_ENV_COMMAND); - - const environmentPick = await InputBox.create(QUICK_PICK_TIMEOUT); - // Filter to the environment by name and accept with Enter rather than clicking the row: the - // quick-pick row contains a description `

` that can intercept a positional click. - await environmentPick.setText(name); - await driver.wait( - async () => (await environmentPick.getQuickPicks()).length > 0, - QUICK_PICK_TIMEOUT, - 'environment quick pick was empty' - ); - await environmentPick.confirm(); - - // Best-effort wait for the "switched successfully" toast; the authoritative gate is the rendered - // output, so a missed (auto-dismissed) toast must not fail the test. - await waitForNotification(/switched successfully/i, KERNEL_CONNECT_TIMEOUT, false); -} diff --git a/test/e2e/helpers/index.ts b/test/e2e/helpers/index.ts index ea3d7b9fae..8fff268cc7 100644 --- a/test/e2e/helpers/index.ts +++ b/test/e2e/helpers/index.ts @@ -1,6 +1,5 @@ export * from './assertions'; export * from './constants'; -export * from './deepnoteEnvironment'; export * from './deepnoteTree'; export * from './fixtures'; export * from './mockOpenAiServer'; diff --git a/test/e2e/helpers/modals.ts b/test/e2e/helpers/modals.ts index 993935171f..4a42ee4f04 100644 --- a/test/e2e/helpers/modals.ts +++ b/test/e2e/helpers/modals.ts @@ -6,7 +6,10 @@ import { WORKBENCH_TIMEOUT } from './constants'; * Confirms a `{modal:true}` dialog by clicking the button matching `label`, driving the raw * `.monaco-dialog-box` (ExTester's `ModalDialog` attaches unreliably); `messageIncludes` disambiguates. */ -export async function confirmModalDialog(label: string, options?: { messageIncludes?: string }): Promise { +export async function confirmModalDialog( + label: string, + options?: { messageIncludes?: string; onVisible?: () => Promise } +): Promise { const driver = VSBrowser.instance.driver; const messageIncludes = options?.messageIncludes; @@ -25,6 +28,9 @@ export async function confirmModalDialog(label: string, options?: { messageInclu `modal dialog${messageIncludes ? ` containing "${messageIncludes}"` : ''} did not appear` ); + // Runs while the dialog is still up — the only chance to capture or inspect it. + await options?.onVisible?.(); + const button = await driver.wait( async () => { const selector = '.monaco-dialog-box .dialog-buttons .monaco-button, .monaco-dialog-box .monaco-button'; diff --git a/test/e2e/helpers/notebook.ts b/test/e2e/helpers/notebook.ts index 0b7a8d5ec1..643c1952fe 100644 --- a/test/e2e/helpers/notebook.ts +++ b/test/e2e/helpers/notebook.ts @@ -264,12 +264,11 @@ export async function assertMarkersStayAbsent( } /** - * Issues a SINGLE "Run All" after the kernel has been selected and polls the notebook output webview - * until the expected text renders. It deliberately does NOT re-issue "Run All" when output is - * missing: the kernel is already bound before we get here (`selectEnvironmentForNotebook` waits for - * the post-binding "switched successfully" toast), so a first run that renders nothing means the - * execution request was dropped — exactly the kernel-binding regression this suite must catch. - * Re-running until output eventually appeared would mask that bug. + * Issues a SINGLE "Run All" and polls the notebook output webview until the expected text renders. + * It deliberately does NOT re-issue "Run All" when output is missing: the controller is registered + * when the notebook opens and the interpreter already carries the toolkit, so a first run that + * renders nothing means the execution request was dropped — exactly the kernel-binding regression + * this suite must catch. Re-running until output eventually appeared would mask that bug. */ export async function runOnceAndAwaitOutput( notebookFileName: string, diff --git a/test/e2e/helpers/notifications.ts b/test/e2e/helpers/notifications.ts index 7f5ccadded..b5d3ca23c4 100644 --- a/test/e2e/helpers/notifications.ts +++ b/test/e2e/helpers/notifications.ts @@ -60,3 +60,30 @@ export async function waitForNotification( return undefined; } } + +/** + * Waits until no visible notification matches `pattern` any more. A progress notification is + * removed when its operation settles, so this gates on "that work finished" rather than on a + * fixed sleep. + */ +export async function waitForNotificationToClear(pattern: RegExp, timeout: number): Promise { + await VSBrowser.instance.driver.wait( + async () => { + const notifications = await new Workbench().getNotifications().catch((error) => { + console.warn('[deepnote-e2e] get notifications:', error); + + return [] as Notification[]; + }); + for (const notification of notifications) { + const message = await notification.getMessage().catch(() => ''); + if (pattern.test(message)) { + return false; + } + } + + return true; + }, + timeout, + `timed out waiting for notifications matching ${pattern} to clear` + ); +} diff --git a/test/e2e/helpers/venv.ts b/test/e2e/helpers/venv.ts index 9fe7d7b38e..fc6888ea2b 100644 --- a/test/e2e/helpers/venv.ts +++ b/test/e2e/helpers/venv.ts @@ -2,7 +2,7 @@ import { execFileSync } from 'child_process'; import * as fs from 'fs'; import * as path from 'path'; -import toolkitSpec from '../../../src/kernels/deepnote/toolkitSpec.json'; +import toolkitSpec from '../../../src/platform/common/toolkitSpec.json'; import { PREBAKED_VENV_DIR_NAME } from './constants'; const REPO_ROOT = process.cwd(); @@ -10,6 +10,7 @@ const VENV_DIR = path.join(REPO_ROOT, PREBAKED_VENV_DIR_NAME); const SETTINGS_SOURCE = path.join(REPO_ROOT, 'test', 'e2e', 'settings.json'); const SETTINGS_TARGET = path.join(REPO_ROOT, 'test', 'e2e', 'settings.generated.json'); +/** The interpreter inside the pre-baked venv. Suites run against it as the active interpreter. */ function venvPython(): string { return process.platform === 'win32' ? path.join(VENV_DIR, 'Scripts', 'python.exe') @@ -123,13 +124,20 @@ export function ensureManagedVenv(): string { } /** - * Writes the settings file extest hands VS Code: the committed base plus `python.venvPath`, which is - * what makes the Python extension discover the baked venv and offer it in the interpreter quick pick. - * Returns the path to pass to `extest -o`. + * Writes the settings file extest hands VS Code: the committed base plus the two interpreter + * settings. Returns the path to pass to `extest -o`. * - * Generated rather than committed because `venvPath` only takes an absolute path, known at run time. - * It has to land in *user* settings — the setting is `scope: machine`, so VS Code ignores it in a - * workspace `.vscode/settings.json` — and extest writes this file to the test instance's User dir. + * `python.defaultInterpreterPath` is what makes every temp workspace resolve to an interpreter that + * already has deepnote-toolkit, so opening a notebook registers a controller and running a cell goes + * straight to execution — no consent prompt, no provisioning. A suite that needs a different + * interpreter overrides it in its own workspace `.vscode/settings.json`; the setting is + * `scope: machine-overridable`, so the workspace value wins. + * + * `python.venvPath` makes the Python extension discover the venv as an environment rather than a + * bare path, which is what populates the `folderUri` the server starter reads. + * + * Generated rather than committed because both only take absolute paths, known at run time. They + * have to land in *user* settings, which is where extest writes this file. * * Deliberately not a `.venv` symlink inside each workspace: the extension names the kernel spec after * the venv directory and writes it INTO the venv, keeping the first one it finds @@ -139,7 +147,11 @@ export function ensureManagedVenv(): string { */ export function writeGeneratedSettings(): string { const base = JSON.parse(fs.readFileSync(SETTINGS_SOURCE, 'utf8')) as Record; - const settings = { ...base, 'python.venvPath': REPO_ROOT }; + const settings = { + ...base, + 'python.defaultInterpreterPath': venvPython(), + 'python.venvPath': REPO_ROOT + }; fs.writeFileSync(SETTINGS_TARGET, `${JSON.stringify(settings, undefined, 4)}\n`, 'utf8'); diff --git a/test/e2e/suite/agent/agentBlock.e2e.test.ts b/test/e2e/suite/agent/agentBlock.e2e.test.ts index 582a82b013..06eabc8371 100644 --- a/test/e2e/suite/agent/agentBlock.e2e.test.ts +++ b/test/e2e/suite/agent/agentBlock.e2e.test.ts @@ -26,14 +26,12 @@ import { confirmModalDialog, copyFixtureIntoDir, copyFixtureToTempDir, - createEnvironment, createScreenshotter, dismissAllNotifications, openFolderViaDialog, openWorkspaceFile, pointExtensionHostAtMockServer, readCellLayoutHeight, - selectEnvironmentForNotebook, startMockOpenAiServer, storeMockOpenAiApiKey } from '../../helpers'; @@ -49,7 +47,6 @@ const CODE_TOOL_NAME = 'add_code_block'; const MARKDOWN_TOOL_NAME = 'add_markdown_block'; // Leg 3 match: agentCellExecutionHandler add_markdown_block tool result. const MARKDOWN_BLOCK_ADDED_TEXT = 'Markdown block added.'; -const ENVIRONMENT_NAME = 'E2E Agent Env'; const AGENT_RUN_TIMEOUT = 60_000; const PYTHON_OUTPUT_MARKER = 'agent-generated-python-ran'; const GENERATED_PYTHON = `print("${PYTHON_OUTPUT_MARKER}")`; @@ -246,14 +243,11 @@ describe('Deepnote — running an agent block against a stand-in OpenAI API', fu await openFolderViaDialog(copy.tempDir); await VSBrowser.instance.waitForWorkbench(WORKBENCH_TIMEOUT); - // createEnvironment needs an active deepnote notebook; which one does not matter, and each - // group below binds the kernel for the notebook it actually runs. await openOnly(AGENT_FILE); - await createEnvironment(ENVIRONMENT_NAME); await dismissAllNotifications(); await storeMockOpenAiApiKey(); - await screenshot('environment-created'); + await screenshot('workspace-open'); }); async function releaseMockServer(): Promise { @@ -295,12 +289,11 @@ describe('Deepnote — running an agent block against a stand-in OpenAI API', fu }); describe('one agent block on its own', function () { - // Opens and binds the notebook this group runs, so the group does not care what ran before - // it. Closing and reopening drops the block's generated cells, which is why it happens here - // once and never between the tests below. + // Opens the notebook this group runs, so the group does not care what ran before it. Closing + // and reopening drops the block's generated cells, which is why it happens here once and + // never between the tests below. before(async function () { await openOnly(AGENT_FILE); - await selectEnvironmentForNotebook(ENVIRONMENT_NAME, AGENT_FILE); await dismissAllNotifications(); }); @@ -624,7 +617,6 @@ describe('Deepnote — running an agent block against a stand-in OpenAI API', fu ]); await openOnly(BATCH_FILE); - await selectEnvironmentForNotebook(ENVIRONMENT_NAME, BATCH_FILE); await dismissAllNotifications(); await clickRunAll(BATCH_FILE); @@ -674,7 +666,6 @@ describe('Deepnote — running an agent block against a stand-in OpenAI API', fu ]); await openOnly(STOP_FILE); - await selectEnvironmentForNotebook(ENVIRONMENT_NAME, STOP_FILE); await dismissAllNotifications(); await clickRunAll(STOP_FILE); @@ -720,7 +711,6 @@ describe('Deepnote — running an agent block against a stand-in OpenAI API', fu describe('re-running an agent block that already carries a transcript', function () { before(async function () { await openOnly(HEIGHT_FILE); - await selectEnvironmentForNotebook(ENVIRONMENT_NAME, HEIGHT_FILE); await dismissAllNotifications(); }); diff --git a/test/e2e/suite/environments/environment.e2e.test.ts b/test/e2e/suite/environments/environment.e2e.test.ts deleted file mode 100644 index 34274020c2..0000000000 --- a/test/e2e/suite/environments/environment.e2e.test.ts +++ /dev/null @@ -1,417 +0,0 @@ -/** - * E2E (ExTester): splitting a multi-notebook file migrates its selected environment onto every child. - * Signal: the `.vscode/deepnote.json` sidecar, deleted post-split so a child regenerates it from the migration. - */ -import { expect } from 'chai'; -import * as fs from 'fs'; -import * as os from 'os'; -import * as path from 'path'; -import { - By, - EditorView, - InputBox, - SideBarView, - VSBrowser, - WebView, - Workbench, - type ViewItem -} from 'vscode-extension-tester'; - -import { - FIRST_RUN_OUTPUT_TIMEOUT, - KERNEL_CONNECT_TIMEOUT, - QUICK_PICK_TIMEOUT, - SUITE_TIMEOUT, - WORKBENCH_TIMEOUT, - assertNotNull, - confirmModalDialog, - copyFixtureToTempDir, - createEnvironment, - createScreenshotter, - openActivityBarView, - openFolderViaDialog, - openWorkspaceFile, - runOnceAndAwaitOutput, - selectDeepnoteContextMenu, - selectEnvironmentForNotebook, - waitForNotification -} 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 Split Migration Env'; -const SPLIT_PROMPT = /multiple notebooks/i; -const SPLIT_ACTION = 'Split into separate files'; -const SPLIT_DONE = /Split into \d+ files\./i; - -const SIDECAR_REGEN_TIMEOUT = 30_000; -const SIDECAR_POLL_INTERVAL = 1_000; - -const INLINE_PROMPT_TIMEOUT = 15_000; -const RELOAD_SETTLE = 3_000; - -/** - * Reloads the VS Code window and waits until it is interactive again. Waiting for the pre-reload - * `.monaco-workbench` to go stale avoids racing the UI against a window still tearing down. - */ -async function reloadWindow(): Promise { - const driver = VSBrowser.instance.driver; - const previousWorkbench = await driver.findElement(By.css('.monaco-workbench')); - - await new Workbench().executeCommand('Developer: Reload Window'); - - await driver - .wait( - async () => { - try { - await previousWorkbench.getTagName(); - - return false; - } catch { - // Stale element reference means the old workbench detached (reload started). - return true; - } - }, - WORKBENCH_TIMEOUT, - 'window did not begin reloading' - ) - .catch(() => undefined); - - await VSBrowser.instance.waitForWorkbench(WORKBENCH_TIMEOUT); - await driver.sleep(RELOAD_SETTLE); -} - -describe('Deepnote — splitting a file migrates its selected environment onto every child', function () { - this.timeout(SUITE_TIMEOUT); - this.retries(0); // destructive (retires the original to .legacy); not idempotent - - let cleanupTempDir: (() => void) | undefined; - let sidecarEnvId: string | undefined; - - before(async function () { - const driver = VSBrowser.instance.driver; - const screenshot = createScreenshotter(this); - const copy = copyFixtureToTempDir(FIXTURE); - cleanupTempDir = copy.cleanup; - const tempDir = copy.tempDir; - - await VSBrowser.instance.waitForWorkbench(WORKBENCH_TIMEOUT); - await openFolderViaDialog(tempDir); - await VSBrowser.instance.waitForWorkbench(WORKBENCH_TIMEOUT); - - await createEnvironment(ENV_NAME); - - await openWorkspaceFile(FIXTURE); - await driver.wait( - async () => (await new EditorView().getOpenEditorTitles()).some((t) => t.includes(FIXTURE)), - WORKBENCH_TIMEOUT, - `${FIXTURE} did not open` - ); - - // Select env WITHOUT dismissing the split prompt: replicate selectEnvironmentForNotebook minus - // its dismissAllNotifications() call, which would kill the still-open split toast. - await new EditorView().openEditor(FIXTURE); - await new Workbench().executeCommand('Deepnote: Select Environment for Notebook'); - const pick = await InputBox.create(QUICK_PICK_TIMEOUT); - await pick.setText(ENV_NAME); - await driver.wait(async () => (await pick.getQuickPicks()).length > 0, QUICK_PICK_TIMEOUT, 'env pick empty'); - await pick.confirm(); - await waitForNotification(/switched successfully/i, KERNEL_CONNECT_TIMEOUT, false); - await screenshot('env-selected'); - - // The kernel rebuild can let the toast collapse out of `getNotifications()` view; if gone, - // reload to clear the prompted-once guard so reopening re-raises the split prompt. - let prompt = await waitForNotification(SPLIT_PROMPT, INLINE_PROMPT_TIMEOUT, false); - if (!prompt) { - console.log('[G1] split prompt not visible inline after env-select; using reload path'); - await reloadWindow(); - await openWorkspaceFile(FIXTURE); - await driver.wait( - async () => (await new EditorView().getOpenEditorTitles()).some((t) => t.includes(FIXTURE)), - WORKBENCH_TIMEOUT, - `${FIXTURE} did not reopen after reload` - ); - prompt = await waitForNotification(SPLIT_PROMPT, WORKBENCH_TIMEOUT, true); - } - await screenshot('split-prompt'); - - // 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'); - fs.rmSync(sidecarPath, { force: true }); - - await new EditorView().closeAllEditors().catch(() => undefined); - await openWorkspaceFile(CHILD); - await driver.wait( - async () => (await new EditorView().getOpenEditorTitles()).some((t) => t.includes(CHILD)), - WORKBENCH_TIMEOUT, - `${CHILD} did not open` - ); - - const deadline = Date.now() + SIDECAR_REGEN_TIMEOUT; - while (Date.now() < deadline) { - if (fs.existsSync(sidecarPath)) { - try { - const parsed = JSON.parse(fs.readFileSync(sidecarPath, 'utf8')); - const id = parsed?.mappings?.[PROJECT_ID]?.environmentId; - if (typeof id === 'string' && id.length > 0) { - sidecarEnvId = id; - break; - } - } catch { - /* mid-write */ - } - } - await driver.sleep(SIDECAR_POLL_INTERVAL); - } - - if (!sidecarEnvId) { - const vscodeDir = path.join(tempDir, '.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)) { - console.log('[G1] sidecar contents:', fs.readFileSync(sidecarPath, 'utf8')); - } - } - console.log('[G1] sidecarEnvId=', JSON.stringify(sidecarEnvId)); - await screenshot('sidecar-regenerated'); - }); - - after(async function () { - await new WebView().switchBack().catch(() => undefined); - await new EditorView().closeAllEditors().catch(() => undefined); - try { - cleanupTempDir?.(); - } catch (e) { - console.warn('[env-g1] cleanup:', e); - } - }); - - it('regenerates the env sidecar for the project from a split child (env migrated)', function () { - expect(sidecarEnvId, 'migrated env id in .vscode/deepnote.json').to.be.a('string').and.not.equal(''); - }); -}); - -// The server starter writes/deletes one PID lock file per running server under this dir. The test -// shares os.tmpdir() with the extension host, so reading the dir is the only cross-process stop signal. -const LOCK_DIR = path.join(os.tmpdir(), 'vscode-deepnote-locks'); - -const DELETE_ENV_NAME = 'E2E Delete Env'; -const G2_FIXTURE = 'marketing-overview.deepnote'; - -const PID_APPEAR_TIMEOUT = 15_000; -const CLOSE_SETTLE = 2_500; -const STOP_AFTER_DELETE_TIMEOUT = 30_000; - -/** PIDs of every currently-tracked running server (parsed from the lock dir). */ -function serverPids(): number[] { - if (!fs.existsSync(LOCK_DIR)) { - return []; - } - - return fs - .readdirSync(LOCK_DIR) - .map((file) => /^server-(\d+)\.json$/.exec(file)) - .filter((match): match is RegExpExecArray => match !== null) - .map((match) => Number(match[1])); -} - -/** - * Cross-platform liveness check via `process.kill(pid, 0)` (sends no signal): throws `ESRCH` when the - * process is gone, `EPERM` when it exists but is owned by another user — so `EPERM` counts as alive. - */ -function isAlive(pid: number): boolean { - try { - process.kill(pid, 0); - - return true; - } catch (error) { - return (error as NodeJS.ErrnoException)?.code === 'EPERM'; - } -} - -/** Opens the Deepnote view container and returns its "Environments" tree section. */ -async function getDeepnoteEnvironmentsSection() { - await openActivityBarView('Deepnote'); - await VSBrowser.instance.driver.sleep(1200); - - const content = new SideBarView().getContent(); - const named = await content.getSection('Environments').catch(() => undefined); - if (named) { - return named; - } - - // Fallback: the environments tree is the second Deepnote section (Explorer is the first). - const sections = await content.getSections(); - - return sections[1] ?? sections[0]; -} - -/** Finds an environment row in the Environments tree by its label (which is the environment name). */ -async function findEnvironmentItem( - section: Awaited>, - name: string -): Promise { - for (const item of await section.getVisibleItems().catch(() => [] as ViewItem[])) { - const label = await (item as unknown as { getLabel(): Promise }).getLabel().catch(() => ''); - if (label.trim() === name) { - return item; - } - } - - return undefined; -} - -describe('Deepnote — deleting an environment stops even a closed-but-running notebook’s server', function () { - this.timeout(SUITE_TIMEOUT); - this.retries(0); // destructive (deletes the venv); not idempotent - - let cleanupTempDir: (() => void) | undefined; - let serverPid: number | undefined; - let aliveWhileClosed = false; - let aliveAfterDelete = true; - let lockFileGoneAfterDelete = false; - - before(async function () { - const driver = VSBrowser.instance.driver; - const screenshot = createScreenshotter(this); - const copy = copyFixtureToTempDir(G2_FIXTURE); - cleanupTempDir = copy.cleanup; - - await VSBrowser.instance.waitForWorkbench(WORKBENCH_TIMEOUT); - await openFolderViaDialog(copy.tempDir); - 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, { useExistingVenv: false }); - - await openWorkspaceFile(G2_FIXTURE); - await driver.wait( - async () => (await new EditorView().getOpenEditorTitles()).some((t) => t.includes(G2_FIXTURE)), - WORKBENCH_TIMEOUT, - `${G2_FIXTURE} did not open` - ); - - // Running the cell starts the server and writes its PID lock file. - await selectEnvironmentForNotebook(DELETE_ENV_NAME, G2_FIXTURE); - await runOnceAndAwaitOutput(G2_FIXTURE, 'overview', FIRST_RUN_OUTPUT_TIMEOUT); - - // Isolate this run's server PID (the lock file that appeared since we started). - const pidDeadline = Date.now() + PID_APPEAR_TIMEOUT; - while (Date.now() < pidDeadline) { - const fresh = serverPids().filter((pid) => !pidsBefore.includes(pid)); - if (fresh.length > 0) { - serverPid = fresh[0]; - break; - } - await driver.sleep(1000); - } - console.log( - '[G2] serverPid=', - serverPid, - 'lockDir=', - LOCK_DIR, - 'lockDirExists=', - fs.existsSync(LOCK_DIR), - 'pids=', - JSON.stringify(serverPids()) - ); - if (serverPid === undefined) { - const tmpEntries = fs.readdirSync(os.tmpdir()).filter((f) => f.includes('deepnote')); - console.log('[G2] no fresh PID; os.tmpdir() deepnote entries=', JSON.stringify(tmpEntries)); - } - await screenshot('server-running'); - - // Closing a tab does NOT stop the server, so it stays alive here — the state env-delete must clean up. - await new EditorView().closeAllEditors().catch(() => undefined); - await driver.sleep(CLOSE_SETTLE); - aliveWhileClosed = serverPid !== undefined && isAlive(serverPid); - console.log('[G2] aliveWhileClosed=', aliveWhileClosed); - - const section = await getDeepnoteEnvironmentsSection(); - const envItem = await driver.wait( - async () => findEnvironmentItem(section, DELETE_ENV_NAME), - WORKBENCH_TIMEOUT, - `environment row "${DELETE_ENV_NAME}" did not appear in the Environments view` - ); - await screenshot('env-row'); - - await selectDeepnoteContextMenu(envItem as ViewItem, 'Delete Environment'); - await screenshot('delete-menu'); - await confirmModalDialog('Delete', { messageIncludes: DELETE_ENV_NAME }); - - await waitForNotification(/Environment .*deleted/i, WORKBENCH_TIMEOUT, true); - await screenshot('env-deleted'); - - const stopDeadline = Date.now() + STOP_AFTER_DELETE_TIMEOUT; - while (Date.now() < stopDeadline) { - if (serverPid !== undefined && !isAlive(serverPid)) { - aliveAfterDelete = false; - break; - } - await driver.sleep(1000); - } - lockFileGoneAfterDelete = - serverPid !== undefined && !fs.existsSync(path.join(LOCK_DIR, `server-${serverPid}.json`)); - console.log( - '[G2] aliveAfterDelete=', - aliveAfterDelete, - 'lockFileGone=', - lockFileGoneAfterDelete, - 'pids=', - JSON.stringify(serverPids()) - ); - await screenshot('after-stop-check'); - }); - - after(async function () { - await new WebView().switchBack().catch(() => undefined); - await new EditorView().closeAllEditors().catch(() => undefined); - try { - cleanupTempDir?.(); - } catch (error) { - console.warn('[env-g2] cleanup:', error); - } - }); - - it('starts a server whose PID is tracked and survives closing the notebook tab', function () { - expect(serverPid, 'server PID from lock file').to.be.a('number'); - expect(aliveWhileClosed, 'server still running after the tab was closed').to.equal(true); - }); - - it('stops that closed notebook’s server when the environment is deleted', function () { - expect(aliveAfterDelete, 'closed notebook server should be stopped after env delete').to.equal(false); - expect(lockFileGoneAfterDelete, 'server lock file removed after env delete').to.equal(true); - }); -}); diff --git a/test/e2e/suite/execution/helloWorld.e2e.test.ts b/test/e2e/suite/execution/helloWorld.e2e.test.ts index 83b2ae1b26..920bcd3fdb 100644 --- a/test/e2e/suite/execution/helloWorld.e2e.test.ts +++ b/test/e2e/suite/execution/helloWorld.e2e.test.ts @@ -3,19 +3,18 @@ * * It exercises the full Deepnote happy path through the *real* VS Code UI: * 1. open a one-notebook `.deepnote` file containing `print("hello world")` - * 2. create a Deepnote environment (command `deepnote.environments.create`) - * 3. select that environment for the notebook (command `deepnote.environments.selectForNotebook`) - * — this builds and selects the notebook's kernel controller ("kernel connected") - * 4. run the cell (the notebook toolbar's "Run All" button) - * 5. assert the rendered stdout output contains "hello world" + * 2. run the cell (the notebook toolbar's "Run All" button) — the click is what starts the + * kernel, since the controller registered on open connects lazily on first execution + * 3. assert the rendered stdout output contains "hello world" * * The reusable interaction helpers live in `test/e2e/helpers/`; this file is only the suite wiring. * * Prerequisites: * - The Python extension (`ms-python.python`) must be installed in the test instance - * (`npm run setup:e2e:deps`) and at least one Python interpreter must be discoverable. - * - Creating the environment provisions a venv and the Deepnote toolkit, which needs network - * access; the first kernel start can take a few minutes. + * (`npm run setup:e2e:deps`). + * - The active interpreter must already provide deepnote-toolkit. `npm run setup:e2e:venv` bakes + * it into `.venv-e2e` and pins that interpreter for every workspace; without it the run stops on + * the install-consent prompt. */ import { expect } from 'chai'; @@ -26,11 +25,9 @@ import { SUITE_TIMEOUT, WORKBENCH_TIMEOUT, copyFixtureToTempDir, - createEnvironment, openFolderViaDialog, openWorkspaceFile, - runOnceAndAwaitOutput, - selectEnvironmentForNotebook + runOnceAndAwaitOutput } from '../../helpers'; const NOTEBOOK_FILE_NAME = 'hello-world.deepnote'; @@ -40,8 +37,6 @@ describe('Deepnote E2E — run "hello world"', function () { // Per-test timeout for the whole suite (overrides the mocharc default for these tests). this.timeout(SUITE_TIMEOUT); - const environmentName = 'E2E Hello Env'; - // Captured in `before` and invoked in `after` to remove the throwaway temp dir. let cleanupTempDir: (() => void) | undefined; @@ -90,10 +85,7 @@ describe('Deepnote E2E — run "hello world"', function () { } }); - it('creates an environment, connects the kernel, runs the cell and renders output', async function () { - await createEnvironment(environmentName); - await selectEnvironmentForNotebook(environmentName, NOTEBOOK_FILE_NAME); - + it('connects the kernel on first run and renders the cell output', async function () { const renderedOutput = await runOnceAndAwaitOutput(NOTEBOOK_FILE_NAME, EXPECTED_OUTPUT, FIRST_RUN_OUTPUT_TIMEOUT); expect(renderedOutput).to.contain(EXPECTED_OUTPUT); }); diff --git a/test/e2e/suite/execution/initNotebookRunner.e2e.test.ts b/test/e2e/suite/execution/initNotebookRunner.e2e.test.ts index e62628da5d..9fa0d3633c 100644 --- a/test/e2e/suite/execution/initNotebookRunner.e2e.test.ts +++ b/test/e2e/suite/execution/initNotebookRunner.e2e.test.ts @@ -14,14 +14,12 @@ import { clickRunAll, copyFixtureIntoDir, copyFixtureToTempDir, - createEnvironment, createScreenshotter, dismissAllNotifications, openFolderViaDialog, openWorkspaceFile, readRenderedOutput, - runOnceAndAwaitOutput, - selectEnvironmentForNotebook + runOnceAndAwaitOutput } from '../../helpers'; const MAIN_FILE = 'etl-pipeline-extract.deepnote'; @@ -93,7 +91,6 @@ 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 Runner Env'; let cleanupTempDir: (() => void) | undefined; let screenshot: (label: string) => Promise; @@ -117,10 +114,7 @@ describe('Deepnote — running the sibling init notebook in a main notebook kern `${MAIN_FILE} did not open` ); - // Selecting the environment connects the kernel, which triggers the init run. - await createEnvironment(environmentName); - await selectEnvironmentForNotebook(environmentName, MAIN_FILE); - await screenshot('kernel-connected'); + await screenshot('notebook-open'); }); after(async function () { @@ -138,6 +132,7 @@ describe('Deepnote — running the sibling init notebook in a main notebook kern }); it('runs the init notebook on kernel start so its variable is defined in the main kernel', async function () { + // The first run is what starts the kernel, and the init notebook runs as part of that start. // The Extract cell prints INIT_MARKER, which only exists if the sibling init notebook ran. const output = await runOnceAndAwaitOutput(MAIN_FILE, INIT_MARKER, FIRST_RUN_OUTPUT_TIMEOUT); await screenshot('init-ran-output'); diff --git a/test/e2e/suite/execution/integrationsEnvFileInjection.e2e.test.ts b/test/e2e/suite/execution/integrationsEnvFileInjection.e2e.test.ts index 3b408cdc72..a90fd3fae1 100644 --- a/test/e2e/suite/execution/integrationsEnvFileInjection.e2e.test.ts +++ b/test/e2e/suite/execution/integrationsEnvFileInjection.e2e.test.ts @@ -13,11 +13,9 @@ import { SUITE_TIMEOUT, WORKBENCH_TIMEOUT, copyFixtureToTempDir, - createEnvironment, openFolderViaDialog, openWorkspaceFile, - runOnceAndAwaitOutput, - selectEnvironmentForNotebook + runOnceAndAwaitOutput } from '../../helpers'; const NOTEBOOK_FILE_NAME = 'integrations-env-file.deepnote'; @@ -49,7 +47,6 @@ const DOTENV_CONTENT = 'DEMO_DB_HOST=injected-host.example.com\n'; describe('Deepnote E2E — inject integration env var from `.deepnote.env.yaml`', function () { this.timeout(SUITE_TIMEOUT); - const environmentName = 'E2E Integrations Env'; let cleanupTempDir: (() => void) | undefined; // The temp workspace dir, so the live-refresh assertion can rewrite `.env`. @@ -98,9 +95,6 @@ describe('Deepnote E2E — inject integration env var from `.deepnote.env.yaml`' }); it('injects `PROD_POSTGRES_HOST` from `.env`, then live-refreshes it on a `.env` change without a restart', async function () { - await createEnvironment(environmentName); - await selectEnvironmentForNotebook(environmentName, NOTEBOOK_FILE_NAME); - const first = await runOnceAndAwaitOutput(NOTEBOOK_FILE_NAME, EXPECTED_OUTPUT, FIRST_RUN_OUTPUT_TIMEOUT); expect(first).to.contain(EXPECTED_OUTPUT); diff --git a/test/e2e/suite/interpreter/interpreterKernel.e2e.test.ts b/test/e2e/suite/interpreter/interpreterKernel.e2e.test.ts new file mode 100644 index 0000000000..ced02436c7 --- /dev/null +++ b/test/e2e/suite/interpreter/interpreterKernel.e2e.test.ts @@ -0,0 +1,201 @@ +/** + * End-to-end UI test for kernel setup WITHOUT Deepnote environments, following the Jupyter + * extension's mechanism for a missing Python dependency: + * 1. opening a `.deepnote` file registers a controller named after the interpreter — nothing is + * installed and no server starts + * 2. running a cell detects that deepnote-toolkit is missing and asks for consent (modal prompt) + * 3. on "Install" the toolkit goes into the workspace's *active interpreter*, not a managed venv + * 4. the server starts, the connection is updated in place, and that same run executes the cell + * + * The workspace's active interpreter is a bare venv this test creates, so the install path runs on + * every execution rather than only on a machine that happens to be missing the package. The cell + * prints `sys.prefix`, so the output proves the kernel ran inside that venv. + * + * Screenshots are captured at each step into `test/e2e/screenshots/interpreterKernel/` so the flow + * can be confirmed visually — in particular that the consent prompt is actually shown. + * + * Prerequisites: + * - The Python extension (`ms-python.python`) must be installed in the test instance. + * - `python3` must be on PATH and able to create a venv (CI installs `python3.12-venv`). + * - Network access: the toolkit is installed from PyPI, which is slow. + */ + +import { expect } from 'chai'; +import { execFileSync } from 'child_process'; +import * as fs from 'fs'; +import * as path from 'path'; +import { EditorView, VSBrowser, WebView, Workbench } from 'vscode-extension-tester'; + +import { + KERNEL_CONNECT_TIMEOUT, + OUTPUT_POLL_INTERVAL, + QUICK_PICK_TIMEOUT, + SUITE_TIMEOUT, + WORKBENCH_TIMEOUT, + clickRunAll, + confirmModalDialog, + copyFixtureToTempDir, + createScreenshotter, + dismissAllNotifications, + openFolderViaDialog, + openWorkspaceFile, + readRenderedOutput, + tryOpenInputBox, + waitForNotification +} from '../../helpers'; + +const NOTEBOOK_FILE_NAME = 'interpreter-kernel.deepnote'; +const EXPECTED_OUTPUT = 'interpreter-kernel-ok'; + +/** How long the notebook is watched to prove that merely opening it installs nothing. */ +const NO_INSTALL_OBSERVATION_MS = 15_000; + +/** Path to the interpreter inside a venv, for the platform the test is running on. */ +function venvPython(venvDir: string): string { + return process.platform === 'win32' + ? path.join(venvDir, 'Scripts', 'python.exe') + : path.join(venvDir, 'bin', 'python'); +} + +/** True when `deepnote_toolkit` imports in the given interpreter. */ +function isToolkitInstalled(python: string): boolean { + try { + execFileSync(python, ['-c', 'import deepnote_toolkit'], { stdio: 'ignore' }); + + return true; + } catch { + return false; + } +} + +describe('Deepnote E2E — consent, then install into the active interpreter', function () { + this.timeout(SUITE_TIMEOUT); + + let cleanupTempDir: (() => void) | undefined; + let venvDir: string; + let interpreter: string; + + before(async function () { + const { cleanup, tempDir } = copyFixtureToTempDir(NOTEBOOK_FILE_NAME); + cleanupTempDir = cleanup; + + venvDir = path.join(tempDir, '.venv'); + execFileSync('python3', ['-m', 'venv', venvDir], { stdio: 'inherit' }); + interpreter = venvPython(venvDir); + + expect(isToolkitInstalled(interpreter)).to.equal( + false, + 'precondition: the fresh venv must not already provide deepnote-toolkit' + ); + + // Pin the workspace's interpreter so the kernel resolves this venv and not whatever the + // Python extension would otherwise discover on the machine. + const vscodeDir = path.join(tempDir, '.vscode'); + fs.mkdirSync(vscodeDir, { recursive: true }); + fs.writeFileSync( + path.join(vscodeDir, 'settings.json'), + JSON.stringify({ 'python.defaultInterpreterPath': interpreter }, undefined, 4) + ); + + await VSBrowser.instance.waitForWorkbench(WORKBENCH_TIMEOUT); + await openFolderViaDialog(tempDir); + await VSBrowser.instance.waitForWorkbench(WORKBENCH_TIMEOUT); + }); + + after(async function () { + await new WebView().switchBack().catch((error) => { + console.warn('[deepnote-e2e] switch back from webview during cleanup:', error); + }); + await new EditorView().closeAllEditors().catch((error) => { + console.warn('[deepnote-e2e] close all editors during cleanup:', error); + }); + + try { + cleanupTempDir?.(); + } catch (error) { + console.warn('[deepnote-e2e] remove temp workspace dir during cleanup:', error); + } + }); + + it('installs nothing on open, asks before installing, then runs the cell', async function () { + const shot = createScreenshotter(this); + const driver = VSBrowser.instance.driver; + + await openWorkspaceFile(NOTEBOOK_FILE_NAME); + await driver.wait( + async () => (await new EditorView().getOpenEditorTitles()).some((t) => t.includes(NOTEBOOK_FILE_NAME)), + WORKBENCH_TIMEOUT, + 'Deepnote notebook editor did not open' + ); + + // The load-bearing half of "detect on kernel start, not notebook open". Watching the install + // toast rather than only the venv is what makes this catch a regression: an open-time install + // announces itself within seconds, long before the package would actually land on disk. + const installStartedOnOpen = await waitForNotification( + /Installing deepnote_toolkit/i, + NO_INSTALL_OBSERVATION_MS, + false + ); + + // Taken after that window so the notebook has rendered: it shows the kernel already named + // after the interpreter while nothing has been installed and no server is running. + await shot('notebook-open'); + + expect(installStartedOnOpen, 'opening the notebook must not start an install').to.equal(undefined); + expect(isToolkitInstalled(interpreter)).to.equal( + false, + 'opening the notebook must not install anything into the interpreter' + ); + + // Running a cell is the user gesture that triggers detection, and the consent prompt. + await dismissAllNotifications().catch(() => undefined); + await clickRunAll(NOTEBOOK_FILE_NAME); + + await confirmModalDialog('Install', { + messageIncludes: 'deepnote-toolkit', + onVisible: async () => { + await shot('consent-prompt'); + } + }); + + await driver.wait( + () => isToolkitInstalled(interpreter), + KERNEL_CONNECT_TIMEOUT, + 'deepnote-toolkit was never installed into the active interpreter' + ); + + // No second click: consent updates the existing controller's connection in place, so the run + // that triggered the prompt is the run that executes. + let renderedOutput = ''; + + await driver.wait( + async () => { + renderedOutput = await readRenderedOutput(); + + return renderedOutput.includes(EXPECTED_OUTPUT); + }, + KERNEL_CONNECT_TIMEOUT, + `the run that triggered the prompt never rendered "${EXPECTED_OUTPUT}"`, + OUTPUT_POLL_INTERVAL + ); + + await shot('cell-output'); + + expect(renderedOutput).to.contain(EXPECTED_OUTPUT); + + // The cell printed sys.prefix: the kernel must be the venv this test created, which is what + // separates "active interpreter" from the old Deepnote-managed environment. + expect(renderedOutput).to.contain(venvDir); + + // The kernel picker is the only place the description is rendered, so open it to capture + // both halves of the entry: the environment name as the label, its path as the description. + await new Workbench().executeCommand('notebook.selectKernel'); + + const picker = await tryOpenInputBox(QUICK_PICK_TIMEOUT); + + expect(picker, 'the kernel picker should open').to.not.equal(undefined); + + await shot('kernel-picker'); + await picker?.cancel(); + }); +}); diff --git a/test/e2e/suite/workspace/snapshots.e2e.test.ts b/test/e2e/suite/workspace/snapshots.e2e.test.ts index ec2429f1ee..d63ac416c4 100644 --- a/test/e2e/suite/workspace/snapshots.e2e.test.ts +++ b/test/e2e/suite/workspace/snapshots.e2e.test.ts @@ -13,13 +13,11 @@ import { copyFixtureIntoDir, copyFixtureToTempDir, copySnapshotIntoDir, - createEnvironment, createScreenshotter, openFolderViaDialog, openWorkspaceFile, readRenderedOutput, runOnceAndAwaitOutput, - selectEnvironmentForNotebook, waitForNotification } from '../../helpers'; @@ -87,7 +85,6 @@ 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 Snapshots Env'; const SIBLINGS = [ { file: 'marketing-overview.deepnote', output: 'overview', notebookId: 'e-nb-overview' }, { file: 'marketing-campaigns.deepnote', output: 'campaigns', notebookId: 'e-nb-campaigns' } @@ -110,8 +107,6 @@ describe('Deepnote — new snapshots are notebook-scoped and do not bleed betwee await openFolderViaDialog(tempDir); await VSBrowser.instance.waitForWorkbench(WORKBENCH_TIMEOUT); - await createEnvironment(ENV_NAME); - for (const sib of SIBLINGS) { // Keep exactly ONE editor open: "Run All" is located via `findElements(...)[0]` (first // toolbar in DOM order), so a lingering prior editor's button would be picked and hang the run. @@ -125,7 +120,6 @@ describe('Deepnote — new snapshots are notebook-scoped and do not bleed betwee WORKBENCH_TIMEOUT, `${sib.file} did not open` ); - await selectEnvironmentForNotebook(ENV_NAME, sib.file); await runOnceAndAwaitOutput(sib.file, sib.output, FIRST_RUN_OUTPUT_TIMEOUT); }