From baddb3ef03e4f59b0e0b87713c89a92250e8eaf3 Mon Sep 17 00:00:00 2001 From: James Hobbs Date: Thu, 10 Sep 2026 14:03:36 +0100 Subject: [PATCH 01/35] feat(integrations): add an "Add Existing Integration" picker to reuse integrations across projects Integrations created through the extension UI are stored in SecretStorage keyed by integration id, but each project's `.deepnote` roster decides which of them apply to it, so the same database had to be configured again for every project in a workspace. Add `deepnote.addExistingIntegration` (command palette, and an "Add Existing Integration" button in the Manage Integrations panel). It scans the workspace's `.deepnote` files for integrations other projects declare that have a stored config, offers them in a QuickPick (name, type, which projects use them), and links the chosen one into the active project's roster through `persistProjectIntegrations`. Credentials are not copied: the roster entry is the only per-project scoping, so the linked project resolves the same config (and federated refresh token). Running kernels of the project get an env refresh and the panel is re-shown, since no storage change event fires for a roster-only edit. Integrations already on the roster, file-only (`.deepnote.env.yaml`) ones and ids whose roster type disagrees with the stored config are not offered; the last case is reported with a warning. Co-Authored-By: Claude Fable 5.1 --- package.json | 6 + package.nls.json | 1 + specs/INTEGRATIONS_CREDENTIALS.md | 14 + src/messageTypes.ts | 1 + .../integrations/existingIntegrationPicker.ts | 204 +++++++++++ .../existingIntegrationPicker.unit.test.ts | 340 ++++++++++++++++++ .../integrations/integrationManager.ts | 188 +++++++++- .../integrationManager.unit.test.ts | 239 ++++++++++++ .../integrations/integrationWebview.ts | 11 + .../integrationWebview.unit.test.ts | 19 + src/platform/analytics/types.ts | 3 + src/platform/common/constants.ts | 1 + src/platform/common/utils/localize.ts | 18 + .../integrations/IntegrationPanel.tsx | 6 +- .../integrations/IntegrationTypeSelector.tsx | 11 +- .../integrations/integrations.css | 11 +- .../webview-side/integrations/types.ts | 1 + .../suite/workspace/integrations.e2e.test.ts | 4 + 18 files changed, 1068 insertions(+), 10 deletions(-) create mode 100644 src/notebooks/deepnote/integrations/existingIntegrationPicker.ts create mode 100644 src/notebooks/deepnote/integrations/existingIntegrationPicker.unit.test.ts create mode 100644 src/notebooks/deepnote/integrations/integrationManager.unit.test.ts diff --git a/package.json b/package.json index c06a6cea0e..f6e2f9d3d4 100644 --- a/package.json +++ b/package.json @@ -115,6 +115,12 @@ "category": "Deepnote", "icon": "$(plug)" }, + { + "command": "deepnote.addExistingIntegration", + "title": "%deepnote.commands.addExistingIntegration.title%", + "category": "Deepnote", + "icon": "$(link)" + }, { "command": "deepnote.authenticateIntegration", "title": "%deepnote.commands.authenticateIntegration.title%", diff --git a/package.nls.json b/package.nls.json index 63e53ab0f7..388ab8d32c 100644 --- a/package.nls.json +++ b/package.nls.json @@ -257,6 +257,7 @@ "deepnote.commands.enableSnapshots.title": "Enable Snapshots", "deepnote.commands.disableSnapshots.title": "Disable Snapshots", "deepnote.commands.manageIntegrations.title": "Manage Integrations", + "deepnote.commands.addExistingIntegration.title": "Add Existing Integration", "deepnote.commands.authenticateIntegration.title": "Authenticate Integration", "deepnote.commands.newProject.title": "New Project", "deepnote.commands.importNotebook.title": "Import Notebook", diff --git a/specs/INTEGRATIONS_CREDENTIALS.md b/specs/INTEGRATIONS_CREDENTIALS.md index 8edc5e2233..8b382cd553 100644 --- a/specs/INTEGRATIONS_CREDENTIALS.md +++ b/specs/INTEGRATIONS_CREDENTIALS.md @@ -433,6 +433,20 @@ Orchestrates the integration management UI and commands. 3. Manager opens webview with integration list 4. Optionally pre-selects a specific integration for configuration +**Add Existing Integration** (`deepnote.addExistingIntegration`, also the "Add Existing Integration" button in the +webview, implemented in `existingIntegrationPicker.ts`): + +1. Scans every `.deepnote` file in the workspace folders (snapshots excluded) and collects the roster entries of + _other_ projects whose id has a config in SecretStorage; ids already on the active project's roster are skipped, + as are file-only (`.deepnote.env.yaml`) integrations, which already apply workspace-wide. +2. Shows a QuickPick (name, type, "Used in: "). An id whose roster type disagrees with the stored config's + type is dropped with a warning rather than offered. +3. On selection, appends `{ id, name, type }` to the active project's roster via `persistProjectIntegrations`. + Nothing is copied in SecretStorage: configs (and federated refresh tokens) are keyed by integration id alone, and + the roster entry is what scopes an integration to a project, so the linked project resolves the same credentials. +4. Re-runs the integration env refresh in the project's running kernels and re-shows the panel, since no storage + change event fires for a roster-only edit. + #### 4. **Integration Webview** (`integrationWebview.ts`) Provides the webview-based UI for managing integration credentials. diff --git a/src/messageTypes.ts b/src/messageTypes.ts index 76d893cd53..17974216e9 100644 --- a/src/messageTypes.ts +++ b/src/messageTypes.ts @@ -186,6 +186,7 @@ export type LocalizedMessages = { integrationsCancel: string; integrationsSave: string; integrationsAddNewIntegration: string; + integrationsAddExistingIntegration: string; integrationsDatabase: string; integrationsDataWarehousesLakes: string; integrationsDatabases: string; diff --git a/src/notebooks/deepnote/integrations/existingIntegrationPicker.ts b/src/notebooks/deepnote/integrations/existingIntegrationPicker.ts new file mode 100644 index 0000000000..215ca7372a --- /dev/null +++ b/src/notebooks/deepnote/integrations/existingIntegrationPicker.ts @@ -0,0 +1,204 @@ +import { RelativePattern, Uri, workspace } from 'vscode'; + +import * as localize from '../../../platform/common/utils/localize'; +import { readDeepnoteProjectFile } from '../../../platform/deepnote/deepnoteProjectFileReader'; +import { logger } from '../../../platform/logging'; +import { + ConfigurableDatabaseIntegrationType, + isConfigurableDatabaseIntegrationType +} from '../../../platform/notebooks/deepnote/integrationTypes'; +import { IDeepnoteNotebookManager, ProjectIntegration } from '../../types'; +import { isSnapshotFile } from '../snapshots/snapshotFiles'; +import { PersistIntegrationsResult, persistProjectIntegrations } from './projectIntegrationsWriter'; +import { IIntegrationStorage } from './types'; + +/** Human-readable type labels for the picker; mirrors `integrationTypeLabels` in the webview bundle. */ +const INTEGRATION_TYPE_LABELS: Record = { + alloydb: localize.Integrations.alloyDBTypeLabel, + athena: localize.Integrations.athenaTypeLabel, + 'big-query': localize.Integrations.bigQueryTypeLabel, + clickhouse: localize.Integrations.clickHouseTypeLabel, + 'cloud-sql': localize.Integrations.cloudSqlTypeLabel, + databricks: localize.Integrations.databricksTypeLabel, + dremio: localize.Integrations.dremioTypeLabel, + mariadb: localize.Integrations.mariaDBTypeLabel, + materialize: localize.Integrations.materializeTypeLabel, + mindsdb: localize.Integrations.mindsDBTypeLabel, + mongodb: localize.Integrations.mongoDBTypeLabel, + mysql: localize.Integrations.mySQLTypeLabel, + pgsql: localize.Integrations.postgresTypeLabel, + redshift: localize.Integrations.redshiftTypeLabel, + snowflake: localize.Integrations.snowflakeTypeLabel, + spanner: localize.Integrations.spannerTypeLabel, + 'sql-server': localize.Integrations.sqlServerTypeLabel, + trino: localize.Integrations.trinoTypeLabel +}; + +export function integrationTypeLabel(type: ConfigurableDatabaseIntegrationType): string { + return INTEGRATION_TYPE_LABELS[type] ?? type; +} + +/** + * A SecretStorage integration declared by at least one *other* project in the workspace, so it can be linked into + * the current project without re-entering credentials. + */ +export interface ReusableIntegration { + id: string; + /** Name from the stored config — the same source the panel writes to the roster on save. */ + name: string; + /** Display names of the other projects whose roster declares this integration; deduped and sorted. */ + projectNames: string[]; + type: ConfigurableDatabaseIntegrationType; +} + +export interface CollectReusableIntegrationsParams { + /** Integration ids already on the current project's roster; never offered again. */ + excludeIntegrationIds: ReadonlySet; + integrationStorage: IIntegrationStorage; + /** The project being extended; its own `.deepnote` files are skipped. */ + projectId: string; +} + +export interface CollectReusableIntegrationsResult { + /** + * Ids skipped because a project's roster declares the integration with a type that differs from the stored + * configuration. Linking such an entry would put a roster type on this project that the credentials cannot + * back, so the caller warns instead. + */ + conflictingIds: string[]; + integrations: ReusableIntegration[]; +} + +export interface AttachExistingIntegrationParams { + activeFileUri: Uri; + /** The current project's roster as cached by the notebook manager; the new entry is appended to it. */ + currentIntegrations: readonly ProjectIntegration[]; + integration: ReusableIntegration; + notebookManager: IDeepnoteNotebookManager; + projectId: string; +} + +/** + * Scans every `.deepnote` file in the open workspace folders and collects the SecretStorage integrations other + * projects declare. + * + * Storage design: `IntegrationStorage` keys configs by integration id alone (there is no per-project namespace), + * and both the env-var provider and the detector resolve credentials from the project roster + * (`project.integrations[].id`). The roster entry is therefore the only thing that "attaches" an integration to a + * project, and reusing one is a pure link: no config is copied. Federated (`google-oauth`) integrations are + * included for the same reason — `FederatedAuthTokenStorage` is also keyed by integration id, and the per-cell + * code generator resolves the config through the roster of the notebook being run. + * + * Integrations configured only in `.deepnote.env.yaml` (no stored config) are not offered: that file already + * applies to every project under it, and the panel cannot write that layer. + */ +export async function collectReusableIntegrations( + params: CollectReusableIntegrationsParams +): Promise { + const { excludeIntegrationIds, integrationStorage, projectId } = params; + + const candidates = new Map }>(); + const conflictingIds = new Set(); + const visited = new Set(); + + for (const workspaceFolder of workspace.workspaceFolders || []) { + let files: Uri[]; + + try { + files = await workspace.findFiles(new RelativePattern(workspaceFolder, '**/*.deepnote')); + } catch (error) { + logger.error('collectReusableIntegrations: failed to enumerate .deepnote files', error); + + continue; + } + + for (const fileUri of files) { + const key = fileUri.toString(); + + if (visited.has(key) || isSnapshotFile(fileUri)) { + continue; + } + + visited.add(key); + + // Per-file try/catch: one unreadable file must not hide every other project's integrations. + try { + const projectData = await readDeepnoteProjectFile(fileUri); + + if (!projectData?.project || projectData.project.id === projectId) { + continue; + } + + const projectName = projectData.project.name || projectData.project.id; + + for (const entry of projectData.project.integrations ?? []) { + if (excludeIntegrationIds.has(entry.id) || !isConfigurableDatabaseIntegrationType(entry.type)) { + continue; + } + + const storedConfig = await integrationStorage.getIntegrationConfig(entry.id); + + if (!storedConfig) { + // File-only or never-configured: there are no credentials in SecretStorage to reuse. + continue; + } + + if (storedConfig.type !== entry.type) { + logger.warn( + `collectReusableIntegrations: ${entry.id} is declared as ${entry.type} in ${fileUri.path} but stored as ${storedConfig.type}; skipping` + ); + conflictingIds.add(entry.id); + + continue; + } + + const existing = candidates.get(entry.id); + + if (existing) { + existing.projectNameSet.add(projectName); + } else { + candidates.set(entry.id, { + id: entry.id, + name: storedConfig.name || entry.name || entry.id, + projectNameSet: new Set([projectName]), + projectNames: [], + type: storedConfig.type + }); + } + } + } catch (error) { + logger.error(`collectReusableIntegrations: failed to read ${fileUri.path}`, error); + } + } + } + + // A conflict in any project disqualifies the id everywhere: the stored config is the single shared truth. + for (const id of conflictingIds) { + candidates.delete(id); + } + + const integrations = Array.from(candidates.values()) + .map(({ projectNameSet, ...candidate }) => ({ + ...candidate, + projectNames: Array.from(projectNameSet).sort((a, b) => a.localeCompare(b)) + })) + .sort((a, b) => a.name.localeCompare(b.name) || a.id.localeCompare(b.id)); + + return { conflictingIds: Array.from(conflictingIds).sort(), integrations }; +} + +/** + * Links `integration` into the project's roster and persists it through the same writer the panel uses, so the + * cache, the active file and every sibling `.deepnote` file of the project are updated together. Idempotent for an + * id already on the roster (the entry is replaced, not duplicated). + */ +export function attachExistingIntegration(params: AttachExistingIntegrationParams): Promise { + const { activeFileUri, currentIntegrations, integration, notebookManager, projectId } = params; + + const integrations: ProjectIntegration[] = [ + ...currentIntegrations.filter((entry) => entry.id !== integration.id), + { id: integration.id, name: integration.name, type: integration.type } + ]; + + return persistProjectIntegrations({ activeFileUri, integrations, notebookManager, projectId }); +} diff --git a/src/notebooks/deepnote/integrations/existingIntegrationPicker.unit.test.ts b/src/notebooks/deepnote/integrations/existingIntegrationPicker.unit.test.ts new file mode 100644 index 0000000000..283c64641a --- /dev/null +++ b/src/notebooks/deepnote/integrations/existingIntegrationPicker.unit.test.ts @@ -0,0 +1,340 @@ +import { deserializeDeepnoteFile, serializeDeepnoteFile, type DeepnoteFile } from '@deepnote/blocks'; +import { assert } from 'chai'; +import { anything, instance, mock, when } from 'ts-mockito'; +import { Uri, workspace } from 'vscode'; + +import { ConfigurableDatabaseIntegrationConfig } from '../../../platform/notebooks/deepnote/integrationTypes'; +import { mockedVSCodeNamespaces, resetVSCodeMocks } from '../../../test/vscode-mock'; +import { IDeepnoteNotebookManager, ProjectIntegration } from '../../types'; +import { createDeepnoteFile, createDeepnoteProject, createWorkspaceFolder } from '../deepnoteTestHelpers'; +import { + attachExistingIntegration, + collectReusableIntegrations, + integrationTypeLabel, + ReusableIntegration +} from './existingIntegrationPicker'; +import { buildGoogleOauthIntegration, buildPostgresIntegration } from './federatedAuth/federatedAuthTestHelpers'; +import { IIntegrationStorage } from './types'; + +const CURRENT_PROJECT_ID = 'project-current'; + +interface OnDiskProject { + uri: Uri; + projectId: string; + projectName?: string; + integrations?: Array<{ id: string; name: string; type: string }>; +} + +function projectFile(project: OnDiskProject): DeepnoteFile { + return createDeepnoteFile({ + project: createDeepnoteProject({ + id: project.projectId, + name: project.projectName ?? project.projectId, + // The roster type is a plain string on disk; the cast keeps the fixture free to declare unknown types. + integrations: project.integrations as ProjectIntegration[] | undefined + }) + }); +} + +/** Stubs `workspace.findFiles` + `workspace.fs` over the given files; `unreadable` URIs reject on read. */ +function stubWorkspace(opts: { projects: OnDiskProject[]; unreadable?: Uri[]; hasWorkspaceFolder?: boolean }): { + writes: Map; +} { + when(mockedVSCodeNamespaces.workspace.workspaceFolders).thenReturn( + opts.hasWorkspaceFolder === false ? undefined : [createWorkspaceFolder(Uri.file('/ws'))] + ); + + const discovered = [...opts.projects.map((project) => project.uri), ...(opts.unreadable ?? [])]; + when(mockedVSCodeNamespaces.workspace.findFiles(anything())).thenReturn(Promise.resolve(discovered)); + when(mockedVSCodeNamespaces.workspace.notebookDocuments).thenReturn([]); + + const byPath = new Map(opts.projects.map((project) => [project.uri.fsPath, projectFile(project)] as const)); + const writes = new Map(); + const mockFs = mock(); + + when(mockFs.readFile(anything())).thenCall((uri: Uri) => { + const file = byPath.get(uri.fsPath); + + return file + ? Promise.resolve(new TextEncoder().encode(serializeDeepnoteFile(file))) + : Promise.reject(new Error(`no readFile stub for ${uri.fsPath}`)); + }); + when(mockFs.writeFile(anything(), anything())).thenCall((uri: Uri, bytes: Uint8Array) => { + writes.set(uri.fsPath, deserializeDeepnoteFile(new TextDecoder().decode(bytes))); + + return Promise.resolve(); + }); + when(mockedVSCodeNamespaces.workspace.fs).thenReturn(instance(mockFs)); + + return { writes }; +} + +function stubStorage(configs: ConfigurableDatabaseIntegrationConfig[]): IIntegrationStorage { + const byId = new Map(configs.map((config) => [config.id, config] as const)); + const storage = mock(); + + when(storage.getIntegrationConfig(anything())).thenCall((id: string) => Promise.resolve(byId.get(id))); + + return instance(storage); +} + +suite('existingIntegrationPicker', () => { + setup(() => { + resetVSCodeMocks(); + }); + + suite('collectReusableIntegrations', () => { + const pgConfig = buildPostgresIntegration({ id: 'pg-shared', name: 'Shared Postgres' }); + const bqConfig = buildGoogleOauthIntegration({ id: 'bq-oauth', name: 'Team BigQuery' }); + + async function collect( + excludeIntegrationIds: string[] = [], + configs: ConfigurableDatabaseIntegrationConfig[] = [pgConfig, bqConfig] + ) { + return collectReusableIntegrations({ + excludeIntegrationIds: new Set(excludeIntegrationIds), + integrationStorage: stubStorage(configs), + projectId: CURRENT_PROJECT_ID + }); + } + + test('lists integrations other projects declare, deduped by id with every using project named', async () => { + stubWorkspace({ + projects: [ + { + uri: Uri.file('/ws/a.deepnote'), + projectId: 'project-a', + projectName: 'Alpha', + integrations: [{ id: 'pg-shared', name: 'Alpha Postgres', type: 'pgsql' }] + }, + { + uri: Uri.file('/ws/b.deepnote'), + projectId: 'project-b', + projectName: 'Beta', + integrations: [ + { id: 'pg-shared', name: 'Beta Postgres', type: 'pgsql' }, + { id: 'bq-oauth', name: 'Team BigQuery', type: 'big-query' } + ] + }, + // A second notebook file of Beta must not list Beta twice. + { + uri: Uri.file('/ws/b-2.deepnote'), + projectId: 'project-b', + projectName: 'Beta', + integrations: [{ id: 'pg-shared', name: 'Beta Postgres', type: 'pgsql' }] + } + ] + }); + + const result = await collect(); + + const expected: ReusableIntegration[] = [ + { id: 'pg-shared', name: 'Shared Postgres', projectNames: ['Alpha', 'Beta'], type: 'pgsql' }, + { id: 'bq-oauth', name: 'Team BigQuery', projectNames: ['Beta'], type: 'big-query' } + ]; + assert.deepStrictEqual(result, { conflictingIds: [], integrations: expected }); + }); + + test('takes the name from the stored config, not from whichever roster was read first', async () => { + stubWorkspace({ + projects: [ + { + uri: Uri.file('/ws/a.deepnote'), + projectId: 'project-a', + integrations: [{ id: 'pg-shared', name: 'Stale roster name', type: 'pgsql' }] + } + ] + }); + + const { integrations } = await collect(); + + assert.strictEqual(integrations[0].name, 'Shared Postgres'); + }); + + test('excludes ids already on the current project roster and the current project files themselves', async () => { + stubWorkspace({ + projects: [ + { + uri: Uri.file('/ws/current.deepnote'), + projectId: CURRENT_PROJECT_ID, + integrations: [{ id: 'bq-oauth', name: 'Team BigQuery', type: 'big-query' }] + }, + { + uri: Uri.file('/ws/a.deepnote'), + projectId: 'project-a', + integrations: [ + { id: 'pg-shared', name: 'Shared Postgres', type: 'pgsql' }, + { id: 'bq-oauth', name: 'Team BigQuery', type: 'big-query' } + ] + } + ] + }); + + const { integrations } = await collect(['pg-shared']); + + assert.deepStrictEqual( + integrations.map((integration) => integration.id), + ['bq-oauth'], + 'pg-shared is already attached; bq-oauth is offered because project-a (not the current project) declares it' + ); + }); + + test('skips roster entries with no stored config (file-only or never configured) and unsupported types', async () => { + stubWorkspace({ + projects: [ + { + uri: Uri.file('/ws/a.deepnote'), + projectId: 'project-a', + integrations: [ + { id: 'file-only', name: 'From env yaml', type: 'pgsql' }, + { id: 'duckdb', name: 'DuckDB', type: 'pandas-dataframe' }, + { id: 'pg-shared', name: 'Shared Postgres', type: 'pgsql' } + ] + } + ] + }); + + const { integrations } = await collect(); + + assert.deepStrictEqual( + integrations.map((integration) => integration.id), + ['pg-shared'] + ); + }); + + test('reports an id whose roster type disagrees with the stored config as conflicting and drops it everywhere', async () => { + stubWorkspace({ + projects: [ + { + uri: Uri.file('/ws/a.deepnote'), + projectId: 'project-a', + integrations: [{ id: 'pg-shared', name: 'Shared Postgres', type: 'pgsql' }] + }, + { + uri: Uri.file('/ws/b.deepnote'), + projectId: 'project-b', + // Same id, but declared as a different database than the credentials are for. + integrations: [{ id: 'pg-shared', name: 'Not really Postgres', type: 'mysql' }] + } + ] + }); + + const result = await collect(); + + assert.deepStrictEqual(result, { conflictingIds: ['pg-shared'], integrations: [] }); + }); + + test('ignores snapshot files and keeps going past an unreadable file', async () => { + stubWorkspace({ + projects: [ + { + uri: Uri.file('/ws/snapshots/a_project-a_2024.snapshot.deepnote'), + projectId: 'project-snapshot', + integrations: [{ id: 'bq-oauth', name: 'Team BigQuery', type: 'big-query' }] + }, + { + uri: Uri.file('/ws/a.deepnote'), + projectId: 'project-a', + integrations: [{ id: 'pg-shared', name: 'Shared Postgres', type: 'pgsql' }] + } + ], + unreadable: [Uri.file('/ws/broken.deepnote')] + }); + + const { integrations } = await collect(); + + assert.deepStrictEqual( + integrations.map((integration) => integration.id), + ['pg-shared'] + ); + }); + + test('returns nothing without an open workspace folder', async () => { + stubWorkspace({ projects: [], hasWorkspaceFolder: false }); + + const result = await collect(); + + assert.deepStrictEqual(result, { conflictingIds: [], integrations: [] }); + }); + }); + + suite('attachExistingIntegration', () => { + const activeUri = Uri.file('/ws/current.deepnote'); + const shared: ReusableIntegration = { + id: 'pg-shared', + name: 'Shared Postgres', + projectNames: ['Alpha'], + type: 'pgsql' + }; + + let notebookManager: IDeepnoteNotebookManager; + let cacheUpdates: Array<{ projectId: string; integrations: ProjectIntegration[] }>; + + setup(() => { + cacheUpdates = []; + const mockManager = mock(); + when(mockManager.updateProjectIntegrations(anything(), anything())).thenCall( + (projectId: string, integrations: ProjectIntegration[]) => { + cacheUpdates.push({ projectId, integrations }); + + return true; + } + ); + notebookManager = instance(mockManager); + }); + + test('appends the linked entry to the roster in the cache and on disk, keeping existing entries', async () => { + const { writes } = stubWorkspace({ + projects: [ + { + uri: activeUri, + projectId: CURRENT_PROJECT_ID, + integrations: [{ id: 'bq-own', name: 'Own BigQuery', type: 'big-query' }] + } + ] + }); + const currentIntegrations: ProjectIntegration[] = [ + { id: 'bq-own', name: 'Own BigQuery', type: 'big-query' } + ]; + + const result = await attachExistingIntegration({ + activeFileUri: activeUri, + currentIntegrations, + integration: shared, + notebookManager, + projectId: CURRENT_PROJECT_ID + }); + + const expectedRoster: ProjectIntegration[] = [ + { id: 'bq-own', name: 'Own BigQuery', type: 'big-query' }, + { id: 'pg-shared', name: 'Shared Postgres', type: 'pgsql' } + ]; + assert.deepStrictEqual(result, { activePersisted: true, siblingsFailed: 0 }); + assert.deepStrictEqual(cacheUpdates, [{ projectId: CURRENT_PROJECT_ID, integrations: expectedRoster }]); + assert.deepStrictEqual(writes.get(activeUri.fsPath)?.project.integrations, expectedRoster); + }); + + test('replaces rather than duplicates an entry whose id is already on the roster', async () => { + const { writes } = stubWorkspace({ + projects: [{ uri: activeUri, projectId: CURRENT_PROJECT_ID }] + }); + + await attachExistingIntegration({ + activeFileUri: activeUri, + currentIntegrations: [{ id: 'pg-shared', name: 'Old name', type: 'pgsql' }], + integration: shared, + notebookManager, + projectId: CURRENT_PROJECT_ID + }); + + assert.deepStrictEqual(writes.get(activeUri.fsPath)?.project.integrations, [ + { id: 'pg-shared', name: 'Shared Postgres', type: 'pgsql' } + ]); + }); + }); + + test('integrationTypeLabel maps every configurable type to a display label', () => { + assert.strictEqual(integrationTypeLabel('pgsql'), 'PostgreSQL'); + assert.strictEqual(integrationTypeLabel('big-query'), 'Google BigQuery'); + }); +}); diff --git a/src/notebooks/deepnote/integrations/integrationManager.ts b/src/notebooks/deepnote/integrations/integrationManager.ts index 72b58dfada..fd1f56b379 100644 --- a/src/notebooks/deepnote/integrations/integrationManager.ts +++ b/src/notebooks/deepnote/integrations/integrationManager.ts @@ -1,12 +1,31 @@ -import { inject, injectable } from 'inversify'; -import { commands, l10n, NotebookDocument, window, workspace } from 'vscode'; +import { inject, injectable, optional } from 'inversify'; +import { commands, l10n, NotebookDocument, QuickPickItem, window, workspace } from 'vscode'; +import { CommandOutcome, ITelemetryService } from '../../../platform/analytics/types'; import { IExtensionContext } from '../../../platform/common/types'; import { Commands } from '../../../platform/common/constants'; +import * as localize from '../../../platform/common/utils/localize'; import { logger } from '../../../platform/logging'; -import { IIntegrationDetector, IIntegrationManager, IIntegrationStorage, IIntegrationWebviewProvider } from './types'; -import { IDeepnoteNotebookManager } from '../../types'; +import { isConfigurableDatabaseIntegrationType } from '../../../platform/notebooks/deepnote/integrationTypes'; +import { + IIntegrationDetector, + IIntegrationEnvLiveRefresher, + IIntegrationManager, + IIntegrationStorage, + IIntegrationWebviewProvider +} from './types'; +import { IDeepnoteNotebookManager, ProjectIntegration } from '../../types'; import { DatabaseIntegrationType, databaseIntegrationTypes } from '@deepnote/database-integrations'; +import { + attachExistingIntegration, + collectReusableIntegrations, + integrationTypeLabel, + ReusableIntegration +} from './existingIntegrationPicker'; + +interface ReusableIntegrationQuickPickItem extends QuickPickItem { + integration: ReusableIntegration; +} /** * Manages integration UI and commands for Deepnote notebooks @@ -18,7 +37,12 @@ export class IntegrationManager implements IIntegrationManager { @inject(IIntegrationDetector) private readonly integrationDetector: IIntegrationDetector, @inject(IIntegrationStorage) private readonly integrationStorage: IIntegrationStorage, @inject(IIntegrationWebviewProvider) private readonly webviewProvider: IIntegrationWebviewProvider, - @inject(IDeepnoteNotebookManager) private readonly notebookManager: IDeepnoteNotebookManager + @inject(IDeepnoteNotebookManager) private readonly notebookManager: IDeepnoteNotebookManager, + @inject(ITelemetryService) private readonly analytics: ITelemetryService, + // Node-only service: the web extension has no kernels to refresh. + @inject(IIntegrationEnvLiveRefresher) + @optional() + private readonly liveRefresher?: IIntegrationEnvLiveRefresher ) {} public activate(): void { @@ -48,6 +72,127 @@ export class IntegrationManager implements IIntegrationManager { return this.showIntegrationsUI(integrationId, notebookUri); }) ); + + // Links an integration another project in the workspace already configured into the active project. + // Takes the same argument shapes as ManageIntegrations so the panel and menus can pass a notebook URI. + this.extensionContext.subscriptions.push( + commands.registerCommand(Commands.AddExistingIntegration, (...args: unknown[]) => { + let notebookUri: string | undefined; + + for (const arg of args) { + notebookUri ??= this.extractNotebookUri(arg); + } + + return this.addExistingIntegration(notebookUri); + }) + ); + } + + /** + * Offers the SecretStorage integrations other projects in the workspace declare, and links the chosen one into + * the active project's roster. Public so tests can drive it without `commands.executeCommand`. + * + * Credentials are not copied: `IntegrationStorage` is keyed by integration id alone, so the roster entry is all + * that scopes an integration to a project (see `collectReusableIntegrations`). + */ + public async addExistingIntegration(notebookUri?: string): Promise { + const activeNotebook = this.resolveDeepnoteNotebook(notebookUri); + + if (!activeNotebook) { + void window.showErrorMessage(l10n.t('No active Deepnote notebook')); + + return 'failed'; + } + + const projectId = activeNotebook.metadata?.deepnoteProjectId; + const notebookId = activeNotebook.metadata?.deepnoteNotebookId; + + if (!projectId || !notebookId) { + void window.showErrorMessage(l10n.t('Cannot determine project or notebook ID')); + + return 'failed'; + } + + const currentIntegrations = this.getCachedRoster(projectId, notebookId); + const { conflictingIds, integrations } = await collectReusableIntegrations({ + excludeIntegrationIds: new Set(currentIntegrations.map((entry) => entry.id)), + integrationStorage: this.integrationStorage, + projectId + }); + + if (conflictingIds.length > 0) { + void window.showWarningMessage( + localize.Integrations.addExistingIntegrationConflictsSkipped(conflictingIds.length) + ); + } + + if (integrations.length === 0) { + void window.showInformationMessage(localize.Integrations.addExistingIntegrationNoneAvailable); + + return 'completed'; + } + + const items: ReusableIntegrationQuickPickItem[] = integrations.map((integration) => ({ + description: integrationTypeLabel(integration.type), + detail: localize.Integrations.addExistingIntegrationUsedIn(integration.projectNames.join(', ')), + integration, + label: integration.name + })); + + const picked = await window.showQuickPick(items, { + matchOnDescription: true, + matchOnDetail: true, + placeHolder: localize.Integrations.addExistingIntegrationPlaceholder + }); + + if (!picked) { + return 'cancelled'; + } + + const { integration } = picked; + let outcome: CommandOutcome = 'failed'; + + try { + const { activePersisted, siblingsFailed } = await attachExistingIntegration({ + activeFileUri: activeNotebook.uri, + currentIntegrations, + integration, + notebookManager: this.notebookManager, + projectId + }); + + if (activePersisted) { + outcome = 'completed'; + void window.showInformationMessage( + localize.Integrations.addExistingIntegrationSucceeded(integration.name) + ); + + if (siblingsFailed > 0) { + void window.showWarningMessage( + l10n.t( + 'Integrations saved, but {0} related notebook file(s) could not be updated.', + siblingsFailed + ) + ); + } + + // Storage did not change, so the storage-change listeners that normally refresh kernels and the + // panel after a save stay silent; do both explicitly for this project. + await this.refreshAfterRosterChange(projectId, activeNotebook); + } else { + void window.showErrorMessage(localize.Integrations.addExistingIntegrationFailed); + } + } catch (error) { + logger.error(`IntegrationManager: failed to add existing integration ${integration.id}`, error); + void window.showErrorMessage(localize.Integrations.addExistingIntegrationFailed); + } + + this.analytics.trackEvent({ + eventName: 'add_existing_integration', + properties: { integrationType: integration.type, outcome } + }); + + return outcome; } /** The notebook URI a menu contribution passed; `notebook/toolbar` sends `{ notebookEditor: { notebookUri } }`. */ @@ -62,6 +207,39 @@ export class IntegrationManager implements IIntegrationManager { return uri ? String(uri) : undefined; } + /** The project's roster as the notebook manager caches it, narrowed to the types the panel can manage. */ + private getCachedRoster(projectId: string, notebookId: string): ProjectIntegration[] { + const project = this.notebookManager.getProjectForNotebook(projectId, notebookId); + const roster: ProjectIntegration[] = []; + + for (const entry of project?.project.integrations ?? []) { + if (isConfigurableDatabaseIntegrationType(entry.type)) { + roster.push({ id: entry.id, name: entry.name, type: entry.type }); + } + } + + return roster; + } + + /** Re-applies integration env in the project's running kernels and re-renders the panel with the new roster. */ + private async refreshAfterRosterChange(projectId: string, activeNotebook: NotebookDocument): Promise { + const projectNotebooks = workspace.notebookDocuments.filter( + (notebook) => notebook.notebookType === 'deepnote' && notebook.metadata?.deepnoteProjectId === projectId + ); + + try { + await this.liveRefresher?.refresh(projectNotebooks, 'integration_config'); + } catch (error) { + logger.error('IntegrationManager: failed to refresh integration env after adding an integration', error); + } + + try { + await this.showIntegrationsUI(undefined, activeNotebook.uri.toString()); + } catch (error) { + logger.error('IntegrationManager: failed to refresh the integrations panel', error); + } + } + /** * The Deepnote notebook to act on: `window.activeNotebookEditor` is unset until an editor is focused, so a * restored but not yet focused notebook resolves via the menu's URI or the one visible editor instead. diff --git a/src/notebooks/deepnote/integrations/integrationManager.unit.test.ts b/src/notebooks/deepnote/integrations/integrationManager.unit.test.ts new file mode 100644 index 0000000000..9b15dcf3a4 --- /dev/null +++ b/src/notebooks/deepnote/integrations/integrationManager.unit.test.ts @@ -0,0 +1,239 @@ +import { deserializeDeepnoteFile, serializeDeepnoteFile, type DeepnoteFile } from '@deepnote/blocks'; +import { assert } from 'chai'; +import sinon from 'sinon'; +import { anything, deepEqual, instance, mock, verify, when } from 'ts-mockito'; +import { NotebookDocument, QuickPickItem, Uri, workspace } from 'vscode'; + +import { ITelemetryService } from '../../../platform/analytics/types'; +import { IExtensionContext } from '../../../platform/common/types'; +import { ConfigurableDatabaseIntegrationConfig } from '../../../platform/notebooks/deepnote/integrationTypes'; +import { mockedVSCodeNamespaces, resetVSCodeMocks } from '../../../test/vscode-mock'; +import { IDeepnoteNotebookManager, ProjectIntegration } from '../../types'; +import { + createDeepnoteFile, + createDeepnoteProject, + createMockNotebook, + createWorkspaceFolder +} from '../deepnoteTestHelpers'; +import { buildPostgresIntegration } from './federatedAuth/federatedAuthTestHelpers'; +import { IntegrationManager } from './integrationManager'; +import { + IIntegrationDetector, + IIntegrationEnvLiveRefresher, + IIntegrationStorage, + IIntegrationWebviewProvider +} from './types'; + +const CURRENT_PROJECT_ID = 'project-current'; +const CURRENT_NOTEBOOK_ID = 'notebook-current'; +const OTHER_PROJECT_ID = 'project-other'; +const CURRENT_URI = Uri.file('/ws/current.deepnote'); +const OTHER_URI = Uri.file('/ws/other.deepnote'); + +const SHARED_CONFIG = buildPostgresIntegration({ id: 'pg-shared', name: 'Shared Postgres' }); + +function projectFile(projectId: string, notebookId: string, integrations: ProjectIntegration[]): DeepnoteFile { + return createDeepnoteFile({ + project: createDeepnoteProject({ + id: projectId, + name: projectId, + notebooks: [{ id: notebookId, name: 'Notebook', blocks: [] }], + integrations + }) + }); +} + +suite('IntegrationManager.addExistingIntegration', () => { + let currentNotebook: NotebookDocument; + let otherNotebook: NotebookDocument; + let currentProject: DeepnoteFile; + let writes: Map; + let cacheUpdates: ProjectIntegration[][]; + let refreshSpy: sinon.SinonSpy; + let quickPickItems: QuickPickItem[] | undefined; + + let detector: IIntegrationDetector; + let webviewProvider: IIntegrationWebviewProvider; + let notebookManager: IDeepnoteNotebookManager; + let telemetry: ITelemetryService; + let storedConfigs: ConfigurableDatabaseIntegrationConfig[]; + let onDiskOther: DeepnoteFile | undefined; + + setup(() => { + resetVSCodeMocks(); + + currentNotebook = createMockNotebook({ + uri: CURRENT_URI, + metadata: { deepnoteProjectId: CURRENT_PROJECT_ID, deepnoteNotebookId: CURRENT_NOTEBOOK_ID } + }); + otherNotebook = createMockNotebook({ + uri: OTHER_URI, + metadata: { deepnoteProjectId: OTHER_PROJECT_ID, deepnoteNotebookId: 'notebook-other' } + }); + currentProject = projectFile(CURRENT_PROJECT_ID, CURRENT_NOTEBOOK_ID, []); + onDiskOther = projectFile(OTHER_PROJECT_ID, 'notebook-other', [ + { id: SHARED_CONFIG.id, name: SHARED_CONFIG.name, type: SHARED_CONFIG.type } + ]); + storedConfigs = [SHARED_CONFIG]; + writes = new Map(); + cacheUpdates = []; + quickPickItems = undefined; + + when(mockedVSCodeNamespaces.workspace.workspaceFolders).thenReturn([createWorkspaceFolder(Uri.file('/ws'))]); + when(mockedVSCodeNamespaces.workspace.notebookDocuments).thenReturn([currentNotebook, otherNotebook]); + when(mockedVSCodeNamespaces.workspace.findFiles(anything())).thenCall(() => + Promise.resolve(onDiskOther ? [CURRENT_URI, OTHER_URI] : [CURRENT_URI]) + ); + + const mockFs = mock(); + when(mockFs.readFile(anything())).thenCall((uri: Uri) => { + const file = uri.fsPath === CURRENT_URI.fsPath ? currentProject : onDiskOther; + + return file + ? Promise.resolve(new TextEncoder().encode(serializeDeepnoteFile(file))) + : Promise.reject(new Error(`no readFile stub for ${uri.fsPath}`)); + }); + when(mockFs.writeFile(anything(), anything())).thenCall((uri: Uri, bytes: Uint8Array) => { + writes.set(uri.fsPath, deserializeDeepnoteFile(new TextDecoder().decode(bytes))); + + return Promise.resolve(); + }); + when(mockedVSCodeNamespaces.workspace.fs).thenReturn(instance(mockFs)); + + // Picks the first offered item; tests that want a cancel override this. + when(mockedVSCodeNamespaces.window.showQuickPick(anything(), anything())).thenCall((items: QuickPickItem[]) => { + quickPickItems = items; + + return Promise.resolve(items[0]); + }); + + const mockDetector = mock(); + when(mockDetector.detectIntegrations(anything())).thenResolve(new Map()); + detector = mockDetector; + + webviewProvider = mock(); + when(webviewProvider.show(anything(), anything(), anything(), anything(), anything())).thenResolve(); + + const mockManager = mock(); + when(mockManager.getProjectForNotebook(CURRENT_PROJECT_ID, CURRENT_NOTEBOOK_ID)).thenCall(() => currentProject); + when(mockManager.updateProjectIntegrations(anything(), anything())).thenCall( + (_projectId: string, integrations: ProjectIntegration[]) => { + cacheUpdates.push(integrations); + + return true; + } + ); + notebookManager = mockManager; + + telemetry = mock(); + refreshSpy = sinon.spy(async () => undefined); + }); + + function buildManager(): IntegrationManager { + const extensionContext = mock(); + when(extensionContext.subscriptions).thenReturn([]); + + const storage = mock(); + when(storage.getIntegrationConfig(anything())).thenCall((id: string) => + Promise.resolve(storedConfigs.find((config) => config.id === id)) + ); + + const liveRefresher: IIntegrationEnvLiveRefresher = { refresh: refreshSpy }; + + return new IntegrationManager( + instance(extensionContext), + instance(detector), + instance(storage), + instance(webviewProvider), + instance(notebookManager), + instance(telemetry), + liveRefresher + ); + } + + test("links the picked integration into the roster, refreshes only this project's kernels and re-shows the panel", async () => { + const outcome = await buildManager().addExistingIntegration(CURRENT_URI.toString()); + + assert.strictEqual(outcome, 'completed'); + + const expectedRoster: ProjectIntegration[] = [{ id: 'pg-shared', name: 'Shared Postgres', type: 'pgsql' }]; + assert.deepStrictEqual(writes.get(CURRENT_URI.fsPath)?.project.integrations, expectedRoster); + assert.deepStrictEqual(cacheUpdates, [expectedRoster]); + assert.isUndefined(writes.get(OTHER_URI.fsPath), 'the other project must not be rewritten'); + + assert.isTrue(refreshSpy.calledOnce); + assert.deepStrictEqual(refreshSpy.firstCall.args, [[currentNotebook], 'integration_config']); + + verify(webviewProvider.show(CURRENT_PROJECT_ID, anything(), anything(), anything(), anything())).once(); + + verify( + telemetry.trackEvent( + deepEqual({ + eventName: 'add_existing_integration', + properties: { integrationType: 'pgsql', outcome: 'completed' } + }) + ) + ).once(); + + assert.strictEqual(quickPickItems?.length, 1); + assert.strictEqual(quickPickItems?.[0].label, 'Shared Postgres'); + assert.strictEqual(quickPickItems?.[0].description, 'PostgreSQL'); + assert.strictEqual(quickPickItems?.[0].detail, `Used in: ${OTHER_PROJECT_ID}`); + }); + + test('shows an information message and writes nothing when no other project has a reusable integration', async () => { + onDiskOther = undefined; + + const outcome = await buildManager().addExistingIntegration(CURRENT_URI.toString()); + + assert.strictEqual(outcome, 'completed'); + assert.strictEqual(writes.size, 0); + assert.deepStrictEqual(cacheUpdates, []); + assert.isTrue(refreshSpy.notCalled); + verify(mockedVSCodeNamespaces.window.showQuickPick(anything(), anything())).never(); + verify(mockedVSCodeNamespaces.window.showInformationMessage(anything())).once(); + }); + + test('does not offer an integration the current project already has', async () => { + currentProject = projectFile(CURRENT_PROJECT_ID, CURRENT_NOTEBOOK_ID, [ + { id: SHARED_CONFIG.id, name: SHARED_CONFIG.name, type: SHARED_CONFIG.type } + ]); + + await buildManager().addExistingIntegration(CURRENT_URI.toString()); + + assert.strictEqual(writes.size, 0); + verify(mockedVSCodeNamespaces.window.showQuickPick(anything(), anything())).never(); + }); + + test('warns and offers nothing when the only candidate is declared with a type the stored config does not match', async () => { + onDiskOther = projectFile(OTHER_PROJECT_ID, 'notebook-other', [ + { id: SHARED_CONFIG.id, name: SHARED_CONFIG.name, type: 'mysql' } + ]); + + await buildManager().addExistingIntegration(CURRENT_URI.toString()); + + assert.strictEqual(writes.size, 0); + verify(mockedVSCodeNamespaces.window.showWarningMessage(anything())).once(); + verify(mockedVSCodeNamespaces.window.showQuickPick(anything(), anything())).never(); + }); + + test('returns cancelled and writes nothing when the picker is dismissed', async () => { + when(mockedVSCodeNamespaces.window.showQuickPick(anything(), anything())).thenResolve(undefined); + + const outcome = await buildManager().addExistingIntegration(CURRENT_URI.toString()); + + assert.strictEqual(outcome, 'cancelled'); + assert.strictEqual(writes.size, 0); + assert.isTrue(refreshSpy.notCalled); + verify(telemetry.trackEvent(anything())).never(); + }); + + test('fails without a Deepnote notebook to act on', async () => { + when(mockedVSCodeNamespaces.workspace.notebookDocuments).thenReturn([]); + + const outcome = await buildManager().addExistingIntegration(Uri.file('/ws/missing.deepnote').toString()); + + assert.strictEqual(outcome, 'failed'); + verify(mockedVSCodeNamespaces.window.showErrorMessage(anything())).once(); + }); +}); diff --git a/src/notebooks/deepnote/integrations/integrationWebview.ts b/src/notebooks/deepnote/integrations/integrationWebview.ts index e47249afc4..0798a363ee 100644 --- a/src/notebooks/deepnote/integrations/integrationWebview.ts +++ b/src/notebooks/deepnote/integrations/integrationWebview.ts @@ -171,6 +171,7 @@ export class IntegrationWebviewProvider implements IIntegrationWebviewProvider { integrationsConfirmDeleteDetails: localize.Integrations.confirmDeleteDetails, integrationsConfigureTitle: localize.Integrations.configureTitle, integrationsAddNewIntegration: localize.Integrations.addNewIntegration, + integrationsAddExistingIntegration: localize.Integrations.addExistingIntegration, integrationsDatabase: localize.Integrations.database, integrationsDataWarehousesLakes: localize.Integrations.dataWarehousesLakes, integrationsDatabases: localize.Integrations.databases, @@ -744,6 +745,16 @@ export class IntegrationWebviewProvider implements IIntegrationWebviewProvider { await this.signOutIntegration(message.integrationId); } break; + case 'addExisting': + // The command owns the QuickPick and the roster write, and re-shows this panel with the new roster. + try { + await commands.executeCommand(Commands.AddExistingIntegration, { + notebookUri: this.activeFileUri?.toString() + }); + } catch (error) { + logger.error('IntegrationWebviewProvider: AddExistingIntegration command failed', error); + } + break; case 'authenticate': if (message.integrationId) { const integrationType = this.integrations.get(message.integrationId)?.integrationType; diff --git a/src/notebooks/deepnote/integrations/integrationWebview.unit.test.ts b/src/notebooks/deepnote/integrations/integrationWebview.unit.test.ts index 903759ea99..149f744670 100644 --- a/src/notebooks/deepnote/integrations/integrationWebview.unit.test.ts +++ b/src/notebooks/deepnote/integrations/integrationWebview.unit.test.ts @@ -517,6 +517,25 @@ suite('IntegrationWebviewProvider', () => { ); }); + test('handleMessage: "addExisting" → executeCommand(AddExistingIntegration, { notebookUri }) for the active file', async () => { + const executeCommandStub = sinon.stub().resolves(undefined); + when(mockedVSCodeNamespaces.commands.executeCommand(anyString(), anything())).thenCall((command, arg) => + executeCommandStub(command, arg) + ); + + const provider = buildProvider(); + await show(provider, new Map()); + + await fakePanel.onDidReceiveMessage({ type: 'addExisting' }); + + assert.isTrue( + executeCommandStub.calledOnceWithExactly(Commands.AddExistingIntegration, { + notebookUri: ACTIVE_FILE_URI.toString() + }), + "expected the command to receive the panel's active notebook so the picker targets the same project" + ); + }); + suite('handleMessage: "authenticate" telemetry outcome', () => { async function authenticate(commandResult: Promise): Promise { when(mockedVSCodeNamespaces.commands.executeCommand(anyString(), anything(), anything())).thenReturn( diff --git a/src/platform/analytics/types.ts b/src/platform/analytics/types.ts index c36a69e7d1..05f4a968c6 100644 --- a/src/platform/analytics/types.ts +++ b/src/platform/analytics/types.ts @@ -10,6 +10,7 @@ export type TelemetryEventName = | 'delete_integration' | 'delete_notebook' | 'duplicate_notebook' + | 'add_existing_integration' | 'execute_cell' | 'execute_notebook' | 'export_notebook' @@ -52,6 +53,8 @@ export interface TelemetryEventProperties { * No `outcome`: nothing here is a command the user waits on — there is no progress UI and no cancel, so * `'cancelled'` is unreachable and a partial pass has no defensible single value. The counts carry it. */ + /** A SecretStorage integration from another project in the workspace was linked into this project's roster. */ + add_existing_integration: { integrationType: string; outcome: CommandOutcome }; refresh_integration_env: { /** Notebooks the refresh ran over, including ones skipped for having no started kernel. */ attemptedCount: number; diff --git a/src/platform/common/constants.ts b/src/platform/common/constants.ts index 75027c68b6..dbbbb80601 100644 --- a/src/platform/common/constants.ts +++ b/src/platform/common/constants.ts @@ -239,6 +239,7 @@ export namespace Commands { export const CopyNotebookDetails = 'deepnote.copyNotebookDetails'; export const EnableSnapshots = 'deepnote.enableSnapshots'; export const DisableSnapshots = 'deepnote.disableSnapshots'; + export const AddExistingIntegration = 'deepnote.addExistingIntegration'; export const AuthenticateIntegration = 'deepnote.authenticateIntegration'; export const ManageIntegrations = 'deepnote.manageIntegrations'; export const SelectInterpreterForNotebook = 'deepnote.selectInterpreterForNotebook'; diff --git a/src/platform/common/utils/localize.ts b/src/platform/common/utils/localize.ts index 8fed4ae07d..1184f95c48 100644 --- a/src/platform/common/utils/localize.ts +++ b/src/platform/common/utils/localize.ts @@ -861,6 +861,24 @@ export namespace Integrations { export const cancel = l10n.t('Cancel'); export const save = l10n.t('Save'); export const addNewIntegration = l10n.t('Add New Integration'); + export const addExistingIntegration = l10n.t('Add Existing Integration'); + export const addExistingIntegrationPlaceholder = l10n.t( + 'Select an integration configured in another project of this workspace' + ); + export const addExistingIntegrationNoneAvailable = l10n.t( + 'No integrations from other projects in this workspace are available to add. Integrations configured only in .deepnote.env.yaml already apply to every project under it.' + ); + export const addExistingIntegrationConflictsSkipped = (count: number) => + l10n.t( + '{0} integration(s) were skipped because another project declares them with a different type than the stored configuration.', + count + ); + export const addExistingIntegrationSucceeded = (integrationName: string) => + l10n.t('Added integration "{0}" to this project.', integrationName); + export const addExistingIntegrationFailed = l10n.t( + 'Failed to add the integration to the notebook file. Please try again.' + ); + export const addExistingIntegrationUsedIn = (projectNames: string) => l10n.t('Used in: {0}', projectNames); export const database = l10n.t('Database'); export const dataWarehousesLakes = l10n.t('Data Warehouses & Lakes'); export const databases = l10n.t('Databases'); diff --git a/src/webviews/webview-side/integrations/IntegrationPanel.tsx b/src/webviews/webview-side/integrations/IntegrationPanel.tsx index 9c92ec19a6..d17bf5826c 100644 --- a/src/webviews/webview-side/integrations/IntegrationPanel.tsx +++ b/src/webviews/webview-side/integrations/IntegrationPanel.tsx @@ -212,6 +212,10 @@ export const IntegrationPanel: React.FC = ({ baseTheme, setSelectedIntegrationType(undefined); }; + const handleAddExisting = () => { + postOutbound({ type: 'addExisting' }); + }; + const handleSelectIntegrationType = (type: ConfigurableDatabaseIntegrationType) => { // Generate a new UUID for the integration const newId = generateUuid(); @@ -239,7 +243,7 @@ export const IntegrationPanel: React.FC = ({ baseTheme, onSignOut={handleSignOut} /> - + {selectedIntegrationId && selectedIntegrationType && ( void; onSelectType: (type: ConfigurableDatabaseIntegrationType) => void; } @@ -111,10 +113,15 @@ const DATABASE_INTEGRATION_TYPES: IntegrationTypeInfo[] = [ } ]; -export const IntegrationTypeSelector: React.FC = ({ onSelectType }) => { +export const IntegrationTypeSelector: React.FC = ({ onAddExisting, onSelectType }) => { return (
-

{getLocString('integrationsAddNewIntegration', 'Add New Integration')}

+
+

{getLocString('integrationsAddNewIntegration', 'Add New Integration')}

+ +

diff --git a/src/webviews/webview-side/integrations/integrations.css b/src/webviews/webview-side/integrations/integrations.css index 2ade190f93..02e691ead3 100644 --- a/src/webviews/webview-side/integrations/integrations.css +++ b/src/webviews/webview-side/integrations/integrations.css @@ -329,9 +329,16 @@ form { border-top: 1px solid var(--vscode-panel-border); } -.integration-type-selector h2 { - margin-top: 0; +.integration-type-selector-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; margin-bottom: 24px; +} + +.integration-type-selector h2 { + margin: 0; font-size: 1.2em; font-weight: 600; } diff --git a/src/webviews/webview-side/integrations/types.ts b/src/webviews/webview-side/integrations/types.ts index 3809658ed8..31978089f6 100644 --- a/src/webviews/webview-side/integrations/types.ts +++ b/src/webviews/webview-side/integrations/types.ts @@ -62,4 +62,5 @@ export type WebviewOutboundMessage = | { type: 'reset'; integrationId: string } | { type: 'delete'; integrationId: string } | { type: 'signOut'; integrationId: string } + | { type: 'addExisting' } | AuthenticateMessage; diff --git a/test/e2e/suite/workspace/integrations.e2e.test.ts b/test/e2e/suite/workspace/integrations.e2e.test.ts index 22f903aae4..733fe1c5fb 100644 --- a/test/e2e/suite/workspace/integrations.e2e.test.ts +++ b/test/e2e/suite/workspace/integrations.e2e.test.ts @@ -24,6 +24,9 @@ const WEBVIEW_READ_TIMEOUT = 15_000; // Empty-state text asserted to prove the panel actually opened (else the negative `not.contain` // below passes trivially against a blank/failed `''` read). const NO_INTEGRATIONS_TEXT = 'No integrations found in this project.'; +// The "Add Existing Integration" entry point (reuse an integration another project configured) must render in +// the panel regardless of whether this project has integrations of its own. +const ADD_EXISTING_INTEGRATION_TEXT = 'Add Existing Integration'; // Prior editors finish closing before reopening the target notebook. const EDITORS_CLOSE_DELAY = 500; // Freshly opened notebook paints before we refocus it. @@ -126,5 +129,6 @@ describe('Deepnote — the integrations UI', function () { // Positive signal that the panel rendered (a blank read would make `not.contain` pass trivially). expect(text, 'integrations webview text').to.contain(NO_INTEGRATIONS_TEXT); expect(text, 'integrations webview text').to.not.contain(INTEGRATION_NAME); + expect(text, 'integrations webview text').to.contain(ADD_EXISTING_INTEGRATION_TEXT); }); }); From c2148f9830459f232c5336b83338323919b21bc2 Mon Sep 17 00:00:00 2001 From: James Hobbs Date: Thu, 10 Sep 2026 14:56:22 +0100 Subject: [PATCH 02/35] fix(integrations): keep the full project roster when attaching an existing integration `getCachedRoster` narrowed the cached roster to the types the panel can manage and `attachExistingIntegration` persisted that narrowed array, so linking an integration silently dropped `pandas-dataframe` (and any type this build does not know) from the project's integrations. The roster now passes through verbatim, following the same cast-not-narrow pattern `SqlCellStatusBarProvider.addToProjectIntegrations` already uses, and only the picker's exclusion set is derived from it. Adds a picker test and a manager test asserting a `pandas-dataframe` entry survives the attach in both the cache update and the written file. Co-Authored-By: Claude Fable 5.1 --- .../integrations/existingIntegrationPicker.ts | 20 ++++++++++---- .../existingIntegrationPicker.unit.test.ts | 26 +++++++++++++++++++ .../integrations/integrationManager.ts | 21 +++++++-------- .../integrationManager.unit.test.ts | 21 ++++++++++++++- 4 files changed, 70 insertions(+), 18 deletions(-) diff --git a/src/notebooks/deepnote/integrations/existingIntegrationPicker.ts b/src/notebooks/deepnote/integrations/existingIntegrationPicker.ts index 215ca7372a..478ec936a7 100644 --- a/src/notebooks/deepnote/integrations/existingIntegrationPicker.ts +++ b/src/notebooks/deepnote/integrations/existingIntegrationPicker.ts @@ -1,3 +1,4 @@ +import type { DeepnoteFile } from '@deepnote/blocks'; import { RelativePattern, Uri, workspace } from 'vscode'; import * as localize from '../../../platform/common/utils/localize'; @@ -38,6 +39,9 @@ export function integrationTypeLabel(type: ConfigurableDatabaseIntegrationType): return INTEGRATION_TYPE_LABELS[type] ?? type; } +/** A roster entry exactly as the `.deepnote` file records it; `type` is not narrowed to the types this build knows. */ +export type RawProjectIntegration = NonNullable[number]; + /** * A SecretStorage integration declared by at least one *other* project in the workspace, so it can be linked into * the current project without re-entering credentials. @@ -71,8 +75,11 @@ export interface CollectReusableIntegrationsResult { export interface AttachExistingIntegrationParams { activeFileUri: Uri; - /** The current project's roster as cached by the notebook manager; the new entry is appended to it. */ - currentIntegrations: readonly ProjectIntegration[]; + /** + * The current project's roster exactly as cached by the notebook manager. Every entry passes through to the + * write verbatim (including `pandas-dataframe` and any type this build does not know), so nothing is pruned. + */ + currentIntegrations: readonly RawProjectIntegration[]; integration: ReusableIntegration; notebookManager: IDeepnoteNotebookManager; projectId: string; @@ -195,10 +202,13 @@ export async function collectReusableIntegrations( export function attachExistingIntegration(params: AttachExistingIntegrationParams): Promise { const { activeFileUri, currentIntegrations, integration, notebookManager, projectId } = params; - const integrations: ProjectIntegration[] = [ + const linked: ProjectIntegration = { id: integration.id, name: integration.name, type: integration.type }; + // Cast rather than narrow: validating the existing entries would silently drop any type this build does not + // know about (`pandas-dataframe` included), which is pruning by another name. + const integrations = [ ...currentIntegrations.filter((entry) => entry.id !== integration.id), - { id: integration.id, name: integration.name, type: integration.type } - ]; + linked + ] as ProjectIntegration[]; return persistProjectIntegrations({ activeFileUri, integrations, notebookManager, projectId }); } diff --git a/src/notebooks/deepnote/integrations/existingIntegrationPicker.unit.test.ts b/src/notebooks/deepnote/integrations/existingIntegrationPicker.unit.test.ts index 283c64641a..212a117029 100644 --- a/src/notebooks/deepnote/integrations/existingIntegrationPicker.unit.test.ts +++ b/src/notebooks/deepnote/integrations/existingIntegrationPicker.unit.test.ts @@ -11,6 +11,7 @@ import { attachExistingIntegration, collectReusableIntegrations, integrationTypeLabel, + RawProjectIntegration, ReusableIntegration } from './existingIntegrationPicker'; import { buildGoogleOauthIntegration, buildPostgresIntegration } from './federatedAuth/federatedAuthTestHelpers'; @@ -314,6 +315,31 @@ suite('existingIntegrationPicker', () => { assert.deepStrictEqual(writes.get(activeUri.fsPath)?.project.integrations, expectedRoster); }); + test('passes roster entries of types it cannot manage (e.g. pandas-dataframe) through verbatim', async () => { + const { writes } = stubWorkspace({ + projects: [{ uri: activeUri, projectId: CURRENT_PROJECT_ID }] + }); + const currentIntegrations: RawProjectIntegration[] = [ + { id: 'duckdb', name: 'DuckDB', type: 'pandas-dataframe' }, + { id: 'future', name: 'Unknown to this build', type: 'some-future-type' } + ]; + + await attachExistingIntegration({ + activeFileUri: activeUri, + currentIntegrations, + integration: shared, + notebookManager, + projectId: CURRENT_PROJECT_ID + }); + + const expectedRoster = [ + ...currentIntegrations, + { id: 'pg-shared', name: 'Shared Postgres', type: 'pgsql' } + ]; + assert.deepStrictEqual(cacheUpdates, [{ projectId: CURRENT_PROJECT_ID, integrations: expectedRoster }]); + assert.deepStrictEqual(writes.get(activeUri.fsPath)?.project.integrations, expectedRoster); + }); + test('replaces rather than duplicates an entry whose id is already on the roster', async () => { const { writes } = stubWorkspace({ projects: [{ uri: activeUri, projectId: CURRENT_PROJECT_ID }] diff --git a/src/notebooks/deepnote/integrations/integrationManager.ts b/src/notebooks/deepnote/integrations/integrationManager.ts index fd1f56b379..44b1dd8c62 100644 --- a/src/notebooks/deepnote/integrations/integrationManager.ts +++ b/src/notebooks/deepnote/integrations/integrationManager.ts @@ -6,7 +6,6 @@ import { IExtensionContext } from '../../../platform/common/types'; import { Commands } from '../../../platform/common/constants'; import * as localize from '../../../platform/common/utils/localize'; import { logger } from '../../../platform/logging'; -import { isConfigurableDatabaseIntegrationType } from '../../../platform/notebooks/deepnote/integrationTypes'; import { IIntegrationDetector, IIntegrationEnvLiveRefresher, @@ -14,12 +13,13 @@ import { IIntegrationStorage, IIntegrationWebviewProvider } from './types'; -import { IDeepnoteNotebookManager, ProjectIntegration } from '../../types'; +import { IDeepnoteNotebookManager } from '../../types'; import { DatabaseIntegrationType, databaseIntegrationTypes } from '@deepnote/database-integrations'; import { attachExistingIntegration, collectReusableIntegrations, integrationTypeLabel, + RawProjectIntegration, ReusableIntegration } from './existingIntegrationPicker'; @@ -207,18 +207,15 @@ export class IntegrationManager implements IIntegrationManager { return uri ? String(uri) : undefined; } - /** The project's roster as the notebook manager caches it, narrowed to the types the panel can manage. */ - private getCachedRoster(projectId: string, notebookId: string): ProjectIntegration[] { + /** + * The project's roster exactly as the notebook manager caches it. Entries are never filtered here: this array + * is what `attachExistingIntegration` persists, so narrowing it (e.g. dropping `pandas-dataframe`) would + * rewrite the project's integrations rather than add to them. Callers derive their own exclusion set from it. + */ + private getCachedRoster(projectId: string, notebookId: string): RawProjectIntegration[] { const project = this.notebookManager.getProjectForNotebook(projectId, notebookId); - const roster: ProjectIntegration[] = []; - - for (const entry of project?.project.integrations ?? []) { - if (isConfigurableDatabaseIntegrationType(entry.type)) { - roster.push({ id: entry.id, name: entry.name, type: entry.type }); - } - } - return roster; + return [...(project?.project.integrations ?? [])]; } /** Re-applies integration env in the project's running kernels and re-renders the panel with the new roster. */ diff --git a/src/notebooks/deepnote/integrations/integrationManager.unit.test.ts b/src/notebooks/deepnote/integrations/integrationManager.unit.test.ts index 9b15dcf3a4..48f56ab156 100644 --- a/src/notebooks/deepnote/integrations/integrationManager.unit.test.ts +++ b/src/notebooks/deepnote/integrations/integrationManager.unit.test.ts @@ -16,6 +16,7 @@ import { createWorkspaceFolder } from '../deepnoteTestHelpers'; import { buildPostgresIntegration } from './federatedAuth/federatedAuthTestHelpers'; +import { RawProjectIntegration } from './existingIntegrationPicker'; import { IntegrationManager } from './integrationManager'; import { IIntegrationDetector, @@ -32,7 +33,7 @@ const OTHER_URI = Uri.file('/ws/other.deepnote'); const SHARED_CONFIG = buildPostgresIntegration({ id: 'pg-shared', name: 'Shared Postgres' }); -function projectFile(projectId: string, notebookId: string, integrations: ProjectIntegration[]): DeepnoteFile { +function projectFile(projectId: string, notebookId: string, integrations: RawProjectIntegration[]): DeepnoteFile { return createDeepnoteFile({ project: createDeepnoteProject({ id: projectId, @@ -194,6 +195,24 @@ suite('IntegrationManager.addExistingIntegration', () => { verify(mockedVSCodeNamespaces.window.showInformationMessage(anything())).once(); }); + test('keeps roster entries the panel cannot manage (pandas-dataframe) when attaching', async () => { + currentProject = projectFile(CURRENT_PROJECT_ID, CURRENT_NOTEBOOK_ID, [ + { id: 'duckdb', name: 'DuckDB', type: 'pandas-dataframe' } + ]); + + const outcome = await buildManager().addExistingIntegration(CURRENT_URI.toString()); + + assert.strictEqual(outcome, 'completed'); + + const expectedRoster = [ + { id: 'duckdb', name: 'DuckDB', type: 'pandas-dataframe' }, + { id: 'pg-shared', name: 'Shared Postgres', type: 'pgsql' } + ]; + assert.deepStrictEqual(writes.get(CURRENT_URI.fsPath)?.project.integrations, expectedRoster); + assert.deepStrictEqual(cacheUpdates, [expectedRoster]); + assert.strictEqual(quickPickItems?.length, 1, 'the DuckDB entry is neither offered nor a candidate'); + }); + test('does not offer an integration the current project already has', async () => { currentProject = projectFile(CURRENT_PROJECT_ID, CURRENT_NOTEBOOK_ID, [ { id: SHARED_CONFIG.id, name: SHARED_CONFIG.name, type: SHARED_CONFIG.type } From 3ea348b294e17f0de454f6f99fef736300d819a3 Mon Sep 17 00:00:00 2001 From: tomas Date: Fri, 18 Sep 2026 11:53:18 +0000 Subject: [PATCH 03/35] chore(integrations): trim comments to carry why, not what Drops comments that restated the code they sat on, condenses the docstrings that explained storage internals at tutorial length, and states the "reuse is a link, not a copy" rationale once in collectReusableIntegrations instead of in three places. Also moves add_existing_integration to its alphabetical slot in TelemetryEventProperties. It had been inserted between the "No `outcome`: ..." docblock and refresh_integration_env, so that block read as documenting an event that does carry an outcome. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NjAiQLqjuPiZmN9GiTXCfd --- .../integrations/existingIntegrationPicker.ts | 51 +++++++------------ .../integrations/integrationManager.ts | 18 ++----- .../integrations/integrationWebview.ts | 2 +- src/platform/analytics/types.ts | 4 +- .../integrations/IntegrationTypeSelector.tsx | 2 +- .../suite/workspace/integrations.e2e.test.ts | 3 +- 6 files changed, 29 insertions(+), 51 deletions(-) diff --git a/src/notebooks/deepnote/integrations/existingIntegrationPicker.ts b/src/notebooks/deepnote/integrations/existingIntegrationPicker.ts index 478ec936a7..c87f2d559c 100644 --- a/src/notebooks/deepnote/integrations/existingIntegrationPicker.ts +++ b/src/notebooks/deepnote/integrations/existingIntegrationPicker.ts @@ -13,7 +13,7 @@ import { isSnapshotFile } from '../snapshots/snapshotFiles'; import { PersistIntegrationsResult, persistProjectIntegrations } from './projectIntegrationsWriter'; import { IIntegrationStorage } from './types'; -/** Human-readable type labels for the picker; mirrors `integrationTypeLabels` in the webview bundle. */ +/** Mirrors `integrationTypeLabels` in the webview bundle. */ const INTEGRATION_TYPE_LABELS: Record = { alloydb: localize.Integrations.alloyDBTypeLabel, athena: localize.Integrations.athenaTypeLabel, @@ -39,24 +39,21 @@ export function integrationTypeLabel(type: ConfigurableDatabaseIntegrationType): return INTEGRATION_TYPE_LABELS[type] ?? type; } -/** A roster entry exactly as the `.deepnote` file records it; `type` is not narrowed to the types this build knows. */ +/** A roster entry as recorded on disk: unlike `ProjectIntegration`, `type` is not narrowed to the known types. */ export type RawProjectIntegration = NonNullable[number]; -/** - * A SecretStorage integration declared by at least one *other* project in the workspace, so it can be linked into - * the current project without re-entering credentials. - */ +/** An integration another project in the workspace has credentials stored for, so linking it needs no re-entry. */ export interface ReusableIntegration { id: string; /** Name from the stored config — the same source the panel writes to the roster on save. */ name: string; - /** Display names of the other projects whose roster declares this integration; deduped and sorted. */ + /** The other projects declaring this integration; deduped and sorted. */ projectNames: string[]; type: ConfigurableDatabaseIntegrationType; } export interface CollectReusableIntegrationsParams { - /** Integration ids already on the current project's roster; never offered again. */ + /** Ids already on the current project's roster. */ excludeIntegrationIds: ReadonlySet; integrationStorage: IIntegrationStorage; /** The project being extended; its own `.deepnote` files are skipped. */ @@ -65,9 +62,8 @@ export interface CollectReusableIntegrationsParams { export interface CollectReusableIntegrationsResult { /** - * Ids skipped because a project's roster declares the integration with a type that differs from the stored - * configuration. Linking such an entry would put a roster type on this project that the credentials cannot - * back, so the caller warns instead. + * Ids skipped because some project's roster type disagrees with the stored config: linking one would put a type + * on this project that the credentials cannot back. */ conflictingIds: string[]; integrations: ReusableIntegration[]; @@ -75,10 +71,7 @@ export interface CollectReusableIntegrationsResult { export interface AttachExistingIntegrationParams { activeFileUri: Uri; - /** - * The current project's roster exactly as cached by the notebook manager. Every entry passes through to the - * write verbatim (including `pandas-dataframe` and any type this build does not know), so nothing is pruned. - */ + /** The project's full roster: every entry is written back verbatim, so a filtered array drops entries. */ currentIntegrations: readonly RawProjectIntegration[]; integration: ReusableIntegration; notebookManager: IDeepnoteNotebookManager; @@ -86,18 +79,13 @@ export interface AttachExistingIntegrationParams { } /** - * Scans every `.deepnote` file in the open workspace folders and collects the SecretStorage integrations other - * projects declare. + * Scans every `.deepnote` file in the open workspace folders for integrations other projects declare. * - * Storage design: `IntegrationStorage` keys configs by integration id alone (there is no per-project namespace), - * and both the env-var provider and the detector resolve credentials from the project roster - * (`project.integrations[].id`). The roster entry is therefore the only thing that "attaches" an integration to a - * project, and reusing one is a pure link: no config is copied. Federated (`google-oauth`) integrations are - * included for the same reason — `FederatedAuthTokenStorage` is also keyed by integration id, and the per-cell - * code generator resolves the config through the roster of the notebook being run. + * Reuse is a link, not a copy: `IntegrationStorage` and `FederatedAuthTokenStorage` both key configs by integration + * id alone, so the roster entry is the only thing that scopes an integration to a project. * - * Integrations configured only in `.deepnote.env.yaml` (no stored config) are not offered: that file already - * applies to every project under it, and the panel cannot write that layer. + * Integrations configured only in `.deepnote.env.yaml` are not offered: that file already applies to every project + * under it, and the panel cannot write that layer. */ export async function collectReusableIntegrations( params: CollectReusableIntegrationsParams @@ -128,7 +116,7 @@ export async function collectReusableIntegrations( visited.add(key); - // Per-file try/catch: one unreadable file must not hide every other project's integrations. + // One unreadable file must not hide every other project's integrations. try { const projectData = await readDeepnoteProjectFile(fileUri); @@ -146,7 +134,7 @@ export async function collectReusableIntegrations( const storedConfig = await integrationStorage.getIntegrationConfig(entry.id); if (!storedConfig) { - // File-only or never-configured: there are no credentials in SecretStorage to reuse. + // File-only or never configured — no stored credentials to reuse. continue; } @@ -195,16 +183,15 @@ export async function collectReusableIntegrations( } /** - * Links `integration` into the project's roster and persists it through the same writer the panel uses, so the - * cache, the active file and every sibling `.deepnote` file of the project are updated together. Idempotent for an - * id already on the roster (the entry is replaced, not duplicated). + * Links `integration` into the project's roster through the same writer the panel uses, so the cache, the active + * file and every sibling `.deepnote` file are updated together. Re-linking an id already there replaces its entry. */ export function attachExistingIntegration(params: AttachExistingIntegrationParams): Promise { const { activeFileUri, currentIntegrations, integration, notebookManager, projectId } = params; const linked: ProjectIntegration = { id: integration.id, name: integration.name, type: integration.type }; - // Cast rather than narrow: validating the existing entries would silently drop any type this build does not - // know about (`pandas-dataframe` included), which is pruning by another name. + // Cast rather than validate: filtering out types this build does not know (`pandas-dataframe`) would delete + // them from the file. const integrations = [ ...currentIntegrations.filter((entry) => entry.id !== integration.id), linked diff --git a/src/notebooks/deepnote/integrations/integrationManager.ts b/src/notebooks/deepnote/integrations/integrationManager.ts index 44b1dd8c62..004d41db22 100644 --- a/src/notebooks/deepnote/integrations/integrationManager.ts +++ b/src/notebooks/deepnote/integrations/integrationManager.ts @@ -73,8 +73,7 @@ export class IntegrationManager implements IIntegrationManager { }) ); - // Links an integration another project in the workspace already configured into the active project. - // Takes the same argument shapes as ManageIntegrations so the panel and menus can pass a notebook URI. + // Accepts the same argument shapes as ManageIntegrations so the panel and menus can pass a notebook URI. this.extensionContext.subscriptions.push( commands.registerCommand(Commands.AddExistingIntegration, (...args: unknown[]) => { let notebookUri: string | undefined; @@ -89,11 +88,9 @@ export class IntegrationManager implements IIntegrationManager { } /** - * Offers the SecretStorage integrations other projects in the workspace declare, and links the chosen one into - * the active project's roster. Public so tests can drive it without `commands.executeCommand`. - * - * Credentials are not copied: `IntegrationStorage` is keyed by integration id alone, so the roster entry is all - * that scopes an integration to a project (see `collectReusableIntegrations`). + * Offers the integrations other projects in the workspace declare and links the chosen one into this project's + * roster; no credentials are copied (see `collectReusableIntegrations`). Public so tests can drive it without + * `commands.executeCommand`. */ public async addExistingIntegration(notebookUri?: string): Promise { const activeNotebook = this.resolveDeepnoteNotebook(notebookUri); @@ -207,18 +204,13 @@ export class IntegrationManager implements IIntegrationManager { return uri ? String(uri) : undefined; } - /** - * The project's roster exactly as the notebook manager caches it. Entries are never filtered here: this array - * is what `attachExistingIntegration` persists, so narrowing it (e.g. dropping `pandas-dataframe`) would - * rewrite the project's integrations rather than add to them. Callers derive their own exclusion set from it. - */ + /** Unfiltered on purpose: `attachExistingIntegration` writes this array back, so anything dropped here is lost. */ private getCachedRoster(projectId: string, notebookId: string): RawProjectIntegration[] { const project = this.notebookManager.getProjectForNotebook(projectId, notebookId); return [...(project?.project.integrations ?? [])]; } - /** Re-applies integration env in the project's running kernels and re-renders the panel with the new roster. */ private async refreshAfterRosterChange(projectId: string, activeNotebook: NotebookDocument): Promise { const projectNotebooks = workspace.notebookDocuments.filter( (notebook) => notebook.notebookType === 'deepnote' && notebook.metadata?.deepnoteProjectId === projectId diff --git a/src/notebooks/deepnote/integrations/integrationWebview.ts b/src/notebooks/deepnote/integrations/integrationWebview.ts index 0798a363ee..5a68229e88 100644 --- a/src/notebooks/deepnote/integrations/integrationWebview.ts +++ b/src/notebooks/deepnote/integrations/integrationWebview.ts @@ -746,7 +746,7 @@ export class IntegrationWebviewProvider implements IIntegrationWebviewProvider { } break; case 'addExisting': - // The command owns the QuickPick and the roster write, and re-shows this panel with the new roster. + // The command owns the picker and the roster write, and re-shows this panel when it is done. try { await commands.executeCommand(Commands.AddExistingIntegration, { notebookUri: this.activeFileUri?.toString() diff --git a/src/platform/analytics/types.ts b/src/platform/analytics/types.ts index 05f4a968c6..10481c841c 100644 --- a/src/platform/analytics/types.ts +++ b/src/platform/analytics/types.ts @@ -34,6 +34,8 @@ export type CommandOutcome = 'completed' | 'cancelled' | 'failed'; /** Caller-supplied properties per event; `undefined` means none beyond the common properties the service attaches. */ export interface TelemetryEventProperties { add_block: { blockType: string; isEphemeral: boolean }; + /** "Existing" means configured in another project of this workspace. */ + add_existing_integration: { integrationType: string; outcome: CommandOutcome }; authenticate_integration: { integrationType: string; outcome: CommandOutcome }; configure_integration: { integrationType: string }; copy_notebook_details: undefined; @@ -53,8 +55,6 @@ export interface TelemetryEventProperties { * No `outcome`: nothing here is a command the user waits on — there is no progress UI and no cancel, so * `'cancelled'` is unreachable and a partial pass has no defensible single value. The counts carry it. */ - /** A SecretStorage integration from another project in the workspace was linked into this project's roster. */ - add_existing_integration: { integrationType: string; outcome: CommandOutcome }; refresh_integration_env: { /** Notebooks the refresh ran over, including ones skipped for having no started kernel. */ attemptedCount: number; diff --git a/src/webviews/webview-side/integrations/IntegrationTypeSelector.tsx b/src/webviews/webview-side/integrations/IntegrationTypeSelector.tsx index 078777fcae..f62d5b409d 100644 --- a/src/webviews/webview-side/integrations/IntegrationTypeSelector.tsx +++ b/src/webviews/webview-side/integrations/IntegrationTypeSelector.tsx @@ -4,7 +4,7 @@ import { ConfigurableDatabaseIntegrationType } from './types'; import { integrationTypeLabels, integrationTypeIcons } from './integrationUtils'; export interface IIntegrationTypeSelectorProps { - /** Opens the extension-side picker of integrations other projects in the workspace already configured. */ + /** Opens the extension-host picker of integrations other projects already configured. */ onAddExisting: () => void; onSelectType: (type: ConfigurableDatabaseIntegrationType) => void; } diff --git a/test/e2e/suite/workspace/integrations.e2e.test.ts b/test/e2e/suite/workspace/integrations.e2e.test.ts index 733fe1c5fb..0256ae637e 100644 --- a/test/e2e/suite/workspace/integrations.e2e.test.ts +++ b/test/e2e/suite/workspace/integrations.e2e.test.ts @@ -24,8 +24,7 @@ const WEBVIEW_READ_TIMEOUT = 15_000; // Empty-state text asserted to prove the panel actually opened (else the negative `not.contain` // below passes trivially against a blank/failed `''` read). const NO_INTEGRATIONS_TEXT = 'No integrations found in this project.'; -// The "Add Existing Integration" entry point (reuse an integration another project configured) must render in -// the panel regardless of whether this project has integrations of its own. +// The reuse entry point must render even when this project has no integrations of its own. const ADD_EXISTING_INTEGRATION_TEXT = 'Add Existing Integration'; // Prior editors finish closing before reopening the target notebook. const EDITORS_CLOSE_DELAY = 500; From 058fe8ffab8ba0d94ff9c0405ca4c216c1228c44 Mon Sep 17 00:00:00 2001 From: tomas Date: Mon, 21 Sep 2026 15:07:52 +0000 Subject: [PATCH 04/35] fix(integrations): close data-loss windows in add existing integration Addresses the review of this branch. Correctness: - Re-read the project's integrations after the QuickPick instead of writing back a snapshot taken before it. The file watcher replaces the cached project on an integrations-only external write without any UI event, so the pre-pick array could be stale, and the writer stamps whatever it is given onto every .deepnote file of the project. - Refuse the command on a *.snapshot.deepnote file. Snapshots match the notebook selector and the writer skips them, so the command reported a failure only after it had already updated the cache. - getCachedProjectIntegrations returns undefined on a cache miss rather than an empty array, so "not cached" can no longer be written back as "no integrations at all". - Make the workspace scan cancellable: window.withProgress plumbs a token into collectReusableIntegrations, findFiles and both scan loops. Types: - Widen the write path to RawProjectIntegration, now defined once in notebooks/types.ts. This removes both `as ProjectIntegration[]` assertions and two duplicate local declarations of the type. Narrowing inside the writer is now a compile error rather than a silently exhaustive switch. Naming: - "roster" becomes "project integrations" throughout. In the SQL status bar the narrower and wider lists are now projectIntegrations and selectableIntegrations, matching getSelectableIntegrations. Localization: - Integration type labels were written out in seven places, one of which (the webview map) was never localized at all. They now live only in localize.Integrations.typeLabels, keyed by a bundle key derived from the integration type so there is no second list to keep in sync. Drops the dead integrationsDuckDBTypeLabel. Tests: - Cover every failure branch of addExistingIntegration, plus the stale re-read, the snapshot guard, the cache miss and both cancellation paths. Each new test was verified to fail against the unfixed code. - Type refreshSpy off IIntegrationEnvLiveRefresher so a signature change fails the compile, not just the runtime assertion. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Ns2d146xs51a6E8LeDgJQS --- src/messageTypes.ts | 23 +- .../deepnote/deepnoteNotebookManager.ts | 4 +- .../integrations/existingIntegrationPicker.ts | 79 +++---- .../existingIntegrationPicker.unit.test.ts | 106 +++++++--- .../integrations/integrationDetector.ts | 15 +- .../integrations/integrationManager.ts | 82 ++++++-- .../integrationManager.unit.test.ts | 198 ++++++++++++++++-- .../integrations/integrationWebview.ts | 22 +- .../integrations/projectIntegrationsWriter.ts | 6 +- .../deepnote/sqlCellStatusBarProvider.ts | 79 +++---- .../sqlCellStatusBarProvider.unit.test.ts | 4 +- src/notebooks/types.ts | 8 +- src/platform/analytics/types.ts | 3 +- src/platform/common/utils/localize.ts | 52 +++-- .../deepnote/integrationTypeLabels.ts | 13 ++ .../integrationTypeLabels.unit.test.ts | 25 +++ .../integrations/ConfigurationForm.tsx | 4 +- .../integrations/IntegrationItem.tsx | 49 +---- .../integrations/IntegrationTypeSelector.tsx | 183 +++++----------- .../integrations/integrationUtils.ts | 31 +-- 20 files changed, 543 insertions(+), 443 deletions(-) create mode 100644 src/platform/notebooks/deepnote/integrationTypeLabels.ts create mode 100644 src/platform/notebooks/deepnote/integrationTypeLabels.unit.test.ts diff --git a/src/messageTypes.ts b/src/messageTypes.ts index 17974216e9..01fea32ffe 100644 --- a/src/messageTypes.ts +++ b/src/messageTypes.ts @@ -10,6 +10,7 @@ import { import { KernelSocketOptions } from './kernels/types'; import { IJupyterVariable, IJupyterVariablesRequest, IJupyterVariablesResponse } from './kernels/variables/types'; import { WidgetScriptSource } from './notebooks/controllers/ipywidgets/types'; +import type { IntegrationTypeLabelKey } from './platform/notebooks/deepnote/integrationTypeLabels'; export type NotifyIPyWidgetWidgetVersionNotSupportedAction = { moduleName: 'qgrid'; @@ -120,7 +121,7 @@ export enum SharedMessages { LocInit = 'loc_init' } -export type LocalizedMessages = { +export type LocalizedMessages = { [K in IntegrationTypeLabelKey]: string } & { collapseSingle: string; expandSingle: string; openExportFileYes: string; @@ -190,26 +191,6 @@ export type LocalizedMessages = { integrationsDatabase: string; integrationsDataWarehousesLakes: string; integrationsDatabases: string; - // Integration type labels - integrationsPostgresTypeLabel: string; - integrationsBigQueryTypeLabel: string; - integrationsSnowflakeTypeLabel: string; - integrationsAlloyDBTypeLabel: string; - integrationsAthenaTypeLabel: string; - integrationsClickHouseTypeLabel: string; - integrationsCloudSqlTypeLabel: string; - integrationsDatabricksTypeLabel: string; - integrationsDremioTypeLabel: string; - integrationsMariaDBTypeLabel: string; - integrationsMaterializeTypeLabel: string; - integrationsMindsDBTypeLabel: string; - integrationsMongoDBTypeLabel: string; - integrationsMySQLTypeLabel: string; - integrationsDuckDBTypeLabel: string; - integrationsRedshiftTypeLabel: string; - integrationsSpannerTypeLabel: string; - integrationsSQLServerTypeLabel: string; - integrationsTrinoTypeLabel: string; // PostgreSQL form strings integrationsPostgresNameLabel: string; integrationsPostgresNamePlaceholder: string; diff --git a/src/notebooks/deepnote/deepnoteNotebookManager.ts b/src/notebooks/deepnote/deepnoteNotebookManager.ts index 3772f20098..1c43874f7c 100644 --- a/src/notebooks/deepnote/deepnoteNotebookManager.ts +++ b/src/notebooks/deepnote/deepnoteNotebookManager.ts @@ -1,7 +1,7 @@ import { injectable } from 'inversify'; import type { DeepnoteFile } from '@deepnote/blocks'; -import { IDeepnoteNotebookManager, ProjectIntegration } from '../types'; +import { IDeepnoteNotebookManager, RawProjectIntegration } from '../types'; /** * Centralized manager for tracking Deepnote notebook selections and project state. @@ -40,7 +40,7 @@ export class DeepnoteNotebookManager implements IDeepnoteNotebookManager { * Updates the integrations list across every cached notebook entry under the project (cache-only). * @returns `true` if at least one cached entry was updated, `false` otherwise. */ - updateProjectIntegrations(projectId: string, integrations: ProjectIntegration[]): boolean { + updateProjectIntegrations(projectId: string, integrations: RawProjectIntegration[]): boolean { const notebookEntries = this.originalProjects.get(projectId); if (!notebookEntries || notebookEntries.size === 0) { diff --git a/src/notebooks/deepnote/integrations/existingIntegrationPicker.ts b/src/notebooks/deepnote/integrations/existingIntegrationPicker.ts index c87f2d559c..ec207d9a8e 100644 --- a/src/notebooks/deepnote/integrations/existingIntegrationPicker.ts +++ b/src/notebooks/deepnote/integrations/existingIntegrationPicker.ts @@ -1,51 +1,20 @@ -import type { DeepnoteFile } from '@deepnote/blocks'; -import { RelativePattern, Uri, workspace } from 'vscode'; +import { CancellationToken, RelativePattern, Uri, workspace } from 'vscode'; -import * as localize from '../../../platform/common/utils/localize'; import { readDeepnoteProjectFile } from '../../../platform/deepnote/deepnoteProjectFileReader'; import { logger } from '../../../platform/logging'; import { ConfigurableDatabaseIntegrationType, isConfigurableDatabaseIntegrationType } from '../../../platform/notebooks/deepnote/integrationTypes'; -import { IDeepnoteNotebookManager, ProjectIntegration } from '../../types'; +import { IDeepnoteNotebookManager, ProjectIntegration, RawProjectIntegration } from '../../types'; import { isSnapshotFile } from '../snapshots/snapshotFiles'; import { PersistIntegrationsResult, persistProjectIntegrations } from './projectIntegrationsWriter'; import { IIntegrationStorage } from './types'; -/** Mirrors `integrationTypeLabels` in the webview bundle. */ -const INTEGRATION_TYPE_LABELS: Record = { - alloydb: localize.Integrations.alloyDBTypeLabel, - athena: localize.Integrations.athenaTypeLabel, - 'big-query': localize.Integrations.bigQueryTypeLabel, - clickhouse: localize.Integrations.clickHouseTypeLabel, - 'cloud-sql': localize.Integrations.cloudSqlTypeLabel, - databricks: localize.Integrations.databricksTypeLabel, - dremio: localize.Integrations.dremioTypeLabel, - mariadb: localize.Integrations.mariaDBTypeLabel, - materialize: localize.Integrations.materializeTypeLabel, - mindsdb: localize.Integrations.mindsDBTypeLabel, - mongodb: localize.Integrations.mongoDBTypeLabel, - mysql: localize.Integrations.mySQLTypeLabel, - pgsql: localize.Integrations.postgresTypeLabel, - redshift: localize.Integrations.redshiftTypeLabel, - snowflake: localize.Integrations.snowflakeTypeLabel, - spanner: localize.Integrations.spannerTypeLabel, - 'sql-server': localize.Integrations.sqlServerTypeLabel, - trino: localize.Integrations.trinoTypeLabel -}; - -export function integrationTypeLabel(type: ConfigurableDatabaseIntegrationType): string { - return INTEGRATION_TYPE_LABELS[type] ?? type; -} - -/** A roster entry as recorded on disk: unlike `ProjectIntegration`, `type` is not narrowed to the known types. */ -export type RawProjectIntegration = NonNullable[number]; - /** An integration another project in the workspace has credentials stored for, so linking it needs no re-entry. */ export interface ReusableIntegration { id: string; - /** Name from the stored config — the same source the panel writes to the roster on save. */ + /** Name from the stored config — the same source the panel writes to the project integrations on save. */ name: string; /** The other projects declaring this integration; deduped and sorted. */ projectNames: string[]; @@ -53,17 +22,21 @@ export interface ReusableIntegration { } export interface CollectReusableIntegrationsParams { - /** Ids already on the current project's roster. */ + /** Ids the current project already declares. */ excludeIntegrationIds: ReadonlySet; integrationStorage: IIntegrationStorage; /** The project being extended; its own `.deepnote` files are skipped. */ projectId: string; + /** Stops the scan; the partial result is only fit to be discarded. */ + token?: CancellationToken; } export interface CollectReusableIntegrationsResult { + /** The scan stopped early, so the other two fields are partial and must not be written anywhere. */ + cancelled: boolean; /** - * Ids skipped because some project's roster type disagrees with the stored config: linking one would put a type - * on this project that the credentials cannot back. + * Ids skipped because some project declares a type the stored config disagrees with: linking one would put a + * type on this project that the credentials cannot back. */ conflictingIds: string[]; integrations: ReusableIntegration[]; @@ -71,7 +44,7 @@ export interface CollectReusableIntegrationsResult { export interface AttachExistingIntegrationParams { activeFileUri: Uri; - /** The project's full roster: every entry is written back verbatim, so a filtered array drops entries. */ + /** The project's full integration list: every entry is written back verbatim, so a filtered array drops entries. */ currentIntegrations: readonly RawProjectIntegration[]; integration: ReusableIntegration; notebookManager: IDeepnoteNotebookManager; @@ -82,7 +55,7 @@ export interface AttachExistingIntegrationParams { * Scans every `.deepnote` file in the open workspace folders for integrations other projects declare. * * Reuse is a link, not a copy: `IntegrationStorage` and `FederatedAuthTokenStorage` both key configs by integration - * id alone, so the roster entry is the only thing that scopes an integration to a project. + * id alone, so the project's own entry is the only thing that scopes an integration to a project. * * Integrations configured only in `.deepnote.env.yaml` are not offered: that file already applies to every project * under it, and the panel cannot write that layer. @@ -90,17 +63,26 @@ export interface AttachExistingIntegrationParams { export async function collectReusableIntegrations( params: CollectReusableIntegrationsParams ): Promise { - const { excludeIntegrationIds, integrationStorage, projectId } = params; + const { excludeIntegrationIds, integrationStorage, projectId, token } = params; const candidates = new Map }>(); const conflictingIds = new Set(); const visited = new Set(); for (const workspaceFolder of workspace.workspaceFolders || []) { + if (token?.isCancellationRequested) { + return { cancelled: true, conflictingIds: [], integrations: [] }; + } + let files: Uri[]; try { - files = await workspace.findFiles(new RelativePattern(workspaceFolder, '**/*.deepnote')); + files = await workspace.findFiles( + new RelativePattern(workspaceFolder, '**/*.deepnote'), + undefined, + undefined, + token + ); } catch (error) { logger.error('collectReusableIntegrations: failed to enumerate .deepnote files', error); @@ -108,6 +90,10 @@ export async function collectReusableIntegrations( } for (const fileUri of files) { + if (token?.isCancellationRequested) { + return { cancelled: true, conflictingIds: [], integrations: [] }; + } + const key = fileUri.toString(); if (visited.has(key) || isSnapshotFile(fileUri)) { @@ -179,23 +165,18 @@ export async function collectReusableIntegrations( })) .sort((a, b) => a.name.localeCompare(b.name) || a.id.localeCompare(b.id)); - return { conflictingIds: Array.from(conflictingIds).sort(), integrations }; + return { cancelled: false, conflictingIds: Array.from(conflictingIds).sort(), integrations }; } /** - * Links `integration` into the project's roster through the same writer the panel uses, so the cache, the active + * Links `integration` into the project's integrations through the same writer the panel uses, so the cache, the active * file and every sibling `.deepnote` file are updated together. Re-linking an id already there replaces its entry. */ export function attachExistingIntegration(params: AttachExistingIntegrationParams): Promise { const { activeFileUri, currentIntegrations, integration, notebookManager, projectId } = params; const linked: ProjectIntegration = { id: integration.id, name: integration.name, type: integration.type }; - // Cast rather than validate: filtering out types this build does not know (`pandas-dataframe`) would delete - // them from the file. - const integrations = [ - ...currentIntegrations.filter((entry) => entry.id !== integration.id), - linked - ] as ProjectIntegration[]; + const integrations = [...currentIntegrations.filter((entry) => entry.id !== integration.id), linked]; return persistProjectIntegrations({ activeFileUri, integrations, notebookManager, projectId }); } diff --git a/src/notebooks/deepnote/integrations/existingIntegrationPicker.unit.test.ts b/src/notebooks/deepnote/integrations/existingIntegrationPicker.unit.test.ts index 212a117029..80c06ca0bf 100644 --- a/src/notebooks/deepnote/integrations/existingIntegrationPicker.unit.test.ts +++ b/src/notebooks/deepnote/integrations/existingIntegrationPicker.unit.test.ts @@ -1,17 +1,15 @@ import { deserializeDeepnoteFile, serializeDeepnoteFile, type DeepnoteFile } from '@deepnote/blocks'; import { assert } from 'chai'; import { anything, instance, mock, when } from 'ts-mockito'; -import { Uri, workspace } from 'vscode'; +import { CancellationToken, CancellationTokenSource, Uri, workspace } from 'vscode'; import { ConfigurableDatabaseIntegrationConfig } from '../../../platform/notebooks/deepnote/integrationTypes'; import { mockedVSCodeNamespaces, resetVSCodeMocks } from '../../../test/vscode-mock'; -import { IDeepnoteNotebookManager, ProjectIntegration } from '../../types'; +import { IDeepnoteNotebookManager, ProjectIntegration, RawProjectIntegration } from '../../types'; import { createDeepnoteFile, createDeepnoteProject, createWorkspaceFolder } from '../deepnoteTestHelpers'; import { attachExistingIntegration, collectReusableIntegrations, - integrationTypeLabel, - RawProjectIntegration, ReusableIntegration } from './existingIntegrationPicker'; import { buildGoogleOauthIntegration, buildPostgresIntegration } from './federatedAuth/federatedAuthTestHelpers'; @@ -31,14 +29,19 @@ function projectFile(project: OnDiskProject): DeepnoteFile { project: createDeepnoteProject({ id: project.projectId, name: project.projectName ?? project.projectId, - // The roster type is a plain string on disk; the cast keeps the fixture free to declare unknown types. - integrations: project.integrations as ProjectIntegration[] | undefined + integrations: project.integrations }) }); } /** Stubs `workspace.findFiles` + `workspace.fs` over the given files; `unreadable` URIs reject on read. */ -function stubWorkspace(opts: { projects: OnDiskProject[]; unreadable?: Uri[]; hasWorkspaceFolder?: boolean }): { +function stubWorkspace(opts: { + projects: OnDiskProject[]; + unreadable?: Uri[]; + hasWorkspaceFolder?: boolean; + onRead?: (uri: Uri) => void; +}): { + reads: string[]; writes: Map; } { when(mockedVSCodeNamespaces.workspace.workspaceFolders).thenReturn( @@ -46,16 +49,25 @@ function stubWorkspace(opts: { projects: OnDiskProject[]; unreadable?: Uri[]; ha ); const discovered = [...opts.projects.map((project) => project.uri), ...(opts.unreadable ?? [])]; + when(mockedVSCodeNamespaces.workspace.findFiles(anything(), anything(), anything(), anything())).thenReturn( + Promise.resolve(discovered) + ); + // `persistProjectIntegrations` enumerates without a token; the scan passes one. when(mockedVSCodeNamespaces.workspace.findFiles(anything())).thenReturn(Promise.resolve(discovered)); when(mockedVSCodeNamespaces.workspace.notebookDocuments).thenReturn([]); const byPath = new Map(opts.projects.map((project) => [project.uri.fsPath, projectFile(project)] as const)); + const reads: string[] = []; const writes = new Map(); const mockFs = mock(); when(mockFs.readFile(anything())).thenCall((uri: Uri) => { + reads.push(uri.fsPath); + const file = byPath.get(uri.fsPath); + opts.onRead?.(uri); + return file ? Promise.resolve(new TextEncoder().encode(serializeDeepnoteFile(file))) : Promise.reject(new Error(`no readFile stub for ${uri.fsPath}`)); @@ -67,7 +79,7 @@ function stubWorkspace(opts: { projects: OnDiskProject[]; unreadable?: Uri[]; ha }); when(mockedVSCodeNamespaces.workspace.fs).thenReturn(instance(mockFs)); - return { writes }; + return { reads, writes }; } function stubStorage(configs: ConfigurableDatabaseIntegrationConfig[]): IIntegrationStorage { @@ -90,12 +102,14 @@ suite('existingIntegrationPicker', () => { async function collect( excludeIntegrationIds: string[] = [], - configs: ConfigurableDatabaseIntegrationConfig[] = [pgConfig, bqConfig] + configs: ConfigurableDatabaseIntegrationConfig[] = [pgConfig, bqConfig], + token?: CancellationToken ) { return collectReusableIntegrations({ excludeIntegrationIds: new Set(excludeIntegrationIds), integrationStorage: stubStorage(configs), - projectId: CURRENT_PROJECT_ID + projectId: CURRENT_PROJECT_ID, + token }); } @@ -133,16 +147,16 @@ suite('existingIntegrationPicker', () => { { id: 'pg-shared', name: 'Shared Postgres', projectNames: ['Alpha', 'Beta'], type: 'pgsql' }, { id: 'bq-oauth', name: 'Team BigQuery', projectNames: ['Beta'], type: 'big-query' } ]; - assert.deepStrictEqual(result, { conflictingIds: [], integrations: expected }); + assert.deepStrictEqual(result, { cancelled: false, conflictingIds: [], integrations: expected }); }); - test('takes the name from the stored config, not from whichever roster was read first', async () => { + test('takes the name from the stored config, not from whichever project was read first', async () => { stubWorkspace({ projects: [ { uri: Uri.file('/ws/a.deepnote'), projectId: 'project-a', - integrations: [{ id: 'pg-shared', name: 'Stale roster name', type: 'pgsql' }] + integrations: [{ id: 'pg-shared', name: 'Stale project name', type: 'pgsql' }] } ] }); @@ -152,7 +166,7 @@ suite('existingIntegrationPicker', () => { assert.strictEqual(integrations[0].name, 'Shared Postgres'); }); - test('excludes ids already on the current project roster and the current project files themselves', async () => { + test('excludes ids the current project already declares and the current project files themselves', async () => { stubWorkspace({ projects: [ { @@ -180,7 +194,7 @@ suite('existingIntegrationPicker', () => { ); }); - test('skips roster entries with no stored config (file-only or never configured) and unsupported types', async () => { + test('skips entries with no stored config (file-only or never configured) and unsupported types', async () => { stubWorkspace({ projects: [ { @@ -203,7 +217,7 @@ suite('existingIntegrationPicker', () => { ); }); - test('reports an id whose roster type disagrees with the stored config as conflicting and drops it everywhere', async () => { + test('reports an id whose declared type disagrees with the stored config as conflicting and drops it everywhere', async () => { stubWorkspace({ projects: [ { @@ -222,7 +236,7 @@ suite('existingIntegrationPicker', () => { const result = await collect(); - assert.deepStrictEqual(result, { conflictingIds: ['pg-shared'], integrations: [] }); + assert.deepStrictEqual(result, { cancelled: false, conflictingIds: ['pg-shared'], integrations: [] }); }); test('ignores snapshot files and keeps going past an unreadable file', async () => { @@ -250,12 +264,41 @@ suite('existingIntegrationPicker', () => { ); }); + test('stops at the next file and reports cancellation when the token trips mid-scan', async () => { + const cts = new CancellationTokenSource(); + + try { + const { reads } = stubWorkspace({ + projects: [ + { + uri: Uri.file('/ws/a.deepnote'), + projectId: 'project-a', + integrations: [{ id: 'pg-shared', name: 'Shared Postgres', type: 'pgsql' }] + }, + { + uri: Uri.file('/ws/b.deepnote'), + projectId: 'project-b', + integrations: [{ id: 'bq-oauth', name: 'Team BigQuery', type: 'big-query' }] + } + ], + onRead: () => cts.cancel() + }); + + const result = await collect([], [pgConfig, bqConfig], cts.token); + + assert.deepStrictEqual(result, { cancelled: true, conflictingIds: [], integrations: [] }); + assert.deepStrictEqual(reads, [Uri.file('/ws/a.deepnote').fsPath], 'the scan must not read on'); + } finally { + cts.dispose(); + } + }); + test('returns nothing without an open workspace folder', async () => { stubWorkspace({ projects: [], hasWorkspaceFolder: false }); const result = await collect(); - assert.deepStrictEqual(result, { conflictingIds: [], integrations: [] }); + assert.deepStrictEqual(result, { cancelled: false, conflictingIds: [], integrations: [] }); }); }); @@ -284,7 +327,7 @@ suite('existingIntegrationPicker', () => { notebookManager = instance(mockManager); }); - test('appends the linked entry to the roster in the cache and on disk, keeping existing entries', async () => { + test('appends the linked entry to the project integrations in the cache and on disk, keeping existing entries', async () => { const { writes } = stubWorkspace({ projects: [ { @@ -306,16 +349,18 @@ suite('existingIntegrationPicker', () => { projectId: CURRENT_PROJECT_ID }); - const expectedRoster: ProjectIntegration[] = [ + const expectedIntegrations: ProjectIntegration[] = [ { id: 'bq-own', name: 'Own BigQuery', type: 'big-query' }, { id: 'pg-shared', name: 'Shared Postgres', type: 'pgsql' } ]; assert.deepStrictEqual(result, { activePersisted: true, siblingsFailed: 0 }); - assert.deepStrictEqual(cacheUpdates, [{ projectId: CURRENT_PROJECT_ID, integrations: expectedRoster }]); - assert.deepStrictEqual(writes.get(activeUri.fsPath)?.project.integrations, expectedRoster); + assert.deepStrictEqual(cacheUpdates, [ + { projectId: CURRENT_PROJECT_ID, integrations: expectedIntegrations } + ]); + assert.deepStrictEqual(writes.get(activeUri.fsPath)?.project.integrations, expectedIntegrations); }); - test('passes roster entries of types it cannot manage (e.g. pandas-dataframe) through verbatim', async () => { + test('passes entries of types it cannot manage (e.g. pandas-dataframe) through verbatim', async () => { const { writes } = stubWorkspace({ projects: [{ uri: activeUri, projectId: CURRENT_PROJECT_ID }] }); @@ -332,15 +377,17 @@ suite('existingIntegrationPicker', () => { projectId: CURRENT_PROJECT_ID }); - const expectedRoster = [ + const expectedIntegrations = [ ...currentIntegrations, { id: 'pg-shared', name: 'Shared Postgres', type: 'pgsql' } ]; - assert.deepStrictEqual(cacheUpdates, [{ projectId: CURRENT_PROJECT_ID, integrations: expectedRoster }]); - assert.deepStrictEqual(writes.get(activeUri.fsPath)?.project.integrations, expectedRoster); + assert.deepStrictEqual(cacheUpdates, [ + { projectId: CURRENT_PROJECT_ID, integrations: expectedIntegrations } + ]); + assert.deepStrictEqual(writes.get(activeUri.fsPath)?.project.integrations, expectedIntegrations); }); - test('replaces rather than duplicates an entry whose id is already on the roster', async () => { + test('replaces rather than duplicates an entry whose id the project already declares', async () => { const { writes } = stubWorkspace({ projects: [{ uri: activeUri, projectId: CURRENT_PROJECT_ID }] }); @@ -358,9 +405,4 @@ suite('existingIntegrationPicker', () => { ]); }); }); - - test('integrationTypeLabel maps every configurable type to a display label', () => { - assert.strictEqual(integrationTypeLabel('pgsql'), 'PostgreSQL'); - assert.strictEqual(integrationTypeLabel('big-query'), 'Google BigQuery'); - }); }); diff --git a/src/notebooks/deepnote/integrations/integrationDetector.ts b/src/notebooks/deepnote/integrations/integrationDetector.ts index d860175e96..e35044fd84 100644 --- a/src/notebooks/deepnote/integrations/integrationDetector.ts +++ b/src/notebooks/deepnote/integrations/integrationDetector.ts @@ -26,11 +26,11 @@ export class IntegrationDetector implements IIntegrationDetector { /** * Detect all integrations for the notebook's project. Three inputs, three roles: - * - `project.integrations` is the roster (ids, names and types only — never credentials), so it decides + * - `project.integrations` holds ids, names and types only — never credentials — so it decides * the order and the names the panel shows. * - SecretStorage supplies the editable config for each one; integrations configured only in * `.deepnote.env.yaml` stay `null` here, since those configs are never persisted through it. - * - `.deepnote.env.yaml` entries missing from the roster are appended, matching what actually applies at + * - `.deepnote.env.yaml` entries the project omits are appended, matching what actually applies at * execution time. Without this a file-only integration works but is invisible, and a federated one is * unusable outright — its Authenticate action exists only as a row in this panel. */ @@ -76,9 +76,9 @@ export class IntegrationDetector implements IIntegrationDetector { } /** - * Adds `.deepnote.env.yaml` integrations the roster omits. `config` stays `null` because the panel edits + * Adds `.deepnote.env.yaml` integrations the project omits. `config` stays `null` because the panel edits * SecretStorage only and the file layer cannot be written back; the name and type are carried so the row - * renders. A failed lookup leaves the roster-only result rather than blocking the panel. + * renders. A failed lookup leaves the project's own integrations rather than blocking the panel. */ private async appendFileOnlyIntegrations( notebookUri: Uri, @@ -89,13 +89,16 @@ export class IntegrationDetector implements IIntegrationDetector { try { mergedIntegrationConfigs = await this.sqlIntegrationEnvVars.getMergedIntegrationConfigs(notebookUri); } catch (error) { - logger.error('IntegrationDetector: failed to read file integrations; listing the roster only', error); + logger.error( + "IntegrationDetector: failed to read file integrations; listing the project's own only", + error + ); return; } for (const config of mergedIntegrationConfigs) { - // Anything merged but absent here came from the file alone — the merge resolves roster ids first. + // Anything merged but absent here came from the file alone — the merge resolves the project's ids first. if (integrations.has(config.id) || !isConfigurableDatabaseIntegrationType(config.type)) { continue; } diff --git a/src/notebooks/deepnote/integrations/integrationManager.ts b/src/notebooks/deepnote/integrations/integrationManager.ts index 004d41db22..f7739c79e4 100644 --- a/src/notebooks/deepnote/integrations/integrationManager.ts +++ b/src/notebooks/deepnote/integrations/integrationManager.ts @@ -1,5 +1,5 @@ import { inject, injectable, optional } from 'inversify'; -import { commands, l10n, NotebookDocument, QuickPickItem, window, workspace } from 'vscode'; +import { commands, l10n, NotebookDocument, ProgressLocation, QuickPickItem, window, workspace } from 'vscode'; import { CommandOutcome, ITelemetryService } from '../../../platform/analytics/types'; import { IExtensionContext } from '../../../platform/common/types'; @@ -13,15 +13,14 @@ import { IIntegrationStorage, IIntegrationWebviewProvider } from './types'; -import { IDeepnoteNotebookManager } from '../../types'; +import { IDeepnoteNotebookManager, RawProjectIntegration } from '../../types'; import { DatabaseIntegrationType, databaseIntegrationTypes } from '@deepnote/database-integrations'; import { attachExistingIntegration, collectReusableIntegrations, - integrationTypeLabel, - RawProjectIntegration, ReusableIntegration } from './existingIntegrationPicker'; +import { isSnapshotFile } from '../snapshots/snapshotFiles'; interface ReusableIntegrationQuickPickItem extends QuickPickItem { integration: ReusableIntegration; @@ -88,8 +87,8 @@ export class IntegrationManager implements IIntegrationManager { } /** - * Offers the integrations other projects in the workspace declare and links the chosen one into this project's - * roster; no credentials are copied (see `collectReusableIntegrations`). Public so tests can drive it without + * Offers the integrations other projects in the workspace declare and links the chosen one into this project; + * no credentials are copied (see `collectReusableIntegrations`). Public so tests can drive it without * `commands.executeCommand`. */ public async addExistingIntegration(notebookUri?: string): Promise { @@ -101,6 +100,14 @@ export class IntegrationManager implements IIntegrationManager { return 'failed'; } + // `*.snapshot.deepnote` matches the notebook selector, so this command can run against a focused snapshot. + // The writer skips snapshots, which would report a failure only after the cache had already been updated. + if (isSnapshotFile(activeNotebook.uri)) { + void window.showErrorMessage(localize.Integrations.addExistingIntegrationSnapshotUnsupported); + + return 'failed'; + } + const projectId = activeNotebook.metadata?.deepnoteProjectId; const notebookId = activeNotebook.metadata?.deepnoteNotebookId; @@ -110,12 +117,32 @@ export class IntegrationManager implements IIntegrationManager { return 'failed'; } - const currentIntegrations = this.getCachedRoster(projectId, notebookId); - const { conflictingIds, integrations } = await collectReusableIntegrations({ - excludeIntegrationIds: new Set(currentIntegrations.map((entry) => entry.id)), - integrationStorage: this.integrationStorage, - projectId - }); + const currentIntegrations = this.getCachedProjectIntegrations(projectId, notebookId); + + if (!currentIntegrations) { + void window.showErrorMessage(localize.Integrations.addExistingIntegrationFailed); + + return 'failed'; + } + + const { cancelled, conflictingIds, integrations } = await window.withProgress( + { + cancellable: true, + location: ProgressLocation.Notification, + title: localize.Integrations.addExistingIntegrationScanning + }, + (_progress, token) => + collectReusableIntegrations({ + excludeIntegrationIds: new Set(currentIntegrations.map((entry) => entry.id)), + integrationStorage: this.integrationStorage, + projectId, + token + }) + ); + + if (cancelled) { + return 'cancelled'; + } if (conflictingIds.length > 0) { void window.showWarningMessage( @@ -130,7 +157,7 @@ export class IntegrationManager implements IIntegrationManager { } const items: ReusableIntegrationQuickPickItem[] = integrations.map((integration) => ({ - description: integrationTypeLabel(integration.type), + description: localize.Integrations.typeLabel(integration.type), detail: localize.Integrations.addExistingIntegrationUsedIn(integration.projectNames.join(', ')), integration, label: integration.name @@ -147,12 +174,23 @@ export class IntegrationManager implements IIntegrationManager { } const { integration } = picked; + // The file watcher replaces the cached project on any external write — including one that changes only + // the integrations, which it reloads past without a UI event — so the pre-pick array can be stale, and + // the writer stamps whatever it is given onto every `.deepnote` file of the project. + const integrationsAtWrite = this.getCachedProjectIntegrations(projectId, notebookId); + + if (!integrationsAtWrite) { + void window.showErrorMessage(localize.Integrations.addExistingIntegrationFailed); + + return 'failed'; + } + let outcome: CommandOutcome = 'failed'; try { const { activePersisted, siblingsFailed } = await attachExistingIntegration({ activeFileUri: activeNotebook.uri, - currentIntegrations, + currentIntegrations: integrationsAtWrite, integration, notebookManager: this.notebookManager, projectId @@ -175,7 +213,7 @@ export class IntegrationManager implements IIntegrationManager { // Storage did not change, so the storage-change listeners that normally refresh kernels and the // panel after a save stay silent; do both explicitly for this project. - await this.refreshAfterRosterChange(projectId, activeNotebook); + await this.refreshAfterProjectIntegrationsChange(projectId, activeNotebook); } else { void window.showErrorMessage(localize.Integrations.addExistingIntegrationFailed); } @@ -204,14 +242,20 @@ export class IntegrationManager implements IIntegrationManager { return uri ? String(uri) : undefined; } - /** Unfiltered on purpose: `attachExistingIntegration` writes this array back, so anything dropped here is lost. */ - private getCachedRoster(projectId: string, notebookId: string): RawProjectIntegration[] { + /** + * Unfiltered on purpose: `attachExistingIntegration` writes this array back, so anything dropped here is lost. + * `undefined` means the project is not cached, which must not be written back as an empty list. + */ + private getCachedProjectIntegrations(projectId: string, notebookId: string): RawProjectIntegration[] | undefined { const project = this.notebookManager.getProjectForNotebook(projectId, notebookId); - return [...(project?.project.integrations ?? [])]; + return project ? [...(project.project.integrations ?? [])] : undefined; } - private async refreshAfterRosterChange(projectId: string, activeNotebook: NotebookDocument): Promise { + private async refreshAfterProjectIntegrationsChange( + projectId: string, + activeNotebook: NotebookDocument + ): Promise { const projectNotebooks = workspace.notebookDocuments.filter( (notebook) => notebook.notebookType === 'deepnote' && notebook.metadata?.deepnoteProjectId === projectId ); diff --git a/src/notebooks/deepnote/integrations/integrationManager.unit.test.ts b/src/notebooks/deepnote/integrations/integrationManager.unit.test.ts index 48f56ab156..993746b728 100644 --- a/src/notebooks/deepnote/integrations/integrationManager.unit.test.ts +++ b/src/notebooks/deepnote/integrations/integrationManager.unit.test.ts @@ -2,13 +2,13 @@ import { deserializeDeepnoteFile, serializeDeepnoteFile, type DeepnoteFile } fro import { assert } from 'chai'; import sinon from 'sinon'; import { anything, deepEqual, instance, mock, verify, when } from 'ts-mockito'; -import { NotebookDocument, QuickPickItem, Uri, workspace } from 'vscode'; +import { CancellationToken, CancellationTokenSource, NotebookDocument, QuickPickItem, Uri, workspace } from 'vscode'; import { ITelemetryService } from '../../../platform/analytics/types'; import { IExtensionContext } from '../../../platform/common/types'; import { ConfigurableDatabaseIntegrationConfig } from '../../../platform/notebooks/deepnote/integrationTypes'; import { mockedVSCodeNamespaces, resetVSCodeMocks } from '../../../test/vscode-mock'; -import { IDeepnoteNotebookManager, ProjectIntegration } from '../../types'; +import { IDeepnoteNotebookManager, ProjectIntegration, RawProjectIntegration } from '../../types'; import { createDeepnoteFile, createDeepnoteProject, @@ -16,7 +16,6 @@ import { createWorkspaceFolder } from '../deepnoteTestHelpers'; import { buildPostgresIntegration } from './federatedAuth/federatedAuthTestHelpers'; -import { RawProjectIntegration } from './existingIntegrationPicker'; import { IntegrationManager } from './integrationManager'; import { IIntegrationDetector, @@ -30,9 +29,14 @@ const CURRENT_NOTEBOOK_ID = 'notebook-current'; const OTHER_PROJECT_ID = 'project-other'; const CURRENT_URI = Uri.file('/ws/current.deepnote'); const OTHER_URI = Uri.file('/ws/other.deepnote'); +/** A second file of the CURRENT project, so the sibling-write branch has something to fail on. */ +const SIBLING_URI = Uri.file('/ws/sibling.deepnote'); +const SNAPSHOT_URI = Uri.file('/ws/snapshots/current_project-current_latest.snapshot.deepnote'); const SHARED_CONFIG = buildPostgresIntegration({ id: 'pg-shared', name: 'Shared Postgres' }); +type RefreshFn = IIntegrationEnvLiveRefresher['refresh']; + function projectFile(projectId: string, notebookId: string, integrations: RawProjectIntegration[]): DeepnoteFile { return createDeepnoteFile({ project: createDeepnoteProject({ @@ -47,18 +51,23 @@ function projectFile(projectId: string, notebookId: string, integrations: RawPro suite('IntegrationManager.addExistingIntegration', () => { let currentNotebook: NotebookDocument; let otherNotebook: NotebookDocument; - let currentProject: DeepnoteFile; + let currentProject: DeepnoteFile | undefined; let writes: Map; let cacheUpdates: ProjectIntegration[][]; - let refreshSpy: sinon.SinonSpy; + // Typed off the interface so a signature change fails the compile, not just the `firstCall.args` assertion. + let refreshSpy: sinon.SinonSpy, ReturnType>; let quickPickItems: QuickPickItem[] | undefined; + let scanProgress: CancellationTokenSource; + let writeFailures: Set; let detector: IIntegrationDetector; let webviewProvider: IIntegrationWebviewProvider; let notebookManager: IDeepnoteNotebookManager; let telemetry: ITelemetryService; + let cacheUpdateError: Error | undefined; let storedConfigs: ConfigurableDatabaseIntegrationConfig[]; let onDiskOther: DeepnoteFile | undefined; + let onDiskSibling: DeepnoteFile | undefined; setup(() => { resetVSCodeMocks(); @@ -75,26 +84,52 @@ suite('IntegrationManager.addExistingIntegration', () => { onDiskOther = projectFile(OTHER_PROJECT_ID, 'notebook-other', [ { id: SHARED_CONFIG.id, name: SHARED_CONFIG.name, type: SHARED_CONFIG.type } ]); + onDiskSibling = undefined; + cacheUpdateError = undefined; storedConfigs = [SHARED_CONFIG]; writes = new Map(); cacheUpdates = []; quickPickItems = undefined; + scanProgress = new CancellationTokenSource(); + writeFailures = new Set(); when(mockedVSCodeNamespaces.workspace.workspaceFolders).thenReturn([createWorkspaceFolder(Uri.file('/ws'))]); when(mockedVSCodeNamespaces.workspace.notebookDocuments).thenReturn([currentNotebook, otherNotebook]); - when(mockedVSCodeNamespaces.workspace.findFiles(anything())).thenCall(() => - Promise.resolve(onDiskOther ? [CURRENT_URI, OTHER_URI] : [CURRENT_URI]) + const discovered = () => + Promise.resolve([ + CURRENT_URI, + ...(onDiskOther ? [OTHER_URI] : []), + ...(onDiskSibling ? [SIBLING_URI] : []) + ]); + + // The writer enumerates without a token; the scan passes one. + when(mockedVSCodeNamespaces.workspace.findFiles(anything())).thenCall(discovered); + when(mockedVSCodeNamespaces.workspace.findFiles(anything(), anything(), anything(), anything())).thenCall( + discovered + ); + + when(mockedVSCodeNamespaces.window.withProgress(anything(), anything())).thenCall( + (_options: unknown, task: (progress: unknown, token: CancellationToken) => unknown) => + task({ report: () => undefined }, scanProgress.token) ); const mockFs = mock(); when(mockFs.readFile(anything())).thenCall((uri: Uri) => { - const file = uri.fsPath === CURRENT_URI.fsPath ? currentProject : onDiskOther; + const file = new Map([ + [CURRENT_URI.fsPath, currentProject], + [OTHER_URI.fsPath, onDiskOther], + [SIBLING_URI.fsPath, onDiskSibling] + ]).get(uri.fsPath); return file ? Promise.resolve(new TextEncoder().encode(serializeDeepnoteFile(file))) : Promise.reject(new Error(`no readFile stub for ${uri.fsPath}`)); }); when(mockFs.writeFile(anything(), anything())).thenCall((uri: Uri, bytes: Uint8Array) => { + if (writeFailures.has(uri.fsPath)) { + return Promise.reject(new Error(`write blocked for ${uri.fsPath}`)); + } + writes.set(uri.fsPath, deserializeDeepnoteFile(new TextDecoder().decode(bytes))); return Promise.resolve(); @@ -119,6 +154,10 @@ suite('IntegrationManager.addExistingIntegration', () => { when(mockManager.getProjectForNotebook(CURRENT_PROJECT_ID, CURRENT_NOTEBOOK_ID)).thenCall(() => currentProject); when(mockManager.updateProjectIntegrations(anything(), anything())).thenCall( (_projectId: string, integrations: ProjectIntegration[]) => { + if (cacheUpdateError) { + throw cacheUpdateError; + } + cacheUpdates.push(integrations); return true; @@ -127,7 +166,11 @@ suite('IntegrationManager.addExistingIntegration', () => { notebookManager = mockManager; telemetry = mock(); - refreshSpy = sinon.spy(async () => undefined); + refreshSpy = sinon.spy(async () => undefined); + }); + + teardown(() => { + scanProgress.dispose(); }); function buildManager(): IntegrationManager { @@ -152,14 +195,16 @@ suite('IntegrationManager.addExistingIntegration', () => { ); } - test("links the picked integration into the roster, refreshes only this project's kernels and re-shows the panel", async () => { + test("links the picked integration, refreshes only this project's kernels and re-shows the panel", async () => { const outcome = await buildManager().addExistingIntegration(CURRENT_URI.toString()); assert.strictEqual(outcome, 'completed'); - const expectedRoster: ProjectIntegration[] = [{ id: 'pg-shared', name: 'Shared Postgres', type: 'pgsql' }]; - assert.deepStrictEqual(writes.get(CURRENT_URI.fsPath)?.project.integrations, expectedRoster); - assert.deepStrictEqual(cacheUpdates, [expectedRoster]); + const expectedIntegrations: ProjectIntegration[] = [ + { id: 'pg-shared', name: 'Shared Postgres', type: 'pgsql' } + ]; + assert.deepStrictEqual(writes.get(CURRENT_URI.fsPath)?.project.integrations, expectedIntegrations); + assert.deepStrictEqual(cacheUpdates, [expectedIntegrations]); assert.isUndefined(writes.get(OTHER_URI.fsPath), 'the other project must not be rewritten'); assert.isTrue(refreshSpy.calledOnce); @@ -195,7 +240,7 @@ suite('IntegrationManager.addExistingIntegration', () => { verify(mockedVSCodeNamespaces.window.showInformationMessage(anything())).once(); }); - test('keeps roster entries the panel cannot manage (pandas-dataframe) when attaching', async () => { + test('keeps entries the panel cannot manage (pandas-dataframe) when attaching', async () => { currentProject = projectFile(CURRENT_PROJECT_ID, CURRENT_NOTEBOOK_ID, [ { id: 'duckdb', name: 'DuckDB', type: 'pandas-dataframe' } ]); @@ -204,12 +249,12 @@ suite('IntegrationManager.addExistingIntegration', () => { assert.strictEqual(outcome, 'completed'); - const expectedRoster = [ + const expectedIntegrations = [ { id: 'duckdb', name: 'DuckDB', type: 'pandas-dataframe' }, { id: 'pg-shared', name: 'Shared Postgres', type: 'pgsql' } ]; - assert.deepStrictEqual(writes.get(CURRENT_URI.fsPath)?.project.integrations, expectedRoster); - assert.deepStrictEqual(cacheUpdates, [expectedRoster]); + assert.deepStrictEqual(writes.get(CURRENT_URI.fsPath)?.project.integrations, expectedIntegrations); + assert.deepStrictEqual(cacheUpdates, [expectedIntegrations]); assert.strictEqual(quickPickItems?.length, 1, 'the DuckDB entry is neither offered nor a candidate'); }); @@ -255,4 +300,123 @@ suite('IntegrationManager.addExistingIntegration', () => { assert.strictEqual(outcome, 'failed'); verify(mockedVSCodeNamespaces.window.showErrorMessage(anything())).once(); }); + // Every branch below reports trouble to the user; without cover they can each regress into silent success. + const earlyFailures: { arrange: () => string; name: string }[] = [ + { + arrange: () => { + when(mockedVSCodeNamespaces.workspace.notebookDocuments).thenReturn([ + createMockNotebook({ uri: CURRENT_URI, metadata: {} }) + ]); + + return CURRENT_URI.toString(); + }, + name: 'the notebook declares no project or notebook id' + }, + { + arrange: () => { + when(mockedVSCodeNamespaces.workspace.notebookDocuments).thenReturn([ + createMockNotebook({ + uri: SNAPSHOT_URI, + metadata: { + deepnoteProjectId: CURRENT_PROJECT_ID, + deepnoteNotebookId: CURRENT_NOTEBOOK_ID + } + }) + ]); + + return SNAPSHOT_URI.toString(); + }, + name: 'the active file is a snapshot' + }, + { + arrange: () => { + currentProject = undefined; + + return CURRENT_URI.toString(); + }, + name: 'the project is not in the cache' + } + ]; + + for (const { arrange, name } of earlyFailures) { + test(`fails without reading or writing any file when ${name}`, async () => { + const uri = arrange(); + + const outcome = await buildManager().addExistingIntegration(uri); + + assert.strictEqual(outcome, 'failed'); + assert.strictEqual(writes.size, 0); + assert.deepStrictEqual(cacheUpdates, [], 'the cache must not move before the file does'); + verify(mockedVSCodeNamespaces.window.showErrorMessage(anything())).once(); + verify(mockedVSCodeNamespaces.window.showQuickPick(anything(), anything())).never(); + verify(telemetry.trackEvent(anything())).never(); + }); + } + + const lateFailures: { arrange: () => void; name: string }[] = [ + { arrange: () => writeFailures.add(CURRENT_URI.fsPath), name: 'the active file cannot be written' }, + { arrange: () => (cacheUpdateError = new Error('cache rejected the update')), name: 'the writer throws' } + ]; + + for (const { arrange, name } of lateFailures) { + test(`reports failure and records the outcome in telemetry when ${name}`, async () => { + arrange(); + + const outcome = await buildManager().addExistingIntegration(CURRENT_URI.toString()); + + assert.strictEqual(outcome, 'failed'); + assert.isTrue(refreshSpy.notCalled, 'nothing reached disk for the kernels to pick up'); + verify(mockedVSCodeNamespaces.window.showErrorMessage(anything())).once(); + verify( + telemetry.trackEvent( + deepEqual({ + eventName: 'add_existing_integration', + properties: { integrationType: 'pgsql', outcome: 'failed' } + }) + ) + ).once(); + }); + } + + test('completes with a warning when a sibling file of the same project cannot be updated', async () => { + onDiskSibling = projectFile(CURRENT_PROJECT_ID, 'notebook-sibling', []); + writeFailures.add(SIBLING_URI.fsPath); + + const outcome = await buildManager().addExistingIntegration(CURRENT_URI.toString()); + + assert.strictEqual(outcome, 'completed'); + assert.isDefined(writes.get(CURRENT_URI.fsPath), 'the active file is still persisted'); + assert.isUndefined(writes.get(SIBLING_URI.fsPath)); + verify(mockedVSCodeNamespaces.window.showWarningMessage(anything())).once(); + }); + + test('writes the integrations as they stand after the pick, not the snapshot taken before it', async () => { + when(mockedVSCodeNamespaces.window.showQuickPick(anything(), anything())).thenCall((items: QuickPickItem[]) => { + // What the file watcher does to the cache when another writer touches the file mid-pick. + currentProject = projectFile(CURRENT_PROJECT_ID, CURRENT_NOTEBOOK_ID, [ + { id: 'added-meanwhile', name: 'Added meanwhile', type: 'mysql' } + ]); + + return Promise.resolve(items[0]); + }); + + const outcome = await buildManager().addExistingIntegration(CURRENT_URI.toString()); + + assert.strictEqual(outcome, 'completed'); + assert.deepStrictEqual(writes.get(CURRENT_URI.fsPath)?.project.integrations, [ + { id: 'added-meanwhile', name: 'Added meanwhile', type: 'mysql' }, + { id: 'pg-shared', name: 'Shared Postgres', type: 'pgsql' } + ]); + }); + + test('returns cancelled and writes nothing when the scan is cancelled', async () => { + scanProgress.cancel(); + + const outcome = await buildManager().addExistingIntegration(CURRENT_URI.toString()); + + assert.strictEqual(outcome, 'cancelled'); + assert.strictEqual(writes.size, 0); + verify(mockedVSCodeNamespaces.window.showQuickPick(anything(), anything())).never(); + verify(telemetry.trackEvent(anything())).never(); + }); }); diff --git a/src/notebooks/deepnote/integrations/integrationWebview.ts b/src/notebooks/deepnote/integrations/integrationWebview.ts index 5a68229e88..052a7421cf 100644 --- a/src/notebooks/deepnote/integrations/integrationWebview.ts +++ b/src/notebooks/deepnote/integrations/integrationWebview.ts @@ -175,25 +175,7 @@ export class IntegrationWebviewProvider implements IIntegrationWebviewProvider { integrationsDatabase: localize.Integrations.database, integrationsDataWarehousesLakes: localize.Integrations.dataWarehousesLakes, integrationsDatabases: localize.Integrations.databases, - integrationsPostgresTypeLabel: localize.Integrations.postgresTypeLabel, - integrationsBigQueryTypeLabel: localize.Integrations.bigQueryTypeLabel, - integrationsSnowflakeTypeLabel: localize.Integrations.snowflakeTypeLabel, - integrationsAlloyDBTypeLabel: localize.Integrations.alloyDBTypeLabel, - integrationsAthenaTypeLabel: localize.Integrations.athenaTypeLabel, - integrationsClickHouseTypeLabel: localize.Integrations.clickHouseTypeLabel, - integrationsCloudSqlTypeLabel: localize.Integrations.cloudSqlTypeLabel, - integrationsDatabricksTypeLabel: localize.Integrations.databricksTypeLabel, - integrationsDremioTypeLabel: localize.Integrations.dremioTypeLabel, - integrationsMariaDBTypeLabel: localize.Integrations.mariaDBTypeLabel, - integrationsMaterializeTypeLabel: localize.Integrations.materializeTypeLabel, - integrationsMindsDBTypeLabel: localize.Integrations.mindsDBTypeLabel, - integrationsMongoDBTypeLabel: localize.Integrations.mongoDBTypeLabel, - integrationsMySQLTypeLabel: localize.Integrations.mySQLTypeLabel, - integrationsDuckDBTypeLabel: localize.Integrations.duckDBTypeLabel, - integrationsRedshiftTypeLabel: localize.Integrations.redshiftTypeLabel, - integrationsSpannerTypeLabel: localize.Integrations.spannerTypeLabel, - integrationsSQLServerTypeLabel: localize.Integrations.sqlServerTypeLabel, - integrationsTrinoTypeLabel: localize.Integrations.trinoTypeLabel, + ...localize.Integrations.typeLabels, integrationsCancel: localize.Integrations.cancel, integrationsSave: localize.Integrations.save, integrationsRequiredField: localize.Integrations.requiredField, @@ -746,7 +728,7 @@ export class IntegrationWebviewProvider implements IIntegrationWebviewProvider { } break; case 'addExisting': - // The command owns the picker and the roster write, and re-shows this panel when it is done. + // The command owns the picker and the integrations write, and re-shows this panel when it is done. try { await commands.executeCommand(Commands.AddExistingIntegration, { notebookUri: this.activeFileUri?.toString() diff --git a/src/notebooks/deepnote/integrations/projectIntegrationsWriter.ts b/src/notebooks/deepnote/integrations/projectIntegrationsWriter.ts index 6822a22fa8..e9f56ba8a7 100644 --- a/src/notebooks/deepnote/integrations/projectIntegrationsWriter.ts +++ b/src/notebooks/deepnote/integrations/projectIntegrationsWriter.ts @@ -4,7 +4,7 @@ import { RelativePattern, Uri, workspace } from 'vscode'; import { flushNotebookDocumentIfDirty } from '../../../platform/deepnote/deepnoteDocumentFlush'; import { readDeepnoteProjectFile } from '../../../platform/deepnote/deepnoteProjectFileReader'; import { logger } from '../../../platform/logging'; -import { IDeepnoteNotebookManager, ProjectIntegration } from '../../types'; +import { IDeepnoteNotebookManager, RawProjectIntegration } from '../../types'; import { isSnapshotFile } from '../snapshots/snapshotFiles'; export interface PersistIntegrationsResult { @@ -15,14 +15,14 @@ export interface PersistIntegrationsResult { export interface PersistProjectIntegrationsParams { notebookManager: IDeepnoteNotebookManager; projectId: string; - integrations: ProjectIntegration[]; + integrations: RawProjectIntegration[]; activeFileUri: Uri; } interface WriteIntegrationsToFileParams { fileUri: Uri; projectId: string; - integrations: ProjectIntegration[]; + integrations: RawProjectIntegration[]; } type IntegrationWriteOutcome = 'failed' | 'skipped' | 'written'; diff --git a/src/notebooks/deepnote/sqlCellStatusBarProvider.ts b/src/notebooks/deepnote/sqlCellStatusBarProvider.ts index c5a32c96ac..e2915da38a 100644 --- a/src/notebooks/deepnote/sqlCellStatusBarProvider.ts +++ b/src/notebooks/deepnote/sqlCellStatusBarProvider.ts @@ -24,26 +24,22 @@ import { IDisposableRegistry } from '../../platform/common/types'; import { IIntegrationStorage } from './integrations/types'; import { Commands } from '../../platform/common/constants'; import { - ConfigurableDatabaseIntegrationType, DATAFRAME_SQL_INTEGRATION_ID, isConfigurableDatabaseIntegrationType, toTelemetryIntegrationType } from '../../platform/notebooks/deepnote/integrationTypes'; import { persistProjectIntegrations } from './integrations/projectIntegrationsWriter'; -import { IDeepnoteNotebookManager, ProjectIntegration } from '../types'; +import { IDeepnoteNotebookManager, RawProjectIntegration } from '../types'; import { logger } from '../../platform/logging'; import { ISqlIntegrationEnvVarsProvider } from '../../platform/notebooks/deepnote/types'; -import { SqlReturnVariableType } from '../../platform/common/utils/localize'; -import type { DeepnoteFile, SqlCellVariableType } from '@deepnote/blocks'; +import { Integrations, SqlReturnVariableType } from '../../platform/common/utils/localize'; +import type { SqlCellVariableType } from '@deepnote/blocks'; import { DatabaseIntegrationConfig, DatabaseIntegrationType, databaseIntegrationTypes } from '@deepnote/database-integrations'; -/** One entry of a project's `integrations` list as it appears in the file, where `type` is not yet validated. */ -type RawProjectIntegration = NonNullable[number]; - /** * QuickPick item with an integration ID */ @@ -69,27 +65,6 @@ const returnVariableTypeDetails: Record = { query_preview: SqlReturnVariableType.queryPreviewDetail }; -const integrationTypeLabels: Record = { - alloydb: l10n.t('Google AlloyDB'), - athena: l10n.t('Amazon Athena'), - 'big-query': l10n.t('Google BigQuery'), - clickhouse: l10n.t('ClickHouse'), - 'cloud-sql': l10n.t('Google Cloud SQL'), - databricks: l10n.t('Databricks'), - dremio: l10n.t('Dremio'), - mariadb: l10n.t('MariaDB'), - materialize: l10n.t('Materialize'), - mindsdb: l10n.t('MindsDB'), - mongodb: l10n.t('MongoDB'), - mysql: l10n.t('MySQL'), - pgsql: l10n.t('PostgreSQL'), - redshift: l10n.t('Amazon Redshift'), - snowflake: l10n.t('Snowflake'), - spanner: l10n.t('Google Spanner'), - 'sql-server': l10n.t('Microsoft SQL Server'), - trino: l10n.t('Trino') -}; - /** * Provides status bar items for SQL cells showing the integration name, variable name and return type */ @@ -252,7 +227,7 @@ export class SqlCellStatusBarProvider implements NotebookCellStatusBarItemProvid // Merged first: at execution time a `.deepnote.env.yaml` config wins on id conflict, so resolving // SecretStorage first would label the cell with a database it does not connect to. const mergedConfig = (await this.getMergedIntegrationConfigs(cell)).find((c) => c.id === integrationId); - // The merge only resolves ids the project roster or the file declares; SecretStorage still answers for the rest. + // The merge only resolves ids the project or the file declares; SecretStorage still answers for the rest. const config = mergedConfig ?? (await this.integrationStorage.getProjectIntegrationConfig(projectId, integrationId)); @@ -458,17 +433,17 @@ export class SqlCellStatusBarProvider implements NotebookCellStatusBarItemProvid } /** - * Appends a picked integration to the project roster so the `.deepnote` file records what it uses. + * Appends a picked integration to the project's integrations so the `.deepnote` file records what it uses. * Additive only: existing entries pass through verbatim, never filtered, because a project's integrations * are shared with sibling notebooks whose blocks this cannot see — dropping one would be data loss. */ private async addToProjectIntegrations( cell: NotebookCell, projectId: string, - roster: RawProjectIntegration[], + projectIntegrations: RawProjectIntegration[], selected: RawProjectIntegration ): Promise { - // No usable `type` means no valid roster entry; leave it out rather than guessing one. + // No usable `type` means no valid entry; leave it out rather than guessing one. if (!isConfigurableDatabaseIntegrationType(selected.type)) { return; } @@ -477,9 +452,7 @@ export class SqlCellStatusBarProvider implements NotebookCellStatusBarItemProvid await persistProjectIntegrations({ notebookManager: this.notebookManager, projectId, - // Cast rather than narrow: validating the existing entries would silently drop any type this - // build does not know about, which is pruning by another name. - integrations: [...roster, selected] as ProjectIntegration[], + integrations: [...projectIntegrations, selected], activeFileUri: cell.notebook.uri }); } catch (error) { @@ -503,19 +476,20 @@ export class SqlCellStatusBarProvider implements NotebookCellStatusBarItemProvid } /** - * The roster plus any `.deepnote.env.yaml` integrations it omits, so a file-only one can be picked here - * instead of only by hand-editing `sql_integration_id`. A failed lookup falls back to the roster alone. + * The project's integrations plus any `.deepnote.env.yaml` ones they omit, so a file-only integration can be + * picked here instead of only by hand-editing `sql_integration_id`. A failed lookup falls back to the + * project's integrations alone. */ private async getSelectableIntegrations( cell: NotebookCell, projectIntegrations: RawProjectIntegration[] ): Promise { const mergedIntegrationConfigs = await this.getMergedIntegrationConfigs(cell); - const rosterIds = new Set(projectIntegrations.map((integration) => integration.id)); + const projectIntegrationIds = new Set(projectIntegrations.map((integration) => integration.id)); - // Anything merged but absent from the roster came from the file alone — the merge resolves roster ids first. + // Anything merged but absent here came from the file alone — the merge resolves the project's ids first. const fileOnly = mergedIntegrationConfigs - .filter((config) => !rosterIds.has(config.id)) + .filter((config) => !projectIntegrationIds.has(config.id)) .map((config) => ({ id: config.id, name: config.name, type: config.type })); return fileOnly.length > 0 ? [...projectIntegrations, ...fileOnly] : projectIntegrations; @@ -542,14 +516,14 @@ export class SqlCellStatusBarProvider implements NotebookCellStatusBarItemProvid // Build quick pick items from project integrations const items: (QuickPickItem | LocalQuickPickItem)[] = []; - const roster = project.project.integrations || []; - const projectIntegrations = await this.getSelectableIntegrations(cell, roster); + const projectIntegrations = project.project.integrations || []; + const selectableIntegrations = await this.getSelectableIntegrations(cell, projectIntegrations); // Check if current integration is unknown (not in the project's list) const isCurrentIntegrationUnknown = currentIntegrationId && currentIntegrationId !== DATAFRAME_SQL_INTEGRATION_ID && - !projectIntegrations.some((i) => i.id === currentIntegrationId); + !selectableIntegrations.some((i) => i.id === currentIntegrationId); // Add current unknown integration first if it exists if (isCurrentIntegrationUnknown && currentIntegrationId) { @@ -563,7 +537,7 @@ export class SqlCellStatusBarProvider implements NotebookCellStatusBarItemProvid } // Add all project integrations - for (const projectIntegration of projectIntegrations) { + for (const projectIntegration of selectableIntegrations) { const integrationType = projectIntegration.type && (databaseIntegrationTypes as readonly string[]).includes(projectIntegration.type) @@ -575,10 +549,9 @@ export class SqlCellStatusBarProvider implements NotebookCellStatusBarItemProvid continue; } - const typeLabel = - integrationType && (databaseIntegrationTypes as readonly string[]).includes(integrationType) - ? integrationTypeLabels[integrationType] ?? integrationType - : projectIntegration.type; + const typeLabel = isConfigurableDatabaseIntegrationType(projectIntegration.type) + ? Integrations.typeLabel(projectIntegration.type) + : projectIntegration.type; const item: LocalQuickPickItem = { label: projectIntegration.name || projectIntegration.id, @@ -657,13 +630,15 @@ export class SqlCellStatusBarProvider implements NotebookCellStatusBarItemProvid return; } - // Picking a file-declared integration is the one place the roster drifts, so reconcile it here. - const selectedIntegration = projectIntegrations.find((integration) => integration.id === selectedId); + // Picking a file-declared integration is the one place the project's integrations drift, so reconcile + // it here. + const selectedIntegration = selectableIntegrations.find((integration) => integration.id === selectedId); // Shared with the telemetry below so the reconciliation and what it reports cannot drift apart. const fromEnvFile = - selectedIntegration !== undefined && !roster.some((integration) => integration.id === selectedId); + selectedIntegration !== undefined && + !projectIntegrations.some((integration) => integration.id === selectedId); if (selectedIntegration && fromEnvFile) { - await this.addToProjectIntegrations(cell, projectId, roster, selectedIntegration); + await this.addToProjectIntegrations(cell, projectId, projectIntegrations, selectedIntegration); } // Trigger status bar update diff --git a/src/notebooks/deepnote/sqlCellStatusBarProvider.unit.test.ts b/src/notebooks/deepnote/sqlCellStatusBarProvider.unit.test.ts index 1f026a03df..33b36f1706 100644 --- a/src/notebooks/deepnote/sqlCellStatusBarProvider.unit.test.ts +++ b/src/notebooks/deepnote/sqlCellStatusBarProvider.unit.test.ts @@ -287,7 +287,7 @@ suite('SqlCellStatusBarProvider', () => { notebookMetadata: { deepnoteProjectId: 'project-1' } }); - // The merge only resolves roster and file ids; a bare merged-first rewrite would regress this to (configure). + // The merge only resolves project and file ids; a bare merged-first rewrite would regress this to (configure). when(integrationStorage.getProjectIntegrationConfig(anything(), anything())).thenResolve({ id: integrationId, name: 'Stored Only', @@ -703,7 +703,7 @@ suite('SqlCellStatusBarProvider', () => { verify(mockedVSCodeNamespaces.window.showQuickPick(anything(), anything())).once(); }); - test('switchSqlIntegration offers `.deepnote.env.yaml` integrations the project roster omits', async () => { + test('switchSqlIntegration offers `.deepnote.env.yaml` integrations the project omits', async () => { let commandHandler: ((cell?: NotebookCell) => Promise) | undefined; when(mockedVSCodeNamespaces.commands.registerCommand('deepnote.switchSqlIntegration', anything())).thenCall( (_name, handler) => { diff --git a/src/notebooks/types.ts b/src/notebooks/types.ts index d1a43b5b23..2598b4c6b0 100644 --- a/src/notebooks/types.ts +++ b/src/notebooks/types.ts @@ -36,6 +36,12 @@ export interface ProjectIntegration { type: ConfigurableDatabaseIntegrationType; } +/** + * An entry as recorded on disk: unlike `ProjectIntegration`, `type` is not narrowed to the known types, so it also + * covers the internal `pandas-dataframe` integration and anything a newer Deepnote release writes. + */ +export type RawProjectIntegration = NonNullable[number]; + export const IDeepnoteNotebookManager = Symbol('IDeepnoteNotebookManager'); export interface IDeepnoteNotebookManager { /** @@ -53,7 +59,7 @@ export interface IDeepnoteNotebookManager { * @param integrations - Array of integration metadata to store in the project * @returns `true` if at least one cached entry was found and updated, `false` otherwise */ - updateProjectIntegrations(projectId: string, integrations: ProjectIntegration[]): boolean; + updateProjectIntegrations(projectId: string, integrations: RawProjectIntegration[]): boolean; } export const IDeepnoteInitNotebookRunner = Symbol('IDeepnoteInitNotebookRunner'); diff --git a/src/platform/analytics/types.ts b/src/platform/analytics/types.ts index 10481c841c..632b710e5c 100644 --- a/src/platform/analytics/types.ts +++ b/src/platform/analytics/types.ts @@ -34,7 +34,6 @@ export type CommandOutcome = 'completed' | 'cancelled' | 'failed'; /** Caller-supplied properties per event; `undefined` means none beyond the common properties the service attaches. */ export interface TelemetryEventProperties { add_block: { blockType: string; isEphemeral: boolean }; - /** "Existing" means configured in another project of this workspace. */ add_existing_integration: { integrationType: string; outcome: CommandOutcome }; authenticate_integration: { integrationType: string; outcome: CommandOutcome }; configure_integration: { integrationType: string }; @@ -69,7 +68,7 @@ export interface TelemetryEventProperties { reset_integration: { integrationType: string }; save_integration: { integrationType: string; authMethod?: 'service-account' | 'google-oauth' }; split_notebook: { notebookCount: number; outcome: CommandOutcome }; - /** `fromEnvFile` is file-ONLY, not file-configured: false when the id is also in the project roster. */ + /** `fromEnvFile` is file-ONLY, not file-configured: false when the id is also in the project's integrations. */ switch_sql_integration: { fromEnvFile: boolean; integrationType: string }; switch_sql_return_variable_type: { returnVariableType: 'dataframe' | 'query_preview' }; toggle_snapshots: { enabled: boolean }; diff --git a/src/platform/common/utils/localize.ts b/src/platform/common/utils/localize.ts index 1184f95c48..38628b35a4 100644 --- a/src/platform/common/utils/localize.ts +++ b/src/platform/common/utils/localize.ts @@ -5,6 +5,8 @@ import { l10n } from 'vscode'; import { PythonEnvironment } from '../../pythonEnvironments/info'; import { fromNow } from './date'; import { getPythonEnvDisplayName } from '../../interpreter/helpers'; +import { integrationTypeLabelKey, type IntegrationTypeLabelKey } from '../../notebooks/deepnote/integrationTypeLabels'; +import type { ConfigurableDatabaseIntegrationType } from '../../notebooks/deepnote/integrationTypes'; function getInterpreterDisplayName(interpreter: PythonEnvironment) { return getPythonEnvDisplayName(interpreter); @@ -862,6 +864,10 @@ export namespace Integrations { export const save = l10n.t('Save'); export const addNewIntegration = l10n.t('Add New Integration'); export const addExistingIntegration = l10n.t('Add Existing Integration'); + export const addExistingIntegrationScanning = l10n.t('Looking for integrations in other projects...'); + export const addExistingIntegrationSnapshotUnsupported = l10n.t( + 'Integrations cannot be added to a snapshot file. Open the project notebook and try again.' + ); export const addExistingIntegrationPlaceholder = l10n.t( 'Select an integration configured in another project of this workspace' ); @@ -888,26 +894,32 @@ export namespace Integrations { export const defaultName = (type: string) => l10n.t('My {0} integration', type); export const unsupportedIntegrationType = (type: string) => l10n.t('Unsupported integration type: {0}', type); - // Integration type labels - export const postgresTypeLabel = l10n.t('PostgreSQL'); - export const bigQueryTypeLabel = l10n.t('Google BigQuery'); - export const snowflakeTypeLabel = l10n.t('Snowflake'); - export const alloyDBTypeLabel = l10n.t('Google AlloyDB'); - export const athenaTypeLabel = l10n.t('Amazon Athena'); - export const clickHouseTypeLabel = l10n.t('ClickHouse'); - export const cloudSqlTypeLabel = l10n.t('Google Cloud SQL'); - export const databricksTypeLabel = l10n.t('Databricks'); - export const dremioTypeLabel = l10n.t('Dremio'); - export const mariaDBTypeLabel = l10n.t('MariaDB'); - export const materializeTypeLabel = l10n.t('Materialize'); - export const mindsDBTypeLabel = l10n.t('MindsDB'); - export const mongoDBTypeLabel = l10n.t('MongoDB'); - export const mySQLTypeLabel = l10n.t('MySQL'); - export const duckDBTypeLabel = l10n.t('DuckDB'); - export const redshiftTypeLabel = l10n.t('Amazon Redshift'); - export const spannerTypeLabel = l10n.t('Google Spanner'); - export const sqlServerTypeLabel = l10n.t('Microsoft SQL Server'); - export const trinoTypeLabel = l10n.t('Trino'); + /** + * The one place integration type labels are written down. Keyed by bundle key so `integrationWebview` can + * spread it into the bundle whole; `l10n.t` extracts literal arguments only, so these stay literal. + */ + export const typeLabels = { + 'integrationType.alloydb': l10n.t('Google AlloyDB'), + 'integrationType.athena': l10n.t('Amazon Athena'), + 'integrationType.big-query': l10n.t('Google BigQuery'), + 'integrationType.clickhouse': l10n.t('ClickHouse'), + 'integrationType.cloud-sql': l10n.t('Google Cloud SQL'), + 'integrationType.databricks': l10n.t('Databricks'), + 'integrationType.dremio': l10n.t('Dremio'), + 'integrationType.mariadb': l10n.t('MariaDB'), + 'integrationType.materialize': l10n.t('Materialize'), + 'integrationType.mindsdb': l10n.t('MindsDB'), + 'integrationType.mongodb': l10n.t('MongoDB'), + 'integrationType.mysql': l10n.t('MySQL'), + 'integrationType.pgsql': l10n.t('PostgreSQL'), + 'integrationType.redshift': l10n.t('Amazon Redshift'), + 'integrationType.snowflake': l10n.t('Snowflake'), + 'integrationType.spanner': l10n.t('Google Spanner'), + 'integrationType.sql-server': l10n.t('Microsoft SQL Server'), + 'integrationType.trino': l10n.t('Trino') + } satisfies Record; + + export const typeLabel = (type: ConfigurableDatabaseIntegrationType) => typeLabels[integrationTypeLabelKey(type)]; // PostgreSQL form strings export const postgresNameLabel = l10n.t('Name (optional)'); diff --git a/src/platform/notebooks/deepnote/integrationTypeLabels.ts b/src/platform/notebooks/deepnote/integrationTypeLabels.ts new file mode 100644 index 0000000000..edc1625fa3 --- /dev/null +++ b/src/platform/notebooks/deepnote/integrationTypeLabels.ts @@ -0,0 +1,13 @@ +import type { ConfigurableDatabaseIntegrationType } from './integrationTypes'; + +/** + * Where an integration type's display label sits in the string bundle the extension host sends the webview. + * + * Derived from the type rather than listed, so `localize.Integrations.typeLabels` is the only place the labels + * themselves are written down — the webview is bundled separately and cannot read `localize.ts` directly. + */ +export type IntegrationTypeLabelKey = `integrationType.${ConfigurableDatabaseIntegrationType}`; + +export function integrationTypeLabelKey(type: ConfigurableDatabaseIntegrationType): IntegrationTypeLabelKey { + return `integrationType.${type}`; +} diff --git a/src/platform/notebooks/deepnote/integrationTypeLabels.unit.test.ts b/src/platform/notebooks/deepnote/integrationTypeLabels.unit.test.ts new file mode 100644 index 0000000000..f2c72238ae --- /dev/null +++ b/src/platform/notebooks/deepnote/integrationTypeLabels.unit.test.ts @@ -0,0 +1,25 @@ +import { databaseIntegrationTypes } from '@deepnote/database-integrations'; +import { assert } from 'chai'; + +import * as localize from '../../common/utils/localize'; +import { integrationTypeLabelKey } from './integrationTypeLabels'; +import { isConfigurableDatabaseIntegrationType } from './integrationTypes'; + +suite('integrationTypeLabels', () => { + // The webview asks the string bundle for exactly this key, so its shape is a cross-process contract. + test('derives the bundle key from the integration type', () => { + assert.strictEqual(integrationTypeLabelKey('pgsql'), 'integrationType.pgsql'); + assert.strictEqual(integrationTypeLabelKey('sql-server'), 'integrationType.sql-server'); + }); + + test('resolves a label for every configurable type', () => { + assert.strictEqual(localize.Integrations.typeLabel('pgsql'), 'PostgreSQL'); + assert.strictEqual(localize.Integrations.typeLabel('big-query'), 'Google BigQuery'); + + const configurable = databaseIntegrationTypes.filter(isConfigurableDatabaseIntegrationType); + const unresolved = configurable.filter((type) => !localize.Integrations.typeLabel(type)); + + assert.deepStrictEqual(unresolved, [], 'every configurable type needs a label the panel can show'); + assert.strictEqual(Object.keys(localize.Integrations.typeLabels).length, configurable.length); + }); +}); diff --git a/src/webviews/webview-side/integrations/ConfigurationForm.tsx b/src/webviews/webview-side/integrations/ConfigurationForm.tsx index 1799d71eb7..893451c18e 100644 --- a/src/webviews/webview-side/integrations/ConfigurationForm.tsx +++ b/src/webviews/webview-side/integrations/ConfigurationForm.tsx @@ -19,7 +19,7 @@ import { SpannerForm } from './SpannerForm'; import { SQLServerForm } from './SQLServerForm'; import { isTrinoPasswordConfig, TrinoForm } from './TrinoForm'; import { ConfigurableDatabaseIntegrationConfig, ConfigurableDatabaseIntegrationType } from './types'; -import { integrationTypeLabels } from './integrationUtils'; +import { integrationTypeLabel } from './integrationUtils'; export interface IConfigurationFormProps { integrationId: string; @@ -38,7 +38,7 @@ export const ConfigurationForm: React.FC = ({ onSave, onCancel }) => { - const typeLabel = integrationTypeLabels[integrationType] || integrationType; + const typeLabel = integrationTypeLabel(integrationType); const title = getLocString('integrationsConfigureTitle', '{0} integration').replace('{0}', typeLabel); return ( diff --git a/src/webviews/webview-side/integrations/IntegrationItem.tsx b/src/webviews/webview-side/integrations/IntegrationItem.tsx index 02607c1156..02d9a31d34 100644 --- a/src/webviews/webview-side/integrations/IntegrationItem.tsx +++ b/src/webviews/webview-side/integrations/IntegrationItem.tsx @@ -1,8 +1,8 @@ import * as React from 'react'; import { getLocString } from '../react-common/locReactSide'; -import { ConfigurableDatabaseIntegrationType, DetectedIntegration } from './types'; -import { integrationTypeIcons } from './integrationUtils'; +import { DetectedIntegration } from './types'; +import { integrationTypeIcons, integrationTypeLabel } from './integrationUtils'; export interface IIntegrationItemProps { integration: DetectedIntegration; @@ -13,49 +13,6 @@ export interface IIntegrationItemProps { onSignOut: (integrationId: string) => void; } -const getIntegrationTypeLabel = (type: ConfigurableDatabaseIntegrationType): string => { - switch (type) { - case 'alloydb': - return getLocString('integrationsAlloyDBTypeLabel', 'Google AlloyDB'); - case 'athena': - return getLocString('integrationsAthenaTypeLabel', 'Amazon Athena'); - case 'big-query': - return getLocString('integrationsBigQueryTypeLabel', 'Google BigQuery'); - case 'clickhouse': - return getLocString('integrationsClickHouseTypeLabel', 'ClickHouse'); - case 'cloud-sql': - return getLocString('integrationsCloudSqlTypeLabel', 'Google Cloud SQL'); - case 'databricks': - return getLocString('integrationsDatabricksTypeLabel', 'Databricks'); - case 'dremio': - return getLocString('integrationsDremioTypeLabel', 'Dremio'); - case 'mariadb': - return getLocString('integrationsMariaDBTypeLabel', 'MariaDB'); - case 'materialize': - return getLocString('integrationsMaterializeTypeLabel', 'Materialize'); - case 'mindsdb': - return getLocString('integrationsMindsDBTypeLabel', 'MindsDB'); - case 'mongodb': - return getLocString('integrationsMongoDBTypeLabel', 'MongoDB'); - case 'mysql': - return getLocString('integrationsMySQLTypeLabel', 'MySQL'); - case 'pgsql': - return getLocString('integrationsPostgresTypeLabel', 'PostgreSQL'); - case 'redshift': - return getLocString('integrationsRedshiftTypeLabel', 'Amazon Redshift'); - case 'snowflake': - return getLocString('integrationsSnowflakeTypeLabel', 'Snowflake'); - case 'spanner': - return getLocString('integrationsSpannerTypeLabel', 'Google Spanner'); - case 'sql-server': - return getLocString('integrationsSQLServerTypeLabel', 'Microsoft SQL Server'); - case 'trino': - return getLocString('integrationsTrinoTypeLabel', 'Trino'); - default: - return type; - } -}; - export const IntegrationItem: React.FC = ({ integration, onConfigure, @@ -86,7 +43,7 @@ export const IntegrationItem: React.FC = ({ const type = integration.config?.type || integration.integrationType; // Get the type label and icon - const typeLabel = type ? getIntegrationTypeLabel(type) : undefined; + const typeLabel = type ? integrationTypeLabel(type) : undefined; const typeIcon = type ? integrationTypeIcons[type] : undefined; // Federated-auth UI: `tokenStatus` alone decides. The extension gates on its candidate set, which also diff --git a/src/webviews/webview-side/integrations/IntegrationTypeSelector.tsx b/src/webviews/webview-side/integrations/IntegrationTypeSelector.tsx index f62d5b409d..d85a5377ae 100644 --- a/src/webviews/webview-side/integrations/IntegrationTypeSelector.tsx +++ b/src/webviews/webview-side/integrations/IntegrationTypeSelector.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; import { getLocString } from '../react-common/locReactSide'; import { ConfigurableDatabaseIntegrationType } from './types'; -import { integrationTypeLabels, integrationTypeIcons } from './integrationUtils'; +import { integrationTypeIcons, integrationTypeLabel } from './integrationUtils'; export interface IIntegrationTypeSelectorProps { /** Opens the extension-host picker of integrations other projects already configured. */ @@ -9,108 +9,29 @@ export interface IIntegrationTypeSelectorProps { onSelectType: (type: ConfigurableDatabaseIntegrationType) => void; } -interface IntegrationTypeInfo { - type: ConfigurableDatabaseIntegrationType; - label: string; - icon: string; -} - -// Data Warehouses & Lakes -const WAREHOUSE_INTEGRATION_TYPES: IntegrationTypeInfo[] = [ - { - type: 'clickhouse', - label: integrationTypeLabels['clickhouse'], - icon: integrationTypeIcons['clickhouse'] - }, - { - type: 'redshift', - label: integrationTypeLabels['redshift'], - icon: integrationTypeIcons['redshift'] - }, - { - type: 'athena', - label: integrationTypeLabels['athena'], - icon: integrationTypeIcons['athena'] - }, - { - type: 'big-query', - label: integrationTypeLabels['big-query'], - icon: integrationTypeIcons['big-query'] - }, - { - type: 'snowflake', - label: integrationTypeLabels['snowflake'], - icon: integrationTypeIcons['snowflake'] - }, - { - type: 'databricks', - label: integrationTypeLabels['databricks'], - icon: integrationTypeIcons['databricks'] - }, - { - type: 'dremio', - label: integrationTypeLabels['dremio'], - icon: integrationTypeIcons['dremio'] - }, - { - type: 'trino', - label: integrationTypeLabels['trino'], - icon: integrationTypeIcons['trino'] - } +// Display order, not the map's alphabetical order. +const WAREHOUSE_INTEGRATION_TYPES: ConfigurableDatabaseIntegrationType[] = [ + 'clickhouse', + 'redshift', + 'athena', + 'big-query', + 'snowflake', + 'databricks', + 'dremio', + 'trino' ]; -// Databases -const DATABASE_INTEGRATION_TYPES: IntegrationTypeInfo[] = [ - { - type: 'mongodb', - label: integrationTypeLabels['mongodb'], - icon: integrationTypeIcons['mongodb'] - }, - { - type: 'pgsql', - label: integrationTypeLabels['pgsql'], - icon: integrationTypeIcons['pgsql'] - }, - { - type: 'mysql', - label: integrationTypeLabels['mysql'], - icon: integrationTypeIcons['mysql'] - }, - { - type: 'mariadb', - label: integrationTypeLabels['mariadb'], - icon: integrationTypeIcons['mariadb'] - }, - { - type: 'sql-server', - label: integrationTypeLabels['sql-server'], - icon: integrationTypeIcons['sql-server'] - }, - { - type: 'alloydb', - label: integrationTypeLabels['alloydb'], - icon: integrationTypeIcons['alloydb'] - }, - { - type: 'spanner', - label: integrationTypeLabels['spanner'], - icon: integrationTypeIcons['spanner'] - }, - { - type: 'cloud-sql', - label: integrationTypeLabels['cloud-sql'], - icon: integrationTypeIcons['cloud-sql'] - }, - { - type: 'materialize', - label: integrationTypeLabels['materialize'], - icon: integrationTypeIcons['materialize'] - }, - { - type: 'mindsdb', - label: integrationTypeLabels['mindsdb'], - icon: integrationTypeIcons['mindsdb'] - } +const DATABASE_INTEGRATION_TYPES: ConfigurableDatabaseIntegrationType[] = [ + 'mongodb', + 'pgsql', + 'mysql', + 'mariadb', + 'sql-server', + 'alloydb', + 'spanner', + 'cloud-sql', + 'materialize', + 'mindsdb' ]; export const IntegrationTypeSelector: React.FC = ({ onAddExisting, onSelectType }) => { @@ -128,38 +49,46 @@ export const IntegrationTypeSelector: React.FC = {getLocString('integrationsDataWarehousesLakes', 'Data Warehouses & Lakes')}

- {WAREHOUSE_INTEGRATION_TYPES.map((integrationInfo) => ( - - ))} + {WAREHOUSE_INTEGRATION_TYPES.map((type) => { + const label = integrationTypeLabel(type); + + return ( + + ); + })}

{getLocString('integrationsDatabases', 'Databases')}

- {DATABASE_INTEGRATION_TYPES.map((integrationInfo) => ( - - ))} + {DATABASE_INTEGRATION_TYPES.map((type) => { + const label = integrationTypeLabel(type); + + return ( + + ); + })}
diff --git a/src/webviews/webview-side/integrations/integrationUtils.ts b/src/webviews/webview-side/integrations/integrationUtils.ts index cd329c8490..4538e1cf72 100644 --- a/src/webviews/webview-side/integrations/integrationUtils.ts +++ b/src/webviews/webview-side/integrations/integrationUtils.ts @@ -1,3 +1,4 @@ +import { integrationTypeLabelKey } from '../../../platform/notebooks/deepnote/integrationTypeLabels'; import { getLocString } from '../react-common/locReactSide'; import { ConfigurableDatabaseIntegrationType } from './types'; @@ -23,27 +24,13 @@ const mindsdbLogo: string = require('./icons/mindsdb.svg'); const trinoLogo: string = require('./icons/trino.svg'); /* eslint-enable @typescript-eslint/no-require-imports */ -// Localized labels for integration types (duplicated from sqlCellStatusBarProvider.ts due to import restrictions) -export const integrationTypeLabels: Record = { - alloydb: 'Google AlloyDB', - athena: 'Amazon Athena', - 'big-query': 'Google BigQuery', - clickhouse: 'ClickHouse', - 'cloud-sql': 'Google Cloud SQL', - databricks: 'Databricks', - dremio: 'Dremio', - mariadb: 'MariaDB', - materialize: 'Materialize', - mindsdb: 'MindsDB', - mongodb: 'MongoDB', - mysql: 'MySQL', - pgsql: 'PostgreSQL', - redshift: 'Amazon Redshift', - snowflake: 'Snowflake', - spanner: 'Google Spanner', - 'sql-server': 'Microsoft SQL Server', - trino: 'Trino' -}; +/** + * The panel is bundled separately and cannot reach `localize.ts`, so labels resolve against the string bundle. + * The host sends that bundle before the first `update`, so the raw type is a fallback that should never render. + */ +export function integrationTypeLabel(type: ConfigurableDatabaseIntegrationType): string { + return getLocString(integrationTypeLabelKey(type), type); +} // Icon mapping for integration types export const integrationTypeIcons: Record = { @@ -73,6 +60,6 @@ export const integrationTypeIcons: Record Date: Tue, 22 Sep 2026 11:45:56 +0000 Subject: [PATCH 05/35] fix(integrations): validate notebook, cache and scan state in add existing integration Three places where the command acted on state it had not re-derived at the moment it acted: - The panel posts the notebook URI it was opened for, and nothing clears it when that notebook closes. `resolveDeepnoteNotebook` then fell through to the focus fallbacks, so the pick was written into whichever other project happened to be visible, along with its siblings and a kernel env refresh. Reject a supplied URI that no longer resolves; the palette path passes no URI and still needs the fallback. - `persistProjectIntegrations` updates the cache before it writes, so a failed write left the link in the roster. The retry then excluded that id and reported nothing available, while the file still lacked it. Restore the pre-write roster on the failure branch. - `collectReusableIntegrations` only checked the cancellation token at loop heads, so a cancel during the last file, or during `findFiles`, returned `cancelled: false`. That opened an unwanted picker and, in a single-root workspace, claimed no integrations were available after an unfinished scan. Recheck the token before assembling the result. Each fix has a regression test that was seen to fail against the unfixed code. The picker test harness now models `findFiles` resolving empty on a tripped token, which is what let the scan bug go uncovered. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Ns2d146xs51a6E8LeDgJQS --- .../integrations/existingIntegrationPicker.ts | 5 ++ .../existingIntegrationPicker.unit.test.ts | 57 ++++++++++++++++++- .../integrations/integrationManager.ts | 9 +++ .../integrationManager.unit.test.ts | 40 ++++++++++++- src/platform/common/utils/localize.ts | 3 + 5 files changed, 111 insertions(+), 3 deletions(-) diff --git a/src/notebooks/deepnote/integrations/existingIntegrationPicker.ts b/src/notebooks/deepnote/integrations/existingIntegrationPicker.ts index ec207d9a8e..6885336d18 100644 --- a/src/notebooks/deepnote/integrations/existingIntegrationPicker.ts +++ b/src/notebooks/deepnote/integrations/existingIntegrationPicker.ts @@ -153,6 +153,11 @@ export async function collectReusableIntegrations( } } + // `workspace.findFiles` resolves empty when its token trips and the per-file awaits are not token-aware. + if (token?.isCancellationRequested) { + return { cancelled: true, conflictingIds: [], integrations: [] }; + } + // A conflict in any project disqualifies the id everywhere: the stored config is the single shared truth. for (const id of conflictingIds) { candidates.delete(id); diff --git a/src/notebooks/deepnote/integrations/existingIntegrationPicker.unit.test.ts b/src/notebooks/deepnote/integrations/existingIntegrationPicker.unit.test.ts index 80c06ca0bf..b968aaa666 100644 --- a/src/notebooks/deepnote/integrations/existingIntegrationPicker.unit.test.ts +++ b/src/notebooks/deepnote/integrations/existingIntegrationPicker.unit.test.ts @@ -39,6 +39,7 @@ function stubWorkspace(opts: { projects: OnDiskProject[]; unreadable?: Uri[]; hasWorkspaceFolder?: boolean; + onFindFiles?: () => void; onRead?: (uri: Uri) => void; }): { reads: string[]; @@ -49,8 +50,13 @@ function stubWorkspace(opts: { ); const discovered = [...opts.projects.map((project) => project.uri), ...(opts.unreadable ?? [])]; - when(mockedVSCodeNamespaces.workspace.findFiles(anything(), anything(), anything(), anything())).thenReturn( - Promise.resolve(discovered) + when(mockedVSCodeNamespaces.workspace.findFiles(anything(), anything(), anything(), anything())).thenCall( + (_include: unknown, _exclude: unknown, _maxResults: unknown, token?: CancellationToken) => { + opts.onFindFiles?.(); + + // Mirrors the real API, which resolves to no results when its token trips instead of rejecting. + return Promise.resolve(token?.isCancellationRequested ? [] : discovered); + } ); // `persistProjectIntegrations` enumerates without a token; the scan passes one. when(mockedVSCodeNamespaces.workspace.findFiles(anything())).thenReturn(Promise.resolve(discovered)); @@ -293,6 +299,53 @@ suite('existingIntegrationPicker', () => { } }); + test('reports cancellation when the token trips while the last file is read', async () => { + const cts = new CancellationTokenSource(); + + try { + stubWorkspace({ + projects: [ + { + uri: Uri.file('/ws/a.deepnote'), + projectId: 'project-a', + integrations: [{ id: 'pg-shared', name: 'Shared Postgres', type: 'pgsql' }] + } + ], + onRead: () => cts.cancel() + }); + + const result = await collect([], [pgConfig, bqConfig], cts.token); + + assert.deepStrictEqual(result, { cancelled: true, conflictingIds: [], integrations: [] }); + } finally { + cts.dispose(); + } + }); + + test('reports cancellation when the token trips during file discovery', async () => { + const cts = new CancellationTokenSource(); + + try { + const { reads } = stubWorkspace({ + projects: [ + { + uri: Uri.file('/ws/a.deepnote'), + projectId: 'project-a', + integrations: [{ id: 'pg-shared', name: 'Shared Postgres', type: 'pgsql' }] + } + ], + onFindFiles: () => cts.cancel() + }); + + const result = await collect([], [pgConfig, bqConfig], cts.token); + + assert.deepStrictEqual(result, { cancelled: true, conflictingIds: [], integrations: [] }); + assert.deepStrictEqual(reads, [], 'discovery never finished, so no file was visited'); + } finally { + cts.dispose(); + } + }); + test('returns nothing without an open workspace folder', async () => { stubWorkspace({ projects: [], hasWorkspaceFolder: false }); diff --git a/src/notebooks/deepnote/integrations/integrationManager.ts b/src/notebooks/deepnote/integrations/integrationManager.ts index f7739c79e4..3e0b084968 100644 --- a/src/notebooks/deepnote/integrations/integrationManager.ts +++ b/src/notebooks/deepnote/integrations/integrationManager.ts @@ -100,6 +100,13 @@ export class IntegrationManager implements IIntegrationManager { return 'failed'; } + // The panel keeps sending the URI it was opened for, so a closed notebook must not fall back to another project. + if (notebookUri && activeNotebook.uri.toString() !== notebookUri) { + void window.showErrorMessage(localize.Integrations.addExistingIntegrationNotebookClosed); + + return 'failed'; + } + // `*.snapshot.deepnote` matches the notebook selector, so this command can run against a focused snapshot. // The writer skips snapshots, which would report a failure only after the cache had already been updated. if (isSnapshotFile(activeNotebook.uri)) { @@ -215,6 +222,8 @@ export class IntegrationManager implements IIntegrationManager { // panel after a save stay silent; do both explicitly for this project. await this.refreshAfterProjectIntegrationsChange(projectId, activeNotebook); } else { + // The writer moves the cache before the disk write, so an unpersisted link must not stay in it. + this.notebookManager.updateProjectIntegrations(projectId, integrationsAtWrite); void window.showErrorMessage(localize.Integrations.addExistingIntegrationFailed); } } catch (error) { diff --git a/src/notebooks/deepnote/integrations/integrationManager.unit.test.ts b/src/notebooks/deepnote/integrations/integrationManager.unit.test.ts index 993746b728..e35288b385 100644 --- a/src/notebooks/deepnote/integrations/integrationManager.unit.test.ts +++ b/src/notebooks/deepnote/integrations/integrationManager.unit.test.ts @@ -2,7 +2,15 @@ import { deserializeDeepnoteFile, serializeDeepnoteFile, type DeepnoteFile } fro import { assert } from 'chai'; import sinon from 'sinon'; import { anything, deepEqual, instance, mock, verify, when } from 'ts-mockito'; -import { CancellationToken, CancellationTokenSource, NotebookDocument, QuickPickItem, Uri, workspace } from 'vscode'; +import { + CancellationToken, + CancellationTokenSource, + NotebookDocument, + NotebookEditor, + QuickPickItem, + Uri, + workspace +} from 'vscode'; import { ITelemetryService } from '../../../platform/analytics/types'; import { IExtensionContext } from '../../../platform/common/types'; @@ -300,6 +308,23 @@ suite('IntegrationManager.addExistingIntegration', () => { assert.strictEqual(outcome, 'failed'); verify(mockedVSCodeNamespaces.window.showErrorMessage(anything())).once(); }); + + test('fails without writing when the panel names a notebook that is no longer open', async () => { + // The panel stayed open for the other project after its notebook closed; only this project's editor is left. + when(mockedVSCodeNamespaces.workspace.notebookDocuments).thenReturn([currentNotebook]); + when(mockedVSCodeNamespaces.window.visibleNotebookEditors).thenReturn([ + { notebook: currentNotebook } as NotebookEditor + ]); + + const outcome = await buildManager().addExistingIntegration(OTHER_URI.toString()); + + assert.strictEqual(outcome, 'failed'); + assert.strictEqual(writes.size, 0, "the visible project must not be written on the stale panel's behalf"); + assert.deepStrictEqual(cacheUpdates, []); + verify(mockedVSCodeNamespaces.window.showErrorMessage(anything())).once(); + verify(mockedVSCodeNamespaces.window.showQuickPick(anything(), anything())).never(); + verify(telemetry.trackEvent(anything())).never(); + }); // Every branch below reports trouble to the user; without cover they can each regress into silent success. const earlyFailures: { arrange: () => string; name: string }[] = [ { @@ -378,6 +403,19 @@ suite('IntegrationManager.addExistingIntegration', () => { }); } + test('restores the cached integrations when the active file cannot be written', async () => { + writeFailures.add(CURRENT_URI.fsPath); + + const outcome = await buildManager().addExistingIntegration(CURRENT_URI.toString()); + + assert.strictEqual(outcome, 'failed'); + assert.deepStrictEqual( + cacheUpdates, + [[{ id: SHARED_CONFIG.id, name: SHARED_CONFIG.name, type: SHARED_CONFIG.type }], []], + 'the cache ends on the roster it started from, so the retry still offers the integration' + ); + }); + test('completes with a warning when a sibling file of the same project cannot be updated', async () => { onDiskSibling = projectFile(CURRENT_PROJECT_ID, 'notebook-sibling', []); writeFailures.add(SIBLING_URI.fsPath); diff --git a/src/platform/common/utils/localize.ts b/src/platform/common/utils/localize.ts index 38628b35a4..9737040e8e 100644 --- a/src/platform/common/utils/localize.ts +++ b/src/platform/common/utils/localize.ts @@ -868,6 +868,9 @@ export namespace Integrations { export const addExistingIntegrationSnapshotUnsupported = l10n.t( 'Integrations cannot be added to a snapshot file. Open the project notebook and try again.' ); + export const addExistingIntegrationNotebookClosed = l10n.t( + 'The notebook this integrations panel was opened for is no longer open. Open it again and retry.' + ); export const addExistingIntegrationPlaceholder = l10n.t( 'Select an integration configured in another project of this workspace' ); From d3ea64c9a710ed28128bf37fdf914fe146789b6c Mon Sep 17 00:00:00 2001 From: tomas Date: Tue, 22 Sep 2026 12:23:14 +0000 Subject: [PATCH 06/35] refactor(integrations): throw CancellationError from the reusable-integration scan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `collectReusableIntegrations` signalled cancellation with a `cancelled` flag alongside two fields that were always empty when it was set — a sum type encoded as a product. The cost showed up as three identical dead literals and two doc comments whose only job was to warn that the other fields were unsafe to read. It also made the failure silent by default: a caller that forgets to check the flag proceeds on an empty roster and reports "no integrations available" after an unfinished scan, which is the bug fixed in the previous commit. A throw cannot be forgotten. Use the helpers the repo already has — `Cancellation.throwIfCanceled` and `isCancellationError` from `platform/common/cancellation`, as the kernel layer does — so the result type is now always meaningful and both warnings are gone. The only caller handles it in the try/catch that AGENTS.md already prescribes around `withProgress`, and rethrows anything that is not a cancellation, which is what happened before. Verified by mutation: neutralising the three guards fails exactly the three cancellation tests and nothing else. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Ns2d146xs51a6E8LeDgJQS --- .../integrations/existingIntegrationPicker.ts | 21 ++++------ .../existingIntegrationPicker.unit.test.ts | 23 +++++----- .../integrations/integrationManager.ts | 42 ++++++++++++------- 3 files changed, 45 insertions(+), 41 deletions(-) diff --git a/src/notebooks/deepnote/integrations/existingIntegrationPicker.ts b/src/notebooks/deepnote/integrations/existingIntegrationPicker.ts index 6885336d18..cd0ae2a9e0 100644 --- a/src/notebooks/deepnote/integrations/existingIntegrationPicker.ts +++ b/src/notebooks/deepnote/integrations/existingIntegrationPicker.ts @@ -1,5 +1,6 @@ import { CancellationToken, RelativePattern, Uri, workspace } from 'vscode'; +import { Cancellation } from '../../../platform/common/cancellation'; import { readDeepnoteProjectFile } from '../../../platform/deepnote/deepnoteProjectFileReader'; import { logger } from '../../../platform/logging'; import { @@ -27,13 +28,11 @@ export interface CollectReusableIntegrationsParams { integrationStorage: IIntegrationStorage; /** The project being extended; its own `.deepnote` files are skipped. */ projectId: string; - /** Stops the scan; the partial result is only fit to be discarded. */ + /** Aborts the scan with a `CancellationError` rather than handing back a partial result. */ token?: CancellationToken; } export interface CollectReusableIntegrationsResult { - /** The scan stopped early, so the other two fields are partial and must not be written anywhere. */ - cancelled: boolean; /** * Ids skipped because some project declares a type the stored config disagrees with: linking one would put a * type on this project that the credentials cannot back. @@ -59,6 +58,8 @@ export interface AttachExistingIntegrationParams { * * Integrations configured only in `.deepnote.env.yaml` are not offered: that file already applies to every project * under it, and the panel cannot write that layer. + * + * @throws `CancellationError` when `token` trips, so a half-finished scan can never be mistaken for a complete one. */ export async function collectReusableIntegrations( params: CollectReusableIntegrationsParams @@ -70,9 +71,7 @@ export async function collectReusableIntegrations( const visited = new Set(); for (const workspaceFolder of workspace.workspaceFolders || []) { - if (token?.isCancellationRequested) { - return { cancelled: true, conflictingIds: [], integrations: [] }; - } + Cancellation.throwIfCanceled(token); let files: Uri[]; @@ -90,9 +89,7 @@ export async function collectReusableIntegrations( } for (const fileUri of files) { - if (token?.isCancellationRequested) { - return { cancelled: true, conflictingIds: [], integrations: [] }; - } + Cancellation.throwIfCanceled(token); const key = fileUri.toString(); @@ -154,9 +151,7 @@ export async function collectReusableIntegrations( } // `workspace.findFiles` resolves empty when its token trips and the per-file awaits are not token-aware. - if (token?.isCancellationRequested) { - return { cancelled: true, conflictingIds: [], integrations: [] }; - } + Cancellation.throwIfCanceled(token); // A conflict in any project disqualifies the id everywhere: the stored config is the single shared truth. for (const id of conflictingIds) { @@ -170,7 +165,7 @@ export async function collectReusableIntegrations( })) .sort((a, b) => a.name.localeCompare(b.name) || a.id.localeCompare(b.id)); - return { cancelled: false, conflictingIds: Array.from(conflictingIds).sort(), integrations }; + return { conflictingIds: Array.from(conflictingIds).sort(), integrations }; } /** diff --git a/src/notebooks/deepnote/integrations/existingIntegrationPicker.unit.test.ts b/src/notebooks/deepnote/integrations/existingIntegrationPicker.unit.test.ts index b968aaa666..3069fe3d26 100644 --- a/src/notebooks/deepnote/integrations/existingIntegrationPicker.unit.test.ts +++ b/src/notebooks/deepnote/integrations/existingIntegrationPicker.unit.test.ts @@ -1,7 +1,8 @@ import { deserializeDeepnoteFile, serializeDeepnoteFile, type DeepnoteFile } from '@deepnote/blocks'; -import { assert } from 'chai'; +import { assert, use } from 'chai'; +import chaiAsPromised from 'chai-as-promised'; import { anything, instance, mock, when } from 'ts-mockito'; -import { CancellationToken, CancellationTokenSource, Uri, workspace } from 'vscode'; +import { CancellationError, CancellationToken, CancellationTokenSource, Uri, workspace } from 'vscode'; import { ConfigurableDatabaseIntegrationConfig } from '../../../platform/notebooks/deepnote/integrationTypes'; import { mockedVSCodeNamespaces, resetVSCodeMocks } from '../../../test/vscode-mock'; @@ -15,6 +16,8 @@ import { import { buildGoogleOauthIntegration, buildPostgresIntegration } from './federatedAuth/federatedAuthTestHelpers'; import { IIntegrationStorage } from './types'; +use(chaiAsPromised); + const CURRENT_PROJECT_ID = 'project-current'; interface OnDiskProject { @@ -153,7 +156,7 @@ suite('existingIntegrationPicker', () => { { id: 'pg-shared', name: 'Shared Postgres', projectNames: ['Alpha', 'Beta'], type: 'pgsql' }, { id: 'bq-oauth', name: 'Team BigQuery', projectNames: ['Beta'], type: 'big-query' } ]; - assert.deepStrictEqual(result, { cancelled: false, conflictingIds: [], integrations: expected }); + assert.deepStrictEqual(result, { conflictingIds: [], integrations: expected }); }); test('takes the name from the stored config, not from whichever project was read first', async () => { @@ -242,7 +245,7 @@ suite('existingIntegrationPicker', () => { const result = await collect(); - assert.deepStrictEqual(result, { cancelled: false, conflictingIds: ['pg-shared'], integrations: [] }); + assert.deepStrictEqual(result, { conflictingIds: ['pg-shared'], integrations: [] }); }); test('ignores snapshot files and keeps going past an unreadable file', async () => { @@ -290,9 +293,8 @@ suite('existingIntegrationPicker', () => { onRead: () => cts.cancel() }); - const result = await collect([], [pgConfig, bqConfig], cts.token); + await assert.isRejected(collect([], [pgConfig, bqConfig], cts.token), CancellationError); - assert.deepStrictEqual(result, { cancelled: true, conflictingIds: [], integrations: [] }); assert.deepStrictEqual(reads, [Uri.file('/ws/a.deepnote').fsPath], 'the scan must not read on'); } finally { cts.dispose(); @@ -314,9 +316,7 @@ suite('existingIntegrationPicker', () => { onRead: () => cts.cancel() }); - const result = await collect([], [pgConfig, bqConfig], cts.token); - - assert.deepStrictEqual(result, { cancelled: true, conflictingIds: [], integrations: [] }); + await assert.isRejected(collect([], [pgConfig, bqConfig], cts.token), CancellationError); } finally { cts.dispose(); } @@ -337,9 +337,8 @@ suite('existingIntegrationPicker', () => { onFindFiles: () => cts.cancel() }); - const result = await collect([], [pgConfig, bqConfig], cts.token); + await assert.isRejected(collect([], [pgConfig, bqConfig], cts.token), CancellationError); - assert.deepStrictEqual(result, { cancelled: true, conflictingIds: [], integrations: [] }); assert.deepStrictEqual(reads, [], 'discovery never finished, so no file was visited'); } finally { cts.dispose(); @@ -351,7 +350,7 @@ suite('existingIntegrationPicker', () => { const result = await collect(); - assert.deepStrictEqual(result, { cancelled: false, conflictingIds: [], integrations: [] }); + assert.deepStrictEqual(result, { conflictingIds: [], integrations: [] }); }); }); diff --git a/src/notebooks/deepnote/integrations/integrationManager.ts b/src/notebooks/deepnote/integrations/integrationManager.ts index 3e0b084968..1e927d6fb2 100644 --- a/src/notebooks/deepnote/integrations/integrationManager.ts +++ b/src/notebooks/deepnote/integrations/integrationManager.ts @@ -2,6 +2,7 @@ import { inject, injectable, optional } from 'inversify'; import { commands, l10n, NotebookDocument, ProgressLocation, QuickPickItem, window, workspace } from 'vscode'; import { CommandOutcome, ITelemetryService } from '../../../platform/analytics/types'; +import { isCancellationError } from '../../../platform/common/cancellation'; import { IExtensionContext } from '../../../platform/common/types'; import { Commands } from '../../../platform/common/constants'; import * as localize from '../../../platform/common/utils/localize'; @@ -18,6 +19,7 @@ import { DatabaseIntegrationType, databaseIntegrationTypes } from '@deepnote/dat import { attachExistingIntegration, collectReusableIntegrations, + CollectReusableIntegrationsResult, ReusableIntegration } from './existingIntegrationPicker'; import { isSnapshotFile } from '../snapshots/snapshotFiles'; @@ -132,25 +134,33 @@ export class IntegrationManager implements IIntegrationManager { return 'failed'; } - const { cancelled, conflictingIds, integrations } = await window.withProgress( - { - cancellable: true, - location: ProgressLocation.Notification, - title: localize.Integrations.addExistingIntegrationScanning - }, - (_progress, token) => - collectReusableIntegrations({ - excludeIntegrationIds: new Set(currentIntegrations.map((entry) => entry.id)), - integrationStorage: this.integrationStorage, - projectId, - token - }) - ); + let scan: CollectReusableIntegrationsResult; - if (cancelled) { - return 'cancelled'; + try { + scan = await window.withProgress( + { + cancellable: true, + location: ProgressLocation.Notification, + title: localize.Integrations.addExistingIntegrationScanning + }, + (_progress, token) => + collectReusableIntegrations({ + excludeIntegrationIds: new Set(currentIntegrations.map((entry) => entry.id)), + integrationStorage: this.integrationStorage, + projectId, + token + }) + ); + } catch (error) { + if (isCancellationError(error as Error)) { + return 'cancelled'; + } + + throw error; } + const { conflictingIds, integrations } = scan; + if (conflictingIds.length > 0) { void window.showWarningMessage( localize.Integrations.addExistingIntegrationConflictsSkipped(conflictingIds.length) From fa216b406564a1d2c9640cb123d634cbb9f57a70 Mon Sep 17 00:00:00 2001 From: tomas Date: Tue, 22 Sep 2026 12:32:20 +0000 Subject: [PATCH 07/35] refactor(localize): reorder imports and improve comment clarity in Integrations namespace --- src/platform/common/utils/localize.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/platform/common/utils/localize.ts b/src/platform/common/utils/localize.ts index 9737040e8e..39c62d948a 100644 --- a/src/platform/common/utils/localize.ts +++ b/src/platform/common/utils/localize.ts @@ -2,11 +2,11 @@ // Licensed under the MIT License. import { l10n } from 'vscode'; -import { PythonEnvironment } from '../../pythonEnvironments/info'; -import { fromNow } from './date'; import { getPythonEnvDisplayName } from '../../interpreter/helpers'; import { integrationTypeLabelKey, type IntegrationTypeLabelKey } from '../../notebooks/deepnote/integrationTypeLabels'; import type { ConfigurableDatabaseIntegrationType } from '../../notebooks/deepnote/integrationTypes'; +import { PythonEnvironment } from '../../pythonEnvironments/info'; +import { fromNow } from './date'; function getInterpreterDisplayName(interpreter: PythonEnvironment) { return getPythonEnvDisplayName(interpreter); @@ -898,8 +898,8 @@ export namespace Integrations { export const unsupportedIntegrationType = (type: string) => l10n.t('Unsupported integration type: {0}', type); /** - * The one place integration type labels are written down. Keyed by bundle key so `integrationWebview` can - * spread it into the bundle whole; `l10n.t` extracts literal arguments only, so these stay literal. + * The one place integration type labels are written down. + * `l10n.t` extracts literal arguments only, so these stay literal. */ export const typeLabels = { 'integrationType.alloydb': l10n.t('Google AlloyDB'), From c27500a05cf1640137e57800226ccb0b91856e9c Mon Sep 17 00:00:00 2001 From: tomas Date: Tue, 22 Sep 2026 14:44:03 +0000 Subject: [PATCH 08/35] test(e2e): cover the add-existing-integration command end to end Drives the real reuse flow in one temp workspace holding two fixture projects: runs the command on "Quick Notes" while the sibling project only declares an integration it never configured, configures a PostgreSQL integration through the integrations panel, then reuses it from "Sales Analytics". Seeding goes through the panel because the picker offers only ids with a config in SecretStorage, and the panel's Save is its one writer. A `.deepnote.env.yaml` cannot stand in: file-only integrations are excluded from the picker by design, since they already apply to every project under the file. Asserts the rules a unit test cannot reach end to end: - a declaration without stored credentials is not offered - the picked row carries the name, the type label and "Used in: " - the target project's file gains the link and KEEPS the integration it already declared - the credentials never reach that file Both halves were verified to fail against mutated builds: dropping the existing entries in attachExistingIntegration fails the roster assertion, and offering entries with no stored config fails the "nothing to reuse" case. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Ns2d146xs51a6E8LeDgJQS --- .../addExistingIntegration.e2e.test.ts | 337 ++++++++++++++++++ 1 file changed, 337 insertions(+) create mode 100644 test/e2e/suite/workspace/addExistingIntegration.e2e.test.ts diff --git a/test/e2e/suite/workspace/addExistingIntegration.e2e.test.ts b/test/e2e/suite/workspace/addExistingIntegration.e2e.test.ts new file mode 100644 index 0000000000..12b26b7514 --- /dev/null +++ b/test/e2e/suite/workspace/addExistingIntegration.e2e.test.ts @@ -0,0 +1,337 @@ +/** + * E2E (ExTester): "Add Existing Integration" links an integration ANOTHER project in the workspace + * has stored credentials for into the current project — a link, not a copy: the credentials stay in + * SecretStorage and only the project's integration list is rewritten. + * + * Candidates come from SecretStorage, which nothing outside the UI can seed, so the suite configures + * a PostgreSQL integration in one project through the integrations panel and reuses it from the + * other. Runs without a Python kernel. + */ + +import { deserializeDeepnoteFile } from '@deepnote/blocks'; +import { expect } from 'chai'; +import * as fs from 'fs'; +import { By, EditorView, InputBox, QuickPickItem, VSBrowser, WebView, Workbench } from 'vscode-extension-tester'; + +import { + QUICK_PICK_TIMEOUT, + SUITE_TIMEOUT, + WORKBENCH_TIMEOUT, + assertNotNull, + copyFixtureIntoDir, + copyFixtureToTempDir, + createScreenshotter, + dismissAllNotifications, + openFolderViaDialog, + openWorkspaceFile, + waitForNotification +} from '../../helpers'; + +/** Where the shared integration is configured. Its project declares none of its own to begin with. */ +const SOURCE_FILE = 'quick-notes.deepnote'; +const SOURCE_PROJECT_NAME = 'Quick Notes'; +/** Where it is reused. Its project already declares an integration, which the link must not drop. */ +const TARGET_FILE = 'sales-analytics-revenue.deepnote'; +const TARGET_OWN_INTEGRATION = { id: 'int-bq-sales', name: 'Sales BigQuery', type: 'big-query' }; + +const INTEGRATION_NAME = 'E2E Shared Postgres'; +const INTEGRATION_TYPE_LABEL = 'PostgreSQL'; +const INTEGRATION_HOST = 'e2e-postgres.invalid'; +const INTEGRATION_DATABASE = 'e2e_reuse_db'; +const INTEGRATION_USER = 'e2e_reuse_user'; +const INTEGRATION_PASSWORD = 'e2e-reuse-password'; + +const MANAGE_INTEGRATIONS = 'Deepnote: Manage Integrations'; +const ADD_EXISTING_INTEGRATION = 'Deepnote: Add Existing Integration'; +const PICKER_PLACEHOLDER = 'Select an integration configured in another project of this workspace'; + +// Prior editors finish closing before the next notebook opens. +const EDITORS_CLOSE_DELAY = 500; +// Freshly opened notebook paints before we refocus it. +const NOTEBOOK_OPEN_SETTLE_DELAY = 1_500; +// Notebook editor becomes active so the command targets it. +const EDITOR_REFOCUS_DELAY = 500; +// The integrations panel opens and its React app renders. +const PANEL_RENDER_TIMEOUT = 45_000; +// Attach to the panel's webview frame per attempt. +const WEBVIEW_FRAME_SWITCH_TIMEOUT = 6_000; +// Pause between attach attempts. +const WEBVIEW_POLL_INTERVAL = 1_000; +// Saving writes SecretStorage and every `.deepnote` file of the project; poll the file for the result. +const FILE_WRITE_TIMEOUT = 30_000; +const FILE_POLL_INTERVAL = 500; + +type DeclaredIntegration = { id: string; name?: string; type: string }; + +function readDeclaredIntegrations(filePath: string): DeclaredIntegration[] { + return deserializeDeepnoteFile(fs.readFileSync(filePath, 'utf8')).project.integrations ?? []; +} + +/** Closes every editor and makes `fileName` the active one — both commands act on the active notebook. */ +async function focusNotebook(fileName: string): Promise { + const driver = VSBrowser.instance.driver; + + // The integrations panel is an editor too, and it would otherwise still be the active one. + await new EditorView().closeAllEditors().catch((error) => { + console.warn('[add-existing] close editors:', error); + }); + await driver.sleep(EDITORS_CLOSE_DELAY); + + await openWorkspaceFile(fileName); + await driver.sleep(NOTEBOOK_OPEN_SETTLE_DELAY); + await new EditorView().openEditor(fileName).catch((error) => { + console.warn(`[add-existing] refocus ${fileName}:`, error); + }); + await driver.sleep(EDITOR_REFOCUS_DELAY); +} + +/** Polls until the integrations panel has rendered, leaving the driver outside the webview frame. */ +async function awaitIntegrationsPanel(): Promise { + const driver = VSBrowser.instance.driver; + const deadline = Date.now() + PANEL_RENDER_TIMEOUT; + let lastError: unknown; + + while (Date.now() < deadline) { + const webview = new WebView(); + + try { + await webview.switchToFrame(WEBVIEW_FRAME_SWITCH_TIMEOUT); + + if ((await driver.findElements(By.css('.integration-type-selector'))).length > 0) { + return; + } + } catch (error) { + lastError = error; + } finally { + await webview.switchBack().catch((error) => { + console.warn('[add-existing] switch back while waiting for the panel:', error); + }); + } + + await driver.sleep(WEBVIEW_POLL_INTERVAL); + } + + throw new Error(`The integrations panel did not render within ${PANEL_RENDER_TIMEOUT}ms: ${lastError}`); +} + +/** Runs `act` inside the integrations panel's webview frame. */ +async function inIntegrationsPanel(act: () => Promise): Promise { + const webview = new WebView(); + await webview.switchToFrame(WEBVIEW_FRAME_SWITCH_TIMEOUT); + + try { + return await act(); + } finally { + await webview.switchBack().catch((error) => { + console.warn('[add-existing] switch back from the panel:', error); + }); + } +} + +/** + * Fills in and saves a new PostgreSQL integration in the panel of the currently open project, which + * is what puts its credentials in SecretStorage and declares it on that project. + */ +async function configurePostgresIntegration(): Promise { + const driver = VSBrowser.instance.driver; + + await focusNotebook(SOURCE_FILE); + await new Workbench().executeCommand(MANAGE_INTEGRATIONS); + await awaitIntegrationsPanel(); + + await inIntegrationsPanel(async () => { + // Locate AND click in one wait: the grid re-renders as the panel receives its first update. + await driver.wait( + async () => { + for (const card of await driver.findElements(By.css('.integration-type-card'))) { + if (!(await card.getText()).includes(INTEGRATION_TYPE_LABEL)) { + continue; + } + + await card.click(); + + return true; + } + + return false; + }, + PANEL_RENDER_TIMEOUT, + `the "${INTEGRATION_TYPE_LABEL}" integration type card could not be clicked` + ); + + await driver.wait( + async () => (await driver.findElements(By.css('.configuration-form-container #password'))).length > 0, + PANEL_RENDER_TIMEOUT, + 'the PostgreSQL configuration form did not open' + ); + + // Port keeps its 5432 default; the rest are required and start empty except the generated name. + for (const [selector, value] of [ + ['#name', INTEGRATION_NAME], + ['#host', INTEGRATION_HOST], + ['#database', INTEGRATION_DATABASE], + ['#username', INTEGRATION_USER], + ['#password', INTEGRATION_PASSWORD] + ]) { + const field = await driver.findElement(By.css(`.configuration-form-container ${selector}`)); + await field.clear(); + await field.sendKeys(value); + } + + await driver.findElement(By.css('.configuration-form-container .form-actions button.primary')).click(); + }); +} + +/** Polls `filePath` until its project declares an integration named `name`, and returns that entry's id. */ +async function awaitDeclaredIntegrationId(filePath: string, name: string): Promise { + const driver = VSBrowser.instance.driver; + const deadline = Date.now() + FILE_WRITE_TIMEOUT; + let declared: DeclaredIntegration[] = []; + + while (Date.now() < deadline) { + declared = readDeclaredIntegrations(filePath); + + const match = declared.find((entry) => entry.name === name); + + if (match) { + return match.id; + } + + await driver.sleep(FILE_POLL_INTERVAL); + } + + throw new Error( + `${filePath} never declared an integration named "${name}" within ${FILE_WRITE_TIMEOUT}ms; ` + + `last saw ${JSON.stringify(declared)}` + ); +} + +describe('Deepnote — adding an integration another project already configured', function () { + this.timeout(SUITE_TIMEOUT); + + let cleanupTempDir: (() => void) | undefined; + let noneAvailableToastShown = false; + let pickedDescription: string | undefined; + let pickedLabel = ''; + let pickedRowText = ''; + let sharedIntegrationId = ''; + let successToastShown = false; + let targetFileContents = ''; + let targetIntegrations: DeclaredIntegration[] = []; + + before(async function () { + const screenshot = createScreenshotter(this); + + const copy = copyFixtureToTempDir(SOURCE_FILE); + cleanupTempDir = copy.cleanup; + const sourceFilePath = copy.filePath; + const targetFilePath = copyFixtureIntoDir(copy.tempDir, TARGET_FILE); + + await VSBrowser.instance.waitForWorkbench(WORKBENCH_TIMEOUT); + await openFolderViaDialog(copy.tempDir); + await VSBrowser.instance.waitForWorkbench(WORKBENCH_TIMEOUT); + + // Nothing is reusable yet: the other project declares "Sales BigQuery" but has never stored + // credentials for it, and a declaration on its own carries nothing to reuse. + await focusNotebook(SOURCE_FILE); + await new Workbench().executeCommand(ADD_EXISTING_INTEGRATION); + noneAvailableToastShown = + (await waitForNotification( + /No integrations from other projects in this workspace are available to add/i, + WORKBENCH_TIMEOUT, + true + )) !== undefined; + await screenshot('nothing-to-reuse'); + await dismissAllNotifications(); + + await configurePostgresIntegration(); + sharedIntegrationId = await awaitDeclaredIntegrationId(sourceFilePath, INTEGRATION_NAME); + await screenshot('configured-in-the-other-project'); + await dismissAllNotifications(); + + await focusNotebook(TARGET_FILE); + await new Workbench().executeCommand(ADD_EXISTING_INTEGRATION); + + const picker = await InputBox.create(QUICK_PICK_TIMEOUT); + let picked: QuickPickItem | undefined; + + // The command palette reuses this widget, so gate on OUR placeholder before reading the items. + await VSBrowser.instance.driver.wait( + async () => { + if ((await picker.getPlaceHolder().catch(() => '')) !== PICKER_PLACEHOLDER) { + return false; + } + + for (const candidate of await picker.getQuickPicks()) { + if ((await candidate.getLabel()) !== INTEGRATION_NAME) { + continue; + } + + picked = candidate; + + return true; + } + + return false; + }, + QUICK_PICK_TIMEOUT, + `the reuse picker never offered "${INTEGRATION_NAME}"` + ); + await screenshot('reuse-picker'); + + const item = assertNotNull(picked, `the reuse picker never offered "${INTEGRATION_NAME}"`); + + pickedLabel = await item.getLabel(); + pickedDescription = await item.getDescription(); + // The detail line ("Used in: …") has no page object; it is part of the row's rendered text. + pickedRowText = await item.getText(); + await item.select(); + + successToastShown = + (await waitForNotification( + new RegExp(`Added integration "${INTEGRATION_NAME}" to this project`), + WORKBENCH_TIMEOUT, + true + )) !== undefined; + await screenshot('linked-into-this-project'); + + targetIntegrations = readDeclaredIntegrations(targetFilePath); + targetFileContents = fs.readFileSync(targetFilePath, 'utf8'); + }); + + after(async function () { + await new WebView().switchBack().catch(() => undefined); + await new EditorView().closeAllEditors().catch(() => undefined); + try { + cleanupTempDir?.(); + } catch (error) { + console.warn('[add-existing] remove temp workspace dir during cleanup:', error); + } + }); + + it('offers nothing while the other project has only declared an integration, never configured it', function () { + expect(noneAvailableToastShown, '"nothing to reuse" notification').to.equal(true); + }); + + it('offers the integration the other project configured, and names that project', function () { + expect(pickedLabel, 'quick pick label').to.equal(INTEGRATION_NAME); + expect(pickedDescription, 'quick pick description').to.equal(INTEGRATION_TYPE_LABEL); + expect(pickedRowText, 'quick pick row').to.contain(`Used in: ${SOURCE_PROJECT_NAME}`); + }); + + it('confirms the link with a toast', function () { + expect(successToastShown, 'integration-added toast').to.equal(true); + }); + + it('writes the link into this project on disk, keeping the integration it already had', function () { + expect(targetIntegrations, 'integrations declared by the target project').to.deep.equal([ + TARGET_OWN_INTEGRATION, + { id: sharedIntegrationId, name: INTEGRATION_NAME, type: 'pgsql' } + ]); + }); + + it('links the integration without copying its credentials into the file', function () { + expect(targetFileContents, 'target project file').to.not.contain(INTEGRATION_HOST); + expect(targetFileContents, 'target project file').to.not.contain(INTEGRATION_PASSWORD); + }); +}); From ff80aa2dbc6c0a840f0a321317f3f9203da149e7 Mon Sep 17 00:00:00 2001 From: tomas Date: Tue, 22 Sep 2026 16:23:16 +0000 Subject: [PATCH 09/35] test(e2e): collapse the add-existing-integration assertions into one test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The five `it()` blocks shared a single `before()` hook and only read state it had already captured, so the split bought separate mocha records and nothing else: the hook takes ~35s, all five assertion bodies together took 296ms in CI. Each former test name stays as a section comment, and the `expect` message strings still name every assertion in failure output. The tradeoff is fail-fast: mocha now stops at the first broken expectation instead of reporting the others alongside it. Verified by running the suite headlessly — 1 passing (35s), with all four scenario screenshots produced. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Ns2d146xs51a6E8LeDgJQS --- .../addExistingIntegration.e2e.test.ts | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/test/e2e/suite/workspace/addExistingIntegration.e2e.test.ts b/test/e2e/suite/workspace/addExistingIntegration.e2e.test.ts index 12b26b7514..3f0461d4cb 100644 --- a/test/e2e/suite/workspace/addExistingIntegration.e2e.test.ts +++ b/test/e2e/suite/workspace/addExistingIntegration.e2e.test.ts @@ -309,28 +309,27 @@ describe('Deepnote — adding an integration another project already configured' } }); - it('offers nothing while the other project has only declared an integration, never configured it', function () { + // Deliberately one test: every expectation reads state the `before` hook already captured, so + // splitting them buys separate mocha records and nothing else. + it('offers only what the other project configured, and links it without its credentials', function () { + // offers nothing while the other project has only declared an integration, never configured it expect(noneAvailableToastShown, '"nothing to reuse" notification').to.equal(true); - }); - it('offers the integration the other project configured, and names that project', function () { + // offers the integration the other project configured, and names that project expect(pickedLabel, 'quick pick label').to.equal(INTEGRATION_NAME); expect(pickedDescription, 'quick pick description').to.equal(INTEGRATION_TYPE_LABEL); expect(pickedRowText, 'quick pick row').to.contain(`Used in: ${SOURCE_PROJECT_NAME}`); - }); - it('confirms the link with a toast', function () { + // confirms the link with a toast expect(successToastShown, 'integration-added toast').to.equal(true); - }); - it('writes the link into this project on disk, keeping the integration it already had', function () { + // writes the link into this project on disk, keeping the integration it already had expect(targetIntegrations, 'integrations declared by the target project').to.deep.equal([ TARGET_OWN_INTEGRATION, { id: sharedIntegrationId, name: INTEGRATION_NAME, type: 'pgsql' } ]); - }); - it('links the integration without copying its credentials into the file', function () { + // links the integration without copying its credentials into the file expect(targetFileContents, 'target project file').to.not.contain(INTEGRATION_HOST); expect(targetFileContents, 'target project file').to.not.contain(INTEGRATION_PASSWORD); }); From f82d19741d7a68321ac1d107d7e10aa8986193f8 Mon Sep 17 00:00:00 2001 From: tomas Date: Tue, 22 Sep 2026 18:57:44 +0000 Subject: [PATCH 10/35] refactor(integrations): merge an added integration into each file on write MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The writer replaced `project.integrations` wholesale with an array its caller had built, so both callers that only wanted to append one entry had to do their own read-modify-write and own the freshness of that snapshot. `addExistingIntegration` re-read the cache after the pick to keep its snapshot fresh, which narrowed the window rather than closing it: four awaits still sat between that read and `workspace.fs.writeFile`, and anything added to the project in between was stamped away — from every `.deepnote` file of the project at once. `writeIntegrationsToFile` already re-reads each file from disk, so it now builds the array to write from that read via a resolver. `persistProjectIntegrations` passes `() => integrations` and is unchanged byte for byte, so the panel's replace path (how deletes happen) keeps its semantics. The new `addProjectIntegration` passes the merge, and the two append callers use it. Drops from `addExistingIntegration`: the second cache read, its guard, and the cache rollback. `getCachedProjectIntegrations` is now only the scan's exclusion set, where staleness is harmless. Two behavior changes: - The base is the file, not a caller snapshot, so the read and the write are adjacent with only the merge between them. Siblings that had already diverged keep their own entries instead of being flattened. - The cache moves only once the active file is on disk. Previously it moved first; `addExistingIntegration` rolled it back on failure while `sqlCellStatusBarProvider` did not, leaving the cache holding an integration that never reached disk. The new regression test was verified to fail against a mutated writer that ignores what the file holds: it wrote [pg-shared] instead of [added-meanwhile, pg-shared]. The SQL status bar test previously stubbed no filesystem at all and passed because of the bug above; it now has a readable project file and asserts the entry reaches disk. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Ns2d146xs51a6E8LeDgJQS --- .../integrations/existingIntegrationPicker.ts | 11 +- .../existingIntegrationPicker.unit.test.ts | 21 ++-- .../integrations/integrationManager.ts | 18 +-- .../integrationManager.unit.test.ts | 31 ++++- .../integrations/projectIntegrationsWriter.ts | 81 ++++++++++-- .../projectIntegrationsWriter.unit.test.ts | 118 +++++++++++++++++- .../deepnote/sqlCellStatusBarProvider.ts | 19 ++- .../sqlCellStatusBarProvider.unit.test.ts | 37 +++++- 8 files changed, 267 insertions(+), 69 deletions(-) diff --git a/src/notebooks/deepnote/integrations/existingIntegrationPicker.ts b/src/notebooks/deepnote/integrations/existingIntegrationPicker.ts index cd0ae2a9e0..801bb0a79c 100644 --- a/src/notebooks/deepnote/integrations/existingIntegrationPicker.ts +++ b/src/notebooks/deepnote/integrations/existingIntegrationPicker.ts @@ -7,9 +7,9 @@ import { ConfigurableDatabaseIntegrationType, isConfigurableDatabaseIntegrationType } from '../../../platform/notebooks/deepnote/integrationTypes'; -import { IDeepnoteNotebookManager, ProjectIntegration, RawProjectIntegration } from '../../types'; +import { IDeepnoteNotebookManager, ProjectIntegration } from '../../types'; import { isSnapshotFile } from '../snapshots/snapshotFiles'; -import { PersistIntegrationsResult, persistProjectIntegrations } from './projectIntegrationsWriter'; +import { addProjectIntegration, PersistIntegrationsResult } from './projectIntegrationsWriter'; import { IIntegrationStorage } from './types'; /** An integration another project in the workspace has credentials stored for, so linking it needs no re-entry. */ @@ -43,8 +43,6 @@ export interface CollectReusableIntegrationsResult { export interface AttachExistingIntegrationParams { activeFileUri: Uri; - /** The project's full integration list: every entry is written back verbatim, so a filtered array drops entries. */ - currentIntegrations: readonly RawProjectIntegration[]; integration: ReusableIntegration; notebookManager: IDeepnoteNotebookManager; projectId: string; @@ -173,10 +171,9 @@ export async function collectReusableIntegrations( * file and every sibling `.deepnote` file are updated together. Re-linking an id already there replaces its entry. */ export function attachExistingIntegration(params: AttachExistingIntegrationParams): Promise { - const { activeFileUri, currentIntegrations, integration, notebookManager, projectId } = params; + const { activeFileUri, integration, notebookManager, projectId } = params; const linked: ProjectIntegration = { id: integration.id, name: integration.name, type: integration.type }; - const integrations = [...currentIntegrations.filter((entry) => entry.id !== integration.id), linked]; - return persistProjectIntegrations({ activeFileUri, integrations, notebookManager, projectId }); + return addProjectIntegration({ activeFileUri, integration: linked, notebookManager, projectId }); } diff --git a/src/notebooks/deepnote/integrations/existingIntegrationPicker.unit.test.ts b/src/notebooks/deepnote/integrations/existingIntegrationPicker.unit.test.ts index 3069fe3d26..1c125f20ad 100644 --- a/src/notebooks/deepnote/integrations/existingIntegrationPicker.unit.test.ts +++ b/src/notebooks/deepnote/integrations/existingIntegrationPicker.unit.test.ts @@ -389,13 +389,8 @@ suite('existingIntegrationPicker', () => { } ] }); - const currentIntegrations: ProjectIntegration[] = [ - { id: 'bq-own', name: 'Own BigQuery', type: 'big-query' } - ]; - const result = await attachExistingIntegration({ activeFileUri: activeUri, - currentIntegrations, integration: shared, notebookManager, projectId: CURRENT_PROJECT_ID @@ -413,17 +408,16 @@ suite('existingIntegrationPicker', () => { }); test('passes entries of types it cannot manage (e.g. pandas-dataframe) through verbatim', async () => { - const { writes } = stubWorkspace({ - projects: [{ uri: activeUri, projectId: CURRENT_PROJECT_ID }] - }); const currentIntegrations: RawProjectIntegration[] = [ { id: 'duckdb', name: 'DuckDB', type: 'pandas-dataframe' }, { id: 'future', name: 'Unknown to this build', type: 'some-future-type' } ]; + const { writes } = stubWorkspace({ + projects: [{ uri: activeUri, projectId: CURRENT_PROJECT_ID, integrations: currentIntegrations }] + }); await attachExistingIntegration({ activeFileUri: activeUri, - currentIntegrations, integration: shared, notebookManager, projectId: CURRENT_PROJECT_ID @@ -441,12 +435,17 @@ suite('existingIntegrationPicker', () => { test('replaces rather than duplicates an entry whose id the project already declares', async () => { const { writes } = stubWorkspace({ - projects: [{ uri: activeUri, projectId: CURRENT_PROJECT_ID }] + projects: [ + { + uri: activeUri, + projectId: CURRENT_PROJECT_ID, + integrations: [{ id: 'pg-shared', name: 'Old name', type: 'pgsql' }] + } + ] }); await attachExistingIntegration({ activeFileUri: activeUri, - currentIntegrations: [{ id: 'pg-shared', name: 'Old name', type: 'pgsql' }], integration: shared, notebookManager, projectId: CURRENT_PROJECT_ID diff --git a/src/notebooks/deepnote/integrations/integrationManager.ts b/src/notebooks/deepnote/integrations/integrationManager.ts index 1e927d6fb2..fab886278d 100644 --- a/src/notebooks/deepnote/integrations/integrationManager.ts +++ b/src/notebooks/deepnote/integrations/integrationManager.ts @@ -191,23 +191,12 @@ export class IntegrationManager implements IIntegrationManager { } const { integration } = picked; - // The file watcher replaces the cached project on any external write — including one that changes only - // the integrations, which it reloads past without a UI event — so the pre-pick array can be stale, and - // the writer stamps whatever it is given onto every `.deepnote` file of the project. - const integrationsAtWrite = this.getCachedProjectIntegrations(projectId, notebookId); - - if (!integrationsAtWrite) { - void window.showErrorMessage(localize.Integrations.addExistingIntegrationFailed); - - return 'failed'; - } let outcome: CommandOutcome = 'failed'; try { const { activePersisted, siblingsFailed } = await attachExistingIntegration({ activeFileUri: activeNotebook.uri, - currentIntegrations: integrationsAtWrite, integration, notebookManager: this.notebookManager, projectId @@ -232,8 +221,6 @@ export class IntegrationManager implements IIntegrationManager { // panel after a save stay silent; do both explicitly for this project. await this.refreshAfterProjectIntegrationsChange(projectId, activeNotebook); } else { - // The writer moves the cache before the disk write, so an unpersisted link must not stay in it. - this.notebookManager.updateProjectIntegrations(projectId, integrationsAtWrite); void window.showErrorMessage(localize.Integrations.addExistingIntegrationFailed); } } catch (error) { @@ -261,10 +248,7 @@ export class IntegrationManager implements IIntegrationManager { return uri ? String(uri) : undefined; } - /** - * Unfiltered on purpose: `attachExistingIntegration` writes this array back, so anything dropped here is lost. - * `undefined` means the project is not cached, which must not be written back as an empty list. - */ + /** The integrations the project declares, or `undefined` when it is not cached at all. */ private getCachedProjectIntegrations(projectId: string, notebookId: string): RawProjectIntegration[] | undefined { const project = this.notebookManager.getProjectForNotebook(projectId, notebookId); diff --git a/src/notebooks/deepnote/integrations/integrationManager.unit.test.ts b/src/notebooks/deepnote/integrations/integrationManager.unit.test.ts index e35288b385..9f72563a72 100644 --- a/src/notebooks/deepnote/integrations/integrationManager.unit.test.ts +++ b/src/notebooks/deepnote/integrations/integrationManager.unit.test.ts @@ -76,6 +76,7 @@ suite('IntegrationManager.addExistingIntegration', () => { let storedConfigs: ConfigurableDatabaseIntegrationConfig[]; let onDiskOther: DeepnoteFile | undefined; let onDiskSibling: DeepnoteFile | undefined; + let onDiskCurrent: DeepnoteFile | undefined; setup(() => { resetVSCodeMocks(); @@ -93,6 +94,7 @@ suite('IntegrationManager.addExistingIntegration', () => { { id: SHARED_CONFIG.id, name: SHARED_CONFIG.name, type: SHARED_CONFIG.type } ]); onDiskSibling = undefined; + onDiskCurrent = undefined; cacheUpdateError = undefined; storedConfigs = [SHARED_CONFIG]; writes = new Map(); @@ -124,7 +126,7 @@ suite('IntegrationManager.addExistingIntegration', () => { const mockFs = mock(); when(mockFs.readFile(anything())).thenCall((uri: Uri) => { const file = new Map([ - [CURRENT_URI.fsPath, currentProject], + [CURRENT_URI.fsPath, onDiskCurrent ?? currentProject], [OTHER_URI.fsPath, onDiskOther], [SIBLING_URI.fsPath, onDiskSibling] ]).get(uri.fsPath); @@ -403,7 +405,7 @@ suite('IntegrationManager.addExistingIntegration', () => { }); } - test('restores the cached integrations when the active file cannot be written', async () => { + test('leaves the cache alone when the active file cannot be written', async () => { writeFailures.add(CURRENT_URI.fsPath); const outcome = await buildManager().addExistingIntegration(CURRENT_URI.toString()); @@ -411,8 +413,8 @@ suite('IntegrationManager.addExistingIntegration', () => { assert.strictEqual(outcome, 'failed'); assert.deepStrictEqual( cacheUpdates, - [[{ id: SHARED_CONFIG.id, name: SHARED_CONFIG.name, type: SHARED_CONFIG.type }], []], - 'the cache ends on the roster it started from, so the retry still offers the integration' + [], + 'an unpersisted link never enters the cache, so the retry still offers the integration' ); }); @@ -430,7 +432,7 @@ suite('IntegrationManager.addExistingIntegration', () => { test('writes the integrations as they stand after the pick, not the snapshot taken before it', async () => { when(mockedVSCodeNamespaces.window.showQuickPick(anything(), anything())).thenCall((items: QuickPickItem[]) => { - // What the file watcher does to the cache when another writer touches the file mid-pick. + // Another writer touching the project file mid-pick; the writer merges into what it finds there. currentProject = projectFile(CURRENT_PROJECT_ID, CURRENT_NOTEBOOK_ID, [ { id: 'added-meanwhile', name: 'Added meanwhile', type: 'mysql' } ]); @@ -447,6 +449,25 @@ suite('IntegrationManager.addExistingIntegration', () => { ]); }); + test('keeps an entry the file gained while the picker was open, though the cache never saw it', async () => { + when(mockedVSCodeNamespaces.window.showQuickPick(anything(), anything())).thenCall((items: QuickPickItem[]) => { + // Only disk moves: the cached project stays on the roster the command read before the scan. + onDiskCurrent = projectFile(CURRENT_PROJECT_ID, CURRENT_NOTEBOOK_ID, [ + { id: 'added-meanwhile', name: 'Added meanwhile', type: 'mysql' } + ]); + + return Promise.resolve(items[0]); + }); + + const outcome = await buildManager().addExistingIntegration(CURRENT_URI.toString()); + + assert.strictEqual(outcome, 'completed'); + assert.deepStrictEqual(writes.get(CURRENT_URI.fsPath)?.project.integrations, [ + { id: 'added-meanwhile', name: 'Added meanwhile', type: 'mysql' }, + { id: 'pg-shared', name: 'Shared Postgres', type: 'pgsql' } + ]); + }); + test('returns cancelled and writes nothing when the scan is cancelled', async () => { scanProgress.cancel(); diff --git a/src/notebooks/deepnote/integrations/projectIntegrationsWriter.ts b/src/notebooks/deepnote/integrations/projectIntegrationsWriter.ts index e9f56ba8a7..3aff209e99 100644 --- a/src/notebooks/deepnote/integrations/projectIntegrationsWriter.ts +++ b/src/notebooks/deepnote/integrations/projectIntegrationsWriter.ts @@ -19,13 +19,31 @@ export interface PersistProjectIntegrationsParams { activeFileUri: Uri; } +export interface AddProjectIntegrationParams { + activeFileUri: Uri; + integration: RawProjectIntegration; + notebookManager: IDeepnoteNotebookManager; + projectId: string; +} + +interface WriteProjectFilesParams { + activeFileUri: Uri; + projectId: string; + resolve: ResolveIntegrations; +} + interface WriteIntegrationsToFileParams { fileUri: Uri; projectId: string; - integrations: RawProjectIntegration[]; + resolve: ResolveIntegrations; } -type IntegrationWriteOutcome = 'failed' | 'skipped' | 'written'; +/** Builds the array to write from the one the file holds, read a step earlier in the same function. */ +type ResolveIntegrations = (existing: RawProjectIntegration[]) => RawProjectIntegration[]; + +type IntegrationWriteOutcome = + | { status: 'failed' | 'skipped' } + | { status: 'written'; integrations: RawProjectIntegration[] }; /** Writes `integrations` to the active file and every on-disk sibling; `activePersisted` reflects disk truth, not the cache. */ export async function persistProjectIntegrations( @@ -36,8 +54,45 @@ export async function persistProjectIntegrations( // Refresh the cache first so live env/kernel behavior stays correct even if a disk write fails. notebookManager.updateProjectIntegrations(projectId, integrations); + const { active, siblingsFailed } = await writeProjectFiles({ + activeFileUri, + projectId, + resolve: () => integrations + }); + + return { activePersisted: active.status === 'written', siblingsFailed }; +} + +/** + * Adds one integration to the project, merging into what each file holds instead of replacing the list: a caller + * holding a snapshot across a prompt cannot delete entries written while it waited. An id already present is + * replaced. The cache follows the active file and moves only once that file is on disk. + */ +export async function addProjectIntegration(params: AddProjectIntegrationParams): Promise { + const { activeFileUri, integration, notebookManager, projectId } = params; + + const { active, siblingsFailed } = await writeProjectFiles({ + activeFileUri, + projectId, + resolve: (existing) => [...existing.filter((entry) => entry.id !== integration.id), integration] + }); + + if (active.status !== 'written') { + return { activePersisted: false, siblingsFailed }; + } + + notebookManager.updateProjectIntegrations(projectId, active.integrations); + + return { activePersisted: true, siblingsFailed }; +} + +async function writeProjectFiles( + params: WriteProjectFilesParams +): Promise<{ active: IntegrationWriteOutcome; siblingsFailed: number }> { + const { activeFileUri, projectId, resolve } = params; + // findFiles only covers open folders, so write the active file explicitly (no open folder / out-of-workspace). - const activeOutcome = await writeIntegrationsToFile({ fileUri: activeFileUri, projectId, integrations }); + const active = await writeIntegrationsToFile({ fileUri: activeFileUri, projectId, resolve }); const visited = new Set([activeFileUri.toString()]); let siblingsFailed = 0; @@ -60,45 +115,47 @@ export async function persistProjectIntegrations( visited.add(fileUri.toString()); - if ((await writeIntegrationsToFile({ fileUri, projectId, integrations })) === 'failed') { + if ((await writeIntegrationsToFile({ fileUri, projectId, resolve })).status === 'failed') { siblingsFailed++; } } } - return { activePersisted: activeOutcome === 'written', siblingsFailed }; + return { active, siblingsFailed }; } /** Returns `'skipped'` (snapshot / other project), `'failed'`, or `'written'` for one `.deepnote` file. */ async function writeIntegrationsToFile(params: WriteIntegrationsToFileParams): Promise { - const { fileUri, projectId, integrations } = params; + const { fileUri, projectId, resolve } = params; if (isSnapshotFile(fileUri)) { - return 'skipped'; + return { status: 'skipped' }; } try { let projectData = await readDeepnoteProjectFile(fileUri); if (projectData?.project?.id !== projectId) { - return 'skipped'; + return { status: 'skipped' }; } // Flush an open dirty file and re-read, so live cell edits aren't clobbered by the watcher reload. if (!(await flushNotebookDocumentIfDirty(fileUri))) { logger.warn(`persistProjectIntegrations: ${fileUri.path} — unsaved edits could not be saved`); - return 'failed'; + return { status: 'failed' }; } projectData = await readDeepnoteProjectFile(fileUri); // The flush may have saved a stale open document, swapping the on-disk project; re-validate before writing. if (projectData?.project?.id !== projectId) { - return 'skipped'; + return { status: 'skipped' }; } // Rewrite ONLY integrations; every other field round-trips from disk, so saved cells are untouched. + const integrations = resolve(projectData.project.integrations ?? []); + projectData.project.integrations = integrations; if (!projectData.metadata) { @@ -109,10 +166,10 @@ async function writeIntegrationsToFile(params: WriteIntegrationsToFileParams): P await workspace.fs.writeFile(fileUri, new TextEncoder().encode(serializeDeepnoteFile(projectData))); - return 'written'; + return { integrations, status: 'written' }; } catch (error) { logger.error(`persistProjectIntegrations: failed to update ${fileUri.path}`, error); - return 'failed'; + return { status: 'failed' }; } } diff --git a/src/notebooks/deepnote/integrations/projectIntegrationsWriter.unit.test.ts b/src/notebooks/deepnote/integrations/projectIntegrationsWriter.unit.test.ts index ff26585630..29669dbddc 100644 --- a/src/notebooks/deepnote/integrations/projectIntegrationsWriter.unit.test.ts +++ b/src/notebooks/deepnote/integrations/projectIntegrationsWriter.unit.test.ts @@ -12,17 +12,22 @@ import { createDeepnoteProject, createWorkspaceFolder } from '../deepnoteTestHelpers'; -import { persistProjectIntegrations } from './projectIntegrationsWriter'; +import { addProjectIntegration, persistProjectIntegrations } from './projectIntegrationsWriter'; const PROJECT_ID = 'project-1'; const NEW_INTEGRATIONS: ProjectIntegration[] = [{ id: 'int-new', name: 'New BigQuery', type: 'big-query' }]; -function projectFile(notebookId: string, projectId: string = PROJECT_ID): DeepnoteFile { +function projectFile( + notebookId: string, + projectId: string = PROJECT_ID, + integrations: ProjectIntegration[] = [] +): DeepnoteFile { return createDeepnoteFile({ metadata: { createdAt: '2020-01-01T00:00:00Z', modifiedAt: '2021-01-01T00:00:00Z' }, project: createDeepnoteProject({ id: projectId, + integrations, name: 'Proj', notebooks: [ createDeepnoteNotebook({ @@ -237,3 +242,112 @@ suite('persistProjectIntegrations', () => { assert.isFalse(writes.has(siblingUri.fsPath), 'integrations must NOT be written into the swapped project'); }); }); + +suite('addProjectIntegration', () => { + const ADDED: ProjectIntegration = { id: 'int-added', name: 'Added Postgres', type: 'pgsql' }; + + let cacheUpdates: ProjectIntegration[][]; + let managerInstance: IDeepnoteNotebookManager; + + setup(() => { + resetVSCodeMocks(); + + cacheUpdates = []; + const mockManager = mock(); + when(mockManager.updateProjectIntegrations(anything(), anything())).thenCall( + (_projectId: string, integrations: ProjectIntegration[]) => { + cacheUpdates.push(integrations); + + return true; + } + ); + managerInstance = instance(mockManager); + }); + + test('merges into each file rather than stamping one roster over them all', async () => { + const activeUri = Uri.file('/ws/active.deepnote'); + const siblingUri = Uri.file('/ws/sibling.deepnote'); + const ownEntry: ProjectIntegration = { id: 'int-own', name: 'Own BigQuery', type: 'big-query' }; + const { writes } = stubWorkspace({ + onDisk: [ + { uri: activeUri, file: projectFile('nb-active') }, + { uri: siblingUri, file: projectFile('nb-sibling', PROJECT_ID, [ownEntry]) } + ], + discovered: [activeUri, siblingUri] + }); + + const result = await addProjectIntegration({ + activeFileUri: activeUri, + integration: ADDED, + notebookManager: managerInstance, + projectId: PROJECT_ID + }); + + assert.deepStrictEqual(result, { activePersisted: true, siblingsFailed: 0 }); + assert.deepStrictEqual(writes.get(activeUri.fsPath)!.project.integrations, [ADDED]); + assert.deepStrictEqual( + writes.get(siblingUri.fsPath)!.project.integrations, + [ownEntry, ADDED], + "the sibling's own entry survives an add driven from another file" + ); + }); + + test('replaces an id the file already declares instead of duplicating it', async () => { + const activeUri = Uri.file('/ws/active.deepnote'); + const { writes } = stubWorkspace({ + onDisk: [ + { + uri: activeUri, + file: projectFile('nb-active', PROJECT_ID, [{ ...ADDED, name: 'Stale name' }]) + } + ], + discovered: [activeUri] + }); + + await addProjectIntegration({ + activeFileUri: activeUri, + integration: ADDED, + notebookManager: managerInstance, + projectId: PROJECT_ID + }); + + assert.deepStrictEqual(writes.get(activeUri.fsPath)!.project.integrations, [ADDED]); + }); + + test('leaves the cache untouched when the active file cannot be written', async () => { + const activeUri = Uri.file('/ws/active.deepnote'); + stubWorkspace({ + onDisk: [{ uri: activeUri, file: projectFile('nb-active') }], + discovered: [activeUri], + failWriteFor: new Set([activeUri.fsPath]) + }); + + const result = await addProjectIntegration({ + activeFileUri: activeUri, + integration: ADDED, + notebookManager: managerInstance, + projectId: PROJECT_ID + }); + + assert.strictEqual(result.activePersisted, false); + assert.deepStrictEqual(cacheUpdates, [], 'nothing reaches the cache that did not reach the disk'); + }); + + test('moves the cache to what the active file actually gained', async () => { + const activeUri = Uri.file('/ws/active.deepnote'); + const ownEntry: ProjectIntegration = { id: 'int-own', name: 'Own BigQuery', type: 'big-query' }; + stubWorkspace({ + onDisk: [{ uri: activeUri, file: projectFile('nb-active', PROJECT_ID, [ownEntry]) }], + discovered: [activeUri] + }); + + await addProjectIntegration({ + activeFileUri: activeUri, + integration: ADDED, + notebookManager: managerInstance, + projectId: PROJECT_ID + }); + + assert.deepStrictEqual(cacheUpdates, [[ownEntry, ADDED]]); + }); +}); diff --git a/src/notebooks/deepnote/sqlCellStatusBarProvider.ts b/src/notebooks/deepnote/sqlCellStatusBarProvider.ts index e2915da38a..0aa9b1887f 100644 --- a/src/notebooks/deepnote/sqlCellStatusBarProvider.ts +++ b/src/notebooks/deepnote/sqlCellStatusBarProvider.ts @@ -28,7 +28,7 @@ import { isConfigurableDatabaseIntegrationType, toTelemetryIntegrationType } from '../../platform/notebooks/deepnote/integrationTypes'; -import { persistProjectIntegrations } from './integrations/projectIntegrationsWriter'; +import { addProjectIntegration } from './integrations/projectIntegrationsWriter'; import { IDeepnoteNotebookManager, RawProjectIntegration } from '../types'; import { logger } from '../../platform/logging'; import { ISqlIntegrationEnvVarsProvider } from '../../platform/notebooks/deepnote/types'; @@ -432,15 +432,10 @@ export class SqlCellStatusBarProvider implements NotebookCellStatusBarItemProvid this._onDidChangeCellStatusBarItems.fire(); } - /** - * Appends a picked integration to the project's integrations so the `.deepnote` file records what it uses. - * Additive only: existing entries pass through verbatim, never filtered, because a project's integrations - * are shared with sibling notebooks whose blocks this cannot see — dropping one would be data loss. - */ + /** Appends a picked integration to the project's integrations so the `.deepnote` file records what it uses. */ private async addToProjectIntegrations( cell: NotebookCell, projectId: string, - projectIntegrations: RawProjectIntegration[], selected: RawProjectIntegration ): Promise { // No usable `type` means no valid entry; leave it out rather than guessing one. @@ -449,11 +444,11 @@ export class SqlCellStatusBarProvider implements NotebookCellStatusBarItemProvid } try { - await persistProjectIntegrations({ + await addProjectIntegration({ + activeFileUri: cell.notebook.uri, + integration: selected, notebookManager: this.notebookManager, - projectId, - integrations: [...projectIntegrations, selected], - activeFileUri: cell.notebook.uri + projectId }); } catch (error) { // The cell metadata edit already succeeded, so the selection stands either way. @@ -638,7 +633,7 @@ export class SqlCellStatusBarProvider implements NotebookCellStatusBarItemProvid selectedIntegration !== undefined && !projectIntegrations.some((integration) => integration.id === selectedId); if (selectedIntegration && fromEnvFile) { - await this.addToProjectIntegrations(cell, projectId, projectIntegrations, selectedIntegration); + await this.addToProjectIntegrations(cell, projectId, selectedIntegration); } // Trigger status bar update diff --git a/src/notebooks/deepnote/sqlCellStatusBarProvider.unit.test.ts b/src/notebooks/deepnote/sqlCellStatusBarProvider.unit.test.ts index 33b36f1706..5645a362c7 100644 --- a/src/notebooks/deepnote/sqlCellStatusBarProvider.unit.test.ts +++ b/src/notebooks/deepnote/sqlCellStatusBarProvider.unit.test.ts @@ -1,6 +1,7 @@ +import { deserializeDeepnoteFile, serializeDeepnoteFile, type DeepnoteFile } from '@deepnote/blocks'; import { assert } from 'chai'; import { anything, capture, deepEqual, instance, mock, verify, when } from 'ts-mockito'; -import { CancellationToken, CancellationTokenSource, EventEmitter, NotebookCell } from 'vscode'; +import { CancellationToken, CancellationTokenSource, EventEmitter, NotebookCell, Uri, workspace } from 'vscode'; import { IDisposableRegistry } from '../../platform/common/types'; import { IIntegrationStorage } from './integrations/types'; @@ -11,9 +12,32 @@ import { mockedVSCodeNamespaces, resetVSCodeMocks } from '../../test/vscode-mock import { createEventHandler } from '../../test/common'; import { Commands } from '../../platform/common/constants'; import { IDeepnoteNotebookManager } from '../types'; -import { createMockCell } from './deepnoteTestHelpers'; +import { createDeepnoteFile, createDeepnoteProject, createMockCell } from './deepnoteTestHelpers'; import { ISqlIntegrationEnvVarsProvider } from '../../platform/notebooks/deepnote/types'; +/** Puts a readable project file behind `uri`; the returned map captures what the integrations writer persists. */ +function stubProjectFileOnDisk(uri: Uri, projectId: string): Map { + const onDisk = createDeepnoteFile({ project: createDeepnoteProject({ id: projectId, name: projectId }) }); + const writes = new Map(); + const mockFs = mock(); + + when(mockedVSCodeNamespaces.workspace.workspaceFolders).thenReturn(undefined); + when(mockedVSCodeNamespaces.workspace.notebookDocuments).thenReturn([]); + when(mockFs.readFile(anything())).thenCall((target: Uri) => + target.fsPath === uri.fsPath + ? Promise.resolve(new TextEncoder().encode(serializeDeepnoteFile(onDisk))) + : Promise.reject(new Error(`no readFile stub for ${target.fsPath}`)) + ); + when(mockFs.writeFile(anything(), anything())).thenCall((target: Uri, bytes: Uint8Array) => { + writes.set(target.fsPath, deserializeDeepnoteFile(new TextDecoder().decode(bytes))); + + return Promise.resolve(); + }); + when(mockedVSCodeNamespaces.workspace.fs).thenReturn(instance(mockFs)); + + return writes; +} + /** * A merged-config source that yields nothing, so integrations resolve from SecretStorage alone. * These suites never assert against it, hence an instance rather than a mock handle. @@ -1224,6 +1248,7 @@ suite('SqlCellStatusBarProvider', () => { const notebookMetadata = { deepnoteProjectId: 'project-1', deepnoteNotebookId: 'notebook-1' }; const cell = createMockCell({ languageId: 'sql', metadata: {}, notebookMetadata }); const fileOnlyId = 'file-only-bq'; + const writes = stubProjectFileOnDisk(cell.notebook.uri, 'project-1'); const envVars = mock(); when(envVars.getMergedIntegrationConfigs(anything())).thenResolve([ @@ -1248,9 +1273,15 @@ suite('SqlCellStatusBarProvider', () => { await switchIntegrationHandler(cell); + const expected = [{ id: fileOnlyId, name: 'BigQuery from file', type: 'big-query' }]; const [projectId, integrations] = capture(commandNotebookManager.updateProjectIntegrations).last(); assert.strictEqual(projectId, 'project-1'); - assert.deepStrictEqual(integrations, [{ id: fileOnlyId, name: 'BigQuery from file', type: 'big-query' }]); + assert.deepStrictEqual(integrations, expected); + assert.deepStrictEqual( + writes.get(cell.notebook.uri.fsPath)?.project.integrations, + expected, + 'the entry reaches the file, not just the cache' + ); verify( fileTelemetry.trackEvent( deepEqual({ From aa03370010de70fa57f947930fc63f9dd6e72953 Mon Sep 17 00:00:00 2001 From: tomas Date: Tue, 22 Sep 2026 19:17:02 +0000 Subject: [PATCH 11/35] fix(integrations): move the cache update ahead of the sibling sweep f82d19741 moved the cache update to after the whole sibling sweep, which is the slow half of the write: findFiles plus a read and a write per file. Saving a notebook rebuilds its entire `.deepnote` from the cached project (`deepnoteSerializer` reads the cache, stores it back, then serializes it), so a save landing inside that window wrote the pre-add roster straight back over the active file, and the watcher reloaded the cache from that. End state: siblings carried the link, the file the user was working in did not, and the success toast had already been shown. Split the sweep out of `writeProjectFiles` so the cache moves between the active write and the siblings. Both invariants hold: nothing reaches the cache that did not reach the disk, and the write is the last await before the cache moves. The sweep still runs when the active write fails, as before. The new test asserts the order is active-write, cache, sibling-write; it was verified to fail against f82d19741's ordering and nothing else did. Also: the doc comment on `writeIntegrationsToFile` still described the string outcomes it stopped returning, the log prefixes still said `persistProjectIntegrations` on lines the new entry point also reaches, and one test message claimed nothing reached disk in a case that now writes the file before the cache throws. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Ns2d146xs51a6E8LeDgJQS --- .../integrationManager.unit.test.ts | 2 +- .../integrations/projectIntegrationsWriter.ts | 50 +++++++++---------- .../projectIntegrationsWriter.unit.test.ts | 34 +++++++++++++ 3 files changed, 59 insertions(+), 27 deletions(-) diff --git a/src/notebooks/deepnote/integrations/integrationManager.unit.test.ts b/src/notebooks/deepnote/integrations/integrationManager.unit.test.ts index 9f72563a72..fb056fddb9 100644 --- a/src/notebooks/deepnote/integrations/integrationManager.unit.test.ts +++ b/src/notebooks/deepnote/integrations/integrationManager.unit.test.ts @@ -392,7 +392,7 @@ suite('IntegrationManager.addExistingIntegration', () => { const outcome = await buildManager().addExistingIntegration(CURRENT_URI.toString()); assert.strictEqual(outcome, 'failed'); - assert.isTrue(refreshSpy.notCalled, 'nothing reached disk for the kernels to pick up'); + assert.isTrue(refreshSpy.notCalled, 'the refresh reserved for a successful link never runs'); verify(mockedVSCodeNamespaces.window.showErrorMessage(anything())).once(); verify( telemetry.trackEvent( diff --git a/src/notebooks/deepnote/integrations/projectIntegrationsWriter.ts b/src/notebooks/deepnote/integrations/projectIntegrationsWriter.ts index 3aff209e99..2c5cf0029d 100644 --- a/src/notebooks/deepnote/integrations/projectIntegrationsWriter.ts +++ b/src/notebooks/deepnote/integrations/projectIntegrationsWriter.ts @@ -26,7 +26,7 @@ export interface AddProjectIntegrationParams { projectId: string; } -interface WriteProjectFilesParams { +interface WriteSiblingFilesParams { activeFileUri: Uri; projectId: string; resolve: ResolveIntegrations; @@ -51,14 +51,14 @@ export async function persistProjectIntegrations( ): Promise { const { notebookManager, projectId, integrations, activeFileUri } = params; + const resolve: ResolveIntegrations = () => integrations; + // Refresh the cache first so live env/kernel behavior stays correct even if a disk write fails. notebookManager.updateProjectIntegrations(projectId, integrations); - const { active, siblingsFailed } = await writeProjectFiles({ - activeFileUri, - projectId, - resolve: () => integrations - }); + // findFiles only covers open folders, so write the active file explicitly (no open folder / out-of-workspace). + const active = await writeIntegrationsToFile({ fileUri: activeFileUri, projectId, resolve }); + const siblingsFailed = await writeSiblingFiles({ activeFileUri, projectId, resolve }); return { activePersisted: active.status === 'written', siblingsFailed }; } @@ -70,30 +70,28 @@ export async function persistProjectIntegrations( */ export async function addProjectIntegration(params: AddProjectIntegrationParams): Promise { const { activeFileUri, integration, notebookManager, projectId } = params; + const resolve: ResolveIntegrations = (existing) => [ + ...existing.filter((entry) => entry.id !== integration.id), + integration + ]; - const { active, siblingsFailed } = await writeProjectFiles({ - activeFileUri, - projectId, - resolve: (existing) => [...existing.filter((entry) => entry.id !== integration.id), integration] - }); + const active = await writeIntegrationsToFile({ fileUri: activeFileUri, projectId, resolve }); - if (active.status !== 'written') { - return { activePersisted: false, siblingsFailed }; + // Before the sibling sweep, not after: saving a notebook rebuilds its whole file from the cached project, so a + // cache still on the old roster would write the link straight back out mid-sweep. + if (active.status === 'written') { + notebookManager.updateProjectIntegrations(projectId, active.integrations); } - notebookManager.updateProjectIntegrations(projectId, active.integrations); + const siblingsFailed = await writeSiblingFiles({ activeFileUri, projectId, resolve }); - return { activePersisted: true, siblingsFailed }; + return { activePersisted: active.status === 'written', siblingsFailed }; } -async function writeProjectFiles( - params: WriteProjectFilesParams -): Promise<{ active: IntegrationWriteOutcome; siblingsFailed: number }> { +/** Writes every discovered `.deepnote` of the project except `activeFileUri`; returns how many failed. */ +async function writeSiblingFiles(params: WriteSiblingFilesParams): Promise { const { activeFileUri, projectId, resolve } = params; - // findFiles only covers open folders, so write the active file explicitly (no open folder / out-of-workspace). - const active = await writeIntegrationsToFile({ fileUri: activeFileUri, projectId, resolve }); - const visited = new Set([activeFileUri.toString()]); let siblingsFailed = 0; @@ -103,7 +101,7 @@ async function writeProjectFiles( try { files = await workspace.findFiles(new RelativePattern(workspaceFolder, '**/*.deepnote')); } catch (error) { - logger.error('persistProjectIntegrations: failed to enumerate .deepnote files', error); + logger.error('projectIntegrationsWriter: failed to enumerate .deepnote files', error); continue; } @@ -121,10 +119,10 @@ async function writeProjectFiles( } } - return { active, siblingsFailed }; + return siblingsFailed; } -/** Returns `'skipped'` (snapshot / other project), `'failed'`, or `'written'` for one `.deepnote` file. */ +/** Writes one `.deepnote` file: `skipped` for a snapshot or another project, `written` carries what it now holds. */ async function writeIntegrationsToFile(params: WriteIntegrationsToFileParams): Promise { const { fileUri, projectId, resolve } = params; @@ -141,7 +139,7 @@ async function writeIntegrationsToFile(params: WriteIntegrationsToFileParams): P // Flush an open dirty file and re-read, so live cell edits aren't clobbered by the watcher reload. if (!(await flushNotebookDocumentIfDirty(fileUri))) { - logger.warn(`persistProjectIntegrations: ${fileUri.path} — unsaved edits could not be saved`); + logger.warn(`projectIntegrationsWriter: ${fileUri.path} — unsaved edits could not be saved`); return { status: 'failed' }; } @@ -168,7 +166,7 @@ async function writeIntegrationsToFile(params: WriteIntegrationsToFileParams): P return { integrations, status: 'written' }; } catch (error) { - logger.error(`persistProjectIntegrations: failed to update ${fileUri.path}`, error); + logger.error(`projectIntegrationsWriter: failed to update ${fileUri.path}`, error); return { status: 'failed' }; } diff --git a/src/notebooks/deepnote/integrations/projectIntegrationsWriter.unit.test.ts b/src/notebooks/deepnote/integrations/projectIntegrationsWriter.unit.test.ts index 29669dbddc..cbbf9d6985 100644 --- a/src/notebooks/deepnote/integrations/projectIntegrationsWriter.unit.test.ts +++ b/src/notebooks/deepnote/integrations/projectIntegrationsWriter.unit.test.ts @@ -46,6 +46,7 @@ function stubWorkspace(opts: { dirtyDocuments?: Array<{ uri: Uri; onSave: (onDisk: Map) => void }>; hasWorkspaceFolder?: boolean; failWriteFor?: Set; + onWrite?: (uri: Uri) => void; }): { writes: Map } { when(mockedVSCodeNamespaces.workspace.workspaceFolders).thenReturn( opts.hasWorkspaceFolder === false ? undefined : [createWorkspaceFolder(Uri.file('/ws'))] @@ -85,6 +86,7 @@ function stubWorkspace(opts: { return Promise.reject(new Error(`write failed for ${uri.fsPath}`)); } + opts.onWrite?.(uri); writes.set(uri.fsPath, deserializeDeepnoteFile(new TextDecoder().decode(bytes))); return Promise.resolve(); @@ -333,6 +335,38 @@ suite('addProjectIntegration', () => { assert.deepStrictEqual(cacheUpdates, [], 'nothing reaches the cache that did not reach the disk'); }); + test('moves the cache before sweeping siblings, so a save mid-sweep cannot revert the add', async () => { + const activeUri = Uri.file('/ws/active.deepnote'); + const siblingUri = Uri.file('/ws/sibling.deepnote'); + // Saving a notebook rebuilds its file from the cached project, so anything written while the cache still + // holds the old roster gets undone. Ordering is the only thing keeping that window shut. + const order: string[] = []; + const { writes } = stubWorkspace({ + onDisk: [ + { uri: activeUri, file: projectFile('nb-active') }, + { uri: siblingUri, file: projectFile('nb-sibling') } + ], + discovered: [activeUri, siblingUri], + onWrite: (uri) => order.push(uri.fsPath === siblingUri.fsPath ? 'sibling-write' : 'active-write') + }); + const mockManager = mock(); + when(mockManager.updateProjectIntegrations(anything(), anything())).thenCall(() => { + order.push('cache'); + + return true; + }); + + await addProjectIntegration({ + activeFileUri: activeUri, + integration: ADDED, + notebookManager: instance(mockManager), + projectId: PROJECT_ID + }); + + assert.deepStrictEqual(order, ['active-write', 'cache', 'sibling-write']); + assert.deepStrictEqual(writes.get(siblingUri.fsPath)!.project.integrations, [ADDED]); + }); + test('moves the cache to what the active file actually gained', async () => { const activeUri = Uri.file('/ws/active.deepnote'); const ownEntry: ProjectIntegration = { id: 'int-own', name: 'Own BigQuery', type: 'big-query' }; From ab1a81e8d73b349c0760fa44e3dde11b5ce91bcb Mon Sep 17 00:00:00 2001 From: tomas Date: Tue, 22 Sep 2026 19:22:43 +0000 Subject: [PATCH 12/35] fix(sql): refuse to switch the integration of a snapshot notebook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `*.snapshot.deepnote` matches the notebook selector, so the SQL cell's integration picker is reachable on a snapshot. Switching there edited the record's own cell metadata and, through `addToProjectIntegrations`, rewrote the real project files behind it — the snapshot's active write is skipped, but the sibling sweep still stamps every matching `.deepnote`. `addExistingIntegration` has guarded this since 058fe8ffa; this path never did. Guard at the top of `switchIntegration` rather than inside `addToProjectIntegrations`, so the cell metadata edit is refused too. The test arranges the project, the pick and the edit so the switch would otherwise go through, leaving the snapshot check as the only thing that can stop it; it was verified to fail with the guard disabled. An earlier version of it passed with the guard disabled, because an unstubbed project sent it down the "Project not found" return instead. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Ns2d146xs51a6E8LeDgJQS --- .../deepnote/sqlCellStatusBarProvider.ts | 9 ++++++ .../sqlCellStatusBarProvider.unit.test.ts | 29 +++++++++++++++++++ src/platform/common/utils/localize.ts | 3 ++ 3 files changed, 41 insertions(+) diff --git a/src/notebooks/deepnote/sqlCellStatusBarProvider.ts b/src/notebooks/deepnote/sqlCellStatusBarProvider.ts index 0aa9b1887f..bb2130edde 100644 --- a/src/notebooks/deepnote/sqlCellStatusBarProvider.ts +++ b/src/notebooks/deepnote/sqlCellStatusBarProvider.ts @@ -29,6 +29,7 @@ import { toTelemetryIntegrationType } from '../../platform/notebooks/deepnote/integrationTypes'; import { addProjectIntegration } from './integrations/projectIntegrationsWriter'; +import { isSnapshotFile } from './snapshots/snapshotFiles'; import { IDeepnoteNotebookManager, RawProjectIntegration } from '../types'; import { logger } from '../../platform/logging'; import { ISqlIntegrationEnvVarsProvider } from '../../platform/notebooks/deepnote/types'; @@ -491,6 +492,14 @@ export class SqlCellStatusBarProvider implements NotebookCellStatusBarItemProvid } private async switchIntegration(cell: NotebookCell): Promise { + // A snapshot matches the notebook selector, but it records a past run: switching would edit that record + // and, through `addToProjectIntegrations`, rewrite the real project files behind it. + if (isSnapshotFile(cell.notebook.uri)) { + void window.showErrorMessage(Integrations.switchIntegrationSnapshotUnsupported); + + return; + } + const currentIntegrationId = this.getIntegrationId(cell); // Get the project ID from the notebook metadata diff --git a/src/notebooks/deepnote/sqlCellStatusBarProvider.unit.test.ts b/src/notebooks/deepnote/sqlCellStatusBarProvider.unit.test.ts index 5645a362c7..27aa11f3f3 100644 --- a/src/notebooks/deepnote/sqlCellStatusBarProvider.unit.test.ts +++ b/src/notebooks/deepnote/sqlCellStatusBarProvider.unit.test.ts @@ -1292,6 +1292,35 @@ suite('SqlCellStatusBarProvider', () => { ).once(); }); + test('refuses to switch the integration of a snapshot notebook', async () => { + // `*.snapshot.deepnote` matches the notebook selector, so the command is reachable there; switching + // would edit the record and rewrite the real project files behind it. Everything else is arranged so + // the switch would go through, leaving the snapshot check as the only thing that can stop it. + const newIntegrationId = 'new-integration'; + const cell = createMockCell({ + languageId: 'sql', + metadata: { sql_integration_id: 'old-integration' }, + notebookMetadata: { deepnoteProjectId: 'project-1', deepnoteNotebookId: 'notebook-1' }, + notebookUri: Uri.file('/ws/report_project-1_nb_main.snapshot.deepnote') + }); + + when(commandNotebookManager.getProjectForNotebook('project-1', 'notebook-1')).thenReturn({ + project: { integrations: [{ id: newIntegrationId, name: 'New Integration', type: 'pgsql' }] } + } as any); + when(mockedVSCodeNamespaces.window.showErrorMessage(anything())).thenReturn(Promise.resolve(undefined)); + when(mockedVSCodeNamespaces.window.showQuickPick(anything(), anything())).thenReturn( + Promise.resolve({ id: newIntegrationId, label: 'New Integration' } as any) + ); + when(mockedVSCodeNamespaces.workspace.applyEdit(anything())).thenReturn(Promise.resolve(true)); + + await switchIntegrationHandler(cell); + + verify(mockedVSCodeNamespaces.window.showErrorMessage(anything())).once(); + verify(mockedVSCodeNamespaces.window.showQuickPick(anything(), anything())).never(); + verify(mockedVSCodeNamespaces.workspace.applyEdit(anything())).never(); + verify(commandTelemetry.trackEvent(anything())).never(); + }); + test('does not update if user cancels quick pick', async () => { const notebookMetadata = { deepnoteProjectId: 'project-1', deepnoteNotebookId: 'notebook-1' }; const cell = createMockCell({ diff --git a/src/platform/common/utils/localize.ts b/src/platform/common/utils/localize.ts index 39c62d948a..f334adb25b 100644 --- a/src/platform/common/utils/localize.ts +++ b/src/platform/common/utils/localize.ts @@ -868,6 +868,9 @@ export namespace Integrations { export const addExistingIntegrationSnapshotUnsupported = l10n.t( 'Integrations cannot be added to a snapshot file. Open the project notebook and try again.' ); + export const switchIntegrationSnapshotUnsupported = l10n.t( + 'The integration of a snapshot cannot be changed. Open the project notebook and try again.' + ); export const addExistingIntegrationNotebookClosed = l10n.t( 'The notebook this integrations panel was opened for is no longer open. Open it again and retry.' ); From ffbc1d3162b5e2a598136783cb956c48d12406c6 Mon Sep 17 00:00:00 2001 From: tomas Date: Wed, 23 Sep 2026 09:51:14 +0000 Subject: [PATCH 13/35] test(integrations): drop duplicated manager tests, pin the error each branch shows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three tests in the addExistingIntegration suite asserted claims that already live in `projectIntegrationsWriter`/`existingIntegrationPicker`, and one assertion in them could not fail at all: - the pandas-dataframe case is the picker's "passes entries of types it cannot manage through verbatim" one level up. Its non-duplicate assertion — that DuckDB is "neither offered nor a candidate" — holds because `storedConfigs` has no entry for that id, so it still passed with both the self-project skip and the configurable-type filter removed. - "leaves the cache alone when the active file cannot be written" is `projectIntegrationsWriter.unit.test.ts`'s test of the same name; the ordering it pins is `addProjectIntegration`'s alone. The claim moves into the `lateFailures` body, where it holds for both rows. - the two mid-pick tests drive one path. The fs stub reads `onDiskCurrent ?? currentProject`, so moving the cache moves disk too, and the cache is read once at the top of the command, before the scan — a mutation during the pick is unobservable. The disk-only test is the stronger of the two and stays. The two standalone early-failure tests assert a subset of the table that followed them, so they become rows in it. `showErrorMessage(anything())` let every early failure claim any diagnosis: pointing the snapshot branch, and then the notebook-closed branch, at the generic failure text passed the whole suite. Each row now names the message it expects, and both mutations were seen to fail. 18 tests -> 15. Verified the rest of the coverage survives: the merge, cache-ordering, snapshot-guard and stale-panel mutations each still fail the suite. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Ns2d146xs51a6E8LeDgJQS --- .../integrationManager.unit.test.ts | 109 +++++------------- 1 file changed, 31 insertions(+), 78 deletions(-) diff --git a/src/notebooks/deepnote/integrations/integrationManager.unit.test.ts b/src/notebooks/deepnote/integrations/integrationManager.unit.test.ts index fb056fddb9..e0cd74b568 100644 --- a/src/notebooks/deepnote/integrations/integrationManager.unit.test.ts +++ b/src/notebooks/deepnote/integrations/integrationManager.unit.test.ts @@ -14,6 +14,7 @@ import { import { ITelemetryService } from '../../../platform/analytics/types'; import { IExtensionContext } from '../../../platform/common/types'; +import { Integrations } from '../../../platform/common/utils/localize'; import { ConfigurableDatabaseIntegrationConfig } from '../../../platform/notebooks/deepnote/integrationTypes'; import { mockedVSCodeNamespaces, resetVSCodeMocks } from '../../../test/vscode-mock'; import { IDeepnoteNotebookManager, ProjectIntegration, RawProjectIntegration } from '../../types'; @@ -250,24 +251,6 @@ suite('IntegrationManager.addExistingIntegration', () => { verify(mockedVSCodeNamespaces.window.showInformationMessage(anything())).once(); }); - test('keeps entries the panel cannot manage (pandas-dataframe) when attaching', async () => { - currentProject = projectFile(CURRENT_PROJECT_ID, CURRENT_NOTEBOOK_ID, [ - { id: 'duckdb', name: 'DuckDB', type: 'pandas-dataframe' } - ]); - - const outcome = await buildManager().addExistingIntegration(CURRENT_URI.toString()); - - assert.strictEqual(outcome, 'completed'); - - const expectedIntegrations = [ - { id: 'duckdb', name: 'DuckDB', type: 'pandas-dataframe' }, - { id: 'pg-shared', name: 'Shared Postgres', type: 'pgsql' } - ]; - assert.deepStrictEqual(writes.get(CURRENT_URI.fsPath)?.project.integrations, expectedIntegrations); - assert.deepStrictEqual(cacheUpdates, [expectedIntegrations]); - assert.strictEqual(quickPickItems?.length, 1, 'the DuckDB entry is neither offered nor a candidate'); - }); - test('does not offer an integration the current project already has', async () => { currentProject = projectFile(CURRENT_PROJECT_ID, CURRENT_NOTEBOOK_ID, [ { id: SHARED_CONFIG.id, name: SHARED_CONFIG.name, type: SHARED_CONFIG.type } @@ -302,33 +285,31 @@ suite('IntegrationManager.addExistingIntegration', () => { verify(telemetry.trackEvent(anything())).never(); }); - test('fails without a Deepnote notebook to act on', async () => { - when(mockedVSCodeNamespaces.workspace.notebookDocuments).thenReturn([]); - - const outcome = await buildManager().addExistingIntegration(Uri.file('/ws/missing.deepnote').toString()); - - assert.strictEqual(outcome, 'failed'); - verify(mockedVSCodeNamespaces.window.showErrorMessage(anything())).once(); - }); - - test('fails without writing when the panel names a notebook that is no longer open', async () => { - // The panel stayed open for the other project after its notebook closed; only this project's editor is left. - when(mockedVSCodeNamespaces.workspace.notebookDocuments).thenReturn([currentNotebook]); - when(mockedVSCodeNamespaces.window.visibleNotebookEditors).thenReturn([ - { notebook: currentNotebook } as NotebookEditor - ]); + // Every branch below reports trouble to the user; without cover they can each regress into silent success, + // or into the wrong diagnosis: the message is what tells the user which one they hit. + const earlyFailures: { arrange: () => string; expectedMessage: string; name: string }[] = [ + { + arrange: () => { + when(mockedVSCodeNamespaces.workspace.notebookDocuments).thenReturn([]); - const outcome = await buildManager().addExistingIntegration(OTHER_URI.toString()); + return Uri.file('/ws/missing.deepnote').toString(); + }, + expectedMessage: 'No active Deepnote notebook', + name: 'no Deepnote notebook is open' + }, + { + arrange: () => { + // The panel stayed open for the other project after its notebook closed; only this editor is left. + when(mockedVSCodeNamespaces.workspace.notebookDocuments).thenReturn([currentNotebook]); + when(mockedVSCodeNamespaces.window.visibleNotebookEditors).thenReturn([ + { notebook: currentNotebook } as NotebookEditor + ]); - assert.strictEqual(outcome, 'failed'); - assert.strictEqual(writes.size, 0, "the visible project must not be written on the stale panel's behalf"); - assert.deepStrictEqual(cacheUpdates, []); - verify(mockedVSCodeNamespaces.window.showErrorMessage(anything())).once(); - verify(mockedVSCodeNamespaces.window.showQuickPick(anything(), anything())).never(); - verify(telemetry.trackEvent(anything())).never(); - }); - // Every branch below reports trouble to the user; without cover they can each regress into silent success. - const earlyFailures: { arrange: () => string; name: string }[] = [ + return OTHER_URI.toString(); + }, + expectedMessage: Integrations.addExistingIntegrationNotebookClosed, + name: 'the panel names a notebook that is no longer open' + }, { arrange: () => { when(mockedVSCodeNamespaces.workspace.notebookDocuments).thenReturn([ @@ -337,6 +318,7 @@ suite('IntegrationManager.addExistingIntegration', () => { return CURRENT_URI.toString(); }, + expectedMessage: 'Cannot determine project or notebook ID', name: 'the notebook declares no project or notebook id' }, { @@ -353,6 +335,7 @@ suite('IntegrationManager.addExistingIntegration', () => { return SNAPSHOT_URI.toString(); }, + expectedMessage: Integrations.addExistingIntegrationSnapshotUnsupported, name: 'the active file is a snapshot' }, { @@ -361,11 +344,12 @@ suite('IntegrationManager.addExistingIntegration', () => { return CURRENT_URI.toString(); }, + expectedMessage: Integrations.addExistingIntegrationFailed, name: 'the project is not in the cache' } ]; - for (const { arrange, name } of earlyFailures) { + for (const { arrange, expectedMessage, name } of earlyFailures) { test(`fails without reading or writing any file when ${name}`, async () => { const uri = arrange(); @@ -374,7 +358,7 @@ suite('IntegrationManager.addExistingIntegration', () => { assert.strictEqual(outcome, 'failed'); assert.strictEqual(writes.size, 0); assert.deepStrictEqual(cacheUpdates, [], 'the cache must not move before the file does'); - verify(mockedVSCodeNamespaces.window.showErrorMessage(anything())).once(); + verify(mockedVSCodeNamespaces.window.showErrorMessage(expectedMessage)).once(); verify(mockedVSCodeNamespaces.window.showQuickPick(anything(), anything())).never(); verify(telemetry.trackEvent(anything())).never(); }); @@ -392,8 +376,9 @@ suite('IntegrationManager.addExistingIntegration', () => { const outcome = await buildManager().addExistingIntegration(CURRENT_URI.toString()); assert.strictEqual(outcome, 'failed'); + assert.deepStrictEqual(cacheUpdates, [], 'no failure path leaves the link in the cache'); assert.isTrue(refreshSpy.notCalled, 'the refresh reserved for a successful link never runs'); - verify(mockedVSCodeNamespaces.window.showErrorMessage(anything())).once(); + verify(mockedVSCodeNamespaces.window.showErrorMessage(Integrations.addExistingIntegrationFailed)).once(); verify( telemetry.trackEvent( deepEqual({ @@ -405,19 +390,6 @@ suite('IntegrationManager.addExistingIntegration', () => { }); } - test('leaves the cache alone when the active file cannot be written', async () => { - writeFailures.add(CURRENT_URI.fsPath); - - const outcome = await buildManager().addExistingIntegration(CURRENT_URI.toString()); - - assert.strictEqual(outcome, 'failed'); - assert.deepStrictEqual( - cacheUpdates, - [], - 'an unpersisted link never enters the cache, so the retry still offers the integration' - ); - }); - test('completes with a warning when a sibling file of the same project cannot be updated', async () => { onDiskSibling = projectFile(CURRENT_PROJECT_ID, 'notebook-sibling', []); writeFailures.add(SIBLING_URI.fsPath); @@ -430,25 +402,6 @@ suite('IntegrationManager.addExistingIntegration', () => { verify(mockedVSCodeNamespaces.window.showWarningMessage(anything())).once(); }); - test('writes the integrations as they stand after the pick, not the snapshot taken before it', async () => { - when(mockedVSCodeNamespaces.window.showQuickPick(anything(), anything())).thenCall((items: QuickPickItem[]) => { - // Another writer touching the project file mid-pick; the writer merges into what it finds there. - currentProject = projectFile(CURRENT_PROJECT_ID, CURRENT_NOTEBOOK_ID, [ - { id: 'added-meanwhile', name: 'Added meanwhile', type: 'mysql' } - ]); - - return Promise.resolve(items[0]); - }); - - const outcome = await buildManager().addExistingIntegration(CURRENT_URI.toString()); - - assert.strictEqual(outcome, 'completed'); - assert.deepStrictEqual(writes.get(CURRENT_URI.fsPath)?.project.integrations, [ - { id: 'added-meanwhile', name: 'Added meanwhile', type: 'mysql' }, - { id: 'pg-shared', name: 'Shared Postgres', type: 'pgsql' } - ]); - }); - test('keeps an entry the file gained while the picker was open, though the cache never saw it', async () => { when(mockedVSCodeNamespaces.window.showQuickPick(anything(), anything())).thenCall((items: QuickPickItem[]) => { // Only disk moves: the cached project stays on the roster the command read before the scan. From a8ee9937fa6f5c982e0452b3db14ce9fe07a66cb Mon Sep 17 00:00:00 2001 From: tomas Date: Wed, 23 Sep 2026 11:49:12 +0000 Subject: [PATCH 14/35] fix(integrations): do not sweep siblings when the active write did not land MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `addProjectIntegration` swept every sibling `.deepnote` whatever the active file returned. `writeIntegrationsToFile` has four ways not to write: a snapshot, a file whose project id does not match, one whose unsaved edits cannot be flushed, and a rejected write. In all four the sweep still stamped the integration across the project's real files on behalf of a file that never took it. The snapshot case is the one that has already cost two fixes — 058fe8ffa and ab1a81e8d both guard at a caller, so the next caller to reach this function gets no protection. `failed` is the milder half: the siblings end up carrying a link the active file and the cache do not, while the user is told the add failed. Return before the sweep instead, so the active file is the single mandate for touching anything. `siblingsFailed: 0` on that return is safe: both callers read it only when `activePersisted` is true. The regression tests cover both halves — a refused active write and a skipped snapshot, each with a sibling on disk — and were seen to fail against the old code on `writes.size`, with the sibling written. `persistProjectIntegrations` keeps its own shape: it updates the cache before the active write by design, so the same early return there is a larger change and is left for its own commit. Also carries a rename of `active` to `writeOutcome` that was already in the tree, on the lines this touches. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Ns2d146xs51a6E8LeDgJQS --- .../integrations/projectIntegrationsWriter.ts | 23 +++++++----- .../projectIntegrationsWriter.unit.test.ts | 37 ++++++++++++++++--- 2 files changed, 46 insertions(+), 14 deletions(-) diff --git a/src/notebooks/deepnote/integrations/projectIntegrationsWriter.ts b/src/notebooks/deepnote/integrations/projectIntegrationsWriter.ts index 2c5cf0029d..6d76083c4e 100644 --- a/src/notebooks/deepnote/integrations/projectIntegrationsWriter.ts +++ b/src/notebooks/deepnote/integrations/projectIntegrationsWriter.ts @@ -57,16 +57,16 @@ export async function persistProjectIntegrations( notebookManager.updateProjectIntegrations(projectId, integrations); // findFiles only covers open folders, so write the active file explicitly (no open folder / out-of-workspace). - const active = await writeIntegrationsToFile({ fileUri: activeFileUri, projectId, resolve }); + const writeOutcome = await writeIntegrationsToFile({ fileUri: activeFileUri, projectId, resolve }); const siblingsFailed = await writeSiblingFiles({ activeFileUri, projectId, resolve }); - return { activePersisted: active.status === 'written', siblingsFailed }; + return { activePersisted: writeOutcome.status === 'written', siblingsFailed }; } /** * Adds one integration to the project, merging into what each file holds instead of replacing the list: a caller * holding a snapshot across a prompt cannot delete entries written while it waited. An id already present is - * replaced. The cache follows the active file and moves only once that file is on disk. + * replaced. The cache and the sibling sweep both follow the active file: neither runs until it is on disk. */ export async function addProjectIntegration(params: AddProjectIntegrationParams): Promise { const { activeFileUri, integration, notebookManager, projectId } = params; @@ -75,17 +75,21 @@ export async function addProjectIntegration(params: AddProjectIntegrationParams) integration ]; - const active = await writeIntegrationsToFile({ fileUri: activeFileUri, projectId, resolve }); + const writeOutcome = await writeIntegrationsToFile({ fileUri: activeFileUri, projectId, resolve }); + + // The active file is the mandate for the sweep. `skipped` means it is a snapshot or another project's file, and + // `failed` means the add landed nowhere — stamping the project's other files on its behalf is data loss either way. + if (writeOutcome.status !== 'written') { + return { activePersisted: false, siblingsFailed: 0 }; + } // Before the sibling sweep, not after: saving a notebook rebuilds its whole file from the cached project, so a // cache still on the old roster would write the link straight back out mid-sweep. - if (active.status === 'written') { - notebookManager.updateProjectIntegrations(projectId, active.integrations); - } + notebookManager.updateProjectIntegrations(projectId, writeOutcome.integrations); const siblingsFailed = await writeSiblingFiles({ activeFileUri, projectId, resolve }); - return { activePersisted: active.status === 'written', siblingsFailed }; + return { activePersisted: true, siblingsFailed }; } /** Writes every discovered `.deepnote` of the project except `activeFileUri`; returns how many failed. */ @@ -113,7 +117,8 @@ async function writeSiblingFiles(params: WriteSiblingFilesParams): Promise { assert.deepStrictEqual(writes.get(activeUri.fsPath)!.project.integrations, [ADDED]); }); - test('leaves the cache untouched when the active file cannot be written', async () => { + test('writes nothing and leaves the cache untouched when the active file cannot be written', async () => { const activeUri = Uri.file('/ws/active.deepnote'); - stubWorkspace({ - onDisk: [{ uri: activeUri, file: projectFile('nb-active') }], - discovered: [activeUri], + const siblingUri = Uri.file('/ws/sibling.deepnote'); + const { writes } = stubWorkspace({ + onDisk: [ + { uri: activeUri, file: projectFile('nb-active') }, + { uri: siblingUri, file: projectFile('nb-sibling') } + ], + discovered: [activeUri, siblingUri], failWriteFor: new Set([activeUri.fsPath]) }); @@ -331,10 +335,33 @@ suite('addProjectIntegration', () => { projectId: PROJECT_ID }); - assert.strictEqual(result.activePersisted, false); + assert.deepStrictEqual(result, { activePersisted: false, siblingsFailed: 0 }); + assert.strictEqual(writes.size, 0, 'the add reaches no file once the one the user acted in refused it'); assert.deepStrictEqual(cacheUpdates, [], 'nothing reaches the cache that did not reach the disk'); }); + test('sweeps no sibling when the active file is skipped rather than written', async () => { + // A snapshot records a past run, so its own write is skipped; sweeping from one stamps the integration + // across the project's real files on behalf of a file that never took it. + const snapshotUri = Uri.file('/ws/snapshots/proj_project-1_latest.snapshot.deepnote'); + const siblingUri = Uri.file('/ws/sibling.deepnote'); + const { writes } = stubWorkspace({ + onDisk: [{ uri: siblingUri, file: projectFile('nb-sibling') }], + discovered: [siblingUri] + }); + + const result = await addProjectIntegration({ + activeFileUri: snapshotUri, + integration: ADDED, + notebookManager: managerInstance, + projectId: PROJECT_ID + }); + + assert.deepStrictEqual(result, { activePersisted: false, siblingsFailed: 0 }); + assert.strictEqual(writes.size, 0, 'the project files stay as they are'); + assert.deepStrictEqual(cacheUpdates, []); + }); + test('moves the cache before sweeping siblings, so a save mid-sweep cannot revert the add', async () => { const activeUri = Uri.file('/ws/active.deepnote'); const siblingUri = Uri.file('/ws/sibling.deepnote'); From 72fa170a96baca29b5b550efa3bef1f93efd0791 Mon Sep 17 00:00:00 2001 From: tomas Date: Wed, 23 Sep 2026 18:42:12 +0000 Subject: [PATCH 15/35] refactor(integrations): narrow the scan's catch variable, not assert it `addExistingIntegration` passed the scan's catch variable to `isCancellationError` as `error as Error` (added in d3ea64c9a). With `useUnknownInCatchVariables` off in tsconfig.base.json, `error` is `any`, so the assertion claims an `Error` without checking for one. It would also keep compiling, and mask `unknown`, if that flag were turned on. `error instanceof Error && isCancellationError(error)` checks what the assertion only claimed. It gives the call the same static type, stays correct under `useUnknownInCatchVariables`, and matches the existing idiom in deepnoteKernelAutoSelector.node.ts. The scan throws nothing but the `CancellationError` from `Cancellation.throwIfCanceled`, so every outcome it can produce is unchanged: a cancellation still returns `cancelled`, and anything else is still rethrown. No test is added. HEAD and this change differ only for a non-`Error` or cross-realm value that looks like a cancellation, which the scan cannot throw, so a test pinning that difference would only detect the change. Verified: - npm run typecheck: exit 0 - npm run lint: exit 0, no findings in integrationManager.ts - npx prettier --write / --check on integrationManager.ts: no changes, check passes - npm run compile-tsc: exit 0 - npm run test:unittests -- --grep "IntegrationManager.addExistingIntegration": 15 passing before and after the change - grep -n "as Error" integrationManager.ts: no matches - mutation: with the guard narrowed to `instanceof TypeError`, "returns cancelled and writes nothing when the scan is cancelled" failed on the `CancellationError` rethrown from `throwIfCanceled`, so the existing test exercises the new condition; it passes again with the change restored Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01DiYErQ9fP3wNhrNaRZAdqv --- src/notebooks/deepnote/integrations/integrationManager.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/notebooks/deepnote/integrations/integrationManager.ts b/src/notebooks/deepnote/integrations/integrationManager.ts index fab886278d..e9d386fc39 100644 --- a/src/notebooks/deepnote/integrations/integrationManager.ts +++ b/src/notebooks/deepnote/integrations/integrationManager.ts @@ -152,7 +152,7 @@ export class IntegrationManager implements IIntegrationManager { }) ); } catch (error) { - if (isCancellationError(error as Error)) { + if (error instanceof Error && isCancellationError(error)) { return 'cancelled'; } From 744417ecda4dee0fb9b475bbef335631896da1a7 Mon Sep 17 00:00:00 2001 From: tomas Date: Wed, 23 Sep 2026 19:14:32 +0000 Subject: [PATCH 16/35] fix(integrations): report cancellations in add_existing_integration `CommandOutcome` exists so telemetry can separate user drop-off from real failures, and `add_existing_integration` is typed with it. But `addExistingIntegration` left both of its cancel exits, a cancelled scan and a dismissed picker, before the only `trackEvent` call, so the event could only ever carry 'completed' or 'failed' and drop-off in this command was never measured. `split_notebook`, the explorer commands and `authenticate_integration` all report 'cancelled'. Both cancel exits now send the event through a private `trackAddExistingIntegration(outcome, integrationType?)`, which the final send uses too. Nothing has been picked on either cancel exit, so the type falls back to 'unknown', the placeholder `integrationWebview.ts` already uses when no type is known. The five early-failure exits and the "none available" exit stay untracked, as before. Counting "none available" as 'completed' would record completions where nothing was linked, so that needs its own product call. The two cancel tests pinned the old silence with `trackEvent(anything()).never()`. Each now asserts exactly one event, `trackEvent(anything()).once()`, and that it is the 'unknown' / 'cancelled' one, `trackEvent(deepEqual(...)).once()`. The count check is what rejects a stray second event. Verified: - npm run typecheck: exit 0 - npm run lint: exit 0, no findings in the changed files - npx prettier --write / --check on both files: no changes, check passes; npm run format: exit 0 - npm run compile-tsc, then npm run test:unittests -- --grep "IntegrationManager.addExistingIntegration": 15 passing. Against the unfixed manager, both updated tests failed with `Expected "trackEvent(anything())" to be called 1 time(s). But has been called 0 time(s).` - Mutations, each recompiled and run, then edited back: - a stray 'failed' event after the dismissal's 'cancelled' one: the picker test fails, trackEvent(anything()) called 2 time(s) - the dismissal tracked twice: the picker test fails the same way - the dismissal tracked as 'completed': the picker test fails, trackEvent(deepEqual(...)) called 0 time(s) - the scan cancel tracked as 'failed': the scan test fails, trackEvent(deepEqual(...)) called 0 time(s) - npm test: 2864 passing, 239 pending, 0 failing Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01DiYErQ9fP3wNhrNaRZAdqv --- .../integrations/integrationManager.ts | 16 +++++++++---- .../integrationManager.unit.test.ts | 23 +++++++++++++++++-- 2 files changed, 33 insertions(+), 6 deletions(-) diff --git a/src/notebooks/deepnote/integrations/integrationManager.ts b/src/notebooks/deepnote/integrations/integrationManager.ts index e9d386fc39..b05b5feb18 100644 --- a/src/notebooks/deepnote/integrations/integrationManager.ts +++ b/src/notebooks/deepnote/integrations/integrationManager.ts @@ -153,6 +153,8 @@ export class IntegrationManager implements IIntegrationManager { ); } catch (error) { if (error instanceof Error && isCancellationError(error)) { + this.trackAddExistingIntegration('cancelled'); + return 'cancelled'; } @@ -187,6 +189,8 @@ export class IntegrationManager implements IIntegrationManager { }); if (!picked) { + this.trackAddExistingIntegration('cancelled'); + return 'cancelled'; } @@ -228,10 +232,7 @@ export class IntegrationManager implements IIntegrationManager { void window.showErrorMessage(localize.Integrations.addExistingIntegrationFailed); } - this.analytics.trackEvent({ - eventName: 'add_existing_integration', - properties: { integrationType: integration.type, outcome } - }); + this.trackAddExistingIntegration(outcome, integration.type); return outcome; } @@ -376,4 +377,11 @@ export class IntegrationManager implements IIntegrationManager { activeNotebook.metadata?.deepnoteProjectName ); } + + private trackAddExistingIntegration(outcome: CommandOutcome, integrationType?: string): void { + this.analytics.trackEvent({ + eventName: 'add_existing_integration', + properties: { integrationType: integrationType ?? 'unknown', outcome } + }); + } } diff --git a/src/notebooks/deepnote/integrations/integrationManager.unit.test.ts b/src/notebooks/deepnote/integrations/integrationManager.unit.test.ts index e0cd74b568..9a61e89af9 100644 --- a/src/notebooks/deepnote/integrations/integrationManager.unit.test.ts +++ b/src/notebooks/deepnote/integrations/integrationManager.unit.test.ts @@ -275,6 +275,8 @@ suite('IntegrationManager.addExistingIntegration', () => { }); test('returns cancelled and writes nothing when the picker is dismissed', async () => { + // Catches: drop-off in the picker never reaching analytics, being sent under another outcome, or being sent + // along with a stray second event. when(mockedVSCodeNamespaces.window.showQuickPick(anything(), anything())).thenResolve(undefined); const outcome = await buildManager().addExistingIntegration(CURRENT_URI.toString()); @@ -282,7 +284,15 @@ suite('IntegrationManager.addExistingIntegration', () => { assert.strictEqual(outcome, 'cancelled'); assert.strictEqual(writes.size, 0); assert.isTrue(refreshSpy.notCalled); - verify(telemetry.trackEvent(anything())).never(); + verify(telemetry.trackEvent(anything())).once(); + verify( + telemetry.trackEvent( + deepEqual({ + eventName: 'add_existing_integration', + properties: { integrationType: 'unknown', outcome: 'cancelled' } + }) + ) + ).once(); }); // Every branch below reports trouble to the user; without cover they can each regress into silent success, @@ -422,6 +432,7 @@ suite('IntegrationManager.addExistingIntegration', () => { }); test('returns cancelled and writes nothing when the scan is cancelled', async () => { + // Catches: a cancelled scan counted as neither drop-off nor failure, or reported as 'failed'. scanProgress.cancel(); const outcome = await buildManager().addExistingIntegration(CURRENT_URI.toString()); @@ -429,6 +440,14 @@ suite('IntegrationManager.addExistingIntegration', () => { assert.strictEqual(outcome, 'cancelled'); assert.strictEqual(writes.size, 0); verify(mockedVSCodeNamespaces.window.showQuickPick(anything(), anything())).never(); - verify(telemetry.trackEvent(anything())).never(); + verify(telemetry.trackEvent(anything())).once(); + verify( + telemetry.trackEvent( + deepEqual({ + eventName: 'add_existing_integration', + properties: { integrationType: 'unknown', outcome: 'cancelled' } + }) + ) + ).once(); }); }); From 3cd68fe5ce519bd5a303fdb97b842a66db342ba3 Mon Sep 17 00:00:00 2001 From: tomas Date: Wed, 23 Sep 2026 19:24:39 +0000 Subject: [PATCH 17/35] fix(integrations): re-show the panel before the kernel env refresh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After a link lands, `addExistingIntegration` shows the success toast and awaits `refreshAfterProjectIntegrationsChange`, which awaited the kernel env refresh before it re-showed the panel. The refresh runs `executeHidden` in every running kernel of the project, and a kernel busy with a running cell answers only after that cell finishes. Until then the panel kept its pre-link list, and a save, reset or delete from it rewrote the cache and every project file from that list, silently dropping the link the user had just been told was added. With no panel open, the panel opened only once the kernels were free. The panel is now re-shown first and the kernels are refreshed after. Each call keeps its own try/catch, so a failed re-show still refreshes the kernels, and both stay awaited. Step 4 of "Add Existing Integration" in specs/INTEGRATIONS_CREDENTIALS.md now gives the order and the reason for it. By reading, this narrows the window but does not close it: the panel is still on the old list while the writer sweeps the project's sibling files, after the cache update and before the re-show. That wait is bounded by local I/O, and its cause, the panel writing back a snapshot that replaces the whole list, predates this branch. Verified: - npm run typecheck: exit 0 - npm run lint: exit 0, no findings in the changed files - npx prettier --write / --check on the three files: no changes, check passes - npm run compile-tsc, then npm run test:unittests -- --grep "IntegrationManager.addExistingIntegration": 15 passing before, 17 passing after. Against the unfixed order, 're-shows the panel before the kernel env refresh settles' failed with `Expected "show(strictEqual(project-current), anything(), anything(), anything(), anything())" to be called 1 time(s). But has been called 0 time(s).` 'still refreshes the kernels when re-showing the panel fails' passed there: it guards the new order and does not reproduce the bug. - Mutations, each recompiled and run, then edited back: - the old order put back: the first new test fails the same way - panel first without its try/catch: the second new test fails with `expected [] to deeply equal [ [ [ { …(11) } ], …(1) ] ]` - npm test: 2866 passing, 239 pending, 0 failing Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01DiYErQ9fP3wNhrNaRZAdqv --- specs/INTEGRATIONS_CREDENTIALS.md | 5 +-- .../integrations/integrationManager.ts | 14 ++++---- .../integrationManager.unit.test.ts | 35 +++++++++++++++++++ 3 files changed, 46 insertions(+), 8 deletions(-) diff --git a/specs/INTEGRATIONS_CREDENTIALS.md b/specs/INTEGRATIONS_CREDENTIALS.md index 8b382cd553..6a5265285c 100644 --- a/specs/INTEGRATIONS_CREDENTIALS.md +++ b/specs/INTEGRATIONS_CREDENTIALS.md @@ -444,8 +444,9 @@ webview, implemented in `existingIntegrationPicker.ts`): 3. On selection, appends `{ id, name, type }` to the active project's roster via `persistProjectIntegrations`. Nothing is copied in SecretStorage: configs (and federated refresh tokens) are keyed by integration id alone, and the roster entry is what scopes an integration to a project, so the linked project resolves the same credentials. -4. Re-runs the integration env refresh in the project's running kernels and re-shows the panel, since no storage - change event fires for a roster-only edit. +4. Re-shows the panel, then re-runs the integration env refresh in the project's running kernels, since no storage + change event fires for a roster-only edit. The panel goes first: a busy kernel answers the refresh only after its + running cell, and until the panel is re-shown it would save its pre-link list back over the project. #### 4. **Integration Webview** (`integrationWebview.ts`) diff --git a/src/notebooks/deepnote/integrations/integrationManager.ts b/src/notebooks/deepnote/integrations/integrationManager.ts index b05b5feb18..adf857d22d 100644 --- a/src/notebooks/deepnote/integrations/integrationManager.ts +++ b/src/notebooks/deepnote/integrations/integrationManager.ts @@ -260,6 +260,14 @@ export class IntegrationManager implements IIntegrationManager { projectId: string, activeNotebook: NotebookDocument ): Promise { + // Panel first: a busy kernel holds the refresh behind its running cell, and a panel still on the old list + // writes that list back over the project on its next save. + try { + await this.showIntegrationsUI(undefined, activeNotebook.uri.toString()); + } catch (error) { + logger.error('IntegrationManager: failed to refresh the integrations panel', error); + } + const projectNotebooks = workspace.notebookDocuments.filter( (notebook) => notebook.notebookType === 'deepnote' && notebook.metadata?.deepnoteProjectId === projectId ); @@ -269,12 +277,6 @@ export class IntegrationManager implements IIntegrationManager { } catch (error) { logger.error('IntegrationManager: failed to refresh integration env after adding an integration', error); } - - try { - await this.showIntegrationsUI(undefined, activeNotebook.uri.toString()); - } catch (error) { - logger.error('IntegrationManager: failed to refresh the integrations panel', error); - } } /** diff --git a/src/notebooks/deepnote/integrations/integrationManager.unit.test.ts b/src/notebooks/deepnote/integrations/integrationManager.unit.test.ts index 9a61e89af9..c654588476 100644 --- a/src/notebooks/deepnote/integrations/integrationManager.unit.test.ts +++ b/src/notebooks/deepnote/integrations/integrationManager.unit.test.ts @@ -14,8 +14,10 @@ import { import { ITelemetryService } from '../../../platform/analytics/types'; import { IExtensionContext } from '../../../platform/common/types'; +import { createDeferred } from '../../../platform/common/utils/async'; import { Integrations } from '../../../platform/common/utils/localize'; import { ConfigurableDatabaseIntegrationConfig } from '../../../platform/notebooks/deepnote/integrationTypes'; +import { waitForCondition } from '../../../test/common'; import { mockedVSCodeNamespaces, resetVSCodeMocks } from '../../../test/vscode-mock'; import { IDeepnoteNotebookManager, ProjectIntegration, RawProjectIntegration } from '../../types'; import { @@ -43,6 +45,8 @@ const SIBLING_URI = Uri.file('/ws/sibling.deepnote'); const SNAPSHOT_URI = Uri.file('/ws/snapshots/current_project-current_latest.snapshot.deepnote'); const SHARED_CONFIG = buildPostgresIntegration({ id: 'pg-shared', name: 'Shared Postgres' }); +/** Below mocha's 2s test timeout, so a refresh that never starts fails with its own message. */ +const REFRESH_START_TIMEOUT_MS = 1_000; type RefreshFn = IIntegrationEnvLiveRefresher['refresh']; @@ -238,6 +242,37 @@ suite('IntegrationManager.addExistingIntegration', () => { assert.strictEqual(quickPickItems?.[0].detail, `Used in: ${OTHER_PROJECT_ID}`); }); + test('re-shows the panel before the kernel env refresh settles', async () => { + // Catches: a busy kernel leaving the panel on the pre-link list, whose next save deletes the link. + const refreshGate = createDeferred(); + + refreshSpy = sinon.spy(() => refreshGate.promise); + + const command = buildManager().addExistingIntegration(CURRENT_URI.toString()); + + try { + await waitForCondition(() => refreshSpy.calledOnce, REFRESH_START_TIMEOUT_MS, 'the refresh never started'); + + verify(webviewProvider.show(CURRENT_PROJECT_ID, anything(), anything(), anything(), anything())).once(); + } finally { + refreshGate.resolve(); + } + + assert.strictEqual(await command, 'completed'); + }); + + test('still refreshes the kernels when re-showing the panel fails', async () => { + // Catches: the kernel env refresh depending on the panel re-show succeeding. + when(webviewProvider.show(anything(), anything(), anything(), anything(), anything())).thenReject( + new Error('panel could not be shown') + ); + + const outcome = await buildManager().addExistingIntegration(CURRENT_URI.toString()); + + assert.strictEqual(outcome, 'completed'); + assert.deepStrictEqual(refreshSpy.args, [[[currentNotebook], 'integration_config']]); + }); + test('shows an information message and writes nothing when no other project has a reusable integration', async () => { onDiskOther = undefined; From 8c8231ad0769d471e0aa02784bf9d4ea1174cc65 Mon Sep 17 00:00:00 2001 From: tomas Date: Wed, 23 Sep 2026 19:23:31 +0000 Subject: [PATCH 18/35] fix(integrations): do not delete credentials another project declares Credentials are keyed by integration id alone, and SecretStorage is shared machine-wide, so once "Add Existing Integration" links one project's integration into another, both resolve the same config and federated token. Delete in either panel still removed both, and the other project silently lost its credentials: its SQL cells fell back to "(configure)", the storage event refreshed its kernels without the env vars, and the picker stopped offering the id. The confirmation only promises removal "from your project". `deleteConfiguration` now asks `findOtherProjectsDeclaring` first. While another project in the open workspace folders still declares the id, Delete only takes it off this project's roster and names the projects that keep the credentials. An unlink changes no credential, so no storage event refreshes kernels; the panel refreshes this project's running kernels itself, through the live refresher injected `@optional()` as `IntegrationManager` does, since the web extension binds none. Only open folders are scanned, so a linking project outside them still loses the credentials; the doc comment says so. The workspace walk moves out of `collectReusableIntegrations` into `forEachOtherProjectFile`, shared by both callers. The picker's rules and its existing tests are unchanged; only the walk's log prefix now names the module. The spec notes what Delete does to a linked integration. `resetConfiguration` clears the same shared credentials and is left for a follow-up. Verified: typecheck and lint exit 0, with no lint findings in the changed files; prettier clean on them; compile-tsc exit 0. IntegrationWebviewProvider|existingIntegrationPicker: 49 passing before, 53 after. The manager, writer and refresh-handler suites: 30 passing. Full unit suite: 2868 passing, 239 pending, 0 failing. Mutations, each restored and recompiled after: - guard removed, the old unconditional delete: only the unlink test fails, "Expected delete(anything()) to be called 0 time(s). But has been called 1 time(s)."; - refresh call removed: only the refresh test fails, "expected spy to be called once and with exact arguments"; - a guard that never deletes: the full-delete test fails, with the two existing delete token tests; - own files counted, snapshots counted, or the dedupe dropped: the project-name test fails with 'Current', 'Gamma' or a second 'Alpha'. A scratch inversify check resolves the provider with no refresher bound, as on web, and injects a bound one, as on node. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01DiYErQ9fP3wNhrNaRZAdqv --- specs/INTEGRATIONS_CREDENTIALS.md | 4 + .../integrations/existingIntegrationPicker.ts | 199 +++++++++++------- .../existingIntegrationPicker.unit.test.ts | 58 +++++ .../integrations/integrationWebview.ts | 62 +++++- .../integrationWebview.unit.test.ts | 144 ++++++++++++- src/platform/common/utils/localize.ts | 5 + 6 files changed, 384 insertions(+), 88 deletions(-) diff --git a/specs/INTEGRATIONS_CREDENTIALS.md b/specs/INTEGRATIONS_CREDENTIALS.md index 6a5265285c..827f3b58e4 100644 --- a/specs/INTEGRATIONS_CREDENTIALS.md +++ b/specs/INTEGRATIONS_CREDENTIALS.md @@ -448,6 +448,10 @@ webview, implemented in `existingIntegrationPicker.ts`): change event fires for a roster-only edit. The panel goes first: a busy kernel answers the refresh only after its running cell, and until the panel is re-shown it would save its pre-link list back over the project. +The panel's Delete only takes a linked integration off the active project's roster, and keeps its credentials, while +another project in the open workspace folders still declares the id. No storage change event fires then either, so +the panel refreshes the project's running kernels itself. + #### 4. **Integration Webview** (`integrationWebview.ts`) Provides the webview-based UI for managing integration credentials. diff --git a/src/notebooks/deepnote/integrations/existingIntegrationPicker.ts b/src/notebooks/deepnote/integrations/existingIntegrationPicker.ts index 801bb0a79c..a390eb1296 100644 --- a/src/notebooks/deepnote/integrations/existingIntegrationPicker.ts +++ b/src/notebooks/deepnote/integrations/existingIntegrationPicker.ts @@ -1,4 +1,5 @@ -import { CancellationToken, RelativePattern, Uri, workspace } from 'vscode'; +import type { DeepnoteFile } from '@deepnote/blocks'; +import { CancellationToken, RelativePattern, Uri, workspace, WorkspaceFolder } from 'vscode'; import { Cancellation } from '../../../platform/common/cancellation'; import { readDeepnoteProjectFile } from '../../../platform/deepnote/deepnoteProjectFileReader'; @@ -48,6 +49,14 @@ export interface AttachExistingIntegrationParams { projectId: string; } +export interface FindOtherProjectsDeclaringParams { + integrationId: string; + /** The project asking; its own `.deepnote` files are skipped. */ + projectId: string; +} + +type OtherProjectFileVisitor = (project: DeepnoteFile['project'], fileUri: Uri) => Promise | void; + /** * Scans every `.deepnote` file in the open workspace folders for integrations other projects declare. * @@ -66,90 +75,46 @@ export async function collectReusableIntegrations( const candidates = new Map }>(); const conflictingIds = new Set(); - const visited = new Set(); - - for (const workspaceFolder of workspace.workspaceFolders || []) { - Cancellation.throwIfCanceled(token); - let files: Uri[]; + await forEachOtherProjectFile(projectId, token, async (project, fileUri) => { + const projectName = project.name || project.id; - try { - files = await workspace.findFiles( - new RelativePattern(workspaceFolder, '**/*.deepnote'), - undefined, - undefined, - token - ); - } catch (error) { - logger.error('collectReusableIntegrations: failed to enumerate .deepnote files', error); + for (const entry of project.integrations ?? []) { + if (excludeIntegrationIds.has(entry.id) || !isConfigurableDatabaseIntegrationType(entry.type)) { + continue; + } - continue; - } + const storedConfig = await integrationStorage.getIntegrationConfig(entry.id); - for (const fileUri of files) { - Cancellation.throwIfCanceled(token); + if (!storedConfig) { + // File-only or never configured — no stored credentials to reuse. + continue; + } - const key = fileUri.toString(); + if (storedConfig.type !== entry.type) { + logger.warn( + `collectReusableIntegrations: ${entry.id} is declared as ${entry.type} in ${fileUri.path} but stored as ${storedConfig.type}; skipping` + ); + conflictingIds.add(entry.id); - if (visited.has(key) || isSnapshotFile(fileUri)) { continue; } - visited.add(key); - - // One unreadable file must not hide every other project's integrations. - try { - const projectData = await readDeepnoteProjectFile(fileUri); - - if (!projectData?.project || projectData.project.id === projectId) { - continue; - } - - const projectName = projectData.project.name || projectData.project.id; - - for (const entry of projectData.project.integrations ?? []) { - if (excludeIntegrationIds.has(entry.id) || !isConfigurableDatabaseIntegrationType(entry.type)) { - continue; - } - - const storedConfig = await integrationStorage.getIntegrationConfig(entry.id); - - if (!storedConfig) { - // File-only or never configured — no stored credentials to reuse. - continue; - } - - if (storedConfig.type !== entry.type) { - logger.warn( - `collectReusableIntegrations: ${entry.id} is declared as ${entry.type} in ${fileUri.path} but stored as ${storedConfig.type}; skipping` - ); - conflictingIds.add(entry.id); - - continue; - } - - const existing = candidates.get(entry.id); - - if (existing) { - existing.projectNameSet.add(projectName); - } else { - candidates.set(entry.id, { - id: entry.id, - name: storedConfig.name || entry.name || entry.id, - projectNameSet: new Set([projectName]), - projectNames: [], - type: storedConfig.type - }); - } - } - } catch (error) { - logger.error(`collectReusableIntegrations: failed to read ${fileUri.path}`, error); + const existing = candidates.get(entry.id); + + if (existing) { + existing.projectNameSet.add(projectName); + } else { + candidates.set(entry.id, { + id: entry.id, + name: storedConfig.name || entry.name || entry.id, + projectNameSet: new Set([projectName]), + projectNames: [], + type: storedConfig.type + }); } } - } - - // `workspace.findFiles` resolves empty when its token trips and the per-file awaits are not token-aware. - Cancellation.throwIfCanceled(token); + }); // A conflict in any project disqualifies the id everywhere: the stored config is the single shared truth. for (const id of conflictingIds) { @@ -177,3 +142,89 @@ export function attachExistingIntegration(params: AttachExistingIntegrationParam return addProjectIntegration({ activeFileUri, integration: linked, notebookManager, projectId }); } + +/** + * Names of the other projects in the open workspace folders whose `.deepnote` files declare `integrationId`, deduped + * and sorted; empty when none does. Only open folders are scanned, while SecretStorage is shared machine-wide. + */ +export async function findOtherProjectsDeclaring(params: FindOtherProjectsDeclaringParams): Promise { + const { integrationId, projectId } = params; + const projectNames = new Set(); + + await forEachOtherProjectFile(projectId, undefined, (project) => { + if (project.integrations?.some((entry) => entry.id === integrationId)) { + projectNames.add(project.name || project.id); + } + }); + + return Array.from(projectNames).sort((a, b) => a.localeCompare(b)); +} + +/** A folder that cannot be enumerated contributes no files instead of ending the walk. */ +async function findDeepnoteFiles( + workspaceFolder: WorkspaceFolder, + token: CancellationToken | undefined +): Promise { + try { + return await workspace.findFiles( + new RelativePattern(workspaceFolder, '**/*.deepnote'), + undefined, + undefined, + token + ); + } catch (error) { + logger.error('existingIntegrationPicker: failed to enumerate .deepnote files', error); + + return []; + } +} + +/** + * Visits each `.deepnote` file in the open workspace folders that belongs to a project other than `projectId`, once; + * snapshots are skipped. + * + * @throws `CancellationError` when `token` trips, including once the last file has been visited. + */ +async function forEachOtherProjectFile( + projectId: string, + token: CancellationToken | undefined, + visit: OtherProjectFileVisitor +): Promise { + const visited = new Set(); + + for (const workspaceFolder of workspace.workspaceFolders || []) { + Cancellation.throwIfCanceled(token); + + for (const fileUri of await findDeepnoteFiles(workspaceFolder, token)) { + Cancellation.throwIfCanceled(token); + + const key = fileUri.toString(); + + if (visited.has(key) || isSnapshotFile(fileUri)) { + continue; + } + + visited.add(key); + await visitOtherProjectFile(fileUri, projectId, visit); + } + } + + // `workspace.findFiles` resolves empty when its token trips and the per-file awaits are not token-aware. + Cancellation.throwIfCanceled(token); +} + +/** `visit` runs inside the per-file catch: a visitor that throws costs only this file, like a failed read. */ +async function visitOtherProjectFile(fileUri: Uri, projectId: string, visit: OtherProjectFileVisitor): Promise { + // One unreadable file must not hide every other project's integrations. + try { + const projectData = await readDeepnoteProjectFile(fileUri); + + if (!projectData?.project || projectData.project.id === projectId) { + return; + } + + await visit(projectData.project, fileUri); + } catch (error) { + logger.error(`existingIntegrationPicker: failed to read ${fileUri.path}`, error); + } +} diff --git a/src/notebooks/deepnote/integrations/existingIntegrationPicker.unit.test.ts b/src/notebooks/deepnote/integrations/existingIntegrationPicker.unit.test.ts index 1c125f20ad..dd27368048 100644 --- a/src/notebooks/deepnote/integrations/existingIntegrationPicker.unit.test.ts +++ b/src/notebooks/deepnote/integrations/existingIntegrationPicker.unit.test.ts @@ -11,6 +11,7 @@ import { createDeepnoteFile, createDeepnoteProject, createWorkspaceFolder } from import { attachExistingIntegration, collectReusableIntegrations, + findOtherProjectsDeclaring, ReusableIntegration } from './existingIntegrationPicker'; import { buildGoogleOauthIntegration, buildPostgresIntegration } from './federatedAuth/federatedAuthTestHelpers'; @@ -456,4 +457,61 @@ suite('existingIntegrationPicker', () => { ]); }); }); + + suite('findOtherProjectsDeclaring', () => { + test('names every other project declaring the id once, ignoring its own files, snapshots and unreadable files', async () => { + // Catches: the current project or a snapshot counted as "another project", which blocks every delete, or + // a project listed twice. + const shared = { id: 'pg-shared', name: 'Shared Postgres', type: 'pgsql' }; + + stubWorkspace({ + projects: [ + { + uri: Uri.file('/ws/beta.deepnote'), + projectId: 'project-beta', + projectName: 'Beta', + integrations: [shared] + }, + { + uri: Uri.file('/ws/alpha.deepnote'), + projectId: 'project-alpha', + projectName: 'Alpha', + integrations: [shared] + }, + { + uri: Uri.file('/ws/alpha-2.deepnote'), + projectId: 'project-alpha', + projectName: 'Alpha', + integrations: [shared] + }, + { + uri: Uri.file('/ws/current.deepnote'), + projectId: CURRENT_PROJECT_ID, + projectName: 'Current', + integrations: [shared] + }, + { + uri: Uri.file('/ws/snapshots/gamma_project-gamma_latest.snapshot.deepnote'), + projectId: 'project-gamma', + projectName: 'Gamma', + integrations: [shared] + }, + { + uri: Uri.file('/ws/delta.deepnote'), + projectId: 'project-delta', + projectName: 'Delta', + integrations: [{ id: 'pg-other', name: 'Other Postgres', type: 'pgsql' }] + } + ], + unreadable: [Uri.file('/ws/broken.deepnote')] + }); + + const projectNames = await findOtherProjectsDeclaring({ + integrationId: 'pg-shared', + projectId: CURRENT_PROJECT_ID + }); + + assert.deepStrictEqual(projectNames, ['Alpha', 'Beta']); + }); + }); }); diff --git a/src/notebooks/deepnote/integrations/integrationWebview.ts b/src/notebooks/deepnote/integrations/integrationWebview.ts index 052a7421cf..119af323bb 100644 --- a/src/notebooks/deepnote/integrations/integrationWebview.ts +++ b/src/notebooks/deepnote/integrations/integrationWebview.ts @@ -1,5 +1,5 @@ import { inject, injectable, optional } from 'inversify'; -import { commands, Disposable, l10n, Uri, ViewColumn, WebviewPanel, window } from 'vscode'; +import { commands, Disposable, l10n, Uri, ViewColumn, WebviewPanel, window, workspace } from 'vscode'; import { BigQueryAuthMethods } from '@deepnote/database-integrations'; @@ -11,8 +11,14 @@ import { logger } from '../../../platform/logging'; import { LocalizedMessages, SharedMessages } from '../../../messageTypes'; import { ISqlIntegrationEnvVarsProvider } from '../../../platform/notebooks/deepnote/types'; import { IDeepnoteNotebookManager, ProjectIntegration } from '../../types'; +import { findOtherProjectsDeclaring } from './existingIntegrationPicker'; import { persistProjectIntegrations } from './projectIntegrationsWriter'; -import { IFederatedAuthTokenStorage, IIntegrationStorage, IIntegrationWebviewProvider } from './types'; +import { + IFederatedAuthTokenStorage, + IIntegrationEnvLiveRefresher, + IIntegrationStorage, + IIntegrationWebviewProvider +} from './types'; import { ConfigurableDatabaseIntegrationConfig, FederatedAuthTokenStatus, @@ -49,7 +55,11 @@ export class IntegrationWebviewProvider implements IIntegrationWebviewProvider { @inject(ISqlIntegrationEnvVarsProvider) private readonly sqlIntegrationEnvVars: ISqlIntegrationEnvVarsProvider, @inject(IFederatedAuthTokenStorage) @optional() - private readonly tokenStorage?: IFederatedAuthTokenStorage + private readonly tokenStorage?: IFederatedAuthTokenStorage, + // Node-only service: the web extension has no kernels to refresh. + @inject(IIntegrationEnvLiveRefresher) + @optional() + private readonly liveRefresher?: IIntegrationEnvLiveRefresher ) { // Refresh on token-storage change so the auth pill flips without panel reload. Pushed into the extension-lifetime registry to survive panel close/reopen. if (this.tokenStorage) { @@ -925,7 +935,8 @@ export class IntegrationWebviewProvider implements IIntegrationWebviewProvider { } /** - * Delete the integration completely (removes credentials and integration entry) + * Removes the integration from the project, and its credentials too unless another workspace project still + * declares the id: credentials are keyed by id alone, so projects that linked one integration share them. */ private async deleteConfiguration(integrationId: string): Promise { if (await this.refuseEditIfFileConfigured(integrationId)) { @@ -933,10 +944,16 @@ export class IntegrationWebviewProvider implements IIntegrationWebviewProvider { } try { - // Token first: a failure here has to abort before the config is committed, otherwise the token is - // stranded with no integration left in the panel to retry from. - await this.tokenStorage?.delete(integrationId); - await this.integrationStorage.delete(integrationId); + const otherProjectNames = this.projectId + ? await findOtherProjectsDeclaring({ integrationId, projectId: this.projectId }) + : []; + + if (otherProjectNames.length === 0) { + // Token first: a failure here has to abort before the config is committed, otherwise the token is + // stranded with no integration left in the panel to retry from. + await this.tokenStorage?.delete(integrationId); + await this.integrationStorage.delete(integrationId); + } // Remove from local state this.integrations.delete(integrationId); @@ -947,12 +964,20 @@ export class IntegrationWebviewProvider implements IIntegrationWebviewProvider { if (persisted) { await this.currentPanel?.webview.postMessage({ - message: l10n.t('Integration deleted successfully'), + message: + otherProjectNames.length > 0 + ? localize.Integrations.integrationUnlinked(otherProjectNames.join(', ')) + : l10n.t('Integration deleted successfully'), type: 'success' }); } - // The credential delete above is the tracked operation; a skipped project-YAML sync is not a failure. + if (otherProjectNames.length > 0) { + await this.refreshProjectKernels(); + } + + // Deleting the credentials, or taking a shared integration off this project, is the tracked operation; + // a skipped project-YAML sync is not a failure. return true; } catch (error) { logger.error('Failed to delete integration', error); @@ -968,6 +993,23 @@ export class IntegrationWebviewProvider implements IIntegrationWebviewProvider { } } + /** + * Re-applies integration env in this project's running kernels. Only needed when no credential changed: a + * SecretStorage change already triggers `IntegrationEnvRefreshHandler`. + */ + private async refreshProjectKernels(): Promise { + const projectNotebooks = workspace.notebookDocuments.filter( + (notebook) => + notebook.notebookType === 'deepnote' && notebook.metadata?.deepnoteProjectId === this.projectId + ); + + try { + await this.liveRefresher?.refresh(projectNotebooks, 'integration_config'); + } catch (error) { + logger.error('IntegrationWebviewProvider: failed to refresh integration env after unlinking', error); + } + } + /** * Update the project's integrations list based on current integrations */ diff --git a/src/notebooks/deepnote/integrations/integrationWebview.unit.test.ts b/src/notebooks/deepnote/integrations/integrationWebview.unit.test.ts index 149f744670..cda780f113 100644 --- a/src/notebooks/deepnote/integrations/integrationWebview.unit.test.ts +++ b/src/notebooks/deepnote/integrations/integrationWebview.unit.test.ts @@ -1,15 +1,28 @@ +import { deserializeDeepnoteFile, serializeDeepnoteFile, type DeepnoteFile } from '@deepnote/blocks'; import { assert } from 'chai'; import sinon from 'sinon'; -import { EventEmitter, Uri } from 'vscode'; +import { EventEmitter, Uri, workspace } from 'vscode'; import { anyString, anything, deepEqual, instance, mock, reset, resetCalls, verify, when } from 'ts-mockito'; import { ITelemetryService } from '../../../platform/analytics/types'; import { IExtensionContext, IDisposable, Resource } from '../../../platform/common/types'; import { Commands } from '../../../platform/common/constants'; +import { Integrations } from '../../../platform/common/utils/localize'; import { ISqlIntegrationEnvVarsProvider } from '../../../platform/notebooks/deepnote/types'; -import { IDeepnoteNotebookManager } from '../../types'; +import { IDeepnoteNotebookManager, RawProjectIntegration } from '../../types'; +import { + createDeepnoteFile, + createDeepnoteProject, + createMockNotebook, + createWorkspaceFolder +} from '../deepnoteTestHelpers'; import { IntegrationWebviewProvider } from './integrationWebview'; -import { FederatedAuthTokenEntry, IFederatedAuthTokenStorage, IIntegrationStorage } from './types'; +import { + FederatedAuthTokenEntry, + IFederatedAuthTokenStorage, + IIntegrationEnvLiveRefresher, + IIntegrationStorage +} from './types'; import { DatabaseIntegrationConfig } from '@deepnote/database-integrations'; import { computeMetadataFingerprint } from './federatedAuth/federatedAuthTokenStorage.node'; import { @@ -37,6 +50,8 @@ interface FakeWebviewPanel { setPostMessageImpl: (impl: (message: CapturedMessage) => Promise) => void; } +type RefreshFn = IIntegrationEnvLiveRefresher['refresh']; + function createFakeWebviewPanel(): FakeWebviewPanel { const posted: CapturedMessage[] = []; let messageHandler: ((message: unknown) => Promise | void) | undefined; @@ -186,6 +201,7 @@ suite('IntegrationWebviewProvider', () => { function buildProvider( opts: { + liveRefresher?: IIntegrationEnvLiveRefresher; sqlIntegrationEnvVars?: ISqlIntegrationEnvVarsProvider; tokenStorage?: IFederatedAuthTokenStorage; } = {} @@ -197,7 +213,8 @@ suite('IntegrationWebviewProvider', () => { instance(mockTelemetryService), extensionSubscriptions, opts.sqlIntegrationEnvVars ?? sqlIntegrationEnvVars, - opts.tokenStorage + opts.tokenStorage, + opts.liveRefresher ); } @@ -704,6 +721,125 @@ suite('IntegrationWebviewProvider', () => { }); }); + suite('deleteConfiguration', () => { + const ALPHA_URI = Uri.file('/ws/alpha.deepnote'); + const SHARED_CONFIG = buildPostgresIntegration({ id: 'pg-shared', name: 'Shared Postgres' }); + const SHARED_ENTRY: RawProjectIntegration = { + id: SHARED_CONFIG.id, + name: SHARED_CONFIG.name, + type: SHARED_CONFIG.type + }; + + let refreshSpy: sinon.SinonSpy, ReturnType>; + let writes: Map; + + setup(() => { + refreshSpy = sinon.spy(async () => undefined); + writes = new Map(); + when(integrationStorage.delete(anyString())).thenResolve(); + preStoreToken(SHARED_CONFIG.id); + }); + + function projectFile(projectId: string, name: string, integrations: RawProjectIntegration[]): DeepnoteFile { + return createDeepnoteFile({ project: createDeepnoteProject({ id: projectId, name, integrations }) }); + } + + /** This project's active file declares `pg-shared`; `/ws/alpha.deepnote` holds project-alpha ("Alpha"). */ + function stubProjectFiles(alphaIntegrations: RawProjectIntegration[]): void { + const onDisk = new Map([ + [ACTIVE_FILE_URI.fsPath, projectFile(PROJECT_ID, 'Active', [SHARED_ENTRY])], + [ALPHA_URI.fsPath, projectFile('project-alpha', 'Alpha', alphaIntegrations)] + ]); + const discovered = [ACTIVE_FILE_URI, ALPHA_URI]; + + when(mockedVSCodeNamespaces.workspace.workspaceFolders).thenReturn([ + createWorkspaceFolder(Uri.file('/ws')) + ]); + when(mockedVSCodeNamespaces.workspace.findFiles(anything(), anything(), anything(), anything())).thenReturn( + Promise.resolve(discovered) + ); + // `persistProjectIntegrations` enumerates without a token; the scan passes one. + when(mockedVSCodeNamespaces.workspace.findFiles(anything())).thenReturn(Promise.resolve(discovered)); + + const mockFs = mock(); + + when(mockFs.readFile(anything())).thenCall((uri: Uri) => { + const file = onDisk.get(uri.fsPath); + + return file + ? Promise.resolve(new TextEncoder().encode(serializeDeepnoteFile(file))) + : Promise.reject(new Error(`no readFile stub for ${uri.fsPath}`)); + }); + when(mockFs.writeFile(anything(), anything())).thenCall((uri: Uri, bytes: Uint8Array) => { + writes.set(uri.fsPath, deserializeDeepnoteFile(new TextDecoder().decode(bytes))); + + return Promise.resolve(); + }); + when(mockedVSCodeNamespaces.workspace.fs).thenReturn(instance(mockFs)); + } + + async function deleteSharedIntegration(): Promise { + const provider = buildProvider({ liveRefresher: { refresh: refreshSpy }, tokenStorage }); + + await show(provider, singleIntegrationMap(SHARED_CONFIG.id, SHARED_CONFIG)); + + await fakePanel.onDidReceiveMessage({ type: 'delete', integrationId: SHARED_CONFIG.id }); + } + + function successMessages(): CapturedMessage[] { + return fakePanel.posted.filter((message) => message.type === 'success'); + } + + test('takes an integration another project declares off this project and keeps its credentials', async () => { + // Catches: deleting a linked integration wipes the credentials another project still uses. + stubProjectFiles([SHARED_ENTRY]); + + await deleteSharedIntegration(); + + verify(integrationStorage.delete(anything())).never(); + sinon.assert.notCalled(tokenDeleteSpy); + assert.deepStrictEqual(writes.get(ACTIVE_FILE_URI.fsPath)?.project.integrations, []); + assert.isFalse(writes.has(ALPHA_URI.fsPath), "Alpha's file must not be rewritten"); + assert.deepStrictEqual(successMessages(), [ + { message: Integrations.integrationUnlinked('Alpha'), type: 'success' } + ]); + }); + + test("refreshes only this project's kernels after taking a shared integration off it", async () => { + // Catches: an unlink fires no storage event, so this project's running kernels keep the removed + // integration's env. + stubProjectFiles([SHARED_ENTRY]); + const activeNotebook = createMockNotebook({ + uri: ACTIVE_FILE_URI, + metadata: { deepnoteProjectId: PROJECT_ID } + }); + const alphaNotebook = createMockNotebook({ + uri: ALPHA_URI, + metadata: { deepnoteProjectId: 'project-alpha' } + }); + + when(mockedVSCodeNamespaces.workspace.notebookDocuments).thenReturn([activeNotebook, alphaNotebook]); + + await deleteSharedIntegration(); + + sinon.assert.calledOnceWithExactly(refreshSpy, [activeNotebook], 'integration_config'); + }); + + test('deletes the credentials of an integration no other project declares', async () => { + // Catches: an over-broad guard never deletes credentials, so they leak. + stubProjectFiles([{ id: 'pg-other', name: 'Other Postgres', type: 'pgsql' }]); + + await deleteSharedIntegration(); + + verify(integrationStorage.delete(SHARED_CONFIG.id)).once(); + sinon.assert.calledOnceWithExactly(tokenDeleteSpy, SHARED_CONFIG.id); + assert.deepStrictEqual(successMessages(), [ + { message: 'Integration deleted successfully', type: 'success' } + ]); + sinon.assert.notCalled(refreshSpy); + }); + }); + test('saveConfiguration: deletes the token BEFORE save when fingerprint changes', async () => { const integrationId = 'bq-save-fp'; const integrationSaveSpy = sinon.spy(); diff --git a/src/platform/common/utils/localize.ts b/src/platform/common/utils/localize.ts index f334adb25b..8502e4b799 100644 --- a/src/platform/common/utils/localize.ts +++ b/src/platform/common/utils/localize.ts @@ -891,6 +891,11 @@ export namespace Integrations { 'Failed to add the integration to the notebook file. Please try again.' ); export const addExistingIntegrationUsedIn = (projectNames: string) => l10n.t('Used in: {0}', projectNames); + export const integrationUnlinked = (projectNames: string) => + l10n.t( + 'Removed from this project. Its credentials were kept because other projects still use them: {0}', + projectNames + ); export const database = l10n.t('Database'); export const dataWarehousesLakes = l10n.t('Data Warehouses & Lakes'); export const databases = l10n.t('Databases'); From 264eb431a66feda8f198e8ba7f4a6a3b2c610068 Mon Sep 17 00:00:00 2001 From: tomas Date: Wed, 23 Sep 2026 18:43:31 +0000 Subject: [PATCH 19/35] fix(integrations): hold back the type cards until their labels arrive 058fe8ffa replaced the webview's hard-coded English type labels with lookups in the string bundle the host posts as `loc_init`, falling back to the raw type id. The panel mounted `IntegrationTypeSelector` before that message arrived, and `storeLocStrings` sets no React state, so all 18 cards were first rendered as `clickhouse`, `pgsql`, `sql-server` and so on, until the first `update` re-rendered the panel. The doc comment on `integrationTypeLabel` said that fallback should never render. The panel now renders the selector only once it has handled `loc_init`, and the doc comment says so. The order inside that case matters and no test pins it: the panel is a legacy `ReactDOM.render` root on React 16, so `setLocStringsLoaded` re-renders synchronously inside the message listener and must run after `storeLocStrings`, or the cards render before the bundle is stored. A one-line comment above the two calls records this. The gate cannot strand the selector: `show()` is the panel's only creation site and always posts `loc_init` before `update`, and no serializer revives the panel. The "Add Existing Integration" button now waits for `loc_init` too; the e2e helper `awaitIntegrationsPanel` polls for `.integration-type-selector`, so the gate only lengthens its wait. Verified: `npm run typecheck` exit 0; `npm run lint` exit 0, no findings in the changed files; `prettier --check` clean on both files; `npm run esbuild-all` exit 0, integrations bundle built. No test is added. A throwaway jsdom harness outside the repo bundled this `IntegrationPanel.tsx` and replayed the host's message order. Before the change: 18 raw-id cards on first render, still raw after `loc_init`, labelled after `update`. After it: no cards on first render, then 18 labelled cards in the same tick as `loc_init`. With the two `loc_init` calls swapped, the cards rendered raw ids after `loc_init`. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01DiYErQ9fP3wNhrNaRZAdqv --- .../webview-side/integrations/IntegrationPanel.tsx | 7 ++++++- src/webviews/webview-side/integrations/integrationUtils.ts | 2 +- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/webviews/webview-side/integrations/IntegrationPanel.tsx b/src/webviews/webview-side/integrations/IntegrationPanel.tsx index d17bf5826c..3a875c84fe 100644 --- a/src/webviews/webview-side/integrations/IntegrationPanel.tsx +++ b/src/webviews/webview-side/integrations/IntegrationPanel.tsx @@ -33,6 +33,7 @@ export const IntegrationPanel: React.FC = ({ baseTheme, const [message, setMessage] = React.useState<{ type: 'success' | 'error'; text: string } | null>(null); const [confirmReset, setConfirmReset] = React.useState(null); const [confirmDelete, setConfirmDelete] = React.useState(null); + const [locStringsLoaded, setLocStringsLoaded] = React.useState(false); const messageTimerRef = React.useRef(null); const confirmResetTimerRef = React.useRef(null); @@ -63,7 +64,9 @@ export const IntegrationPanel: React.FC = ({ baseTheme, switch (msg.type) { case 'loc_init': + // Store first: this state change re-renders synchronously, and the cards read the bundle. storeLocStrings(msg.locStrings); + setLocStringsLoaded(true); break; case 'update': @@ -243,7 +246,9 @@ export const IntegrationPanel: React.FC = ({ baseTheme, onSignOut={handleSignOut} /> - + {locStringsLoaded && ( + + )} {selectedIntegrationId && selectedIntegrationType && ( Date: Wed, 23 Sep 2026 18:41:59 +0000 Subject: [PATCH 20/35] test(e2e): drop add-existing assertions that can never fail `waitForNotification(pattern, timeout, true)` either resolves with the matching notification or throws: the helper rethrows when `required`, and selenium's `wait` resolves a function condition only on a truthy value. Mocha skips the `it` once `before` fails, so the two toast flags that `before` set from `result !== undefined` were always `true` by the time the `it` read them. `pickedLabel` had the same property: it re-read the label of the row the picker wait had selected because that label matched `INTEGRATION_NAME`. None of the three assertions could fail, and they suggested a gate that really lives in `before`. Await both required waits as plain statements, and drop the three variables with their assertions. `required: true` stays: a missed toast should stop the hook where it happened, and the success-toast wait also orders the file reads that follow it. The label comparison inside the picker wait stays as the real check, and so do the description and "Used in" row assertions: the wait matches on the label only, so they still check something. Verified: - `npm run compile-e2e` exit 0. It is the only script that type-checks test/e2e; typecheck, lint and format cover src/ only. - `npx prettier --check` on the file: clean. - grep: none of `noneAvailableToastShown`, `successToastShown` or `pickedLabel` remains in the file. - A scratch mocha run of this tree's compiled helper over the real `WebDriver.wait`, faking only the notification list: a required wait resolves with the matching notification, and otherwise rejects with `TimeoutError`, never with `undefined` (5 passing). A model of the hook with the old flag and with a plain `await` behaves the same: both pass when the toast shows; when it is missing, both fail in "before all" with the same timeout message and the `it` body never runs. No test is added: this only removes assertions that cannot fail. The e2e suite was not run, so the mutation checks (making either toast pattern or the label comparison unmatchable) were not done. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01DiYErQ9fP3wNhrNaRZAdqv --- .../addExistingIntegration.e2e.test.ts | 33 ++++++------------- 1 file changed, 10 insertions(+), 23 deletions(-) diff --git a/test/e2e/suite/workspace/addExistingIntegration.e2e.test.ts b/test/e2e/suite/workspace/addExistingIntegration.e2e.test.ts index 3f0461d4cb..2881ce5818 100644 --- a/test/e2e/suite/workspace/addExistingIntegration.e2e.test.ts +++ b/test/e2e/suite/workspace/addExistingIntegration.e2e.test.ts @@ -210,12 +210,9 @@ describe('Deepnote — adding an integration another project already configured' this.timeout(SUITE_TIMEOUT); let cleanupTempDir: (() => void) | undefined; - let noneAvailableToastShown = false; let pickedDescription: string | undefined; - let pickedLabel = ''; let pickedRowText = ''; let sharedIntegrationId = ''; - let successToastShown = false; let targetFileContents = ''; let targetIntegrations: DeclaredIntegration[] = []; @@ -235,12 +232,11 @@ describe('Deepnote — adding an integration another project already configured' // credentials for it, and a declaration on its own carries nothing to reuse. await focusNotebook(SOURCE_FILE); await new Workbench().executeCommand(ADD_EXISTING_INTEGRATION); - noneAvailableToastShown = - (await waitForNotification( - /No integrations from other projects in this workspace are available to add/i, - WORKBENCH_TIMEOUT, - true - )) !== undefined; + await waitForNotification( + /No integrations from other projects in this workspace are available to add/i, + WORKBENCH_TIMEOUT, + true + ); await screenshot('nothing-to-reuse'); await dismissAllNotifications(); @@ -281,18 +277,16 @@ describe('Deepnote — adding an integration another project already configured' const item = assertNotNull(picked, `the reuse picker never offered "${INTEGRATION_NAME}"`); - pickedLabel = await item.getLabel(); pickedDescription = await item.getDescription(); // The detail line ("Used in: …") has no page object; it is part of the row's rendered text. pickedRowText = await item.getText(); await item.select(); - successToastShown = - (await waitForNotification( - new RegExp(`Added integration "${INTEGRATION_NAME}" to this project`), - WORKBENCH_TIMEOUT, - true - )) !== undefined; + await waitForNotification( + new RegExp(`Added integration "${INTEGRATION_NAME}" to this project`), + WORKBENCH_TIMEOUT, + true + ); await screenshot('linked-into-this-project'); targetIntegrations = readDeclaredIntegrations(targetFilePath); @@ -312,17 +306,10 @@ describe('Deepnote — adding an integration another project already configured' // Deliberately one test: every expectation reads state the `before` hook already captured, so // splitting them buys separate mocha records and nothing else. it('offers only what the other project configured, and links it without its credentials', function () { - // offers nothing while the other project has only declared an integration, never configured it - expect(noneAvailableToastShown, '"nothing to reuse" notification').to.equal(true); - // offers the integration the other project configured, and names that project - expect(pickedLabel, 'quick pick label').to.equal(INTEGRATION_NAME); expect(pickedDescription, 'quick pick description').to.equal(INTEGRATION_TYPE_LABEL); expect(pickedRowText, 'quick pick row').to.contain(`Used in: ${SOURCE_PROJECT_NAME}`); - // confirms the link with a toast - expect(successToastShown, 'integration-added toast').to.equal(true); - // writes the link into this project on disk, keeping the integration it already had expect(targetIntegrations, 'integrations declared by the target project').to.deep.equal([ TARGET_OWN_INTEGRATION, From d2abd3aad192dd145c6d3e26bb6aee8873d0f14a Mon Sep 17 00:00:00 2001 From: tomas Date: Wed, 23 Sep 2026 19:37:21 +0000 Subject: [PATCH 21/35] test(integrations): type the cache-update doubles off the interface 058fe8ffa widened `IDeepnoteNotebookManager.updateProjectIntegrations` to `RawProjectIntegration[]`, whose `type` is any string, but the three doubles that capture its calls annotated the pre-widening `ProjectIntegration[]`, and so did their capture arrays. ts-mockito's `thenCall` takes `(...args: any[]) => any` and checks no signature, so nothing flagged them: the picker test that passes `pandas-dataframe` and `some-future-type` entries through verbatim ran through a callback whose declared type forbids them. The next contract change would slip past them the same way. Each double is now a `recordCacheUpdate` typed with `UpdateProjectIntegrationsFn`, the interface method's type, named like the existing `RefreshFn`, and its capture array holds `RawProjectIntegration[]`. The callback parameters stay unannotated: the alias comes from a method signature, whose parameters TypeScript checks bivariantly, so a hand-written annotation, even the stale one, would still compile and reopen the gap. Assertions and expected literals are unchanged. The writer's `thenReturn(true)` stub, which ts-mockito types, and its zero-argument ordering double, which reads no arguments, are left as they are. Verified: - `npm run typecheck` exit 0 before and after. - Mutation: with `integrations` widened to `readonly RawProjectIntegration[]` in src/notebooks/types.ts, the old doubles still typecheck (exit 0, no errors). The retyped ones fail with exit 2 and exactly three errors, one per file, each on the line that pushes into `cacheUpdates`: existingIntegrationPicker.unit.test.ts(376,48) TS4104, integrationManager.unit.test.ts(177,31) TS2345 and projectIntegrationsWriter.unit.test.ts(262,31) TS2345. Reverted by editing: typecheck exit 0. - Bivariance, on scratch files importing the real types: the stale annotation against the method-derived alias compiles; the same callback against a plain function type fails with TS2322. - `npm run lint` exit 0, no findings in the changed files; `prettier --check` clean on all three. - `npm run compile-tsc` exit 0. The focused suites pass unchanged, 43 before and after: existingIntegrationPicker 14, IntegrationManager.addExistingIntegration 17, persistProjectIntegrations 6, addProjectIntegration 6. - `npm test`: 2870 passing, 239 pending, 0 failing. No test is added: the change only touches types, and the compiler is the check. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01DiYErQ9fP3wNhrNaRZAdqv --- .../existingIntegrationPicker.unit.test.ts | 18 +++++++++------- .../integrationManager.unit.test.ts | 21 ++++++++++--------- .../projectIntegrationsWriter.unit.test.ts | 20 ++++++++++-------- 3 files changed, 32 insertions(+), 27 deletions(-) diff --git a/src/notebooks/deepnote/integrations/existingIntegrationPicker.unit.test.ts b/src/notebooks/deepnote/integrations/existingIntegrationPicker.unit.test.ts index dd27368048..fd9ca58fdd 100644 --- a/src/notebooks/deepnote/integrations/existingIntegrationPicker.unit.test.ts +++ b/src/notebooks/deepnote/integrations/existingIntegrationPicker.unit.test.ts @@ -21,6 +21,9 @@ use(chaiAsPromised); const CURRENT_PROJECT_ID = 'project-current'; +// `thenCall` checks no signature, so doubles take their parameter types from here; annotating them by hand undoes it. +type UpdateProjectIntegrationsFn = IDeepnoteNotebookManager['updateProjectIntegrations']; + interface OnDiskProject { uri: Uri; projectId: string; @@ -365,18 +368,17 @@ suite('existingIntegrationPicker', () => { }; let notebookManager: IDeepnoteNotebookManager; - let cacheUpdates: Array<{ projectId: string; integrations: ProjectIntegration[] }>; + let cacheUpdates: Array<{ projectId: string; integrations: RawProjectIntegration[] }>; setup(() => { cacheUpdates = []; - const mockManager = mock(); - when(mockManager.updateProjectIntegrations(anything(), anything())).thenCall( - (projectId: string, integrations: ProjectIntegration[]) => { - cacheUpdates.push({ projectId, integrations }); + const recordCacheUpdate: UpdateProjectIntegrationsFn = (projectId, integrations) => { + cacheUpdates.push({ projectId, integrations }); - return true; - } - ); + return true; + }; + const mockManager = mock(); + when(mockManager.updateProjectIntegrations(anything(), anything())).thenCall(recordCacheUpdate); notebookManager = instance(mockManager); }); diff --git a/src/notebooks/deepnote/integrations/integrationManager.unit.test.ts b/src/notebooks/deepnote/integrations/integrationManager.unit.test.ts index c654588476..79bc59d5a4 100644 --- a/src/notebooks/deepnote/integrations/integrationManager.unit.test.ts +++ b/src/notebooks/deepnote/integrations/integrationManager.unit.test.ts @@ -49,6 +49,8 @@ const SHARED_CONFIG = buildPostgresIntegration({ id: 'pg-shared', name: 'Shared const REFRESH_START_TIMEOUT_MS = 1_000; type RefreshFn = IIntegrationEnvLiveRefresher['refresh']; +// `thenCall` checks no signature, so doubles take their parameter types from here; annotating them by hand undoes it. +type UpdateProjectIntegrationsFn = IDeepnoteNotebookManager['updateProjectIntegrations']; function projectFile(projectId: string, notebookId: string, integrations: RawProjectIntegration[]): DeepnoteFile { return createDeepnoteFile({ @@ -66,7 +68,7 @@ suite('IntegrationManager.addExistingIntegration', () => { let otherNotebook: NotebookDocument; let currentProject: DeepnoteFile | undefined; let writes: Map; - let cacheUpdates: ProjectIntegration[][]; + let cacheUpdates: RawProjectIntegration[][]; // Typed off the interface so a signature change fails the compile, not just the `firstCall.args` assertion. let refreshSpy: sinon.SinonSpy, ReturnType>; let quickPickItems: QuickPickItem[] | undefined; @@ -167,17 +169,16 @@ suite('IntegrationManager.addExistingIntegration', () => { const mockManager = mock(); when(mockManager.getProjectForNotebook(CURRENT_PROJECT_ID, CURRENT_NOTEBOOK_ID)).thenCall(() => currentProject); - when(mockManager.updateProjectIntegrations(anything(), anything())).thenCall( - (_projectId: string, integrations: ProjectIntegration[]) => { - if (cacheUpdateError) { - throw cacheUpdateError; - } + const recordCacheUpdate: UpdateProjectIntegrationsFn = (_projectId, integrations) => { + if (cacheUpdateError) { + throw cacheUpdateError; + } - cacheUpdates.push(integrations); + cacheUpdates.push(integrations); - return true; - } - ); + return true; + }; + when(mockManager.updateProjectIntegrations(anything(), anything())).thenCall(recordCacheUpdate); notebookManager = mockManager; telemetry = mock(); diff --git a/src/notebooks/deepnote/integrations/projectIntegrationsWriter.unit.test.ts b/src/notebooks/deepnote/integrations/projectIntegrationsWriter.unit.test.ts index 163b18286d..102b41531a 100644 --- a/src/notebooks/deepnote/integrations/projectIntegrationsWriter.unit.test.ts +++ b/src/notebooks/deepnote/integrations/projectIntegrationsWriter.unit.test.ts @@ -4,7 +4,7 @@ import { anything, instance, mock, when } from 'ts-mockito'; import { Uri, workspace, type NotebookDocument } from 'vscode'; import { mockedVSCodeNamespaces, resetVSCodeMocks } from '../../../test/vscode-mock'; -import { IDeepnoteNotebookManager, ProjectIntegration } from '../../types'; +import { IDeepnoteNotebookManager, ProjectIntegration, RawProjectIntegration } from '../../types'; import { createDeepnoteBlock, createDeepnoteFile, @@ -18,6 +18,9 @@ const PROJECT_ID = 'project-1'; const NEW_INTEGRATIONS: ProjectIntegration[] = [{ id: 'int-new', name: 'New BigQuery', type: 'big-query' }]; +// `thenCall` checks no signature, so doubles take their parameter types from here; annotating them by hand undoes it. +type UpdateProjectIntegrationsFn = IDeepnoteNotebookManager['updateProjectIntegrations']; + function projectFile( notebookId: string, projectId: string = PROJECT_ID, @@ -248,21 +251,20 @@ suite('persistProjectIntegrations', () => { suite('addProjectIntegration', () => { const ADDED: ProjectIntegration = { id: 'int-added', name: 'Added Postgres', type: 'pgsql' }; - let cacheUpdates: ProjectIntegration[][]; + let cacheUpdates: RawProjectIntegration[][]; let managerInstance: IDeepnoteNotebookManager; setup(() => { resetVSCodeMocks(); cacheUpdates = []; - const mockManager = mock(); - when(mockManager.updateProjectIntegrations(anything(), anything())).thenCall( - (_projectId: string, integrations: ProjectIntegration[]) => { - cacheUpdates.push(integrations); + const recordCacheUpdate: UpdateProjectIntegrationsFn = (_projectId, integrations) => { + cacheUpdates.push(integrations); - return true; - } - ); + return true; + }; + const mockManager = mock(); + when(mockManager.updateProjectIntegrations(anything(), anything())).thenCall(recordCacheUpdate); managerInstance = instance(mockManager); }); From 5b2c08708543cfd948dc14f9d382e291d75d3137 Mon Sep 17 00:00:00 2001 From: tomas Date: Thu, 24 Sep 2026 08:58:48 +0000 Subject: [PATCH 22/35] test(integrations): groom the existing-integration picker tests Drop the test that takes the name from the stored config rather than from the first project read. Its one project declares the id once, so it cannot tell a first read from a later one, and the dedupe test already expects the stored name over two declared ones. Drop the attach test that replaces an id the file already declares. The rule lives in `addProjectIntegration`, whose own test pins it with the same arrangement; attaching only builds the entry and delegates. The dropped test also checked only the file, which cannot show a stray key on an entry: `serializeDeepnoteFile` strips unknown keys. Merge the two remaining attach tests. Both attached the same entry and checked the cache and the file the same way; only the entries the file held before differed. The merged test starts from all three existing entries, the `pandas-dataframe` and `some-future-type` ones included, and keeps the check of the returned result. `ProjectIntegration` is no longer used, so its import goes. Compare whole entries in the exclusion test, not only ids. The current project and project-a both declare `bq-oauth`, so the id is offered whether or not the current project's own file is visited; visiting it only adds `project-current` to `projectNames`, which the test now pins. Give the unsupported-types test a declaration only the type check can skip. Its one unsupported entry had no stored config, so the missing-config branch skipped it with or without the check. Project-b now declares `pg-shared` with a type this build cannot manage; without the check, that declaration counts as a type conflict and hides `pg-shared` everywhere. Turn the three cancellation tests into one table of three rows. They shared one skeleton and differed only in the files on disk, the hook that trips the token and the files read before the stop. The last-file row now also checks the reads, which its test did not. Verified: - `npm run typecheck` exit 0; `npm run lint` exit 0, no findings in the file; `npx prettier --check` clean; `npm run compile-tsc` exit 0. - existingIntegrationPicker: 11 passing, down from 14 (three tests dropped; the table keeps three cases). - Mutation harness of the test-grooming pass: one find/replace in the production code per run, compile, then the seven focused suites (148 tests, all passing unmutated). Newly caught: - the configurable-type check removed: the unsupported-types test. No test in the whole suite failed before. - the shared walk visiting the current project's own files: the exclusion test, besides the three that already failed. - `collectReusableIntegrations` alone scanning with a project id that matches nothing: the exclusion test. No test in the whole suite failed before. Still caught after the drops and merges: - the declared name winning over the stored one, or each later declaration renaming the candidate: the dedupe test. - `return` instead of `continue` on a missing stored config: the unsupported-types test. - a declared id appended again, or its old entry kept: the writer's replace test. - attach passing `projectNames` through, naming the entry after its id or using the integration id as the project id; the writer stamping one list over every file, caching the new entry alone, keying the cache by the integration id, never assigning the merged list, or pruning types it cannot manage: the merged test. The other tests that caught these before still do. - the cancellation checks: dropping the per-file check fails the mid-scan row; dropping the final check fails the last-file and discovery rows; moving it after each visited file fails the discovery row; re-checking only after an empty discovery fails the last-file row. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01DiYErQ9fP3wNhrNaRZAdqv --- .../existingIntegrationPicker.unit.test.ts | 207 ++++++------------ 1 file changed, 64 insertions(+), 143 deletions(-) diff --git a/src/notebooks/deepnote/integrations/existingIntegrationPicker.unit.test.ts b/src/notebooks/deepnote/integrations/existingIntegrationPicker.unit.test.ts index fd9ca58fdd..790471864a 100644 --- a/src/notebooks/deepnote/integrations/existingIntegrationPicker.unit.test.ts +++ b/src/notebooks/deepnote/integrations/existingIntegrationPicker.unit.test.ts @@ -6,7 +6,7 @@ import { CancellationError, CancellationToken, CancellationTokenSource, Uri, wor import { ConfigurableDatabaseIntegrationConfig } from '../../../platform/notebooks/deepnote/integrationTypes'; import { mockedVSCodeNamespaces, resetVSCodeMocks } from '../../../test/vscode-mock'; -import { IDeepnoteNotebookManager, ProjectIntegration, RawProjectIntegration } from '../../types'; +import { IDeepnoteNotebookManager, RawProjectIntegration } from '../../types'; import { createDeepnoteFile, createDeepnoteProject, createWorkspaceFolder } from '../deepnoteTestHelpers'; import { attachExistingIntegration, @@ -163,22 +163,6 @@ suite('existingIntegrationPicker', () => { assert.deepStrictEqual(result, { conflictingIds: [], integrations: expected }); }); - test('takes the name from the stored config, not from whichever project was read first', async () => { - stubWorkspace({ - projects: [ - { - uri: Uri.file('/ws/a.deepnote'), - projectId: 'project-a', - integrations: [{ id: 'pg-shared', name: 'Stale project name', type: 'pgsql' }] - } - ] - }); - - const { integrations } = await collect(); - - assert.strictEqual(integrations[0].name, 'Shared Postgres'); - }); - test('excludes ids the current project already declares and the current project files themselves', async () => { stubWorkspace({ projects: [ @@ -200,9 +184,12 @@ suite('existingIntegrationPicker', () => { const { integrations } = await collect(['pg-shared']); + const expected: ReusableIntegration[] = [ + { id: 'bq-oauth', name: 'Team BigQuery', projectNames: ['project-a'], type: 'big-query' } + ]; assert.deepStrictEqual( - integrations.map((integration) => integration.id), - ['bq-oauth'], + integrations, + expected, 'pg-shared is already attached; bq-oauth is offered because project-a (not the current project) declares it' ); }); @@ -218,6 +205,12 @@ suite('existingIntegrationPicker', () => { { id: 'duckdb', name: 'DuckDB', type: 'pandas-dataframe' }, { id: 'pg-shared', name: 'Shared Postgres', type: 'pgsql' } ] + }, + // A type this build cannot manage is skipped, not taken for a conflict that hides pg-shared. + { + uri: Uri.file('/ws/b.deepnote'), + projectId: 'project-b', + integrations: [{ id: 'pg-shared', name: 'Shared Postgres', type: 'some-future-type' }] } ] }); @@ -277,77 +270,54 @@ suite('existingIntegrationPicker', () => { ); }); - test('stops at the next file and reports cancellation when the token trips mid-scan', async () => { - const cts = new CancellationTokenSource(); - - try { - const { reads } = stubWorkspace({ - projects: [ - { - uri: Uri.file('/ws/a.deepnote'), - projectId: 'project-a', - integrations: [{ id: 'pg-shared', name: 'Shared Postgres', type: 'pgsql' }] - }, - { - uri: Uri.file('/ws/b.deepnote'), - projectId: 'project-b', - integrations: [{ id: 'bq-oauth', name: 'Team BigQuery', type: 'big-query' }] - } - ], - onRead: () => cts.cancel() - }); - - await assert.isRejected(collect([], [pgConfig, bqConfig], cts.token), CancellationError); - - assert.deepStrictEqual(reads, [Uri.file('/ws/a.deepnote').fsPath], 'the scan must not read on'); - } finally { - cts.dispose(); - } - }); - - test('reports cancellation when the token trips while the last file is read', async () => { - const cts = new CancellationTokenSource(); - - try { - stubWorkspace({ - projects: [ - { - uri: Uri.file('/ws/a.deepnote'), - projectId: 'project-a', - integrations: [{ id: 'pg-shared', name: 'Shared Postgres', type: 'pgsql' }] - } - ], - onRead: () => cts.cancel() - }); - - await assert.isRejected(collect([], [pgConfig, bqConfig], cts.token), CancellationError); - } finally { - cts.dispose(); - } - }); - - test('reports cancellation when the token trips during file discovery', async () => { - const cts = new CancellationTokenSource(); - - try { - const { reads } = stubWorkspace({ - projects: [ - { - uri: Uri.file('/ws/a.deepnote'), - projectId: 'project-a', - integrations: [{ id: 'pg-shared', name: 'Shared Postgres', type: 'pgsql' }] - } - ], - onFindFiles: () => cts.cancel() - }); - - await assert.isRejected(collect([], [pgConfig, bqConfig], cts.token), CancellationError); - - assert.deepStrictEqual(reads, [], 'discovery never finished, so no file was visited'); - } finally { - cts.dispose(); - } - }); + const projectA: OnDiskProject = { + uri: Uri.file('/ws/a.deepnote'), + projectId: 'project-a', + integrations: [{ id: 'pg-shared', name: 'Shared Postgres', type: 'pgsql' }] + }; + const projectB: OnDiskProject = { + uri: Uri.file('/ws/b.deepnote'), + projectId: 'project-b', + integrations: [{ id: 'bq-oauth', name: 'Team BigQuery', type: 'big-query' }] + }; + const cancellations: { + name: string; + projects: OnDiskProject[]; + readsBeforeStop: Uri[]; + trip: 'discovery' | 'read'; + }[] = [ + { name: 'mid-scan', projects: [projectA, projectB], readsBeforeStop: [projectA.uri], trip: 'read' }, + { + name: 'while the last file is read', + projects: [projectA], + readsBeforeStop: [projectA.uri], + trip: 'read' + }, + { name: 'during file discovery', projects: [projectA], readsBeforeStop: [], trip: 'discovery' } + ]; + + for (const { name, projects, readsBeforeStop, trip } of cancellations) { + test(`reports cancellation and reads no further when the token trips ${name}`, async () => { + const cts = new CancellationTokenSource(); + + try { + const { reads } = stubWorkspace({ + projects, + onFindFiles: trip === 'discovery' ? () => cts.cancel() : undefined, + onRead: trip === 'read' ? () => cts.cancel() : undefined + }); + + await assert.isRejected(collect([], [pgConfig, bqConfig], cts.token), CancellationError); + + assert.deepStrictEqual( + reads, + readsBeforeStop.map((uri) => uri.fsPath) + ); + } finally { + cts.dispose(); + } + }); + } test('returns nothing without an open workspace folder', async () => { stubWorkspace({ projects: [], hasWorkspaceFolder: false }); @@ -382,36 +352,9 @@ suite('existingIntegrationPicker', () => { notebookManager = instance(mockManager); }); - test('appends the linked entry to the project integrations in the cache and on disk, keeping existing entries', async () => { - const { writes } = stubWorkspace({ - projects: [ - { - uri: activeUri, - projectId: CURRENT_PROJECT_ID, - integrations: [{ id: 'bq-own', name: 'Own BigQuery', type: 'big-query' }] - } - ] - }); - const result = await attachExistingIntegration({ - activeFileUri: activeUri, - integration: shared, - notebookManager, - projectId: CURRENT_PROJECT_ID - }); - - const expectedIntegrations: ProjectIntegration[] = [ - { id: 'bq-own', name: 'Own BigQuery', type: 'big-query' }, - { id: 'pg-shared', name: 'Shared Postgres', type: 'pgsql' } - ]; - assert.deepStrictEqual(result, { activePersisted: true, siblingsFailed: 0 }); - assert.deepStrictEqual(cacheUpdates, [ - { projectId: CURRENT_PROJECT_ID, integrations: expectedIntegrations } - ]); - assert.deepStrictEqual(writes.get(activeUri.fsPath)?.project.integrations, expectedIntegrations); - }); - - test('passes entries of types it cannot manage (e.g. pandas-dataframe) through verbatim', async () => { + test('appends the linked entry in the cache and on disk, keeping every existing entry verbatim, even of types it cannot manage', async () => { const currentIntegrations: RawProjectIntegration[] = [ + { id: 'bq-own', name: 'Own BigQuery', type: 'big-query' }, { id: 'duckdb', name: 'DuckDB', type: 'pandas-dataframe' }, { id: 'future', name: 'Unknown to this build', type: 'some-future-type' } ]; @@ -419,7 +362,7 @@ suite('existingIntegrationPicker', () => { projects: [{ uri: activeUri, projectId: CURRENT_PROJECT_ID, integrations: currentIntegrations }] }); - await attachExistingIntegration({ + const result = await attachExistingIntegration({ activeFileUri: activeUri, integration: shared, notebookManager, @@ -430,34 +373,12 @@ suite('existingIntegrationPicker', () => { ...currentIntegrations, { id: 'pg-shared', name: 'Shared Postgres', type: 'pgsql' } ]; + assert.deepStrictEqual(result, { activePersisted: true, siblingsFailed: 0 }); assert.deepStrictEqual(cacheUpdates, [ { projectId: CURRENT_PROJECT_ID, integrations: expectedIntegrations } ]); assert.deepStrictEqual(writes.get(activeUri.fsPath)?.project.integrations, expectedIntegrations); }); - - test('replaces rather than duplicates an entry whose id the project already declares', async () => { - const { writes } = stubWorkspace({ - projects: [ - { - uri: activeUri, - projectId: CURRENT_PROJECT_ID, - integrations: [{ id: 'pg-shared', name: 'Old name', type: 'pgsql' }] - } - ] - }); - - await attachExistingIntegration({ - activeFileUri: activeUri, - integration: shared, - notebookManager, - projectId: CURRENT_PROJECT_ID - }); - - assert.deepStrictEqual(writes.get(activeUri.fsPath)?.project.integrations, [ - { id: 'pg-shared', name: 'Shared Postgres', type: 'pgsql' } - ]); - }); }); suite('findOtherProjectsDeclaring', () => { From d419c872b32fc2d7e5f33ebf2b4015e1fbe45c09 Mon Sep 17 00:00:00 2001 From: tomas Date: Thu, 24 Sep 2026 09:10:59 +0000 Subject: [PATCH 23/35] test(integrations): check what the add-existing manager tests claim Make the early-failure rows check that no file is read. Their title says each branch fails without reading or writing any file, but the body checked only writes, and the fs double does not record reads. The scan is the only step that reads files before the picker, and it runs inside `withProgress`, so each row now verifies that `withProgress` is never called. Check the cache only in the late-failure row that can see it. In the writer-throws row the cache double throws before it records anything, so `cacheUpdates` stayed empty whatever the manager did. A row field now limits that assertion to the failed-write row, and a comment on the other row says why it has none. Drop the sibling-warning test's check that the sibling file was not written. The fs double rejects writes to that file before recording them, so the check held whatever the manager did. Pin the conflict warning in the type-mismatch test. It accepted any warning, so one counting the offered integrations instead of the skipped ones ("0 integration(s) were skipped ...") passed. The test now expects `addExistingIntegrationConflictsSkipped(1)`; the `l10n.t` mock formats the count, so the pinned text is the real one. Verified: - `npm run typecheck` exit 0; `npm run lint` exit 0, no findings in the file; `npx prettier --check` clean; `npm run compile-tsc` exit 0. - IntegrationManager.addExistingIntegration: 17 passing, as before. - Mutation harness of the test-grooming pass: one find/replace in the production code per run, compile, then the seven focused suites (148 tests, all passing unmutated). Newly caught: - the snapshot guard moved after the scan, so every `.deepnote` file is read before the refusal: the snapshot row. No test in the whole suite failed before. - the conflict warning counting the offered integrations: the type-mismatch test. None of the focused suites failed before. Still caught: - each early-failure branch by its own row: the missing-notebook, closed-notebook, no-ids and uncached-project messages, and the snapshot guard applied only when no URI is passed. - going back to a roster write that updates the cache first: the failed-write row, on the cache assertion, and the test that keeps an entry the file gained while the picker was open. - no catch around the attach: the writer-throws row. - a failed write showing no error, or refreshing anyway: the failed-write row. Completion telemetry without the integration type: both late-failure rows and the link test. - the sibling warning needing two failed siblings: the sibling-warning test. - the conflict warning needing two conflicts: the type-mismatch test. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01DiYErQ9fP3wNhrNaRZAdqv --- .../integrationManager.unit.test.ts | 23 ++++++++++++++----- 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/src/notebooks/deepnote/integrations/integrationManager.unit.test.ts b/src/notebooks/deepnote/integrations/integrationManager.unit.test.ts index 79bc59d5a4..63d128b672 100644 --- a/src/notebooks/deepnote/integrations/integrationManager.unit.test.ts +++ b/src/notebooks/deepnote/integrations/integrationManager.unit.test.ts @@ -306,7 +306,9 @@ suite('IntegrationManager.addExistingIntegration', () => { await buildManager().addExistingIntegration(CURRENT_URI.toString()); assert.strictEqual(writes.size, 0); - verify(mockedVSCodeNamespaces.window.showWarningMessage(anything())).once(); + verify( + mockedVSCodeNamespaces.window.showWarningMessage(Integrations.addExistingIntegrationConflictsSkipped(1)) + ).once(); verify(mockedVSCodeNamespaces.window.showQuickPick(anything(), anything())).never(); }); @@ -405,24 +407,34 @@ suite('IntegrationManager.addExistingIntegration', () => { assert.strictEqual(writes.size, 0); assert.deepStrictEqual(cacheUpdates, [], 'the cache must not move before the file does'); verify(mockedVSCodeNamespaces.window.showErrorMessage(expectedMessage)).once(); + verify(mockedVSCodeNamespaces.window.withProgress(anything(), anything())).never(); verify(mockedVSCodeNamespaces.window.showQuickPick(anything(), anything())).never(); verify(telemetry.trackEvent(anything())).never(); }); } - const lateFailures: { arrange: () => void; name: string }[] = [ - { arrange: () => writeFailures.add(CURRENT_URI.fsPath), name: 'the active file cannot be written' }, + const lateFailures: { arrange: () => void; checksCache?: boolean; name: string }[] = [ + { + arrange: () => writeFailures.add(CURRENT_URI.fsPath), + checksCache: true, + name: 'the active file cannot be written' + }, + // The cache double throws before recording, so `cacheUpdates` stays empty whatever the manager does. { arrange: () => (cacheUpdateError = new Error('cache rejected the update')), name: 'the writer throws' } ]; - for (const { arrange, name } of lateFailures) { + for (const { arrange, checksCache, name } of lateFailures) { test(`reports failure and records the outcome in telemetry when ${name}`, async () => { arrange(); const outcome = await buildManager().addExistingIntegration(CURRENT_URI.toString()); assert.strictEqual(outcome, 'failed'); - assert.deepStrictEqual(cacheUpdates, [], 'no failure path leaves the link in the cache'); + + if (checksCache) { + assert.deepStrictEqual(cacheUpdates, [], 'no failure path leaves the link in the cache'); + } + assert.isTrue(refreshSpy.notCalled, 'the refresh reserved for a successful link never runs'); verify(mockedVSCodeNamespaces.window.showErrorMessage(Integrations.addExistingIntegrationFailed)).once(); verify( @@ -444,7 +456,6 @@ suite('IntegrationManager.addExistingIntegration', () => { assert.strictEqual(outcome, 'completed'); assert.isDefined(writes.get(CURRENT_URI.fsPath), 'the active file is still persisted'); - assert.isUndefined(writes.get(SIBLING_URI.fsPath)); verify(mockedVSCodeNamespaces.window.showWarningMessage(anything())).once(); }); From 9d1c92f435bd19072db81fe23694629e3867c44f Mon Sep 17 00:00:00 2001 From: tomas Date: Thu, 24 Sep 2026 09:20:14 +0000 Subject: [PATCH 24/35] test(integrations): drop the bundle-key change detector Drop the test that pins the format of the label keys. The compiler already fixes it: `IntegrationTypeLabelKey` is a template literal type, `integrationTypeLabelKey` returns it, and `typeLabels` is declared `satisfies Record`, so a missing, extra or misspelled key does not compile. The host and the webview derive the key with that same function, so the only change this test alone caught was a consistent rename of the format, which works end to end. Its comment goes with it, and so does the `integrationTypeLabelKey` import, which only it used; `noUnusedLocals` rejects it otherwise. Verified: - `npm run typecheck` exit 0, and exit 2 with TS6133 if the import is kept; `npm run lint` exit 0, no findings in the file; `npx prettier --check` clean; `npm run compile-tsc` exit 0. - integrationTypeLabels: 1 passing, down from 2. - Mutation harness of the test-grooming pass: one find/replace in the production code per run, compile, then the seven focused suites (147 tests, all passing unmutated). - the key function returning one constant key: still fails the label-coverage test and the SQL big-query label test. - the runtime predicate no longer excluding `pandas-dataframe`: still fails the label-coverage test. - a label entry removed, one added, or the prefix changed in the function alone: tsc still rejects each (TS1360, TS2353, TS2322). - the format renamed in the type, the function and all 18 keys: compiles, and no test in the whole suite fails (2866 passing, 239 pending), as intended. Before, only the dropped test did. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01DiYErQ9fP3wNhrNaRZAdqv --- .../notebooks/deepnote/integrationTypeLabels.unit.test.ts | 7 ------- 1 file changed, 7 deletions(-) diff --git a/src/platform/notebooks/deepnote/integrationTypeLabels.unit.test.ts b/src/platform/notebooks/deepnote/integrationTypeLabels.unit.test.ts index f2c72238ae..a784f13026 100644 --- a/src/platform/notebooks/deepnote/integrationTypeLabels.unit.test.ts +++ b/src/platform/notebooks/deepnote/integrationTypeLabels.unit.test.ts @@ -2,16 +2,9 @@ import { databaseIntegrationTypes } from '@deepnote/database-integrations'; import { assert } from 'chai'; import * as localize from '../../common/utils/localize'; -import { integrationTypeLabelKey } from './integrationTypeLabels'; import { isConfigurableDatabaseIntegrationType } from './integrationTypes'; suite('integrationTypeLabels', () => { - // The webview asks the string bundle for exactly this key, so its shape is a cross-process contract. - test('derives the bundle key from the integration type', () => { - assert.strictEqual(integrationTypeLabelKey('pgsql'), 'integrationType.pgsql'); - assert.strictEqual(integrationTypeLabelKey('sql-server'), 'integrationType.sql-server'); - }); - test('resolves a label for every configurable type', () => { assert.strictEqual(localize.Integrations.typeLabel('pgsql'), 'PostgreSQL'); assert.strictEqual(localize.Integrations.typeLabel('big-query'), 'Google BigQuery'); From fff28474214bbcbfc1995dc2ce73a10834c9044e Mon Sep 17 00:00:00 2001 From: tomas Date: Thu, 24 Sep 2026 09:26:09 +0000 Subject: [PATCH 25/35] test(sql): pin the snapshot refusal message The snapshot test accepted any error message, so the guard could show the add-existing command's `addExistingIntegrationSnapshotUnsupported`, which `localize.ts` declares right next to this command's own `switchIntegrationSnapshotUnsupported`: a realistic copy-paste swap. The test now expects `switchIntegrationSnapshotUnsupported`, as the manager's snapshot row already expects its own message. Verified: - `npm run typecheck` exit 0; `npm run lint` exit 0, no findings in the file; `npx prettier --check` clean; `npm run compile-tsc` exit 0. - SqlCellStatusBarProvider: 67 passing, as before. - Mutation harness of the test-grooming pass: one find/replace in the production code per run, compile, then the seven focused suites (147 tests, all passing unmutated). - the guard showing the add-existing message instead of its own: now fails the snapshot test. No test in the whole suite failed before. - the guard disabled, or showing its message but carrying on: still fails the snapshot test. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01DiYErQ9fP3wNhrNaRZAdqv --- src/notebooks/deepnote/sqlCellStatusBarProvider.unit.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/notebooks/deepnote/sqlCellStatusBarProvider.unit.test.ts b/src/notebooks/deepnote/sqlCellStatusBarProvider.unit.test.ts index 27aa11f3f3..acee88daea 100644 --- a/src/notebooks/deepnote/sqlCellStatusBarProvider.unit.test.ts +++ b/src/notebooks/deepnote/sqlCellStatusBarProvider.unit.test.ts @@ -14,6 +14,7 @@ import { Commands } from '../../platform/common/constants'; import { IDeepnoteNotebookManager } from '../types'; import { createDeepnoteFile, createDeepnoteProject, createMockCell } from './deepnoteTestHelpers'; import { ISqlIntegrationEnvVarsProvider } from '../../platform/notebooks/deepnote/types'; +import { Integrations } from '../../platform/common/utils/localize'; /** Puts a readable project file behind `uri`; the returned map captures what the integrations writer persists. */ function stubProjectFileOnDisk(uri: Uri, projectId: string): Map { @@ -1315,7 +1316,9 @@ suite('SqlCellStatusBarProvider', () => { await switchIntegrationHandler(cell); - verify(mockedVSCodeNamespaces.window.showErrorMessage(anything())).once(); + verify( + mockedVSCodeNamespaces.window.showErrorMessage(Integrations.switchIntegrationSnapshotUnsupported) + ).once(); verify(mockedVSCodeNamespaces.window.showQuickPick(anything(), anything())).never(); verify(mockedVSCodeNamespaces.workspace.applyEdit(anything())).never(); verify(commandTelemetry.trackEvent(anything())).never(); From 1f7ba405029b9e756335c29c59cd2ec656b0e8e9 Mon Sep 17 00:00:00 2001 From: tomas Date: Thu, 24 Sep 2026 13:07:15 +0000 Subject: [PATCH 26/35] test(integrations): replace this branch's force-cast fixtures with typed values Apply #544's treatment to the fixtures this branch added: - The snapshot refusal test in `sqlCellStatusBarProvider.unit.test.ts` stubbed `getProjectForNotebook` with `{ project: { integrations } } as any`, the shape #544 rebuilt 18 times in the same file. It now returns `createDeepnoteFile({ project: createDeepnoteProject(...) })` with the same integrations array. - The closed-notebook case in `integrationManager.unit.test.ts` built its visible editor as `{ notebook } as NotebookEditor`. It is now a ts-mockito `mock()` instance whose `notebook` returns the same document, as other suites stub editors. - The `addProjectIntegration` tests read `writes.get(uri)!.project`; they use `?.` now, so a missing write fails the deep-equal instead of throwing a TypeError. Left as is: the snapshot test's `showQuickPick` stub keeps `as any`, like the 15 identical stubs #544 left in that file, and the `persistProjectIntegrations` suite's `!` reads, which predate this branch. Verified: - `npm run typecheck` exit 0; oxlint and prettier clean on the three files. - SqlCellStatusBarProvider, IntegrationManager.addExistingIntegration and the writer suites: 96 passing, as before. - The fixtures still reach the code under test: making the snapshot guard carry on after its message, or dropping the manager's closed-notebook check, fails exactly the rewritten test in each suite (quick pick shown; 'completed' instead of 'failed'). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DiYErQ9fP3wNhrNaRZAdqv --- .../integrations/integrationManager.unit.test.ts | 7 ++++--- .../projectIntegrationsWriter.unit.test.ts | 8 ++++---- .../deepnote/sqlCellStatusBarProvider.unit.test.ts | 10 +++++++--- 3 files changed, 15 insertions(+), 10 deletions(-) diff --git a/src/notebooks/deepnote/integrations/integrationManager.unit.test.ts b/src/notebooks/deepnote/integrations/integrationManager.unit.test.ts index 63d128b672..f32024ecae 100644 --- a/src/notebooks/deepnote/integrations/integrationManager.unit.test.ts +++ b/src/notebooks/deepnote/integrations/integrationManager.unit.test.ts @@ -348,10 +348,11 @@ suite('IntegrationManager.addExistingIntegration', () => { { arrange: () => { // The panel stayed open for the other project after its notebook closed; only this editor is left. + const editor = mock(); + + when(editor.notebook).thenReturn(currentNotebook); when(mockedVSCodeNamespaces.workspace.notebookDocuments).thenReturn([currentNotebook]); - when(mockedVSCodeNamespaces.window.visibleNotebookEditors).thenReturn([ - { notebook: currentNotebook } as NotebookEditor - ]); + when(mockedVSCodeNamespaces.window.visibleNotebookEditors).thenReturn([instance(editor)]); return OTHER_URI.toString(); }, diff --git a/src/notebooks/deepnote/integrations/projectIntegrationsWriter.unit.test.ts b/src/notebooks/deepnote/integrations/projectIntegrationsWriter.unit.test.ts index 102b41531a..38bfeeb468 100644 --- a/src/notebooks/deepnote/integrations/projectIntegrationsWriter.unit.test.ts +++ b/src/notebooks/deepnote/integrations/projectIntegrationsWriter.unit.test.ts @@ -288,9 +288,9 @@ suite('addProjectIntegration', () => { }); assert.deepStrictEqual(result, { activePersisted: true, siblingsFailed: 0 }); - assert.deepStrictEqual(writes.get(activeUri.fsPath)!.project.integrations, [ADDED]); + assert.deepStrictEqual(writes.get(activeUri.fsPath)?.project.integrations, [ADDED]); assert.deepStrictEqual( - writes.get(siblingUri.fsPath)!.project.integrations, + writes.get(siblingUri.fsPath)?.project.integrations, [ownEntry, ADDED], "the sibling's own entry survives an add driven from another file" ); @@ -315,7 +315,7 @@ suite('addProjectIntegration', () => { projectId: PROJECT_ID }); - assert.deepStrictEqual(writes.get(activeUri.fsPath)!.project.integrations, [ADDED]); + assert.deepStrictEqual(writes.get(activeUri.fsPath)?.project.integrations, [ADDED]); }); test('writes nothing and leaves the cache untouched when the active file cannot be written', async () => { @@ -393,7 +393,7 @@ suite('addProjectIntegration', () => { }); assert.deepStrictEqual(order, ['active-write', 'cache', 'sibling-write']); - assert.deepStrictEqual(writes.get(siblingUri.fsPath)!.project.integrations, [ADDED]); + assert.deepStrictEqual(writes.get(siblingUri.fsPath)?.project.integrations, [ADDED]); }); test('moves the cache to what the active file actually gained', async () => { diff --git a/src/notebooks/deepnote/sqlCellStatusBarProvider.unit.test.ts b/src/notebooks/deepnote/sqlCellStatusBarProvider.unit.test.ts index 2d7c5b8431..a54bc6b15a 100644 --- a/src/notebooks/deepnote/sqlCellStatusBarProvider.unit.test.ts +++ b/src/notebooks/deepnote/sqlCellStatusBarProvider.unit.test.ts @@ -1329,9 +1329,13 @@ suite('SqlCellStatusBarProvider', () => { notebookUri: Uri.file('/ws/report_project-1_nb_main.snapshot.deepnote') }); - when(commandNotebookManager.getProjectForNotebook('project-1', 'notebook-1')).thenReturn({ - project: { integrations: [{ id: newIntegrationId, name: 'New Integration', type: 'pgsql' }] } - } as any); + when(commandNotebookManager.getProjectForNotebook('project-1', 'notebook-1')).thenReturn( + createDeepnoteFile({ + project: createDeepnoteProject({ + integrations: [{ id: newIntegrationId, name: 'New Integration', type: 'pgsql' }] + }) + }) + ); when(mockedVSCodeNamespaces.window.showErrorMessage(anything())).thenReturn(Promise.resolve(undefined)); when(mockedVSCodeNamespaces.window.showQuickPick(anything(), anything())).thenReturn( Promise.resolve({ id: newIntegrationId, label: 'New Integration' } as any) From efea1fe5e68a1ee0f273f03b73b12364c591c909 Mon Sep 17 00:00:00 2001 From: tomas Date: Thu, 24 Sep 2026 13:15:02 +0000 Subject: [PATCH 27/35] fix(integrations): move each notebook's cached list with its own file's write `addProjectIntegration` merged into each file on disk, but then stamped the active file's merged list into every cached notebook of the project. Saving a notebook rebuilds its whole file from its cache entry, so a sibling holding entries the active file lacks lost them on its next save. When that sibling had unsaved edits the loss was immediate: the sweep's own flush saved it from the stamped cache before the merge re-read the file, and the command still reported success. The writer now updates the cache entry of the notebook open from each file right after that file's write, with the list that file now holds (`IDeepnoteNotebookManager.updateProjectIntegrationsForNotebook`). The active file's entry still moves before the sibling sweep starts, and a file that could not be written still leaves its entry alone. The open-document lookup moves out of `flushNotebookDocumentIfDirty` into `findOpenNotebookDocument`, so both use the same URI comparison. The cache update sits after the disk write's try block, so a failing update propagates as before instead of being reported as a failed write. `persistProjectIntegrations` keeps its project-wide `updateProjectIntegrations` for now. Verified: - New writer tests, run against a real `DeepnoteNotebookManager`, failed before the fix: a dirty sibling declaring `[int-sibling]` was written as `[int-added]`, and a clean sibling's cache held the active file's `[int-own, int-added]`. Both pass now. - The manager, picker and SQL status bar suites now observe the per-notebook update (projectId, notebookId, list) instead of the project-wide one; DeepnoteNotebookManager gains tests for the new method (single entry, uncached entry, deep clone). - Affected suites: 162 passing. `npm run compile-tsc` and `npm run typecheck` exit 0; oxlint and prettier clean. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DiYErQ9fP3wNhrNaRZAdqv --- .../deepnote/deepnoteNotebookManager.ts | 19 ++ .../deepnoteNotebookManager.unit.test.ts | 45 +++++ .../existingIntegrationPicker.unit.test.ts | 31 +-- .../integrationManager.unit.test.ts | 16 +- .../integrations/projectIntegrationsWriter.ts | 70 ++++--- .../projectIntegrationsWriter.unit.test.ts | 186 +++++++++++++----- .../sqlCellStatusBarProvider.unit.test.ts | 33 +++- src/notebooks/types.ts | 10 + .../deepnote/deepnoteDocumentFlush.ts | 13 +- 9 files changed, 311 insertions(+), 112 deletions(-) diff --git a/src/notebooks/deepnote/deepnoteNotebookManager.ts b/src/notebooks/deepnote/deepnoteNotebookManager.ts index 1c43874f7c..47599b5caf 100644 --- a/src/notebooks/deepnote/deepnoteNotebookManager.ts +++ b/src/notebooks/deepnote/deepnoteNotebookManager.ts @@ -56,4 +56,23 @@ export class DeepnoteNotebookManager implements IDeepnoteNotebookManager { return true; } + + /** Replaces the integrations list of one cached (projectId, notebookId) entry (cache-only). */ + updateProjectIntegrationsForNotebook( + projectId: string, + notebookId: string, + integrations: RawProjectIntegration[] + ): void { + const notebookEntries = this.originalProjects.get(projectId); + const project = notebookEntries?.get(notebookId); + + if (!notebookEntries || !project) { + return; + } + + const updatedProject = structuredClone(project); + updatedProject.project.integrations = structuredClone(integrations); + + notebookEntries.set(notebookId, updatedProject); + } } diff --git a/src/notebooks/deepnote/deepnoteNotebookManager.unit.test.ts b/src/notebooks/deepnote/deepnoteNotebookManager.unit.test.ts index ef1f9b5421..8be3168c7d 100644 --- a/src/notebooks/deepnote/deepnoteNotebookManager.unit.test.ts +++ b/src/notebooks/deepnote/deepnoteNotebookManager.unit.test.ts @@ -217,5 +217,50 @@ suite('DeepnoteNotebookManager', () => { // Each sibling holds its own array instance rather than a shared reference. assert.notStrictEqual(cachedA, cachedB); }); + + test('updateProjectIntegrationsForNotebook updates that entry alone, leaving the siblings on their own lists', () => { + const siblingIntegrations: ProjectIntegration[] = [{ id: 'int-b', name: 'MySQL', type: 'mysql' }]; + const projectB = siblingProject(nbB, 'Sibling B'); + projectB.project.integrations = siblingIntegrations; + manager.storeOriginalProject(projectId, nbA, siblingProject(nbA, 'Sibling A')); + manager.storeOriginalProject(projectId, nbB, projectB); + + const integrations: ProjectIntegration[] = [{ id: 'int-1', name: 'PostgreSQL', type: 'pgsql' }]; + + manager.updateProjectIntegrationsForNotebook(projectId, nbA, integrations); + + assert.deepStrictEqual(manager.getProjectForNotebook(projectId, nbA)?.project.integrations, integrations); + assert.deepStrictEqual( + manager.getProjectForNotebook(projectId, nbB)?.project.integrations, + siblingIntegrations + ); + }); + + test('updateProjectIntegrationsForNotebook caches nothing for a notebook that is not cached', () => { + manager.storeOriginalProject(projectId, nbA, siblingProject(nbA, 'Sibling A')); + + manager.updateProjectIntegrationsForNotebook(projectId, nbB, [ + { id: 'int-1', name: 'PostgreSQL', type: 'pgsql' } + ]); + + assert.strictEqual(manager.getProjectForNotebook(projectId, nbB), undefined); + assert.strictEqual(manager.getProjectForNotebook(projectId, nbA)?.project.integrations, undefined); + }); + + test('updateProjectIntegrationsForNotebook deep-clones integrations so the cache is isolated from the caller', () => { + manager.storeOriginalProject(projectId, nbA, siblingProject(nbA, 'Sibling A')); + + const pg: ProjectIntegration = { id: 'int-1', name: 'PostgreSQL', type: 'pgsql' }; + const integrations: ProjectIntegration[] = [pg]; + + manager.updateProjectIntegrationsForNotebook(projectId, nbA, integrations); + + pg.name = 'MUTATED'; + integrations.push({ id: 'int-2', name: 'BigQuery', type: 'big-query' }); + + assert.deepStrictEqual(manager.getProjectForNotebook(projectId, nbA)?.project.integrations, [ + { id: 'int-1', name: 'PostgreSQL', type: 'pgsql' } + ]); + }); }); }); diff --git a/src/notebooks/deepnote/integrations/existingIntegrationPicker.unit.test.ts b/src/notebooks/deepnote/integrations/existingIntegrationPicker.unit.test.ts index 790471864a..4032a5ccc7 100644 --- a/src/notebooks/deepnote/integrations/existingIntegrationPicker.unit.test.ts +++ b/src/notebooks/deepnote/integrations/existingIntegrationPicker.unit.test.ts @@ -7,7 +7,12 @@ import { CancellationError, CancellationToken, CancellationTokenSource, Uri, wor import { ConfigurableDatabaseIntegrationConfig } from '../../../platform/notebooks/deepnote/integrationTypes'; import { mockedVSCodeNamespaces, resetVSCodeMocks } from '../../../test/vscode-mock'; import { IDeepnoteNotebookManager, RawProjectIntegration } from '../../types'; -import { createDeepnoteFile, createDeepnoteProject, createWorkspaceFolder } from '../deepnoteTestHelpers'; +import { + createDeepnoteFile, + createDeepnoteProject, + createMockNotebook, + createWorkspaceFolder +} from '../deepnoteTestHelpers'; import { attachExistingIntegration, collectReusableIntegrations, @@ -22,7 +27,7 @@ use(chaiAsPromised); const CURRENT_PROJECT_ID = 'project-current'; // `thenCall` checks no signature, so doubles take their parameter types from here; annotating them by hand undoes it. -type UpdateProjectIntegrationsFn = IDeepnoteNotebookManager['updateProjectIntegrations']; +type UpdateProjectIntegrationsFn = IDeepnoteNotebookManager['updateProjectIntegrationsForNotebook']; interface OnDiskProject { uri: Uri; @@ -338,17 +343,17 @@ suite('existingIntegrationPicker', () => { }; let notebookManager: IDeepnoteNotebookManager; - let cacheUpdates: Array<{ projectId: string; integrations: RawProjectIntegration[] }>; + let cacheUpdates: Parameters[]; setup(() => { cacheUpdates = []; - const recordCacheUpdate: UpdateProjectIntegrationsFn = (projectId, integrations) => { - cacheUpdates.push({ projectId, integrations }); - - return true; + const recordCacheUpdate: UpdateProjectIntegrationsFn = (...update) => { + cacheUpdates.push(update); }; const mockManager = mock(); - when(mockManager.updateProjectIntegrations(anything(), anything())).thenCall(recordCacheUpdate); + when(mockManager.updateProjectIntegrationsForNotebook(anything(), anything(), anything())).thenCall( + recordCacheUpdate + ); notebookManager = instance(mockManager); }); @@ -361,6 +366,12 @@ suite('existingIntegrationPicker', () => { const { writes } = stubWorkspace({ projects: [{ uri: activeUri, projectId: CURRENT_PROJECT_ID, integrations: currentIntegrations }] }); + when(mockedVSCodeNamespaces.workspace.notebookDocuments).thenReturn([ + createMockNotebook({ + metadata: { deepnoteNotebookId: 'notebook-current', deepnoteProjectId: CURRENT_PROJECT_ID }, + uri: activeUri + }) + ]); const result = await attachExistingIntegration({ activeFileUri: activeUri, @@ -374,9 +385,7 @@ suite('existingIntegrationPicker', () => { { id: 'pg-shared', name: 'Shared Postgres', type: 'pgsql' } ]; assert.deepStrictEqual(result, { activePersisted: true, siblingsFailed: 0 }); - assert.deepStrictEqual(cacheUpdates, [ - { projectId: CURRENT_PROJECT_ID, integrations: expectedIntegrations } - ]); + assert.deepStrictEqual(cacheUpdates, [[CURRENT_PROJECT_ID, 'notebook-current', expectedIntegrations]]); assert.deepStrictEqual(writes.get(activeUri.fsPath)?.project.integrations, expectedIntegrations); }); }); diff --git a/src/notebooks/deepnote/integrations/integrationManager.unit.test.ts b/src/notebooks/deepnote/integrations/integrationManager.unit.test.ts index f32024ecae..aafbe4c0cb 100644 --- a/src/notebooks/deepnote/integrations/integrationManager.unit.test.ts +++ b/src/notebooks/deepnote/integrations/integrationManager.unit.test.ts @@ -50,7 +50,7 @@ const REFRESH_START_TIMEOUT_MS = 1_000; type RefreshFn = IIntegrationEnvLiveRefresher['refresh']; // `thenCall` checks no signature, so doubles take their parameter types from here; annotating them by hand undoes it. -type UpdateProjectIntegrationsFn = IDeepnoteNotebookManager['updateProjectIntegrations']; +type UpdateProjectIntegrationsFn = IDeepnoteNotebookManager['updateProjectIntegrationsForNotebook']; function projectFile(projectId: string, notebookId: string, integrations: RawProjectIntegration[]): DeepnoteFile { return createDeepnoteFile({ @@ -68,7 +68,7 @@ suite('IntegrationManager.addExistingIntegration', () => { let otherNotebook: NotebookDocument; let currentProject: DeepnoteFile | undefined; let writes: Map; - let cacheUpdates: RawProjectIntegration[][]; + let cacheUpdates: Parameters[]; // Typed off the interface so a signature change fails the compile, not just the `firstCall.args` assertion. let refreshSpy: sinon.SinonSpy, ReturnType>; let quickPickItems: QuickPickItem[] | undefined; @@ -169,16 +169,16 @@ suite('IntegrationManager.addExistingIntegration', () => { const mockManager = mock(); when(mockManager.getProjectForNotebook(CURRENT_PROJECT_ID, CURRENT_NOTEBOOK_ID)).thenCall(() => currentProject); - const recordCacheUpdate: UpdateProjectIntegrationsFn = (_projectId, integrations) => { + const recordCacheUpdate: UpdateProjectIntegrationsFn = (projectId, notebookId, integrations) => { if (cacheUpdateError) { throw cacheUpdateError; } - cacheUpdates.push(integrations); - - return true; + cacheUpdates.push([projectId, notebookId, integrations]); }; - when(mockManager.updateProjectIntegrations(anything(), anything())).thenCall(recordCacheUpdate); + when(mockManager.updateProjectIntegrationsForNotebook(anything(), anything(), anything())).thenCall( + recordCacheUpdate + ); notebookManager = mockManager; telemetry = mock(); @@ -220,7 +220,7 @@ suite('IntegrationManager.addExistingIntegration', () => { { id: 'pg-shared', name: 'Shared Postgres', type: 'pgsql' } ]; assert.deepStrictEqual(writes.get(CURRENT_URI.fsPath)?.project.integrations, expectedIntegrations); - assert.deepStrictEqual(cacheUpdates, [expectedIntegrations]); + assert.deepStrictEqual(cacheUpdates, [[CURRENT_PROJECT_ID, CURRENT_NOTEBOOK_ID, expectedIntegrations]]); assert.isUndefined(writes.get(OTHER_URI.fsPath), 'the other project must not be rewritten'); assert.isTrue(refreshSpy.calledOnce); diff --git a/src/notebooks/deepnote/integrations/projectIntegrationsWriter.ts b/src/notebooks/deepnote/integrations/projectIntegrationsWriter.ts index 6d76083c4e..887b28100b 100644 --- a/src/notebooks/deepnote/integrations/projectIntegrationsWriter.ts +++ b/src/notebooks/deepnote/integrations/projectIntegrationsWriter.ts @@ -1,7 +1,10 @@ import { serializeDeepnoteFile } from '@deepnote/blocks'; import { RelativePattern, Uri, workspace } from 'vscode'; -import { flushNotebookDocumentIfDirty } from '../../../platform/deepnote/deepnoteDocumentFlush'; +import { + findOpenNotebookDocument, + flushNotebookDocumentIfDirty +} from '../../../platform/deepnote/deepnoteDocumentFlush'; import { readDeepnoteProjectFile } from '../../../platform/deepnote/deepnoteProjectFileReader'; import { logger } from '../../../platform/logging'; import { IDeepnoteNotebookManager, RawProjectIntegration } from '../../types'; @@ -28,12 +31,14 @@ export interface AddProjectIntegrationParams { interface WriteSiblingFilesParams { activeFileUri: Uri; + notebookManager: IDeepnoteNotebookManager; projectId: string; resolve: ResolveIntegrations; } interface WriteIntegrationsToFileParams { fileUri: Uri; + notebookManager: IDeepnoteNotebookManager; projectId: string; resolve: ResolveIntegrations; } @@ -41,9 +46,7 @@ interface WriteIntegrationsToFileParams { /** Builds the array to write from the one the file holds, read a step earlier in the same function. */ type ResolveIntegrations = (existing: RawProjectIntegration[]) => RawProjectIntegration[]; -type IntegrationWriteOutcome = - | { status: 'failed' | 'skipped' } - | { status: 'written'; integrations: RawProjectIntegration[] }; +type IntegrationWriteStatus = 'failed' | 'skipped' | 'written'; /** Writes `integrations` to the active file and every on-disk sibling; `activePersisted` reflects disk truth, not the cache. */ export async function persistProjectIntegrations( @@ -57,16 +60,16 @@ export async function persistProjectIntegrations( notebookManager.updateProjectIntegrations(projectId, integrations); // findFiles only covers open folders, so write the active file explicitly (no open folder / out-of-workspace). - const writeOutcome = await writeIntegrationsToFile({ fileUri: activeFileUri, projectId, resolve }); - const siblingsFailed = await writeSiblingFiles({ activeFileUri, projectId, resolve }); + const status = await writeIntegrationsToFile({ fileUri: activeFileUri, notebookManager, projectId, resolve }); + const siblingsFailed = await writeSiblingFiles({ activeFileUri, notebookManager, projectId, resolve }); - return { activePersisted: writeOutcome.status === 'written', siblingsFailed }; + return { activePersisted: status === 'written', siblingsFailed }; } /** * Adds one integration to the project, merging into what each file holds instead of replacing the list: a caller * holding a snapshot across a prompt cannot delete entries written while it waited. An id already present is - * replaced. The cache and the sibling sweep both follow the active file: neither runs until it is on disk. + * replaced. The sibling sweep follows the active file: it does not run until that file is on disk. */ export async function addProjectIntegration(params: AddProjectIntegrationParams): Promise { const { activeFileUri, integration, notebookManager, projectId } = params; @@ -75,26 +78,22 @@ export async function addProjectIntegration(params: AddProjectIntegrationParams) integration ]; - const writeOutcome = await writeIntegrationsToFile({ fileUri: activeFileUri, projectId, resolve }); + const status = await writeIntegrationsToFile({ fileUri: activeFileUri, notebookManager, projectId, resolve }); // The active file is the mandate for the sweep. `skipped` means it is a snapshot or another project's file, and // `failed` means the add landed nowhere — stamping the project's other files on its behalf is data loss either way. - if (writeOutcome.status !== 'written') { + if (status !== 'written') { return { activePersisted: false, siblingsFailed: 0 }; } - // Before the sibling sweep, not after: saving a notebook rebuilds its whole file from the cached project, so a - // cache still on the old roster would write the link straight back out mid-sweep. - notebookManager.updateProjectIntegrations(projectId, writeOutcome.integrations); - - const siblingsFailed = await writeSiblingFiles({ activeFileUri, projectId, resolve }); + const siblingsFailed = await writeSiblingFiles({ activeFileUri, notebookManager, projectId, resolve }); return { activePersisted: true, siblingsFailed }; } /** Writes every discovered `.deepnote` of the project except `activeFileUri`; returns how many failed. */ async function writeSiblingFiles(params: WriteSiblingFilesParams): Promise { - const { activeFileUri, projectId, resolve } = params; + const { activeFileUri, notebookManager, projectId, resolve } = params; const visited = new Set([activeFileUri.toString()]); let siblingsFailed = 0; @@ -117,8 +116,8 @@ async function writeSiblingFiles(params: WriteSiblingFilesParams): Promise { - const { fileUri, projectId, resolve } = params; +/** + * Writes one `.deepnote` file, and moves the cache entry of the notebook open from it to the same list: `skipped` + * for a snapshot or another project's file. + */ +async function writeIntegrationsToFile(params: WriteIntegrationsToFileParams): Promise { + const { fileUri, notebookManager, projectId, resolve } = params; if (isSnapshotFile(fileUri)) { - return { status: 'skipped' }; + return 'skipped'; } + let integrations: RawProjectIntegration[]; + try { let projectData = await readDeepnoteProjectFile(fileUri); if (projectData?.project?.id !== projectId) { - return { status: 'skipped' }; + return 'skipped'; } // Flush an open dirty file and re-read, so live cell edits aren't clobbered by the watcher reload. if (!(await flushNotebookDocumentIfDirty(fileUri))) { logger.warn(`projectIntegrationsWriter: ${fileUri.path} — unsaved edits could not be saved`); - return { status: 'failed' }; + return 'failed'; } projectData = await readDeepnoteProjectFile(fileUri); // The flush may have saved a stale open document, swapping the on-disk project; re-validate before writing. if (projectData?.project?.id !== projectId) { - return { status: 'skipped' }; + return 'skipped'; } // Rewrite ONLY integrations; every other field round-trips from disk, so saved cells are untouched. - const integrations = resolve(projectData.project.integrations ?? []); + integrations = resolve(projectData.project.integrations ?? []); projectData.project.integrations = integrations; @@ -168,11 +172,19 @@ async function writeIntegrationsToFile(params: WriteIntegrationsToFileParams): P projectData.metadata.modifiedAt = new Date().toISOString(); await workspace.fs.writeFile(fileUri, new TextEncoder().encode(serializeDeepnoteFile(projectData))); - - return { integrations, status: 'written' }; } catch (error) { logger.error(`projectIntegrationsWriter: failed to update ${fileUri.path}`, error); - return { status: 'failed' }; + return 'failed'; } + + // Saving the notebook rebuilds this whole file from its cache entry, so the entry has to hold this file's list: + // another file's list would overwrite the entries only this one declares on that save. + const notebookId = findOpenNotebookDocument(fileUri)?.metadata?.deepnoteNotebookId; + + if (notebookId) { + notebookManager.updateProjectIntegrationsForNotebook(projectId, notebookId, integrations); + } + + return 'written'; } diff --git a/src/notebooks/deepnote/integrations/projectIntegrationsWriter.unit.test.ts b/src/notebooks/deepnote/integrations/projectIntegrationsWriter.unit.test.ts index 38bfeeb468..9c0dd48f4e 100644 --- a/src/notebooks/deepnote/integrations/projectIntegrationsWriter.unit.test.ts +++ b/src/notebooks/deepnote/integrations/projectIntegrationsWriter.unit.test.ts @@ -5,11 +5,13 @@ import { Uri, workspace, type NotebookDocument } from 'vscode'; import { mockedVSCodeNamespaces, resetVSCodeMocks } from '../../../test/vscode-mock'; import { IDeepnoteNotebookManager, ProjectIntegration, RawProjectIntegration } from '../../types'; +import { DeepnoteNotebookManager } from '../deepnoteNotebookManager'; import { createDeepnoteBlock, createDeepnoteFile, createDeepnoteNotebook, createDeepnoteProject, + createMockNotebook, createWorkspaceFolder } from '../deepnoteTestHelpers'; import { addProjectIntegration, persistProjectIntegrations } from './projectIntegrationsWriter'; @@ -18,9 +20,6 @@ const PROJECT_ID = 'project-1'; const NEW_INTEGRATIONS: ProjectIntegration[] = [{ id: 'int-new', name: 'New BigQuery', type: 'big-query' }]; -// `thenCall` checks no signature, so doubles take their parameter types from here; annotating them by hand undoes it. -type UpdateProjectIntegrationsFn = IDeepnoteNotebookManager['updateProjectIntegrations']; - function projectFile( notebookId: string, projectId: string = PROJECT_ID, @@ -42,11 +41,18 @@ function projectFile( }); } +/** A notebook open from `uri`; an `onSave` makes it dirty, and mutating the on-disk map models its save. */ +interface OpenNotebook { + notebookId: string; + onSave?: (onDisk: Map) => void; + uri: Uri; +} + /** Wires workspace.fs/findFiles; `discovered` is decoupled from the active file to model out-of-folder writes. */ function stubWorkspace(opts: { onDisk: Array<{ uri: Uri; file: DeepnoteFile }>; discovered: Uri[]; - dirtyDocuments?: Array<{ uri: Uri; onSave: (onDisk: Map) => void }>; + openNotebooks?: OpenNotebook[]; hasWorkspaceFolder?: boolean; failWriteFor?: Set; onWrite?: (uri: Uri) => void; @@ -59,20 +65,24 @@ function stubWorkspace(opts: { const byPath = new Map(opts.onDisk.map((entry) => [entry.uri.fsPath, entry.file] as const)); const writes = new Map(); - // A save() that mutates the on-disk map models a dirty open document being flushed before the re-read. - const documents = (opts.dirtyDocuments ?? []).map( - ({ uri, onSave }) => - ({ - uri, - isDirty: true, - notebookType: 'deepnote', - save: async () => { - onSave(byPath); - - return true; - } - }) as unknown as NotebookDocument - ); + const documents = (opts.openNotebooks ?? []).map(({ notebookId, onSave, uri }): NotebookDocument => { + const notebook = createMockNotebook({ + metadata: { deepnoteNotebookId: notebookId, deepnoteProjectId: PROJECT_ID }, + uri + }); + + return onSave + ? { + ...notebook, + isDirty: true, + save: async () => { + onSave(byPath); + + return true; + } + } + : notebook; + }); when(mockedVSCodeNamespaces.workspace.notebookDocuments).thenReturn(documents); const mockFs = mock(); @@ -227,10 +237,11 @@ suite('persistProjectIntegrations', () => { ], discovered: [activeUri, siblingUri], // The open document for the sibling is stale: saving it rewrites the file to a DIFFERENT project. - dirtyDocuments: [ + openNotebooks: [ { - uri: siblingUri, - onSave: (onDisk) => onDisk.set(siblingUri.fsPath, projectFile('nb-other', 'project-2')) + notebookId: 'nb-sibling', + onSave: (onDisk) => onDisk.set(siblingUri.fsPath, projectFile('nb-other', 'project-2')), + uri: siblingUri } ] }); @@ -251,23 +262,18 @@ suite('persistProjectIntegrations', () => { suite('addProjectIntegration', () => { const ADDED: ProjectIntegration = { id: 'int-added', name: 'Added Postgres', type: 'pgsql' }; - let cacheUpdates: RawProjectIntegration[][]; - let managerInstance: IDeepnoteNotebookManager; + let managerInstance: DeepnoteNotebookManager; setup(() => { resetVSCodeMocks(); - cacheUpdates = []; - const recordCacheUpdate: UpdateProjectIntegrationsFn = (_projectId, integrations) => { - cacheUpdates.push(integrations); - - return true; - }; - const mockManager = mock(); - when(mockManager.updateProjectIntegrations(anything(), anything())).thenCall(recordCacheUpdate); - managerInstance = instance(mockManager); + managerInstance = new DeepnoteNotebookManager(); }); + function cachedIntegrations(notebookId: string): RawProjectIntegration[] | undefined { + return managerInstance.getProjectForNotebook(PROJECT_ID, notebookId)?.project.integrations; + } + test('merges into each file rather than stamping one roster over them all', async () => { const activeUri = Uri.file('/ws/active.deepnote'); const siblingUri = Uri.file('/ws/sibling.deepnote'); @@ -327,8 +333,14 @@ suite('addProjectIntegration', () => { { uri: siblingUri, file: projectFile('nb-sibling') } ], discovered: [activeUri, siblingUri], - failWriteFor: new Set([activeUri.fsPath]) + failWriteFor: new Set([activeUri.fsPath]), + openNotebooks: [ + { notebookId: 'nb-active', uri: activeUri }, + { notebookId: 'nb-sibling', uri: siblingUri } + ] }); + managerInstance.storeOriginalProject(PROJECT_ID, 'nb-active', projectFile('nb-active')); + managerInstance.storeOriginalProject(PROJECT_ID, 'nb-sibling', projectFile('nb-sibling')); const result = await addProjectIntegration({ activeFileUri: activeUri, @@ -339,7 +351,12 @@ suite('addProjectIntegration', () => { assert.deepStrictEqual(result, { activePersisted: false, siblingsFailed: 0 }); assert.strictEqual(writes.size, 0, 'the add reaches no file once the one the user acted in refused it'); - assert.deepStrictEqual(cacheUpdates, [], 'nothing reaches the cache that did not reach the disk'); + assert.deepStrictEqual( + cachedIntegrations('nb-active'), + [], + 'nothing reaches the cache that did not reach the disk' + ); + assert.deepStrictEqual(cachedIntegrations('nb-sibling'), []); }); test('sweeps no sibling when the active file is skipped rather than written', async () => { @@ -349,8 +366,10 @@ suite('addProjectIntegration', () => { const siblingUri = Uri.file('/ws/sibling.deepnote'); const { writes } = stubWorkspace({ onDisk: [{ uri: siblingUri, file: projectFile('nb-sibling') }], - discovered: [siblingUri] + discovered: [siblingUri], + openNotebooks: [{ notebookId: 'nb-sibling', uri: siblingUri }] }); + managerInstance.storeOriginalProject(PROJECT_ID, 'nb-sibling', projectFile('nb-sibling')); const result = await addProjectIntegration({ activeFileUri: snapshotUri, @@ -361,48 +380,65 @@ suite('addProjectIntegration', () => { assert.deepStrictEqual(result, { activePersisted: false, siblingsFailed: 0 }); assert.strictEqual(writes.size, 0, 'the project files stay as they are'); - assert.deepStrictEqual(cacheUpdates, []); + assert.deepStrictEqual(cachedIntegrations('nb-sibling'), []); }); test('moves the cache before sweeping siblings, so a save mid-sweep cannot revert the add', async () => { const activeUri = Uri.file('/ws/active.deepnote'); const siblingUri = Uri.file('/ws/sibling.deepnote'); // Saving a notebook rebuilds its file from the cached project, so anything written while the cache still - // holds the old roster gets undone. Ordering is the only thing keeping that window shut. - const order: string[] = []; + // holds the old list gets undone. Ordering is the only thing keeping that window shut. + let activeCacheAtSiblingWrite: RawProjectIntegration[] | undefined; const { writes } = stubWorkspace({ onDisk: [ { uri: activeUri, file: projectFile('nb-active') }, { uri: siblingUri, file: projectFile('nb-sibling') } ], discovered: [activeUri, siblingUri], - onWrite: (uri) => order.push(uri.fsPath === siblingUri.fsPath ? 'sibling-write' : 'active-write') - }); - const mockManager = mock(); - when(mockManager.updateProjectIntegrations(anything(), anything())).thenCall(() => { - order.push('cache'); - - return true; + onWrite: (uri) => { + if (uri.fsPath === siblingUri.fsPath) { + activeCacheAtSiblingWrite = cachedIntegrations('nb-active'); + } + }, + openNotebooks: [{ notebookId: 'nb-active', uri: activeUri }] }); + managerInstance.storeOriginalProject(PROJECT_ID, 'nb-active', projectFile('nb-active')); await addProjectIntegration({ activeFileUri: activeUri, integration: ADDED, - notebookManager: instance(mockManager), + notebookManager: managerInstance, projectId: PROJECT_ID }); - assert.deepStrictEqual(order, ['active-write', 'cache', 'sibling-write']); + assert.deepStrictEqual(activeCacheAtSiblingWrite, [ADDED]); assert.deepStrictEqual(writes.get(siblingUri.fsPath)?.project.integrations, [ADDED]); }); - test('moves the cache to what the active file actually gained', async () => { + test("moves each open notebook's cache to what its own file now declares", async () => { + // Catches: every cached notebook of the project stamped with the active file's list, which a later save of + // a sibling then writes over the entries only that sibling declares. const activeUri = Uri.file('/ws/active.deepnote'); + const siblingUri = Uri.file('/ws/sibling.deepnote'); const ownEntry: ProjectIntegration = { id: 'int-own', name: 'Own BigQuery', type: 'big-query' }; + const siblingEntry: ProjectIntegration = { id: 'int-sibling', name: 'Sibling MySQL', type: 'mysql' }; stubWorkspace({ - onDisk: [{ uri: activeUri, file: projectFile('nb-active', PROJECT_ID, [ownEntry]) }], - discovered: [activeUri] + onDisk: [ + { uri: activeUri, file: projectFile('nb-active', PROJECT_ID, [ownEntry]) }, + { uri: siblingUri, file: projectFile('nb-sibling', PROJECT_ID, [siblingEntry]) } + ], + discovered: [activeUri, siblingUri], + openNotebooks: [ + { notebookId: 'nb-active', uri: activeUri }, + { notebookId: 'nb-sibling', uri: siblingUri } + ] }); + managerInstance.storeOriginalProject(PROJECT_ID, 'nb-active', projectFile('nb-active', PROJECT_ID, [ownEntry])); + managerInstance.storeOriginalProject( + PROJECT_ID, + 'nb-sibling', + projectFile('nb-sibling', PROJECT_ID, [siblingEntry]) + ); await addProjectIntegration({ activeFileUri: activeUri, @@ -411,6 +447,54 @@ suite('addProjectIntegration', () => { projectId: PROJECT_ID }); - assert.deepStrictEqual(cacheUpdates, [[ownEntry, ADDED]]); + assert.deepStrictEqual(cachedIntegrations('nb-active'), [ownEntry, ADDED]); + assert.deepStrictEqual(cachedIntegrations('nb-sibling'), [siblingEntry, ADDED]); + }); + + test("keeps the entries only a dirty sibling declares when the sweep's flush saves it", async () => { + // Catches: the sibling's cache stamped with the active file's list before the sweep reaches it, so the flush + // that saves its unsaved edits writes that list over the sibling's own entries before the merge re-reads it. + const activeUri = Uri.file('/ws/active.deepnote'); + const siblingUri = Uri.file('/ws/sibling.deepnote'); + const siblingEntry: ProjectIntegration = { id: 'int-sibling', name: 'Sibling MySQL', type: 'mysql' }; + const { writes } = stubWorkspace({ + onDisk: [ + { uri: activeUri, file: projectFile('nb-active') }, + { uri: siblingUri, file: projectFile('nb-sibling', PROJECT_ID, [siblingEntry]) } + ], + discovered: [activeUri, siblingUri], + openNotebooks: [ + { notebookId: 'nb-active', uri: activeUri }, + { + notebookId: 'nb-sibling', + // Like the serializer: a save writes the whole file from the notebook's cache entry. + onSave: (onDisk) => { + const cached = managerInstance.getProjectForNotebook(PROJECT_ID, 'nb-sibling'); + + if (cached) { + onDisk.set(siblingUri.fsPath, cached); + } + }, + uri: siblingUri + } + ] + }); + managerInstance.storeOriginalProject(PROJECT_ID, 'nb-active', projectFile('nb-active')); + managerInstance.storeOriginalProject( + PROJECT_ID, + 'nb-sibling', + projectFile('nb-sibling', PROJECT_ID, [siblingEntry]) + ); + + const result = await addProjectIntegration({ + activeFileUri: activeUri, + integration: ADDED, + notebookManager: managerInstance, + projectId: PROJECT_ID + }); + + assert.deepStrictEqual(result, { activePersisted: true, siblingsFailed: 0 }); + assert.deepStrictEqual(writes.get(siblingUri.fsPath)?.project.integrations, [siblingEntry, ADDED]); + assert.deepStrictEqual(cachedIntegrations('nb-sibling'), [siblingEntry, ADDED]); }); }); diff --git a/src/notebooks/deepnote/sqlCellStatusBarProvider.unit.test.ts b/src/notebooks/deepnote/sqlCellStatusBarProvider.unit.test.ts index a54bc6b15a..2d4cdfdfec 100644 --- a/src/notebooks/deepnote/sqlCellStatusBarProvider.unit.test.ts +++ b/src/notebooks/deepnote/sqlCellStatusBarProvider.unit.test.ts @@ -1,7 +1,15 @@ import { deserializeDeepnoteFile, serializeDeepnoteFile, type DeepnoteFile } from '@deepnote/blocks'; import { assert } from 'chai'; import { anything, capture, deepEqual, instance, mock, verify, when } from 'ts-mockito'; -import { CancellationToken, CancellationTokenSource, EventEmitter, NotebookCell, Uri, workspace } from 'vscode'; +import { + CancellationToken, + CancellationTokenSource, + EventEmitter, + NotebookCell, + NotebookDocument, + Uri, + workspace +} from 'vscode'; import { IDisposableRegistry } from '../../platform/common/types'; import { IIntegrationStorage } from './integrations/types'; @@ -16,16 +24,19 @@ import { createDeepnoteFile, createDeepnoteProject, createMockCell } from './dee import { ISqlIntegrationEnvVarsProvider } from '../../platform/notebooks/deepnote/types'; import { Integrations } from '../../platform/common/utils/localize'; -/** Puts a readable project file behind `uri`; the returned map captures what the integrations writer persists. */ -function stubProjectFileOnDisk(uri: Uri, projectId: string): Map { +/** + * Puts a readable project file behind the open `notebook`; the returned map captures what the integrations writer + * persists. + */ +function stubProjectFileOnDisk(notebook: NotebookDocument, projectId: string): Map { const onDisk = createDeepnoteFile({ project: createDeepnoteProject({ id: projectId, name: projectId }) }); const writes = new Map(); const mockFs = mock(); when(mockedVSCodeNamespaces.workspace.workspaceFolders).thenReturn(undefined); - when(mockedVSCodeNamespaces.workspace.notebookDocuments).thenReturn([]); + when(mockedVSCodeNamespaces.workspace.notebookDocuments).thenReturn([notebook]); when(mockFs.readFile(anything())).thenCall((target: Uri) => - target.fsPath === uri.fsPath + target.fsPath === notebook.uri.fsPath ? Promise.resolve(new TextEncoder().encode(serializeDeepnoteFile(onDisk))) : Promise.reject(new Error(`no readFile stub for ${target.fsPath}`)) ); @@ -1268,7 +1279,7 @@ suite('SqlCellStatusBarProvider', () => { const notebookMetadata = { deepnoteProjectId: 'project-1', deepnoteNotebookId: 'notebook-1' }; const cell = createMockCell({ languageId: 'sql', metadata: {}, notebookMetadata }); const fileOnlyId = 'file-only-bq'; - const writes = stubProjectFileOnDisk(cell.notebook.uri, 'project-1'); + const writes = stubProjectFileOnDisk(cell.notebook, 'project-1'); const envVars = mock(); when(envVars.getMergedIntegrationConfigs(anything())).thenResolve([ @@ -1299,9 +1310,13 @@ suite('SqlCellStatusBarProvider', () => { await switchIntegrationHandler(cell); const expected = [{ id: fileOnlyId, name: 'BigQuery from file', type: 'big-query' }]; - const [projectId, integrations] = capture(commandNotebookManager.updateProjectIntegrations).last(); - assert.strictEqual(projectId, 'project-1'); - assert.deepStrictEqual(integrations, expected); + const [projectId, notebookId, integrations] = capture( + commandNotebookManager.updateProjectIntegrationsForNotebook + ).last(); + assert.deepStrictEqual( + { integrations, notebookId, projectId }, + { integrations: expected, notebookId: 'notebook-1', projectId: 'project-1' } + ); assert.deepStrictEqual( writes.get(cell.notebook.uri.fsPath)?.project.integrations, expected, diff --git a/src/notebooks/types.ts b/src/notebooks/types.ts index 2598b4c6b0..202fb67e3c 100644 --- a/src/notebooks/types.ts +++ b/src/notebooks/types.ts @@ -60,6 +60,16 @@ export interface IDeepnoteNotebookManager { * @returns `true` if at least one cached entry was found and updated, `false` otherwise */ updateProjectIntegrations(projectId: string, integrations: RawProjectIntegration[]): boolean; + + /** + * Replaces the integrations list of the one cached (projectId, notebookId) entry (cache-only); does nothing + * when that entry is not cached. Sibling entries keep their own lists: each mirrors its own file. + */ + updateProjectIntegrationsForNotebook( + projectId: string, + notebookId: string, + integrations: RawProjectIntegration[] + ): void; } export const IDeepnoteInitNotebookRunner = Symbol('IDeepnoteInitNotebookRunner'); diff --git a/src/platform/deepnote/deepnoteDocumentFlush.ts b/src/platform/deepnote/deepnoteDocumentFlush.ts index 8cbac88337..d82c2bfaaf 100644 --- a/src/platform/deepnote/deepnoteDocumentFlush.ts +++ b/src/platform/deepnote/deepnoteDocumentFlush.ts @@ -1,7 +1,14 @@ -import { Uri, workspace } from 'vscode'; +import { NotebookDocument, Uri, workspace } from 'vscode'; import { logger } from '../logging'; +/** The notebook document open from `fileUri`, if any; a query or fragment on the document's URI is ignored. */ +export function findOpenNotebookDocument(fileUri: Uri): NotebookDocument | undefined { + return workspace.notebookDocuments.find( + (doc) => doc.uri.with({ query: '', fragment: '' }).toString() === fileUri.toString() + ); +} + /** * Saves a dirty open notebook for `fileUri` before a disk read-modify-write so live edits are not * overwritten; callers that read the file first must re-read it when this returns `true`. @@ -9,9 +16,7 @@ import { logger } from '../logging'; * @returns `false` only when a dirty document could not be saved (the save was declined or threw). */ export async function flushNotebookDocumentIfDirty(fileUri: Uri): Promise { - const openDocument = workspace.notebookDocuments.find( - (doc) => doc.uri.with({ query: '', fragment: '' }).toString() === fileUri.toString() - ); + const openDocument = findOpenNotebookDocument(fileUri); if (!openDocument?.isDirty) { return true; From f85f92d00397ac823f5952ede81978b96428368e Mon Sep 17 00:00:00 2001 From: tomas Date: Thu, 24 Sep 2026 13:24:42 +0000 Subject: [PATCH 28/35] fix(integrations): edit only the panel's own entry in the project files Every panel Save, Reset and Delete rebuilt the project's integrations list from the panel's in-memory map and stamped it over the cache, the active file and every sibling. The map is built by the detector, which skips every type the panel cannot configure, and is only refreshed by `show()`. So any panel action deleted `pandas-dataframe` and unknown-type entries (which `RawProjectIntegration` and `addProjectIntegration` deliberately keep), and an integration linked while the panel was open was dropped by its next save. Each action now edits the entry it acts on, through the same per-file merge `addProjectIntegration` already used: - Save adds the saved integration, or replaces its entry in place. Replacing in place is new: the merge used to move a re-added id to the end of the list. - Delete takes the id off each file (`removeProjectIntegration`). - Reset writes no project file; it only clears credentials. `persistProjectIntegrations` and the project-wide `IDeepnoteNotebookManager.updateProjectIntegrations` are gone. The stamp was also what made a new integration reach a running kernel: the credential save refreshes the kernels right away, and the old code updated the cache before any await, so that refresh saw the new integration. The list now reaches the cache only after the file write, so a Save refreshes the project's kernels again once its file declares the integration. The ordering comment in the manager keeps only its real reason: a busy kernel would hold the panel back. The specs describe the per-entry edits, and the live-refresh spec now lists edits to the integrations list as a refresh trigger alongside the storage and file triggers. Verified: - Four new panel tests failed before the change. A save wrote only `[pg-1]` over `[pg-1, pg-linked, duckdb, future]`, a delete wrote `[]`, a reset rewrote the file, and nothing refreshed the kernels after a save. All four pass now. - The coverage that tested only `persistProjectIntegrations` now runs through `addProjectIntegration`: no open folder, an active file outside the folders, a failing sibling, a sibling whose flush swaps its project. The writer also gains an in-place test and a `removeProjectIntegration` suite. - Putting back the old re-add ordering (filter, then append) fails the writer's and the panel's ordering tests. - Integrations, SQL status bar and notebook manager suites: 197 passing. `npm run typecheck` and `npm run compile-tsc` exit 0; oxlint and prettier clean on the changed sources. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DiYErQ9fP3wNhrNaRZAdqv --- specs/INTEGRATIONS_CREDENTIALS.md | 20 +- specs/INTEGRATION_ENV_LIVE_REFRESH.md | 7 +- .../deepnote/deepnoteNotebookManager.ts | 21 -- .../deepnoteNotebookManager.unit.test.ts | 139 +------- .../existingIntegrationPicker.unit.test.ts | 2 +- .../integrations/integrationManager.ts | 3 +- .../integrationManager.unit.test.ts | 2 +- .../integrations/integrationWebview.ts | 79 ++--- .../integrationWebview.unit.test.ts | 230 ++++++++----- .../integrations/projectIntegrationsWriter.ts | 72 ++--- .../projectIntegrationsWriter.unit.test.ts | 301 ++++++++++-------- src/notebooks/types.ts | 10 - 12 files changed, 436 insertions(+), 450 deletions(-) diff --git a/specs/INTEGRATIONS_CREDENTIALS.md b/specs/INTEGRATIONS_CREDENTIALS.md index 827f3b58e4..16db88273b 100644 --- a/specs/INTEGRATIONS_CREDENTIALS.md +++ b/specs/INTEGRATIONS_CREDENTIALS.md @@ -441,12 +441,13 @@ webview, implemented in `existingIntegrationPicker.ts`): as are file-only (`.deepnote.env.yaml`) integrations, which already apply workspace-wide. 2. Shows a QuickPick (name, type, "Used in: "). An id whose roster type disagrees with the stored config's type is dropped with a warning rather than offered. -3. On selection, appends `{ id, name, type }` to the active project's roster via `persistProjectIntegrations`. - Nothing is copied in SecretStorage: configs (and federated refresh tokens) are keyed by integration id alone, and - the roster entry is what scopes an integration to a project, so the linked project resolves the same credentials. +3. On selection, appends `{ id, name, type }` to the active project's roster via `addProjectIntegration` (see "Project + files" below). Nothing is copied in SecretStorage: configs (and federated refresh tokens) are keyed by integration + id alone, and the roster entry is what scopes an integration to a project, so the linked project resolves the same + credentials. 4. Re-shows the panel, then re-runs the integration env refresh in the project's running kernels, since no storage change event fires for a roster-only edit. The panel goes first: a busy kernel answers the refresh only after its - running cell, and until the panel is re-shown it would save its pre-link list back over the project. + running cell. The panel's Delete only takes a linked integration off the active project's roster, and keeps its credentials, while another project in the open workspace folders still declares the id. No storage change event fires then either, so @@ -463,6 +464,17 @@ Provides the webview-based UI for managing integration credentials. - Configuration forms for each integration type - Delete/reset functionality +**Project files:** + +Each panel action edits the roster entry it acts on, through `projectIntegrationsWriter.ts`: Save adds the saved +integration or updates its entry in place (`addProjectIntegration`), Delete removes it (`removeProjectIntegration`), +and Reset leaves the roster alone. Every edit merges into what each of the project's `.deepnote` files holds rather +than writing the panel's list, so entries the panel does not list survive it: types it cannot configure, like +`pandas-dataframe`, and integrations linked after it opened. The active file goes first, and the siblings are only +swept once it is on disk; each open notebook's cached project then takes the list its own file now holds. A Save +refreshes the project's running kernels once the roster holds the integration, since the refresh its credential save +triggers may have read the roster before. + **Connection Status:** `IntegrationItem.tsx` derives the status pill from two inputs: `config` (the SecretStorage credentials the panel diff --git a/specs/INTEGRATION_ENV_LIVE_REFRESH.md b/specs/INTEGRATION_ENV_LIVE_REFRESH.md index f5a1fb238b..0de59270e2 100644 --- a/specs/INTEGRATION_ENV_LIVE_REFRESH.md +++ b/specs/INTEGRATION_ENV_LIVE_REFRESH.md @@ -42,7 +42,10 @@ Reacts to **file** changes. ### 3. `IntegrationEnvLiveRefresher` (`integrationEnvLiveRefresher.node.ts`) -Performs the refresh, for both triggers. +Performs the refresh, for both triggers above and for edits to a project's `integrations` list, which decides the +stored configs its kernels get. Such an edit writes no storage, so no event fires and the code making it calls the +refresher for the project's open notebooks itself: `IntegrationManager` after Add Existing Integration, and +`IntegrationWebviewProvider` after a Save, or after a Delete that only takes a shared integration off the project. - Skips notebooks with no kernel, or whose kernel has never started - Runs a hidden execution in each remaining kernel: @@ -127,7 +130,7 @@ web counterpart, since the loopback server and the file reads both require Node - `IntegrationEnvLiveRefresher.refreshNotebook()` never throws; a per-notebook failure is logged and the other notebooks still refresh -- Both triggers wrap their async entry point and log rather than surfacing an unhandled rejection +- Every trigger wraps its async entry point and logs rather than surfacing an unhandled rejection - A refresh that produces `error` outputs is counted as failed, so the success message only appears when at least one kernel actually applied the new environment - `UserpodApiEndpoints` keeps a persistent `error` listener (an `error` with no listener would crash the diff --git a/src/notebooks/deepnote/deepnoteNotebookManager.ts b/src/notebooks/deepnote/deepnoteNotebookManager.ts index 47599b5caf..b166be0845 100644 --- a/src/notebooks/deepnote/deepnoteNotebookManager.ts +++ b/src/notebooks/deepnote/deepnoteNotebookManager.ts @@ -36,27 +36,6 @@ export class DeepnoteNotebookManager implements IDeepnoteNotebookManager { notebookEntries.set(notebookId, clonedProject); } - /** - * Updates the integrations list across every cached notebook entry under the project (cache-only). - * @returns `true` if at least one cached entry was updated, `false` otherwise. - */ - updateProjectIntegrations(projectId: string, integrations: RawProjectIntegration[]): boolean { - const notebookEntries = this.originalProjects.get(projectId); - - if (!notebookEntries || notebookEntries.size === 0) { - return false; - } - - for (const [notebookId, project] of notebookEntries) { - const updatedProject = structuredClone(project); - updatedProject.project.integrations = structuredClone(integrations); - - notebookEntries.set(notebookId, updatedProject); - } - - return true; - } - /** Replaces the integrations list of one cached (projectId, notebookId) entry (cache-only). */ updateProjectIntegrationsForNotebook( projectId: string, diff --git a/src/notebooks/deepnote/deepnoteNotebookManager.unit.test.ts b/src/notebooks/deepnote/deepnoteNotebookManager.unit.test.ts index 8be3168c7d..ff3b322057 100644 --- a/src/notebooks/deepnote/deepnoteNotebookManager.unit.test.ts +++ b/src/notebooks/deepnote/deepnoteNotebookManager.unit.test.ts @@ -52,91 +52,33 @@ suite('DeepnoteNotebookManager', () => { }); }); - suite('updateProjectIntegrations', () => { - test('should update integrations list for existing project and return true', () => { - manager.storeOriginalProject('project-123', 'notebook-456', mockProject); - - const integrations: ProjectIntegration[] = [ - { id: 'int-1', name: 'PostgreSQL', type: 'pgsql' }, - { id: 'int-2', name: 'BigQuery', type: 'big-query' } - ]; - - const result = manager.updateProjectIntegrations('project-123', integrations); - - assert.strictEqual(result, true); - - const updatedProject = manager.getProjectForNotebook('project-123', 'notebook-456'); - assert.deepStrictEqual(updatedProject?.project.integrations, integrations); - }); - - test('should replace existing integrations list and return true', () => { - const projectWithIntegrations: DeepnoteFile = { + suite('updateProjectIntegrationsForNotebook', () => { + test('replaces the list of the entry and keeps every other field of it', () => { + manager.storeOriginalProject('project-123', 'notebook-456', { ...mockProject, - project: { - ...mockProject.project, - integrations: [{ id: 'old-int', name: 'Old Integration', type: 'pgsql' }] - } - }; - - manager.storeOriginalProject('project-123', 'notebook-456', projectWithIntegrations); - - const newIntegrations: ProjectIntegration[] = [ - { id: 'new-int-1', name: 'New Integration 1', type: 'pgsql' }, - { id: 'new-int-2', name: 'New Integration 2', type: 'big-query' } - ]; - - const result = manager.updateProjectIntegrations('project-123', newIntegrations); + project: { ...mockProject.project, integrations: [{ id: 'old-int', name: 'Old', type: 'pgsql' }] } + }); - assert.strictEqual(result, true); + const integrations: ProjectIntegration[] = [{ id: 'int-1', name: 'PostgreSQL', type: 'pgsql' }]; - const updatedProject = manager.getProjectForNotebook('project-123', 'notebook-456'); - assert.deepStrictEqual(updatedProject?.project.integrations, newIntegrations); - }); + manager.updateProjectIntegrationsForNotebook('project-123', 'notebook-456', integrations); - test('should handle empty integrations array and return true', () => { - const projectWithIntegrations: DeepnoteFile = { + assert.deepStrictEqual(manager.getProjectForNotebook('project-123', 'notebook-456'), { ...mockProject, - project: { - ...mockProject.project, - integrations: [{ id: 'int-1', name: 'Integration 1', type: 'pgsql' }] - } - }; - - manager.storeOriginalProject('project-123', 'notebook-456', projectWithIntegrations); - - const result = manager.updateProjectIntegrations('project-123', []); - - assert.strictEqual(result, true); - - const updatedProject = manager.getProjectForNotebook('project-123', 'notebook-456'); - assert.deepStrictEqual(updatedProject?.project.integrations, []); - }); - - test('should return false for unknown project', () => { - const result = manager.updateProjectIntegrations('unknown-project', [ - { id: 'int-1', name: 'Integration', type: 'pgsql' } - ]); - - assert.strictEqual(result, false); - - const project = manager.getProjectForNotebook('unknown-project', 'notebook-456'); - assert.strictEqual(project, undefined); + project: { ...mockProject.project, integrations } + }); }); - test('should preserve other project properties and return true', () => { + test('caches nothing for a project or notebook that is not cached', () => { manager.storeOriginalProject('project-123', 'notebook-456', mockProject); - const integrations: ProjectIntegration[] = [{ id: 'int-1', name: 'PostgreSQL', type: 'pgsql' }]; - const result = manager.updateProjectIntegrations('project-123', integrations); + manager.updateProjectIntegrationsForNotebook('unknown-project', 'notebook-456', integrations); + manager.updateProjectIntegrationsForNotebook('project-123', 'unknown-notebook', integrations); - assert.strictEqual(result, true); - - const updatedProject = manager.getProjectForNotebook('project-123', 'notebook-456'); - assert.strictEqual(updatedProject?.project.id, mockProject.project.id); - assert.strictEqual(updatedProject?.project.name, mockProject.project.name); - assert.strictEqual(updatedProject?.version, mockProject.version); - assert.deepStrictEqual(updatedProject?.metadata, mockProject.metadata); + assert.strictEqual(manager.getProjectForNotebook('unknown-project', 'notebook-456'), undefined); + assert.strictEqual(manager.getProjectForNotebook('project-123', 'unknown-notebook'), undefined); + assert.deepStrictEqual(manager.getProjectForNotebook('project-123', 'notebook-456'), mockProject); }); }); @@ -180,44 +122,6 @@ suite('DeepnoteNotebookManager', () => { assert.strictEqual(result, undefined); }); - test('updateProjectIntegrations updates every cached notebook entry under the project', () => { - manager.storeOriginalProject(projectId, nbA, siblingProject(nbA, 'Sibling A')); - manager.storeOriginalProject(projectId, nbB, siblingProject(nbB, 'Sibling B')); - - const integrations: ProjectIntegration[] = [ - { id: 'int-1', name: 'PostgreSQL', type: 'pgsql' }, - { id: 'int-2', name: 'BigQuery', type: 'big-query' } - ]; - - const updated = manager.updateProjectIntegrations(projectId, integrations); - - assert.strictEqual(updated, true); - // BOTH siblings of the project must see the new integrations. - assert.deepStrictEqual(manager.getProjectForNotebook(projectId, nbA)?.project.integrations, integrations); - assert.deepStrictEqual(manager.getProjectForNotebook(projectId, nbB)?.project.integrations, integrations); - }); - - test('updateProjectIntegrations deep-clones integrations so cached siblings are isolated from the caller and each other', () => { - manager.storeOriginalProject(projectId, nbA, siblingProject(nbA, 'Sibling A')); - manager.storeOriginalProject(projectId, nbB, siblingProject(nbB, 'Sibling B')); - - const pg: ProjectIntegration = { id: 'int-1', name: 'PostgreSQL', type: 'pgsql' }; - const integrations: ProjectIntegration[] = [pg]; - - manager.updateProjectIntegrations(projectId, integrations); - - // Mutating the caller-owned array and its contents after the call must not leak into the cache. - pg.name = 'MUTATED'; - integrations.push({ id: 'int-2', name: 'BigQuery', type: 'big-query' }); - - const cachedA = manager.getProjectForNotebook(projectId, nbA)?.project.integrations; - const cachedB = manager.getProjectForNotebook(projectId, nbB)?.project.integrations; - - assert.deepStrictEqual(cachedA, [{ id: 'int-1', name: 'PostgreSQL', type: 'pgsql' }]); - // Each sibling holds its own array instance rather than a shared reference. - assert.notStrictEqual(cachedA, cachedB); - }); - test('updateProjectIntegrationsForNotebook updates that entry alone, leaving the siblings on their own lists', () => { const siblingIntegrations: ProjectIntegration[] = [{ id: 'int-b', name: 'MySQL', type: 'mysql' }]; const projectB = siblingProject(nbB, 'Sibling B'); @@ -236,17 +140,6 @@ suite('DeepnoteNotebookManager', () => { ); }); - test('updateProjectIntegrationsForNotebook caches nothing for a notebook that is not cached', () => { - manager.storeOriginalProject(projectId, nbA, siblingProject(nbA, 'Sibling A')); - - manager.updateProjectIntegrationsForNotebook(projectId, nbB, [ - { id: 'int-1', name: 'PostgreSQL', type: 'pgsql' } - ]); - - assert.strictEqual(manager.getProjectForNotebook(projectId, nbB), undefined); - assert.strictEqual(manager.getProjectForNotebook(projectId, nbA)?.project.integrations, undefined); - }); - test('updateProjectIntegrationsForNotebook deep-clones integrations so the cache is isolated from the caller', () => { manager.storeOriginalProject(projectId, nbA, siblingProject(nbA, 'Sibling A')); diff --git a/src/notebooks/deepnote/integrations/existingIntegrationPicker.unit.test.ts b/src/notebooks/deepnote/integrations/existingIntegrationPicker.unit.test.ts index 4032a5ccc7..9124c8ffe2 100644 --- a/src/notebooks/deepnote/integrations/existingIntegrationPicker.unit.test.ts +++ b/src/notebooks/deepnote/integrations/existingIntegrationPicker.unit.test.ts @@ -70,7 +70,7 @@ function stubWorkspace(opts: { return Promise.resolve(token?.isCancellationRequested ? [] : discovered); } ); - // `persistProjectIntegrations` enumerates without a token; the scan passes one. + // The writer enumerates without a token; the scan passes one. when(mockedVSCodeNamespaces.workspace.findFiles(anything())).thenReturn(Promise.resolve(discovered)); when(mockedVSCodeNamespaces.workspace.notebookDocuments).thenReturn([]); diff --git a/src/notebooks/deepnote/integrations/integrationManager.ts b/src/notebooks/deepnote/integrations/integrationManager.ts index adf857d22d..a7822e551b 100644 --- a/src/notebooks/deepnote/integrations/integrationManager.ts +++ b/src/notebooks/deepnote/integrations/integrationManager.ts @@ -260,8 +260,7 @@ export class IntegrationManager implements IIntegrationManager { projectId: string, activeNotebook: NotebookDocument ): Promise { - // Panel first: a busy kernel holds the refresh behind its running cell, and a panel still on the old list - // writes that list back over the project on its next save. + // Panel first: a busy kernel holds the refresh behind its running cell. try { await this.showIntegrationsUI(undefined, activeNotebook.uri.toString()); } catch (error) { diff --git a/src/notebooks/deepnote/integrations/integrationManager.unit.test.ts b/src/notebooks/deepnote/integrations/integrationManager.unit.test.ts index aafbe4c0cb..2fb8e33254 100644 --- a/src/notebooks/deepnote/integrations/integrationManager.unit.test.ts +++ b/src/notebooks/deepnote/integrations/integrationManager.unit.test.ts @@ -244,7 +244,7 @@ suite('IntegrationManager.addExistingIntegration', () => { }); test('re-shows the panel before the kernel env refresh settles', async () => { - // Catches: a busy kernel leaving the panel on the pre-link list, whose next save deletes the link. + // Catches: a busy kernel leaving the panel on the pre-link list until its running cell finishes. const refreshGate = createDeferred(); refreshSpy = sinon.spy(() => refreshGate.promise); diff --git a/src/notebooks/deepnote/integrations/integrationWebview.ts b/src/notebooks/deepnote/integrations/integrationWebview.ts index 119af323bb..050e849c8d 100644 --- a/src/notebooks/deepnote/integrations/integrationWebview.ts +++ b/src/notebooks/deepnote/integrations/integrationWebview.ts @@ -10,9 +10,14 @@ import * as localize from '../../../platform/common/utils/localize'; import { logger } from '../../../platform/logging'; import { LocalizedMessages, SharedMessages } from '../../../messageTypes'; import { ISqlIntegrationEnvVarsProvider } from '../../../platform/notebooks/deepnote/types'; -import { IDeepnoteNotebookManager, ProjectIntegration } from '../../types'; +import { IDeepnoteNotebookManager } from '../../types'; import { findOtherProjectsDeclaring } from './existingIntegrationPicker'; -import { persistProjectIntegrations } from './projectIntegrationsWriter'; +import { + addProjectIntegration, + PersistIntegrationsResult, + ProjectFilesParams, + removeProjectIntegration +} from './projectIntegrationsWriter'; import { IFederatedAuthTokenStorage, IIntegrationEnvLiveRefresher, @@ -840,7 +845,12 @@ export class IntegrationWebviewProvider implements IIntegrationWebviewProvider { }); } - const persisted = await this.updateProjectIntegrationsList(); + const persisted = await this.updateProjectFiles((target) => + addProjectIntegration({ + ...target, + integration: { id: integrationId, name: config.name || integrationId, type: config.type } + }) + ); await this.updateWebview(); @@ -849,6 +859,10 @@ export class IntegrationWebviewProvider implements IIntegrationWebviewProvider { message: l10n.t('Configuration saved successfully'), type: 'success' }); + + // The credential save above already refreshed the kernels, possibly before this project declared a + // new integration; refresh again now that it does. + await this.refreshProjectKernels(); } // The credential save above is the tracked operation; a skipped project-YAML sync is not a failure. @@ -907,18 +921,13 @@ export class IntegrationWebviewProvider implements IIntegrationWebviewProvider { this.integrations.set(integrationId, integration); } - const persisted = await this.updateProjectIntegrationsList(); - await this.updateWebview(); - if (persisted) { - await this.currentPanel?.webview.postMessage({ - message: l10n.t('Configuration reset successfully'), - type: 'success' - }); - } + await this.currentPanel?.webview.postMessage({ + message: l10n.t('Configuration reset successfully'), + type: 'success' + }); - // The credential reset above is the tracked operation; a skipped project-YAML sync is not a failure. return true; } catch (error) { logger.error('Failed to reset integration configuration', error); @@ -958,7 +967,9 @@ export class IntegrationWebviewProvider implements IIntegrationWebviewProvider { // Remove from local state this.integrations.delete(integrationId); - const persisted = await this.updateProjectIntegrationsList(); + const persisted = await this.updateProjectFiles((target) => + removeProjectIntegration({ ...target, integrationId }) + ); await this.updateWebview(); @@ -994,8 +1005,8 @@ export class IntegrationWebviewProvider implements IIntegrationWebviewProvider { } /** - * Re-applies integration env in this project's running kernels. Only needed when no credential changed: a - * SecretStorage change already triggers `IntegrationEnvRefreshHandler`. + * Re-applies integration env in this project's running kernels. A SecretStorage change triggers + * `IntegrationEnvRefreshHandler` by itself, but an edit to the project's integrations list fires no event. */ private async refreshProjectKernels(): Promise { const projectNotebooks = workspace.notebookDocuments.filter( @@ -1006,46 +1017,26 @@ export class IntegrationWebviewProvider implements IIntegrationWebviewProvider { try { await this.liveRefresher?.refresh(projectNotebooks, 'integration_config'); } catch (error) { - logger.error('IntegrationWebviewProvider: failed to refresh integration env after unlinking', error); + logger.error('IntegrationWebviewProvider: failed to refresh integration env', error); } } /** - * Update the project's integrations list based on current integrations + * Applies one edit to the project's `.deepnote` files and reports how it went: an error when the active file did + * not take it, a warning for the siblings that did not. Returns whether the active file took it. */ - private async updateProjectIntegrationsList(): Promise { + private async updateProjectFiles( + edit: (target: ProjectFilesParams) => Promise + ): Promise { if (!this.projectId || !this.activeFileUri) { logger.warn('IntegrationWebviewProvider: No project ID / active file available, skipping project update'); return false; } - // Build the integrations list from current integrations - const projectIntegrations: ProjectIntegration[] = Array.from(this.integrations.entries()) - .map(([id, integration]): ProjectIntegration | null => { - // Get the integration type from config or integration metadata - const type = integration.config?.type || integration.integrationType; - if (!type) { - logger.warn(`IntegrationWebviewProvider: No type found for integration ${id}, skipping`); - return null; - } - - return { - id, - name: integration.config?.name || integration.integrationName || id, - type - }; - }) - .filter((integration): integration is ProjectIntegration => integration !== null); - - logger.debug( - `IntegrationWebviewProvider: Updating project ${this.projectId} with ${projectIntegrations.length} integrations` - ); - - const { activePersisted, siblingsFailed } = await persistProjectIntegrations({ + const { activePersisted, siblingsFailed } = await edit({ + activeFileUri: this.activeFileUri, notebookManager: this.notebookManager, - projectId: this.projectId, - integrations: projectIntegrations, - activeFileUri: this.activeFileUri + projectId: this.projectId }); if (!activePersisted) { diff --git a/src/notebooks/deepnote/integrations/integrationWebview.unit.test.ts b/src/notebooks/deepnote/integrations/integrationWebview.unit.test.ts index cda780f113..fe099a524e 100644 --- a/src/notebooks/deepnote/integrations/integrationWebview.unit.test.ts +++ b/src/notebooks/deepnote/integrations/integrationWebview.unit.test.ts @@ -241,6 +241,46 @@ suite('IntegrationWebviewProvider', () => { }); } + function projectFile(projectId: string, name: string, integrations: RawProjectIntegration[]): DeepnoteFile { + return createDeepnoteFile({ project: createDeepnoteProject({ id: projectId, name, integrations }) }); + } + + /** Serves `files` through `workspace.fs` and `/ws` file discovery; returns what the panel writes, by path. */ + function stubProjectFiles(files: Array<{ file: DeepnoteFile; uri: Uri }>): Map { + const onDisk = new Map(files.map(({ file, uri }) => [uri.fsPath, file] as const)); + const discovered = files.map(({ uri }) => uri); + const writes = new Map(); + + when(mockedVSCodeNamespaces.workspace.workspaceFolders).thenReturn([createWorkspaceFolder(Uri.file('/ws'))]); + when(mockedVSCodeNamespaces.workspace.findFiles(anything(), anything(), anything(), anything())).thenReturn( + Promise.resolve(discovered) + ); + // The writer enumerates without a token; the scan passes one. + when(mockedVSCodeNamespaces.workspace.findFiles(anything())).thenReturn(Promise.resolve(discovered)); + + const mockFs = mock(); + + when(mockFs.readFile(anything())).thenCall((uri: Uri) => { + const file = onDisk.get(uri.fsPath); + + return file + ? Promise.resolve(new TextEncoder().encode(serializeDeepnoteFile(file))) + : Promise.reject(new Error(`no readFile stub for ${uri.fsPath}`)); + }); + when(mockFs.writeFile(anything(), anything())).thenCall((uri: Uri, bytes: Uint8Array) => { + writes.set(uri.fsPath, deserializeDeepnoteFile(new TextDecoder().decode(bytes))); + + return Promise.resolve(); + }); + when(mockedVSCodeNamespaces.workspace.fs).thenReturn(instance(mockFs)); + + return writes; + } + + function successMessages(): CapturedMessage[] { + return fakePanel.posted.filter((message) => message.type === 'success'); + } + suite('updateWebview tokenStatus', () => { // Eligibility now comes entirely from the candidate set; `config` no longer gates the status. test('candidate but no tokenStorage → unsupported', async () => { @@ -735,47 +775,16 @@ suite('IntegrationWebviewProvider', () => { setup(() => { refreshSpy = sinon.spy(async () => undefined); - writes = new Map(); when(integrationStorage.delete(anyString())).thenResolve(); preStoreToken(SHARED_CONFIG.id); }); - function projectFile(projectId: string, name: string, integrations: RawProjectIntegration[]): DeepnoteFile { - return createDeepnoteFile({ project: createDeepnoteProject({ id: projectId, name, integrations }) }); - } - /** This project's active file declares `pg-shared`; `/ws/alpha.deepnote` holds project-alpha ("Alpha"). */ - function stubProjectFiles(alphaIntegrations: RawProjectIntegration[]): void { - const onDisk = new Map([ - [ACTIVE_FILE_URI.fsPath, projectFile(PROJECT_ID, 'Active', [SHARED_ENTRY])], - [ALPHA_URI.fsPath, projectFile('project-alpha', 'Alpha', alphaIntegrations)] + function stubActiveAndAlphaFiles(alphaIntegrations: RawProjectIntegration[]): void { + writes = stubProjectFiles([ + { file: projectFile(PROJECT_ID, 'Active', [SHARED_ENTRY]), uri: ACTIVE_FILE_URI }, + { file: projectFile('project-alpha', 'Alpha', alphaIntegrations), uri: ALPHA_URI } ]); - const discovered = [ACTIVE_FILE_URI, ALPHA_URI]; - - when(mockedVSCodeNamespaces.workspace.workspaceFolders).thenReturn([ - createWorkspaceFolder(Uri.file('/ws')) - ]); - when(mockedVSCodeNamespaces.workspace.findFiles(anything(), anything(), anything(), anything())).thenReturn( - Promise.resolve(discovered) - ); - // `persistProjectIntegrations` enumerates without a token; the scan passes one. - when(mockedVSCodeNamespaces.workspace.findFiles(anything())).thenReturn(Promise.resolve(discovered)); - - const mockFs = mock(); - - when(mockFs.readFile(anything())).thenCall((uri: Uri) => { - const file = onDisk.get(uri.fsPath); - - return file - ? Promise.resolve(new TextEncoder().encode(serializeDeepnoteFile(file))) - : Promise.reject(new Error(`no readFile stub for ${uri.fsPath}`)); - }); - when(mockFs.writeFile(anything(), anything())).thenCall((uri: Uri, bytes: Uint8Array) => { - writes.set(uri.fsPath, deserializeDeepnoteFile(new TextDecoder().decode(bytes))); - - return Promise.resolve(); - }); - when(mockedVSCodeNamespaces.workspace.fs).thenReturn(instance(mockFs)); } async function deleteSharedIntegration(): Promise { @@ -786,13 +795,9 @@ suite('IntegrationWebviewProvider', () => { await fakePanel.onDidReceiveMessage({ type: 'delete', integrationId: SHARED_CONFIG.id }); } - function successMessages(): CapturedMessage[] { - return fakePanel.posted.filter((message) => message.type === 'success'); - } - test('takes an integration another project declares off this project and keeps its credentials', async () => { // Catches: deleting a linked integration wipes the credentials another project still uses. - stubProjectFiles([SHARED_ENTRY]); + stubActiveAndAlphaFiles([SHARED_ENTRY]); await deleteSharedIntegration(); @@ -808,7 +813,7 @@ suite('IntegrationWebviewProvider', () => { test("refreshes only this project's kernels after taking a shared integration off it", async () => { // Catches: an unlink fires no storage event, so this project's running kernels keep the removed // integration's env. - stubProjectFiles([SHARED_ENTRY]); + stubActiveAndAlphaFiles([SHARED_ENTRY]); const activeNotebook = createMockNotebook({ uri: ACTIVE_FILE_URI, metadata: { deepnoteProjectId: PROJECT_ID } @@ -827,7 +832,7 @@ suite('IntegrationWebviewProvider', () => { test('deletes the credentials of an integration no other project declares', async () => { // Catches: an over-broad guard never deletes credentials, so they leak. - stubProjectFiles([{ id: 'pg-other', name: 'Other Postgres', type: 'pgsql' }]); + stubActiveAndAlphaFiles([{ id: 'pg-other', name: 'Other Postgres', type: 'pgsql' }]); await deleteSharedIntegration(); @@ -840,6 +845,94 @@ suite('IntegrationWebviewProvider', () => { }); }); + suite('project file edits', () => { + const PG_CONFIG = buildPostgresIntegration({ id: 'pg-1', name: 'Team Postgres' }); + const PG_ENTRY: RawProjectIntegration = { id: PG_CONFIG.id, name: PG_CONFIG.name, type: PG_CONFIG.type }; + // The panel lists none of these: one was linked after it opened, and it cannot configure the other types. + const UNLISTED: RawProjectIntegration[] = [ + { id: 'pg-linked', name: 'Linked Postgres', type: 'pgsql' }, + { id: 'duckdb', name: 'DuckDB', type: 'pandas-dataframe' }, + { id: 'future', name: 'Unknown to this build', type: 'some-future-type' } + ]; + + let refreshSpy: sinon.SinonSpy, ReturnType>; + let writes: Map; + + setup(() => { + refreshSpy = sinon.spy(async () => undefined); + when(integrationStorage.save(anything())).thenResolve(); + when(integrationStorage.delete(anyString())).thenResolve(); + writes = stubProjectFiles([ + { file: projectFile(PROJECT_ID, 'Active', [PG_ENTRY, ...UNLISTED]), uri: ACTIVE_FILE_URI } + ]); + }); + + async function showPanelListingOnly(config: ConfigurableDatabaseIntegrationConfig): Promise { + const provider = buildProvider({ liveRefresher: { refresh: refreshSpy }, tokenStorage }); + + await show(provider, singleIntegrationMap(config.id, config)); + } + + test('a save rewrites the saved entry in place and keeps every entry the panel does not list', async () => { + // Catches: the panel's own list stamped over the file, deleting what it never listed. + await showPanelListingOnly(PG_CONFIG); + const renamed = { ...PG_CONFIG, name: 'Renamed Postgres' }; + + await fakePanel.onDidReceiveMessage({ type: 'save', integrationId: renamed.id, config: renamed }); + + assert.deepStrictEqual(writes.get(ACTIVE_FILE_URI.fsPath)?.project.integrations, [ + { id: 'pg-1', name: 'Renamed Postgres', type: 'pgsql' }, + ...UNLISTED + ]); + }); + + test('a delete takes only the deleted entry off the file', async () => { + await showPanelListingOnly(PG_CONFIG); + + await fakePanel.onDidReceiveMessage({ type: 'delete', integrationId: PG_CONFIG.id }); + + assert.deepStrictEqual(writes.get(ACTIVE_FILE_URI.fsPath)?.project.integrations, UNLISTED); + }); + + test('a reset clears the credentials and leaves the project file alone', async () => { + await showPanelListingOnly(PG_CONFIG); + + await fakePanel.onDidReceiveMessage({ type: 'reset', integrationId: PG_CONFIG.id }); + + verify(integrationStorage.delete(PG_CONFIG.id)).once(); + assert.strictEqual(writes.size, 0); + assert.deepStrictEqual(successMessages(), [ + { message: 'Configuration reset successfully', type: 'success' } + ]); + }); + + test("a save refreshes this project's kernels once the file declares the saved integration", async () => { + // Catches: the refresh the credential save triggers reading the list before it holds a new integration, + // with nothing refreshing the kernels again once it does. + const activeNotebook = createMockNotebook({ + metadata: { deepnoteProjectId: PROJECT_ID }, + uri: ACTIVE_FILE_URI + }); + const added = buildPostgresIntegration({ id: 'pg-new', name: 'New Postgres' }); + let declaredAtRefresh: RawProjectIntegration[] | undefined; + + refreshSpy = sinon.spy(async () => { + declaredAtRefresh = writes.get(ACTIVE_FILE_URI.fsPath)?.project.integrations; + }); + when(mockedVSCodeNamespaces.workspace.notebookDocuments).thenReturn([activeNotebook]); + await showPanelListingOnly(PG_CONFIG); + + await fakePanel.onDidReceiveMessage({ type: 'save', integrationId: added.id, config: added }); + + sinon.assert.calledOnceWithExactly(refreshSpy, [activeNotebook], 'integration_config'); + assert.deepStrictEqual(declaredAtRefresh, [ + PG_ENTRY, + ...UNLISTED, + { id: 'pg-new', name: 'New Postgres', type: 'pgsql' } + ]); + }); + }); + test('saveConfiguration: deletes the token BEFORE save when fingerprint changes', async () => { const integrationId = 'bq-save-fp'; const integrationSaveSpy = sinon.spy(); @@ -1032,49 +1125,24 @@ suite('IntegrationWebviewProvider', () => { assert.isEmpty(updateMessages, 'no `update` postMessage should be issued after the panel disposes mid-update'); }); - suite('project integrations list update (via save message)', () => { - async function callUpdateProjectIntegrationsList(provider: IntegrationWebviewProvider): Promise { - when(integrationStorage.save(anything())).thenResolve(); - - const pgConfig = buildPostgresIntegration({ id: 'pg-1' }); - // `show()` seeds projectId + the integrations map; the `save` message drives the cache update through the real handler. - await show(provider, singleIntegrationMap('pg-1', pgConfig)); + test('a save that cannot update the notebook file says so instead of reporting success', async () => { + const errors: string[] = []; + when(mockedVSCodeNamespaces.window.showErrorMessage(anything())).thenCall((msg: string) => { + errors.push(msg); - await fakePanel.onDidReceiveMessage({ type: 'save', integrationId: 'pg-1', config: pgConfig }); - } - - test('updates the cached project integrations via notebookManager.updateProjectIntegrations', async () => { - const updateProjectIntegrationsSpy = sinon.spy((_projectId: string, _integrations: unknown[]) => true); - when(notebookManager.updateProjectIntegrations(anyString(), anything())).thenCall( - updateProjectIntegrationsSpy - ); - - const provider = buildProvider({ tokenStorage }); - await callUpdateProjectIntegrationsList(provider); - - sinon.assert.calledOnce(updateProjectIntegrationsSpy); - sinon.assert.calledWith(updateProjectIntegrationsSpy, PROJECT_ID); + return Promise.resolve(undefined); }); + when(integrationStorage.save(anything())).thenResolve(); + // No file is served, so the active one cannot be read, let alone rewritten. + stubProjectFiles([]); - test('shows a "project not found" error when no cached entry was updated', async () => { - const errors: string[] = []; - when(mockedVSCodeNamespaces.window.showErrorMessage(anything())).thenCall((msg: string) => { - errors.push(msg); - - return Promise.resolve(undefined); - }); - - // updateProjectIntegrations returns false → no cached entry for the project → error. - when(notebookManager.updateProjectIntegrations(anyString(), anything())).thenReturn(false); + const provider = buildProvider({ tokenStorage }); + const pgConfig = buildPostgresIntegration({ id: 'pg-1' }); + await show(provider, singleIntegrationMap('pg-1', pgConfig)); - const provider = buildProvider({ tokenStorage }); - await callUpdateProjectIntegrationsList(provider); + await fakePanel.onDidReceiveMessage({ type: 'save', integrationId: 'pg-1', config: pgConfig }); - assert.strictEqual( - errors.length, - 1, - 'project-not-found error should show when no cached entry was updated' - ); - }); + assert.deepStrictEqual(errors, ['Failed to save integrations to the notebook file. Please try again.']); + assert.deepStrictEqual(successMessages(), []); }); }); diff --git a/src/notebooks/deepnote/integrations/projectIntegrationsWriter.ts b/src/notebooks/deepnote/integrations/projectIntegrationsWriter.ts index 887b28100b..5485a5f8d0 100644 --- a/src/notebooks/deepnote/integrations/projectIntegrationsWriter.ts +++ b/src/notebooks/deepnote/integrations/projectIntegrationsWriter.ts @@ -15,24 +15,22 @@ export interface PersistIntegrationsResult { siblingsFailed: number; } -export interface PersistProjectIntegrationsParams { +/** The project whose `.deepnote` files an edit goes to, starting with the one the user acted in. */ +export interface ProjectFilesParams { + activeFileUri: Uri; notebookManager: IDeepnoteNotebookManager; projectId: string; - integrations: RawProjectIntegration[]; - activeFileUri: Uri; } -export interface AddProjectIntegrationParams { - activeFileUri: Uri; +export interface AddProjectIntegrationParams extends ProjectFilesParams { integration: RawProjectIntegration; - notebookManager: IDeepnoteNotebookManager; - projectId: string; } -interface WriteSiblingFilesParams { - activeFileUri: Uri; - notebookManager: IDeepnoteNotebookManager; - projectId: string; +export interface RemoveProjectIntegrationParams extends ProjectFilesParams { + integrationId: string; +} + +interface WriteSiblingFilesParams extends ProjectFilesParams { resolve: ResolveIntegrations; } @@ -48,40 +46,40 @@ type ResolveIntegrations = (existing: RawProjectIntegration[]) => RawProjectInte type IntegrationWriteStatus = 'failed' | 'skipped' | 'written'; -/** Writes `integrations` to the active file and every on-disk sibling; `activePersisted` reflects disk truth, not the cache. */ -export async function persistProjectIntegrations( - params: PersistProjectIntegrationsParams -): Promise { - const { notebookManager, projectId, integrations, activeFileUri } = params; - - const resolve: ResolveIntegrations = () => integrations; +/** + * Adds one integration to the project; an id a file already declares is replaced where it stands. Like every edit + * here it merges into what each file holds instead of replacing the list, so a caller holding a list across a prompt + * cannot delete entries written while it waited, nor ones it never listed. + */ +export function addProjectIntegration(params: AddProjectIntegrationParams): Promise { + const { integration, ...target } = params; + + return editProjectFiles(target, (existing) => + existing.some((entry) => entry.id === integration.id) + ? existing.map((entry) => (entry.id === integration.id ? integration : entry)) + : [...existing, integration] + ); +} - // Refresh the cache first so live env/kernel behavior stays correct even if a disk write fails. - notebookManager.updateProjectIntegrations(projectId, integrations); +/** Takes one integration off the project, leaving every other entry each file holds as it is. */ +export function removeProjectIntegration(params: RemoveProjectIntegrationParams): Promise { + const { integrationId, ...target } = params; - // findFiles only covers open folders, so write the active file explicitly (no open folder / out-of-workspace). - const status = await writeIntegrationsToFile({ fileUri: activeFileUri, notebookManager, projectId, resolve }); - const siblingsFailed = await writeSiblingFiles({ activeFileUri, notebookManager, projectId, resolve }); - - return { activePersisted: status === 'written', siblingsFailed }; + return editProjectFiles(target, (existing) => existing.filter((entry) => entry.id !== integrationId)); } -/** - * Adds one integration to the project, merging into what each file holds instead of replacing the list: a caller - * holding a snapshot across a prompt cannot delete entries written while it waited. An id already present is - * replaced. The sibling sweep follows the active file: it does not run until that file is on disk. - */ -export async function addProjectIntegration(params: AddProjectIntegrationParams): Promise { - const { activeFileUri, integration, notebookManager, projectId } = params; - const resolve: ResolveIntegrations = (existing) => [ - ...existing.filter((entry) => entry.id !== integration.id), - integration - ]; +/** Applies `resolve` to the active file, then to every sibling; `activePersisted` reflects disk truth, not the cache. */ +async function editProjectFiles( + params: ProjectFilesParams, + resolve: ResolveIntegrations +): Promise { + const { activeFileUri, notebookManager, projectId } = params; + // findFiles only covers open folders, so write the active file explicitly (no open folder / out-of-workspace). const status = await writeIntegrationsToFile({ fileUri: activeFileUri, notebookManager, projectId, resolve }); // The active file is the mandate for the sweep. `skipped` means it is a snapshot or another project's file, and - // `failed` means the add landed nowhere — stamping the project's other files on its behalf is data loss either way. + // `failed` means the edit landed nowhere — stamping the project's other files on its behalf is data loss either way. if (status !== 'written') { return { activePersisted: false, siblingsFailed: 0 }; } diff --git a/src/notebooks/deepnote/integrations/projectIntegrationsWriter.unit.test.ts b/src/notebooks/deepnote/integrations/projectIntegrationsWriter.unit.test.ts index 9c0dd48f4e..17ba5d15e5 100644 --- a/src/notebooks/deepnote/integrations/projectIntegrationsWriter.unit.test.ts +++ b/src/notebooks/deepnote/integrations/projectIntegrationsWriter.unit.test.ts @@ -4,7 +4,7 @@ import { anything, instance, mock, when } from 'ts-mockito'; import { Uri, workspace, type NotebookDocument } from 'vscode'; import { mockedVSCodeNamespaces, resetVSCodeMocks } from '../../../test/vscode-mock'; -import { IDeepnoteNotebookManager, ProjectIntegration, RawProjectIntegration } from '../../types'; +import { ProjectIntegration, RawProjectIntegration } from '../../types'; import { DeepnoteNotebookManager } from '../deepnoteNotebookManager'; import { createDeepnoteBlock, @@ -14,16 +14,14 @@ import { createMockNotebook, createWorkspaceFolder } from '../deepnoteTestHelpers'; -import { addProjectIntegration, persistProjectIntegrations } from './projectIntegrationsWriter'; +import { addProjectIntegration, removeProjectIntegration } from './projectIntegrationsWriter'; const PROJECT_ID = 'project-1'; -const NEW_INTEGRATIONS: ProjectIntegration[] = [{ id: 'int-new', name: 'New BigQuery', type: 'big-query' }]; - function projectFile( notebookId: string, projectId: string = PROJECT_ID, - integrations: ProjectIntegration[] = [] + integrations: RawProjectIntegration[] = [] ): DeepnoteFile { return createDeepnoteFile({ metadata: { createdAt: '2020-01-01T00:00:00Z', modifiedAt: '2021-01-01T00:00:00Z' }, @@ -109,41 +107,96 @@ function stubWorkspace(opts: { return { writes }; } -suite('persistProjectIntegrations', () => { - let managerInstance: IDeepnoteNotebookManager; +suite('addProjectIntegration', () => { + const ADDED: ProjectIntegration = { id: 'int-added', name: 'Added Postgres', type: 'pgsql' }; + + let managerInstance: DeepnoteNotebookManager; setup(() => { resetVSCodeMocks(); - const mockManager = mock(); - when(mockManager.updateProjectIntegrations(anything(), anything())).thenReturn(true); - managerInstance = instance(mockManager); + managerInstance = new DeepnoteNotebookManager(); }); - test('writes the active file AND every discovered sibling of the project (disk-driven, not open-state driven)', async () => { + function cachedIntegrations(notebookId: string): RawProjectIntegration[] | undefined { + return managerInstance.getProjectForNotebook(PROJECT_ID, notebookId)?.project.integrations; + } + + test('merges into each file rather than stamping one roster over them all', async () => { const activeUri = Uri.file('/ws/active.deepnote'); const siblingUri = Uri.file('/ws/sibling.deepnote'); + const ownEntry: ProjectIntegration = { id: 'int-own', name: 'Own BigQuery', type: 'big-query' }; const { writes } = stubWorkspace({ onDisk: [ { uri: activeUri, file: projectFile('nb-active') }, - { uri: siblingUri, file: projectFile('nb-sibling') } + { uri: siblingUri, file: projectFile('nb-sibling', PROJECT_ID, [ownEntry]) } ], discovered: [activeUri, siblingUri] }); - const result = await persistProjectIntegrations({ + const result = await addProjectIntegration({ + activeFileUri: activeUri, + integration: ADDED, notebookManager: managerInstance, - projectId: PROJECT_ID, - integrations: NEW_INTEGRATIONS, - activeFileUri: activeUri + projectId: PROJECT_ID }); assert.deepStrictEqual(result, { activePersisted: true, siblingsFailed: 0 }); - assert.deepStrictEqual(writes.get(activeUri.fsPath)!.project.integrations, NEW_INTEGRATIONS); - assert.deepStrictEqual(writes.get(siblingUri.fsPath)!.project.integrations, NEW_INTEGRATIONS); + assert.deepStrictEqual(writes.get(activeUri.fsPath)?.project.integrations, [ADDED]); + assert.deepStrictEqual( + writes.get(siblingUri.fsPath)?.project.integrations, + [ownEntry, ADDED], + "the sibling's own entry survives an add driven from another file" + ); }); - test('no workspace folder: still writes the ACTIVE file and reports activePersisted (regression: cache-only success wrote nothing to disk)', async () => { + test('replaces an id the file already declares instead of duplicating it', async () => { + const activeUri = Uri.file('/ws/active.deepnote'); + const { writes } = stubWorkspace({ + onDisk: [ + { + uri: activeUri, + file: projectFile('nb-active', PROJECT_ID, [{ ...ADDED, name: 'Stale name' }]) + } + ], + discovered: [activeUri] + }); + + await addProjectIntegration({ + activeFileUri: activeUri, + integration: ADDED, + notebookManager: managerInstance, + projectId: PROJECT_ID + }); + + assert.deepStrictEqual(writes.get(activeUri.fsPath)?.project.integrations, [ADDED]); + }); + + test('keeps a replaced entry where it was', async () => { + const activeUri = Uri.file('/ws/active.deepnote'); + const before: ProjectIntegration = { id: 'int-before', name: 'Before', type: 'mysql' }; + const after: ProjectIntegration = { id: 'int-after', name: 'After', type: 'mysql' }; + const { writes } = stubWorkspace({ + onDisk: [ + { + uri: activeUri, + file: projectFile('nb-active', PROJECT_ID, [before, { ...ADDED, name: 'Stale name' }, after]) + } + ], + discovered: [activeUri] + }); + + await addProjectIntegration({ + activeFileUri: activeUri, + integration: ADDED, + notebookManager: managerInstance, + projectId: PROJECT_ID + }); + + assert.deepStrictEqual(writes.get(activeUri.fsPath)?.project.integrations, [before, ADDED, after]); + }); + + test('writes the active file even with no workspace folder open', async () => { const activeUri = Uri.file('/loose/foo.deepnote'); const { writes } = stubWorkspace({ onDisk: [{ uri: activeUri, file: projectFile('nb-active') }], @@ -151,18 +204,18 @@ suite('persistProjectIntegrations', () => { hasWorkspaceFolder: false }); - const result = await persistProjectIntegrations({ + const result = await addProjectIntegration({ + activeFileUri: activeUri, + integration: ADDED, notebookManager: managerInstance, - projectId: PROJECT_ID, - integrations: NEW_INTEGRATIONS, - activeFileUri: activeUri + projectId: PROJECT_ID }); - assert.isTrue(result.activePersisted, 'the active file must be written even with no workspace folder'); - assert.deepStrictEqual(writes.get(activeUri.fsPath)!.project.integrations, NEW_INTEGRATIONS); + assert.deepStrictEqual(result, { activePersisted: true, siblingsFailed: 0 }); + assert.deepStrictEqual(writes.get(activeUri.fsPath)?.project.integrations, [ADDED]); }); - test('active file outside every workspace folder is still written (findFiles never returns it)', async () => { + test('writes an active file outside every workspace folder, which discovery never returns', async () => { const activeUri = Uri.file('/other/foo.deepnote'); const wsSibling = Uri.file('/ws/sibling.deepnote'); const { writes } = stubWorkspace({ @@ -173,18 +226,19 @@ suite('persistProjectIntegrations', () => { discovered: [wsSibling] }); - const result = await persistProjectIntegrations({ + const result = await addProjectIntegration({ + activeFileUri: activeUri, + integration: ADDED, notebookManager: managerInstance, - projectId: PROJECT_ID, - integrations: NEW_INTEGRATIONS, - activeFileUri: activeUri + projectId: PROJECT_ID }); - assert.isTrue(result.activePersisted); - assert.isTrue(writes.has(activeUri.fsPath), 'the active file outside the folder must be written'); + assert.deepStrictEqual(result, { activePersisted: true, siblingsFailed: 0 }); + assert.deepStrictEqual(writes.get(activeUri.fsPath)?.project.integrations, [ADDED]); + assert.deepStrictEqual(writes.get(wsSibling.fsPath)?.project.integrations, [ADDED]); }); - test('a sibling whose write rejects yields siblingsFailed=1 while activePersisted stays true', async () => { + test('counts a sibling whose write fails, while the active file still lands', async () => { const activeUri = Uri.file('/ws/active.deepnote'); const badSibling = Uri.file('/ws/bad.deepnote'); const { writes } = stubWorkspace({ @@ -196,11 +250,11 @@ suite('persistProjectIntegrations', () => { failWriteFor: new Set([badSibling.fsPath]) }); - const result = await persistProjectIntegrations({ + const result = await addProjectIntegration({ + activeFileUri: activeUri, + integration: ADDED, notebookManager: managerInstance, - projectId: PROJECT_ID, - integrations: NEW_INTEGRATIONS, - activeFileUri: activeUri + projectId: PROJECT_ID }); assert.deepStrictEqual(result, { activePersisted: true, siblingsFailed: 1 }); @@ -208,26 +262,7 @@ suite('persistProjectIntegrations', () => { assert.isFalse(writes.has(badSibling.fsPath)); }); - test('a failed ACTIVE-file write reports activePersisted=false (regression: no false success)', async () => { - const activeUri = Uri.file('/ws/active.deepnote'); - const { writes } = stubWorkspace({ - onDisk: [{ uri: activeUri, file: projectFile('nb-active') }], - discovered: [activeUri], - failWriteFor: new Set([activeUri.fsPath]) - }); - - const result = await persistProjectIntegrations({ - notebookManager: managerInstance, - projectId: PROJECT_ID, - integrations: NEW_INTEGRATIONS, - activeFileUri: activeUri - }); - - assert.isFalse(result.activePersisted, 'a failed active-file write must NOT report success'); - assert.isFalse(writes.has(activeUri.fsPath)); - }); - - test('a sibling whose flush swaps its on-disk project is skipped, not written to the wrong project', async () => { + test('skips a sibling whose flush swaps its on-disk project for another one', async () => { const activeUri = Uri.file('/ws/active.deepnote'); const siblingUri = Uri.file('/ws/sibling.deepnote'); const { writes } = stubWorkspace({ @@ -246,46 +281,6 @@ suite('persistProjectIntegrations', () => { ] }); - const result = await persistProjectIntegrations({ - notebookManager: managerInstance, - projectId: PROJECT_ID, - integrations: NEW_INTEGRATIONS, - activeFileUri: activeUri - }); - - assert.deepStrictEqual(result, { activePersisted: true, siblingsFailed: 0 }); - assert.deepStrictEqual(writes.get(activeUri.fsPath)!.project.integrations, NEW_INTEGRATIONS); - assert.isFalse(writes.has(siblingUri.fsPath), 'integrations must NOT be written into the swapped project'); - }); -}); - -suite('addProjectIntegration', () => { - const ADDED: ProjectIntegration = { id: 'int-added', name: 'Added Postgres', type: 'pgsql' }; - - let managerInstance: DeepnoteNotebookManager; - - setup(() => { - resetVSCodeMocks(); - - managerInstance = new DeepnoteNotebookManager(); - }); - - function cachedIntegrations(notebookId: string): RawProjectIntegration[] | undefined { - return managerInstance.getProjectForNotebook(PROJECT_ID, notebookId)?.project.integrations; - } - - test('merges into each file rather than stamping one roster over them all', async () => { - const activeUri = Uri.file('/ws/active.deepnote'); - const siblingUri = Uri.file('/ws/sibling.deepnote'); - const ownEntry: ProjectIntegration = { id: 'int-own', name: 'Own BigQuery', type: 'big-query' }; - const { writes } = stubWorkspace({ - onDisk: [ - { uri: activeUri, file: projectFile('nb-active') }, - { uri: siblingUri, file: projectFile('nb-sibling', PROJECT_ID, [ownEntry]) } - ], - discovered: [activeUri, siblingUri] - }); - const result = await addProjectIntegration({ activeFileUri: activeUri, integration: ADDED, @@ -295,33 +290,7 @@ suite('addProjectIntegration', () => { assert.deepStrictEqual(result, { activePersisted: true, siblingsFailed: 0 }); assert.deepStrictEqual(writes.get(activeUri.fsPath)?.project.integrations, [ADDED]); - assert.deepStrictEqual( - writes.get(siblingUri.fsPath)?.project.integrations, - [ownEntry, ADDED], - "the sibling's own entry survives an add driven from another file" - ); - }); - - test('replaces an id the file already declares instead of duplicating it', async () => { - const activeUri = Uri.file('/ws/active.deepnote'); - const { writes } = stubWorkspace({ - onDisk: [ - { - uri: activeUri, - file: projectFile('nb-active', PROJECT_ID, [{ ...ADDED, name: 'Stale name' }]) - } - ], - discovered: [activeUri] - }); - - await addProjectIntegration({ - activeFileUri: activeUri, - integration: ADDED, - notebookManager: managerInstance, - projectId: PROJECT_ID - }); - - assert.deepStrictEqual(writes.get(activeUri.fsPath)?.project.integrations, [ADDED]); + assert.isFalse(writes.has(siblingUri.fsPath), 'the integration must not be written into the other project'); }); test('writes nothing and leaves the cache untouched when the active file cannot be written', async () => { @@ -498,3 +467,87 @@ suite('addProjectIntegration', () => { assert.deepStrictEqual(cachedIntegrations('nb-sibling'), [siblingEntry, ADDED]); }); }); + +suite('removeProjectIntegration', () => { + const REMOVED: ProjectIntegration = { id: 'int-removed', name: 'Removed Postgres', type: 'pgsql' }; + const KEPT: RawProjectIntegration[] = [ + { id: 'int-kept', name: 'Kept BigQuery', type: 'big-query' }, + { id: 'duckdb', name: 'DuckDB', type: 'pandas-dataframe' } + ]; + + let managerInstance: DeepnoteNotebookManager; + + setup(() => { + resetVSCodeMocks(); + + managerInstance = new DeepnoteNotebookManager(); + }); + + test("takes the id off the active file and every sibling, keeping each file's other entries and its cache in step", async () => { + const activeUri = Uri.file('/ws/active.deepnote'); + const siblingUri = Uri.file('/ws/sibling.deepnote'); + const siblingEntry: ProjectIntegration = { id: 'int-sibling', name: 'Sibling MySQL', type: 'mysql' }; + const { writes } = stubWorkspace({ + onDisk: [ + { uri: activeUri, file: projectFile('nb-active', PROJECT_ID, [...KEPT, REMOVED]) }, + { uri: siblingUri, file: projectFile('nb-sibling', PROJECT_ID, [REMOVED, siblingEntry]) } + ], + discovered: [activeUri, siblingUri], + openNotebooks: [ + { notebookId: 'nb-active', uri: activeUri }, + { notebookId: 'nb-sibling', uri: siblingUri } + ] + }); + managerInstance.storeOriginalProject( + PROJECT_ID, + 'nb-active', + projectFile('nb-active', PROJECT_ID, [...KEPT, REMOVED]) + ); + managerInstance.storeOriginalProject( + PROJECT_ID, + 'nb-sibling', + projectFile('nb-sibling', PROJECT_ID, [REMOVED, siblingEntry]) + ); + + const result = await removeProjectIntegration({ + activeFileUri: activeUri, + integrationId: REMOVED.id, + notebookManager: managerInstance, + projectId: PROJECT_ID + }); + + assert.deepStrictEqual(result, { activePersisted: true, siblingsFailed: 0 }); + assert.deepStrictEqual(writes.get(activeUri.fsPath)?.project.integrations, KEPT); + assert.deepStrictEqual(writes.get(siblingUri.fsPath)?.project.integrations, [siblingEntry]); + assert.deepStrictEqual( + managerInstance.getProjectForNotebook(PROJECT_ID, 'nb-active')?.project.integrations, + KEPT + ); + assert.deepStrictEqual(managerInstance.getProjectForNotebook(PROJECT_ID, 'nb-sibling')?.project.integrations, [ + siblingEntry + ]); + }); + + test('sweeps no sibling when the active file cannot be written', async () => { + const activeUri = Uri.file('/ws/active.deepnote'); + const siblingUri = Uri.file('/ws/sibling.deepnote'); + const { writes } = stubWorkspace({ + onDisk: [ + { uri: activeUri, file: projectFile('nb-active', PROJECT_ID, [REMOVED]) }, + { uri: siblingUri, file: projectFile('nb-sibling', PROJECT_ID, [REMOVED]) } + ], + discovered: [activeUri, siblingUri], + failWriteFor: new Set([activeUri.fsPath]) + }); + + const result = await removeProjectIntegration({ + activeFileUri: activeUri, + integrationId: REMOVED.id, + notebookManager: managerInstance, + projectId: PROJECT_ID + }); + + assert.deepStrictEqual(result, { activePersisted: false, siblingsFailed: 0 }); + assert.strictEqual(writes.size, 0, 'the removal reaches no file once the one the user acted in refused it'); + }); +}); diff --git a/src/notebooks/types.ts b/src/notebooks/types.ts index 202fb67e3c..4bdaca0dff 100644 --- a/src/notebooks/types.ts +++ b/src/notebooks/types.ts @@ -51,16 +51,6 @@ export interface IDeepnoteNotebookManager { getProjectForNotebook(projectId: string, notebookId: string): DeepnoteFile | undefined; storeOriginalProject(projectId: string, notebookId: string, project: DeepnoteFile): void; - /** - * Updates the integrations list in the cached project data (cache-only). - * Iterates every cached notebook entry under the project and updates each. - * - * @param projectId - Project identifier - * @param integrations - Array of integration metadata to store in the project - * @returns `true` if at least one cached entry was found and updated, `false` otherwise - */ - updateProjectIntegrations(projectId: string, integrations: RawProjectIntegration[]): boolean; - /** * Replaces the integrations list of the one cached (projectId, notebookId) entry (cache-only); does nothing * when that entry is not cached. Sibling entries keep their own lists: each mirrors its own file. From 5aad111fe93ffab7d33b386fedecdb19b4c3bf89 Mon Sep 17 00:00:00 2001 From: tomas Date: Thu, 24 Sep 2026 13:27:10 +0000 Subject: [PATCH 29/35] fix(integrations): name the projects a shared reset, sign-out or save affects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Credentials and federated refresh tokens are keyed by integration id alone, so once Add Existing Integration links an id into several projects, a Reset, Sign out or Save in one of them acts on all of them. Delete already checked `findOtherProjectsDeclaring` and only unlinks. Reset and Sign out still wiped the shared credential or token with no sign of it, and Save changed it for every project just as silently: the other projects' SQL cells started failing, or ran against new credentials, and nothing said why. The actions still go ahead. Reset is how a user removes credentials, and Save is how a shared credential gets fixed. Instead, each one now names the other projects in its success message: - Reset: "These credentials were shared, so this also removed them from: …" - Sign out: "This sign-in was shared, so this also signed out: …" (Sign out posted no message before and still posts none when nothing else shares the token.) - Save of an integration the panel already lists: "These credentials are shared, so this also changed them for: …". A brand-new integration has a fresh id, so it skips the scan. The scan call moves into `findOtherProjectsSharing`, which Delete uses too. Verified: - New tests (active project and "Alpha" both declare `bq-shared`): reset, sign-out and save each still act and name Alpha. All three failed before the change; a sign-out Alpha does not share posts no message. - Integrations and SQL status bar suites: 192 passing. `npm run typecheck` exit 0; oxlint, prettier and `npm run spell-check` clean. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DiYErQ9fP3wNhrNaRZAdqv --- specs/INTEGRATIONS_CREDENTIALS.md | 5 +- .../integrations/integrationWebview.ts | 38 ++++++++-- .../integrationWebview.unit.test.ts | 69 +++++++++++++++++++ src/platform/common/utils/localize.ts | 12 ++++ 4 files changed, 117 insertions(+), 7 deletions(-) diff --git a/specs/INTEGRATIONS_CREDENTIALS.md b/specs/INTEGRATIONS_CREDENTIALS.md index 16db88273b..d792a77c87 100644 --- a/specs/INTEGRATIONS_CREDENTIALS.md +++ b/specs/INTEGRATIONS_CREDENTIALS.md @@ -450,8 +450,9 @@ webview, implemented in `existingIntegrationPicker.ts`): running cell. The panel's Delete only takes a linked integration off the active project's roster, and keeps its credentials, while -another project in the open workspace folders still declares the id. No storage change event fires then either, so -the panel refreshes the project's running kernels itself. +another project in the open workspace folders still declares the id. No storage change event fires then either, so the +panel refreshes the project's running kernels itself. Reset, Sign out and Save go ahead on shared credentials, which +are the same for every project declaring the id, and their success message names the other projects they affected. #### 4. **Integration Webview** (`integrationWebview.ts`) diff --git a/src/notebooks/deepnote/integrations/integrationWebview.ts b/src/notebooks/deepnote/integrations/integrationWebview.ts index 050e849c8d..9d97267b30 100644 --- a/src/notebooks/deepnote/integrations/integrationWebview.ts +++ b/src/notebooks/deepnote/integrations/integrationWebview.ts @@ -823,6 +823,11 @@ export class IntegrationWebviewProvider implements IIntegrationWebviewProvider { } try { + // A new integration has a fresh id no other project can declare yet. + const otherProjectNames = this.integrations.has(integrationId) + ? await this.findOtherProjectsSharing(integrationId) + : []; + // Invalidate stale federated tokens before saving (fingerprint change or auth-method switch). await this.invalidateStaleFederatedToken(integrationId, config); @@ -856,7 +861,10 @@ export class IntegrationWebviewProvider implements IIntegrationWebviewProvider { if (persisted) { await this.currentPanel?.webview.postMessage({ - message: l10n.t('Configuration saved successfully'), + message: + otherProjectNames.length > 0 + ? localize.Integrations.integrationSavedShared(otherProjectNames.join(', ')) + : l10n.t('Configuration saved successfully'), type: 'success' }); @@ -889,8 +897,17 @@ export class IntegrationWebviewProvider implements IIntegrationWebviewProvider { */ private async signOutIntegration(integrationId: string): Promise { try { + const otherProjectNames = await this.findOtherProjectsSharing(integrationId); + // `delete` fires onDidChangeTokens, which re-renders the panel; no explicit updateWebview needed. await this.tokenStorage?.delete(integrationId); + + if (otherProjectNames.length > 0) { + await this.currentPanel?.webview.postMessage({ + message: localize.Integrations.integrationSignedOutShared(otherProjectNames.join(', ')), + type: 'success' + }); + } } catch (error) { logger.error('Failed to sign out integration', error); await this.currentPanel?.webview.postMessage({ @@ -909,6 +926,8 @@ export class IntegrationWebviewProvider implements IIntegrationWebviewProvider { } try { + const otherProjectNames = await this.findOtherProjectsSharing(integrationId); + // Token first: a failure here has to abort before the config is committed, otherwise the token is // stranded with no integration left in the panel to retry from. await this.tokenStorage?.delete(integrationId); @@ -924,7 +943,10 @@ export class IntegrationWebviewProvider implements IIntegrationWebviewProvider { await this.updateWebview(); await this.currentPanel?.webview.postMessage({ - message: l10n.t('Configuration reset successfully'), + message: + otherProjectNames.length > 0 + ? localize.Integrations.integrationResetShared(otherProjectNames.join(', ')) + : l10n.t('Configuration reset successfully'), type: 'success' }); @@ -953,9 +975,7 @@ export class IntegrationWebviewProvider implements IIntegrationWebviewProvider { } try { - const otherProjectNames = this.projectId - ? await findOtherProjectsDeclaring({ integrationId, projectId: this.projectId }) - : []; + const otherProjectNames = await this.findOtherProjectsSharing(integrationId); if (otherProjectNames.length === 0) { // Token first: a failure here has to abort before the config is committed, otherwise the token is @@ -1004,6 +1024,14 @@ export class IntegrationWebviewProvider implements IIntegrationWebviewProvider { } } + /** + * Other workspace projects declaring `integrationId`: credentials are keyed by id alone, so they share whatever + * this panel does to its stored config or token. + */ + private async findOtherProjectsSharing(integrationId: string): Promise { + return this.projectId ? findOtherProjectsDeclaring({ integrationId, projectId: this.projectId }) : []; + } + /** * Re-applies integration env in this project's running kernels. A SecretStorage change triggers * `IntegrationEnvRefreshHandler` by itself, but an edit to the project's integrations list fires no event. diff --git a/src/notebooks/deepnote/integrations/integrationWebview.unit.test.ts b/src/notebooks/deepnote/integrations/integrationWebview.unit.test.ts index fe099a524e..a7e0859c65 100644 --- a/src/notebooks/deepnote/integrations/integrationWebview.unit.test.ts +++ b/src/notebooks/deepnote/integrations/integrationWebview.unit.test.ts @@ -933,6 +933,75 @@ suite('IntegrationWebviewProvider', () => { }); }); + suite('credentials another project shares', () => { + const ALPHA_URI = Uri.file('/ws/alpha.deepnote'); + const SHARED_CONFIG = buildGoogleOauthIntegration({ id: 'bq-shared', name: 'Shared BigQuery' }); + const SHARED_ENTRY: RawProjectIntegration = { + id: SHARED_CONFIG.id, + name: SHARED_CONFIG.name, + type: SHARED_CONFIG.type + }; + + setup(() => { + when(integrationStorage.save(anything())).thenResolve(); + when(integrationStorage.delete(anyString())).thenResolve(); + preStoreToken(SHARED_CONFIG.id); + }); + + /** This project's active file declares `bq-shared`; `/ws/alpha.deepnote` holds project-alpha ("Alpha"). */ + async function showSharedIntegration(alphaIntegrations: RawProjectIntegration[]): Promise { + stubProjectFiles([ + { file: projectFile(PROJECT_ID, 'Active', [SHARED_ENTRY]), uri: ACTIVE_FILE_URI }, + { file: projectFile('project-alpha', 'Alpha', alphaIntegrations), uri: ALPHA_URI } + ]); + + await show(buildProvider({ tokenStorage }), singleIntegrationMap(SHARED_CONFIG.id, SHARED_CONFIG)); + } + + test('a reset still clears shared credentials, and names the projects that lose them', async () => { + await showSharedIntegration([SHARED_ENTRY]); + + await fakePanel.onDidReceiveMessage({ type: 'reset', integrationId: SHARED_CONFIG.id }); + + verify(integrationStorage.delete(SHARED_CONFIG.id)).once(); + sinon.assert.calledOnceWithExactly(tokenDeleteSpy, SHARED_CONFIG.id); + assert.deepStrictEqual(successMessages(), [ + { message: Integrations.integrationResetShared('Alpha'), type: 'success' } + ]); + }); + + test('a sign-out names the projects signed out along with this one', async () => { + await showSharedIntegration([SHARED_ENTRY]); + + await fakePanel.onDidReceiveMessage({ type: 'signOut', integrationId: SHARED_CONFIG.id }); + + sinon.assert.calledOnceWithExactly(tokenDeleteSpy, SHARED_CONFIG.id); + assert.deepStrictEqual(successMessages(), [ + { message: Integrations.integrationSignedOutShared('Alpha'), type: 'success' } + ]); + }); + + test('a sign-out no other project shares posts no message', async () => { + await showSharedIntegration([]); + + await fakePanel.onDidReceiveMessage({ type: 'signOut', integrationId: SHARED_CONFIG.id }); + + sinon.assert.calledOnceWithExactly(tokenDeleteSpy, SHARED_CONFIG.id); + assert.deepStrictEqual(successMessages(), []); + }); + + test('a save of shared credentials names the projects it also changes them for', async () => { + await showSharedIntegration([SHARED_ENTRY]); + const changed = { ...SHARED_CONFIG, name: 'Renamed BigQuery' }; + + await fakePanel.onDidReceiveMessage({ type: 'save', integrationId: changed.id, config: changed }); + + assert.deepStrictEqual(successMessages(), [ + { message: Integrations.integrationSavedShared('Alpha'), type: 'success' } + ]); + }); + }); + test('saveConfiguration: deletes the token BEFORE save when fingerprint changes', async () => { const integrationId = 'bq-save-fp'; const integrationSaveSpy = sinon.spy(); diff --git a/src/platform/common/utils/localize.ts b/src/platform/common/utils/localize.ts index 8502e4b799..8c1948e7e6 100644 --- a/src/platform/common/utils/localize.ts +++ b/src/platform/common/utils/localize.ts @@ -896,6 +896,18 @@ export namespace Integrations { 'Removed from this project. Its credentials were kept because other projects still use them: {0}', projectNames ); + export const integrationSavedShared = (projectNames: string) => + l10n.t( + 'Configuration saved successfully. These credentials are shared, so this also changed them for: {0}', + projectNames + ); + export const integrationResetShared = (projectNames: string) => + l10n.t( + 'Configuration reset successfully. These credentials were shared, so this also removed them from: {0}', + projectNames + ); + export const integrationSignedOutShared = (projectNames: string) => + l10n.t('Signed out. This sign-in was shared, so this also signed out: {0}', projectNames); export const database = l10n.t('Database'); export const dataWarehousesLakes = l10n.t('Data Warehouses & Lakes'); export const databases = l10n.t('Databases'); From 5fdb73a81986e1702b54b9da9cbc1607cd9b6f69 Mon Sep 17 00:00:00 2001 From: tomas Date: Thu, 24 Sep 2026 13:30:25 +0000 Subject: [PATCH 30/35] fix(integrations): never fall back to another notebook when the given one is closed `resolveDeepnoteNotebook` looked up the notebook URI a menu or the panel passed, and when that notebook was no longer open it fell back to the focused editor, then to the one visible editor. Only `addExistingIntegration` guarded against that, by comparing the result with the URI afterwards. Manage Integrations, including the re-show after a link, opened the panel for whatever project the focused editor belonged to, so its next Save or Delete landed in the wrong project. The resolver now treats a given URI as the only notebook to act on: when it is not open, the command stops with "The notebook this was run for is no longer open". The focused and single-visible fallbacks remain, but only when no URI was given (the command palette). The resolver reports its own refusal, so both commands just return, and the manager's separate URI check is gone. The message constant loses its Add-Existing-only name and wording now that both commands show it. Verified: - New Manage Integrations test: the handler, given the URI of a closed notebook while another is focused, opened the panel for the focused one before the change; it now refuses with the message. A second test keeps the palette path (no URI) opening the panel for the focused notebook. - The Add Existing "no notebook open" case now passes no URI, matching the palette; with a URI it is the closed-notebook case, which still refuses. - IntegrationManager suites: 19 passing. `npm run typecheck` exit 0; oxlint and prettier clean. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DiYErQ9fP3wNhrNaRZAdqv --- .../integrations/integrationManager.ts | 37 ++++----- .../integrationManager.unit.test.ts | 78 ++++++++++++++++++- src/platform/common/utils/localize.ts | 4 +- 3 files changed, 96 insertions(+), 23 deletions(-) diff --git a/src/notebooks/deepnote/integrations/integrationManager.ts b/src/notebooks/deepnote/integrations/integrationManager.ts index a7822e551b..d534da63da 100644 --- a/src/notebooks/deepnote/integrations/integrationManager.ts +++ b/src/notebooks/deepnote/integrations/integrationManager.ts @@ -97,15 +97,6 @@ export class IntegrationManager implements IIntegrationManager { const activeNotebook = this.resolveDeepnoteNotebook(notebookUri); if (!activeNotebook) { - void window.showErrorMessage(l10n.t('No active Deepnote notebook')); - - return 'failed'; - } - - // The panel keeps sending the URI it was opened for, so a closed notebook must not fall back to another project. - if (notebookUri && activeNotebook.uri.toString() !== notebookUri) { - void window.showErrorMessage(localize.Integrations.addExistingIntegrationNotebookClosed); - return 'failed'; } @@ -279,19 +270,30 @@ export class IntegrationManager implements IIntegrationManager { } /** - * The Deepnote notebook to act on: `window.activeNotebookEditor` is unset until an editor is focused, so a - * restored but not yet focused notebook resolves via the menu's URI or the one visible editor instead. + * The Deepnote notebook to act on, or `undefined` after telling the user why there is none. A URI from a menu or + * the panel names the only notebook to act on: once it is closed, the focused editor may belong to another project. */ private resolveDeepnoteNotebook(notebookUri: string | undefined): NotebookDocument | undefined { - if (notebookUri) { - const fromUri = workspace.notebookDocuments.find( - (notebook) => notebook.notebookType === 'deepnote' && notebook.uri.toString() === notebookUri + const notebook = notebookUri + ? workspace.notebookDocuments.find( + (candidate) => candidate.notebookType === 'deepnote' && candidate.uri.toString() === notebookUri + ) + : this.findFocusedDeepnoteNotebook(); + + if (!notebook) { + void window.showErrorMessage( + notebookUri ? localize.Integrations.commandNotebookClosed : l10n.t('No active Deepnote notebook') ); - if (fromUri) { - return fromUri; - } } + return notebook; + } + + /** + * `window.activeNotebookEditor` is unset until an editor is focused, so a restored but not yet focused notebook + * resolves via the one visible editor instead. + */ + private findFocusedDeepnoteNotebook(): NotebookDocument | undefined { const active = window.activeNotebookEditor?.notebook; if (active?.notebookType === 'deepnote') { return active; @@ -314,7 +316,6 @@ export class IntegrationManager implements IIntegrationManager { const activeNotebook = this.resolveDeepnoteNotebook(notebookUri); if (!activeNotebook) { - void window.showErrorMessage(l10n.t('No active Deepnote notebook')); return; } diff --git a/src/notebooks/deepnote/integrations/integrationManager.unit.test.ts b/src/notebooks/deepnote/integrations/integrationManager.unit.test.ts index 2fb8e33254..60f456f114 100644 --- a/src/notebooks/deepnote/integrations/integrationManager.unit.test.ts +++ b/src/notebooks/deepnote/integrations/integrationManager.unit.test.ts @@ -13,6 +13,7 @@ import { } from 'vscode'; import { ITelemetryService } from '../../../platform/analytics/types'; +import { Commands } from '../../../platform/common/constants'; import { IExtensionContext } from '../../../platform/common/types'; import { createDeferred } from '../../../platform/common/utils/async'; import { Integrations } from '../../../platform/common/utils/localize'; @@ -335,12 +336,12 @@ suite('IntegrationManager.addExistingIntegration', () => { // Every branch below reports trouble to the user; without cover they can each regress into silent success, // or into the wrong diagnosis: the message is what tells the user which one they hit. - const earlyFailures: { arrange: () => string; expectedMessage: string; name: string }[] = [ + const earlyFailures: { arrange: () => string | undefined; expectedMessage: string; name: string }[] = [ { arrange: () => { when(mockedVSCodeNamespaces.workspace.notebookDocuments).thenReturn([]); - return Uri.file('/ws/missing.deepnote').toString(); + return undefined; }, expectedMessage: 'No active Deepnote notebook', name: 'no Deepnote notebook is open' @@ -356,7 +357,7 @@ suite('IntegrationManager.addExistingIntegration', () => { return OTHER_URI.toString(); }, - expectedMessage: Integrations.addExistingIntegrationNotebookClosed, + expectedMessage: Integrations.commandNotebookClosed, name: 'the panel names a notebook that is no longer open' }, { @@ -499,3 +500,74 @@ suite('IntegrationManager.addExistingIntegration', () => { ).once(); }); }); + +suite('IntegrationManager: the Manage Integrations command', () => { + let currentNotebook: NotebookDocument; + let manageIntegrations: ((...args: unknown[]) => Promise) | undefined; + let webviewProvider: IIntegrationWebviewProvider; + + setup(() => { + resetVSCodeMocks(); + + currentNotebook = createMockNotebook({ + uri: CURRENT_URI, + metadata: { deepnoteProjectId: CURRENT_PROJECT_ID, deepnoteNotebookId: CURRENT_NOTEBOOK_ID } + }); + manageIntegrations = undefined; + when(mockedVSCodeNamespaces.commands.registerCommand(anything(), anything())).thenCall( + (command: string, handler: (...args: unknown[]) => Promise) => { + if (command === Commands.ManageIntegrations) { + manageIntegrations = handler; + } + + return { dispose: () => undefined }; + } + ); + + webviewProvider = mock(); + when(webviewProvider.show(anything(), anything(), anything(), anything(), anything())).thenResolve(); + + const detector = mock(); + when(detector.detectIntegrations(anything())).thenResolve(new Map()); + + const extensionContext = mock(); + when(extensionContext.subscriptions).thenReturn([]); + + new IntegrationManager( + instance(extensionContext), + instance(detector), + instance(mock()), + instance(webviewProvider), + instance(mock()), + instance(mock()) + ).activate(); + }); + + function focus(notebook: NotebookDocument): void { + const editor = mock(); + + when(editor.notebook).thenReturn(notebook); + when(mockedVSCodeNamespaces.window.activeNotebookEditor).thenReturn(instance(editor)); + } + + test('refuses a notebook URI that is no longer open instead of opening the panel for another notebook', async () => { + // Catches: a URI that resolves to nothing falling back to the focused editor, which re-targets the panel + // at whatever project that editor belongs to. + when(mockedVSCodeNamespaces.workspace.notebookDocuments).thenReturn([currentNotebook]); + focus(currentNotebook); + + await manageIntegrations?.({ notebookUri: OTHER_URI.toString() }); + + verify(webviewProvider.show(anything(), anything(), anything(), anything(), anything())).never(); + verify(mockedVSCodeNamespaces.window.showErrorMessage(Integrations.commandNotebookClosed)).once(); + }); + + test('opens the panel for the focused notebook when no URI is given', async () => { + when(mockedVSCodeNamespaces.workspace.notebookDocuments).thenReturn([currentNotebook]); + focus(currentNotebook); + + await manageIntegrations?.(); + + verify(webviewProvider.show(CURRENT_PROJECT_ID, anything(), CURRENT_URI, undefined, anything())).once(); + }); +}); diff --git a/src/platform/common/utils/localize.ts b/src/platform/common/utils/localize.ts index 8c1948e7e6..a56f43cd6f 100644 --- a/src/platform/common/utils/localize.ts +++ b/src/platform/common/utils/localize.ts @@ -871,8 +871,8 @@ export namespace Integrations { export const switchIntegrationSnapshotUnsupported = l10n.t( 'The integration of a snapshot cannot be changed. Open the project notebook and try again.' ); - export const addExistingIntegrationNotebookClosed = l10n.t( - 'The notebook this integrations panel was opened for is no longer open. Open it again and retry.' + export const commandNotebookClosed = l10n.t( + 'The notebook this was run for is no longer open. Open it again and retry.' ); export const addExistingIntegrationPlaceholder = l10n.t( 'Select an integration configured in another project of this workspace' From 83f1985ba874760186e3db552177b74b9e9786fd Mon Sep 17 00:00:00 2001 From: tomas Date: Thu, 24 Sep 2026 13:32:08 +0000 Subject: [PATCH 31/35] fix(integrations): refuse snapshots once, in the notebook resolver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `*.snapshot.deepnote` matches the notebook selector, and the Manage Integrations toolbar button is gated only on the notebook type, so the panel opened on a snapshot. Its roster edits were all skipped (the writer never edits a snapshot), while Save and Delete still wrote and deleted credentials, and the user got "Failed to save integrations" after the credentials had already changed. Add Existing Integration had its own snapshot check; Manage Integrations had none. `resolveDeepnoteNotebook` now refuses a snapshot for both commands, after either lookup path, and Add Existing's own check is gone. The message is reworded for both commands ("Integrations cannot be managed from a snapshot file…") and renamed from its Add-Existing-only name. The SQL cell's switch keeps its own refusal: it guards a cell-metadata edit, not the integrations panel. The spec's Integration Manager section now states both resolver refusals: a closed notebook URI (previous commit) and a snapshot. Verified: - New Manage Integrations test: with a snapshot focused, the palette command opened the panel for it before the change. It now refuses with the message, and the panel never opens. - The Add Existing snapshot case still refuses, now through the resolver. - IntegrationManager and SqlCellStatusBarProvider: 87 passing. `npm run typecheck` exit 0; oxlint and prettier clean. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DiYErQ9fP3wNhrNaRZAdqv --- specs/INTEGRATIONS_CREDENTIALS.md | 4 ++++ .../integrations/integrationManager.ts | 18 ++++++++++-------- .../integrationManager.unit.test.ts | 17 ++++++++++++++++- src/platform/common/utils/localize.ts | 4 ++-- 4 files changed, 32 insertions(+), 11 deletions(-) diff --git a/specs/INTEGRATIONS_CREDENTIALS.md b/specs/INTEGRATIONS_CREDENTIALS.md index d792a77c87..dea4ef1d24 100644 --- a/specs/INTEGRATIONS_CREDENTIALS.md +++ b/specs/INTEGRATIONS_CREDENTIALS.md @@ -433,6 +433,10 @@ Orchestrates the integration management UI and commands. 3. Manager opens webview with integration list 4. Optionally pre-selects a specific integration for configuration +Both commands act only on the notebook a menu or the panel names, when one is given, and refuse it once it is closed +rather than acting on whichever editor has focus. They also refuse a snapshot (`*.snapshot.deepnote`): it matches the +notebook selector, but it records a past run, and the integrations writer never edits one. + **Add Existing Integration** (`deepnote.addExistingIntegration`, also the "Add Existing Integration" button in the webview, implemented in `existingIntegrationPicker.ts`): diff --git a/src/notebooks/deepnote/integrations/integrationManager.ts b/src/notebooks/deepnote/integrations/integrationManager.ts index d534da63da..71a32ba525 100644 --- a/src/notebooks/deepnote/integrations/integrationManager.ts +++ b/src/notebooks/deepnote/integrations/integrationManager.ts @@ -100,14 +100,6 @@ export class IntegrationManager implements IIntegrationManager { return 'failed'; } - // `*.snapshot.deepnote` matches the notebook selector, so this command can run against a focused snapshot. - // The writer skips snapshots, which would report a failure only after the cache had already been updated. - if (isSnapshotFile(activeNotebook.uri)) { - void window.showErrorMessage(localize.Integrations.addExistingIntegrationSnapshotUnsupported); - - return 'failed'; - } - const projectId = activeNotebook.metadata?.deepnoteProjectId; const notebookId = activeNotebook.metadata?.deepnoteNotebookId; @@ -272,6 +264,8 @@ export class IntegrationManager implements IIntegrationManager { /** * The Deepnote notebook to act on, or `undefined` after telling the user why there is none. A URI from a menu or * the panel names the only notebook to act on: once it is closed, the focused editor may belong to another project. + * A snapshot is refused: `*.snapshot.deepnote` matches the notebook selector, but it records a past run, so the + * writer never edits one, while credentials are saved and deleted all the same. */ private resolveDeepnoteNotebook(notebookUri: string | undefined): NotebookDocument | undefined { const notebook = notebookUri @@ -284,6 +278,14 @@ export class IntegrationManager implements IIntegrationManager { void window.showErrorMessage( notebookUri ? localize.Integrations.commandNotebookClosed : l10n.t('No active Deepnote notebook') ); + + return undefined; + } + + if (isSnapshotFile(notebook.uri)) { + void window.showErrorMessage(localize.Integrations.snapshotIntegrationsUnsupported); + + return undefined; } return notebook; diff --git a/src/notebooks/deepnote/integrations/integrationManager.unit.test.ts b/src/notebooks/deepnote/integrations/integrationManager.unit.test.ts index 60f456f114..708bc3d003 100644 --- a/src/notebooks/deepnote/integrations/integrationManager.unit.test.ts +++ b/src/notebooks/deepnote/integrations/integrationManager.unit.test.ts @@ -385,7 +385,7 @@ suite('IntegrationManager.addExistingIntegration', () => { return SNAPSHOT_URI.toString(); }, - expectedMessage: Integrations.addExistingIntegrationSnapshotUnsupported, + expectedMessage: Integrations.snapshotIntegrationsUnsupported, name: 'the active file is a snapshot' }, { @@ -562,6 +562,21 @@ suite('IntegrationManager: the Manage Integrations command', () => { verify(mockedVSCodeNamespaces.window.showErrorMessage(Integrations.commandNotebookClosed)).once(); }); + test('refuses a focused snapshot instead of opening the panel for it', async () => { + // Catches: a panel on a snapshot saving and deleting credentials while every project-file edit is skipped. + const snapshot = createMockNotebook({ + uri: SNAPSHOT_URI, + metadata: { deepnoteProjectId: CURRENT_PROJECT_ID, deepnoteNotebookId: CURRENT_NOTEBOOK_ID } + }); + when(mockedVSCodeNamespaces.workspace.notebookDocuments).thenReturn([snapshot]); + focus(snapshot); + + await manageIntegrations?.(); + + verify(webviewProvider.show(anything(), anything(), anything(), anything(), anything())).never(); + verify(mockedVSCodeNamespaces.window.showErrorMessage(Integrations.snapshotIntegrationsUnsupported)).once(); + }); + test('opens the panel for the focused notebook when no URI is given', async () => { when(mockedVSCodeNamespaces.workspace.notebookDocuments).thenReturn([currentNotebook]); focus(currentNotebook); diff --git a/src/platform/common/utils/localize.ts b/src/platform/common/utils/localize.ts index a56f43cd6f..6ce22bf72b 100644 --- a/src/platform/common/utils/localize.ts +++ b/src/platform/common/utils/localize.ts @@ -865,8 +865,8 @@ export namespace Integrations { export const addNewIntegration = l10n.t('Add New Integration'); export const addExistingIntegration = l10n.t('Add Existing Integration'); export const addExistingIntegrationScanning = l10n.t('Looking for integrations in other projects...'); - export const addExistingIntegrationSnapshotUnsupported = l10n.t( - 'Integrations cannot be added to a snapshot file. Open the project notebook and try again.' + export const snapshotIntegrationsUnsupported = l10n.t( + 'Integrations cannot be managed from a snapshot file. Open the project notebook and try again.' ); export const switchIntegrationSnapshotUnsupported = l10n.t( 'The integration of a snapshot cannot be changed. Open the project notebook and try again.' From 3393f04cb5023576cb1393747cc42e4fc1b14d10 Mon Sep 17 00:00:00 2001 From: tomas Date: Thu, 24 Sep 2026 13:37:17 +0000 Subject: [PATCH 32/35] fix(integrations): refresh an open panel after a link instead of opening one After a successful link, `refreshAfterProjectIntegrationsChange` called `showIntegrationsUI`, which reaches `IIntegrationWebviewProvider.show`. `show` creates and focuses a panel when none is open. So running "Deepnote: Add Existing Integration" from the command palette (it has no menu entry) ended by opening the Integrations tab over the notebook and taking focus. The re-show only guarded against a stale panel writing its list back, which cannot happen when no panel is open. It no longer happens when one is, either: panel edits now touch only their own entry. `IIntegrationWebviewProvider` gains `refresh(projectId, integrations)`, which re-renders a panel already open for that project without revealing it, and does nothing otherwise. The manager detects the project's integrations and calls it; the kernel env refresh still comes after it, so a busy kernel cannot hold the panel back. The panel's "Add Existing Integration" button, which runs the same command with the panel open, now gets its list refreshed in place. The E2E suite drives exactly this palette path. It now watches the editor tabs for 3 s after the success message (the old command opened the panel within a few hundred ms of it). It asserts the Integrations tab never appeared, that the target notebook's tab was seen (so an unreadable tab strip cannot pass the check vacuously), and that the notebook is still the active editor. The spec's Add Existing step 4 is updated. Verified: - The manager tests now expect `refresh` for this project and `show` never. Both refresh tests failed before the manager change (refresh called 0 times, since `show` ran instead) and pass now. - New provider tests: `refresh` opens no panel, re-renders an open panel of the project without revealing it, and leaves another project's panel alone. Adding a `reveal()` or dropping the project check fails them. - Integrations and SQL status bar suites: 198 passing. `npm run typecheck` and `npm run compile-e2e` exit 0; oxlint and prettier clean. - The E2E is compiled, not run: this workspace has no display or Electron libraries. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DiYErQ9fP3wNhrNaRZAdqv --- specs/INTEGRATIONS_CREDENTIALS.md | 7 +-- .../integrations/integrationManager.ts | 14 ++++-- .../integrationManager.unit.test.ts | 19 ++++---- .../integrations/integrationWebview.ts | 12 ++++- .../integrationWebview.unit.test.ts | 46 ++++++++++++++++++ src/notebooks/deepnote/integrations/types.ts | 6 +++ .../addExistingIntegration.e2e.test.ts | 48 ++++++++++++++++++- 7 files changed, 135 insertions(+), 17 deletions(-) diff --git a/specs/INTEGRATIONS_CREDENTIALS.md b/specs/INTEGRATIONS_CREDENTIALS.md index dea4ef1d24..b46c57852f 100644 --- a/specs/INTEGRATIONS_CREDENTIALS.md +++ b/specs/INTEGRATIONS_CREDENTIALS.md @@ -449,9 +449,10 @@ webview, implemented in `existingIntegrationPicker.ts`): files" below). Nothing is copied in SecretStorage: configs (and federated refresh tokens) are keyed by integration id alone, and the roster entry is what scopes an integration to a project, so the linked project resolves the same credentials. -4. Re-shows the panel, then re-runs the integration env refresh in the project's running kernels, since no storage - change event fires for a roster-only edit. The panel goes first: a busy kernel answers the refresh only after its - running cell. +4. Refreshes the integrations panel when one is open for this project, without revealing it; run from the command + palette, the command never opens one. Then re-runs the integration env refresh in the project's running kernels, + since no storage change event fires for a roster-only edit. The panel goes first: a busy kernel answers the refresh + only after its running cell. The panel's Delete only takes a linked integration off the active project's roster, and keeps its credentials, while another project in the open workspace folders still declares the id. No storage change event fires then either, so the diff --git a/src/notebooks/deepnote/integrations/integrationManager.ts b/src/notebooks/deepnote/integrations/integrationManager.ts index 71a32ba525..a449f2f476 100644 --- a/src/notebooks/deepnote/integrations/integrationManager.ts +++ b/src/notebooks/deepnote/integrations/integrationManager.ts @@ -206,7 +206,7 @@ export class IntegrationManager implements IIntegrationManager { // Storage did not change, so the storage-change listeners that normally refresh kernels and the // panel after a save stay silent; do both explicitly for this project. - await this.refreshAfterProjectIntegrationsChange(projectId, activeNotebook); + await this.refreshAfterProjectIntegrationsChange(projectId, notebookId, activeNotebook); } else { void window.showErrorMessage(localize.Integrations.addExistingIntegrationFailed); } @@ -241,11 +241,19 @@ export class IntegrationManager implements IIntegrationManager { private async refreshAfterProjectIntegrationsChange( projectId: string, + notebookId: string, activeNotebook: NotebookDocument ): Promise { - // Panel first: a busy kernel holds the refresh behind its running cell. + // Panel first: a busy kernel holds the refresh behind its running cell. Only a panel already open for this + // project is refreshed: run from the palette, the command must not open one and take focus from the notebook. try { - await this.showIntegrationsUI(undefined, activeNotebook.uri.toString()); + const integrations = await this.integrationDetector.detectIntegrations({ + notebookId, + notebookUri: activeNotebook.uri, + projectId + }); + + await this.webviewProvider.refresh(projectId, integrations); } catch (error) { logger.error('IntegrationManager: failed to refresh the integrations panel', error); } diff --git a/src/notebooks/deepnote/integrations/integrationManager.unit.test.ts b/src/notebooks/deepnote/integrations/integrationManager.unit.test.ts index 708bc3d003..1bce5fb4bd 100644 --- a/src/notebooks/deepnote/integrations/integrationManager.unit.test.ts +++ b/src/notebooks/deepnote/integrations/integrationManager.unit.test.ts @@ -166,6 +166,7 @@ suite('IntegrationManager.addExistingIntegration', () => { detector = mockDetector; webviewProvider = mock(); + when(webviewProvider.refresh(anything(), anything())).thenResolve(); when(webviewProvider.show(anything(), anything(), anything(), anything(), anything())).thenResolve(); const mockManager = mock(); @@ -212,7 +213,7 @@ suite('IntegrationManager.addExistingIntegration', () => { ); } - test("links the picked integration, refreshes only this project's kernels and re-shows the panel", async () => { + test("links the picked integration, refreshes only this project's kernels and refreshes an open panel", async () => { const outcome = await buildManager().addExistingIntegration(CURRENT_URI.toString()); assert.strictEqual(outcome, 'completed'); @@ -227,7 +228,9 @@ suite('IntegrationManager.addExistingIntegration', () => { assert.isTrue(refreshSpy.calledOnce); assert.deepStrictEqual(refreshSpy.firstCall.args, [[currentNotebook], 'integration_config']); - verify(webviewProvider.show(CURRENT_PROJECT_ID, anything(), anything(), anything(), anything())).once(); + verify(webviewProvider.refresh(CURRENT_PROJECT_ID, anything())).once(); + // Catches: the command opening, or re-targeting, the panel when run from the palette. + verify(webviewProvider.show(anything(), anything(), anything(), anything(), anything())).never(); verify( telemetry.trackEvent( @@ -244,7 +247,7 @@ suite('IntegrationManager.addExistingIntegration', () => { assert.strictEqual(quickPickItems?.[0].detail, `Used in: ${OTHER_PROJECT_ID}`); }); - test('re-shows the panel before the kernel env refresh settles', async () => { + test('refreshes an open panel before the kernel env refresh settles', async () => { // Catches: a busy kernel leaving the panel on the pre-link list until its running cell finishes. const refreshGate = createDeferred(); @@ -255,7 +258,7 @@ suite('IntegrationManager.addExistingIntegration', () => { try { await waitForCondition(() => refreshSpy.calledOnce, REFRESH_START_TIMEOUT_MS, 'the refresh never started'); - verify(webviewProvider.show(CURRENT_PROJECT_ID, anything(), anything(), anything(), anything())).once(); + verify(webviewProvider.refresh(CURRENT_PROJECT_ID, anything())).once(); } finally { refreshGate.resolve(); } @@ -263,11 +266,9 @@ suite('IntegrationManager.addExistingIntegration', () => { assert.strictEqual(await command, 'completed'); }); - test('still refreshes the kernels when re-showing the panel fails', async () => { - // Catches: the kernel env refresh depending on the panel re-show succeeding. - when(webviewProvider.show(anything(), anything(), anything(), anything(), anything())).thenReject( - new Error('panel could not be shown') - ); + test('still refreshes the kernels when refreshing the panel fails', async () => { + // Catches: the kernel env refresh depending on the panel refresh succeeding. + when(webviewProvider.refresh(anything(), anything())).thenReject(new Error('panel could not be refreshed')); const outcome = await buildManager().addExistingIntegration(CURRENT_URI.toString()); diff --git a/src/notebooks/deepnote/integrations/integrationWebview.ts b/src/notebooks/deepnote/integrations/integrationWebview.ts index 9d97267b30..9b48b17a15 100644 --- a/src/notebooks/deepnote/integrations/integrationWebview.ts +++ b/src/notebooks/deepnote/integrations/integrationWebview.ts @@ -78,6 +78,16 @@ export class IntegrationWebviewProvider implements IIntegrationWebviewProvider { } } + public async refresh(projectId: string, integrations: Map): Promise { + if (!this.currentPanel || this.projectId !== projectId) { + return; + } + + this.integrations = integrations; + + await this.updateWebview(); + } + /** * Show the integration management webview * @param projectId The Deepnote project ID @@ -743,7 +753,7 @@ export class IntegrationWebviewProvider implements IIntegrationWebviewProvider { } break; case 'addExisting': - // The command owns the picker and the integrations write, and re-shows this panel when it is done. + // The command owns the picker and the integrations write, and refreshes this panel when it is done. try { await commands.executeCommand(Commands.AddExistingIntegration, { notebookUri: this.activeFileUri?.toString() diff --git a/src/notebooks/deepnote/integrations/integrationWebview.unit.test.ts b/src/notebooks/deepnote/integrations/integrationWebview.unit.test.ts index a7e0859c65..57a6e776e2 100644 --- a/src/notebooks/deepnote/integrations/integrationWebview.unit.test.ts +++ b/src/notebooks/deepnote/integrations/integrationWebview.unit.test.ts @@ -593,6 +593,52 @@ suite('IntegrationWebviewProvider', () => { ); }); + suite('refresh', () => { + const PG_CONFIG = buildPostgresIntegration({ id: 'pg-1', name: 'Team Postgres' }); + const LINKED_CONFIG = buildPostgresIntegration({ id: 'pg-linked', name: 'Linked Postgres' }); + + function linkedMap(): Map { + return new Map([ + ...singleIntegrationMap(PG_CONFIG.id, PG_CONFIG), + ...singleIntegrationMap(LINKED_CONFIG.id, LINKED_CONFIG) + ]); + } + + test('opens no panel when none is open', async () => { + // Catches: a refresh after a command-palette run opening the panel and taking focus from the notebook. + await buildProvider().refresh(PROJECT_ID, linkedMap()); + + verify( + mockedVSCodeNamespaces.window.createWebviewPanel(anything(), anything(), anything(), anything()) + ).never(); + }); + + test('re-renders an open panel of the project with the new list, without revealing it', async () => { + const provider = buildProvider(); + await show(provider, singleIntegrationMap(PG_CONFIG.id, PG_CONFIG)); + const reveal = sinon.spy(); + fakePanel.panel.reveal = reveal; + + await provider.refresh(PROJECT_ID, linkedMap()); + + assert.deepStrictEqual(lastUpdate().integrations?.map((integration) => integration.id), [ + 'pg-1', + 'pg-linked' + ]); + sinon.assert.notCalled(reveal); + }); + + test('leaves a panel that shows another project alone', async () => { + const provider = buildProvider(); + await show(provider, singleIntegrationMap(PG_CONFIG.id, PG_CONFIG)); + const postedBefore = fakePanel.posted.length; + + await provider.refresh('project-other', linkedMap()); + + assert.strictEqual(fakePanel.posted.length, postedBefore); + }); + }); + suite('handleMessage: "authenticate" telemetry outcome', () => { async function authenticate(commandResult: Promise): Promise { when(mockedVSCodeNamespaces.commands.executeCommand(anyString(), anything(), anything())).thenReturn( diff --git a/src/notebooks/deepnote/integrations/types.ts b/src/notebooks/deepnote/integrations/types.ts index 1065b35f57..9a82bc1fae 100644 --- a/src/notebooks/deepnote/integrations/types.ts +++ b/src/notebooks/deepnote/integrations/types.ts @@ -27,6 +27,12 @@ export interface IIntegrationDetector { export const IIntegrationWebviewProvider = Symbol('IIntegrationWebviewProvider'); export interface IIntegrationWebviewProvider { + /** + * Re-renders an open panel that shows `projectId` with `integrations`, without revealing it; does nothing when no + * panel is open or it shows another project. + */ + refresh(projectId: string, integrations: Map): Promise; + /** * Show the integration management webview * @param projectId The Deepnote project ID diff --git a/test/e2e/suite/workspace/addExistingIntegration.e2e.test.ts b/test/e2e/suite/workspace/addExistingIntegration.e2e.test.ts index 2881ce5818..b72a6dc092 100644 --- a/test/e2e/suite/workspace/addExistingIntegration.e2e.test.ts +++ b/test/e2e/suite/workspace/addExistingIntegration.e2e.test.ts @@ -44,6 +44,7 @@ const INTEGRATION_PASSWORD = 'e2e-reuse-password'; const MANAGE_INTEGRATIONS = 'Deepnote: Manage Integrations'; const ADD_EXISTING_INTEGRATION = 'Deepnote: Add Existing Integration'; const PICKER_PLACEHOLDER = 'Select an integration configured in another project of this workspace'; +const INTEGRATIONS_PANEL_TITLE = 'Deepnote Integrations'; // Prior editors finish closing before the next notebook opens. const EDITORS_CLOSE_DELAY = 500; @@ -60,6 +61,10 @@ const WEBVIEW_POLL_INTERVAL = 1_000; // Saving writes SecretStorage and every `.deepnote` file of the project; poll the file for the result. const FILE_WRITE_TIMEOUT = 30_000; const FILE_POLL_INTERVAL = 500; +// How long the editors are watched for a panel the palette run must not open. The command used to open it within a +// few hundred milliseconds of its success message; every passing run spends the whole window. +const PANEL_STAYS_CLOSED_WINDOW = 3_000; +const EDITOR_POLL_INTERVAL = 500; type DeclaredIntegration = { id: string; name?: string; type: string }; @@ -182,6 +187,29 @@ async function configurePostgresIntegration(): Promise { }); } +/** + * Every editor title seen over `windowMs`. Absence needs a window rather than one read: the panel used to open a beat + * after the command's success message. A failed read sees nothing, so callers also require a title they expect. + */ +async function watchOpenEditorTitles(windowMs: number): Promise { + const driver = VSBrowser.instance.driver; + const deadline = Date.now() + windowMs; + const seen = new Set(); + + while (Date.now() < deadline) { + const titles = await new EditorView().getOpenEditorTitles().catch((error) => { + console.warn('[add-existing] read editor titles:', error); + + return []; + }); + + titles.forEach((title) => seen.add(title)); + await driver.sleep(EDITOR_POLL_INTERVAL); + } + + return Array.from(seen); +} + /** Polls `filePath` until its project declares an integration named `name`, and returns that entry's id. */ async function awaitDeclaredIntegrationId(filePath: string, name: string): Promise { const driver = VSBrowser.instance.driver; @@ -209,7 +237,9 @@ async function awaitDeclaredIntegrationId(filePath: string, name: string): Promi describe('Deepnote — adding an integration another project already configured', function () { this.timeout(SUITE_TIMEOUT); + let activeEditorAfterLink: string | undefined; let cleanupTempDir: (() => void) | undefined; + let editorTitlesAfterLink: string[] = []; let pickedDescription: string | undefined; let pickedRowText = ''; let sharedIntegrationId = ''; @@ -289,6 +319,14 @@ describe('Deepnote — adding an integration another project already configured' ); await screenshot('linked-into-this-project'); + // No panel is open (focusing the target closed every editor), so a palette run must leave it that way. + editorTitlesAfterLink = await watchOpenEditorTitles(PANEL_STAYS_CLOSED_WINDOW); + activeEditorAfterLink = await new EditorView() + .getActiveTab() + .then((tab) => tab?.getTitle()) + .catch(() => undefined); + await screenshot('panel-stays-closed'); + targetIntegrations = readDeclaredIntegrations(targetFilePath); targetFileContents = fs.readFileSync(targetFilePath, 'utf8'); }); @@ -305,7 +343,7 @@ describe('Deepnote — adding an integration another project already configured' // Deliberately one test: every expectation reads state the `before` hook already captured, so // splitting them buys separate mocha records and nothing else. - it('offers only what the other project configured, and links it without its credentials', function () { + it('offers only what the other project configured, and links it without its credentials or the panel', function () { // offers the integration the other project configured, and names that project expect(pickedDescription, 'quick pick description').to.equal(INTEGRATION_TYPE_LABEL); expect(pickedRowText, 'quick pick row').to.contain(`Used in: ${SOURCE_PROJECT_NAME}`); @@ -319,5 +357,13 @@ describe('Deepnote — adding an integration another project already configured' // links the integration without copying its credentials into the file expect(targetFileContents, 'target project file').to.not.contain(INTEGRATION_HOST); expect(targetFileContents, 'target project file').to.not.contain(INTEGRATION_PASSWORD); + + // leaves the integrations panel closed and the notebook focused when run from the palette + expect( + editorTitlesAfterLink.some((title) => title.includes(TARGET_FILE)), + `the target notebook among the editors seen after the link: ${JSON.stringify(editorTitlesAfterLink)}` + ).to.equal(true); + expect(editorTitlesAfterLink, 'editors seen after the link').to.not.include(INTEGRATIONS_PANEL_TITLE); + expect(activeEditorAfterLink, 'active editor after the link').to.contain(TARGET_FILE); }); }); From 18a6f510a081e1f03fa39ab9fe42530ed25f38f4 Mon Sep 17 00:00:00 2001 From: tomas Date: Thu, 24 Sep 2026 13:39:51 +0000 Subject: [PATCH 33/35] fix(integrations): send the panel's loc strings whenever its webview starts The type grid and the "Add Existing Integration" button render only after `loc_init`, which the extension posted exactly once, right after setting the panel's HTML. A message posted before the React app attaches its listener is lost. That happens on "Developer: Reload Webviews", after moving the tab to another window, and plausibly when the 1.6 MB bundle takes longer than VS Code's 200 ms inner-frame fallback. The panel was then left with no way to add or link an integration until it was closed and reopened. As `webviewHost.ts` does for the other webviews, the panel now posts `started` from the effect that attaches its message listener, so once per mount. The extension answers with the loc strings and then the list. The one-off `loc_init` at panel creation is gone, so the loc strings have one trigger. `show()` still posts the list straight away, as before. Verified: - New host test: a `started` message is answered with `loc_init` then `update`. Before the change it was ignored and nothing was posted. - Integrations suites: 132 passing. `npm run typecheck` exit 0; oxlint and prettier clean. `npm run esbuild-all` exit 0, and the rebuilt integrations bundle posts `started`. - The webview side has no component-test setup in this repo, so its mount behaviour is checked only by the E2E suites that wait for the type grid (not run here). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DiYErQ9fP3wNhrNaRZAdqv --- specs/INTEGRATIONS_CREDENTIALS.md | 3 +++ .../deepnote/integrations/integrationWebview.ts | 7 ++++++- .../integrations/integrationWebview.unit.test.ts | 15 +++++++++++++++ .../integrations/IntegrationPanel.tsx | 5 +++++ src/webviews/webview-side/integrations/types.ts | 1 + 5 files changed, 30 insertions(+), 1 deletion(-) diff --git a/specs/INTEGRATIONS_CREDENTIALS.md b/specs/INTEGRATIONS_CREDENTIALS.md index b46c57852f..0693f22c8e 100644 --- a/specs/INTEGRATIONS_CREDENTIALS.md +++ b/specs/INTEGRATIONS_CREDENTIALS.md @@ -517,6 +517,9 @@ Extension → Webview: Webview → Extension: ```typescript +// Posted on every mount, once the listener is attached; answered with the loc strings, then the list +{ type: 'started' } + // Save configuration { type: 'save', integrationId: string, config: IntegrationConfig } diff --git a/src/notebooks/deepnote/integrations/integrationWebview.ts b/src/notebooks/deepnote/integrations/integrationWebview.ts index 9b48b17a15..e103537a0c 100644 --- a/src/notebooks/deepnote/integrations/integrationWebview.ts +++ b/src/notebooks/deepnote/integrations/integrationWebview.ts @@ -160,7 +160,6 @@ export class IntegrationWebviewProvider implements IIntegrationWebviewProvider { this.disposables ); - await this.sendLocStrings(); await this.updateWebview(); // If a specific integration was requested, show its configuration form @@ -701,6 +700,12 @@ export class IntegrationWebviewProvider implements IIntegrationWebviewProvider { config?: ConfigurableDatabaseIntegrationConfig; }): Promise { switch (message.type) { + // Posted by the webview once its listener is attached, on every mount: anything sent before that is lost, + // including after a reload. + case SharedMessages.Started: + await this.sendLocStrings(); + await this.updateWebview(); + break; case 'configure': if (message.integrationId) { await this.showConfigurationForm(message.integrationId); diff --git a/src/notebooks/deepnote/integrations/integrationWebview.unit.test.ts b/src/notebooks/deepnote/integrations/integrationWebview.unit.test.ts index 57a6e776e2..d977610d72 100644 --- a/src/notebooks/deepnote/integrations/integrationWebview.unit.test.ts +++ b/src/notebooks/deepnote/integrations/integrationWebview.unit.test.ts @@ -593,6 +593,21 @@ suite('IntegrationWebviewProvider', () => { ); }); + test('answers a webview that starts up, or reloads, with the loc strings and then the list', async () => { + // Catches: loc strings posted only once, at creation, which a reloaded webview, or one whose listener attached + // late, never receives, leaving it without the Add New and Add Existing controls. + const provider = buildProvider(); + await show(provider, singleIntegrationMap('pg-1', buildPostgresIntegration({ id: 'pg-1' }))); + fakePanel.posted.length = 0; + + await fakePanel.onDidReceiveMessage({ type: 'started' }); + + assert.deepStrictEqual( + fakePanel.posted.map((message) => message.type), + ['loc_init', 'update'] + ); + }); + suite('refresh', () => { const PG_CONFIG = buildPostgresIntegration({ id: 'pg-1', name: 'Team Postgres' }); const LINKED_CONFIG = buildPostgresIntegration({ id: 'pg-linked', name: 'Linked Postgres' }); diff --git a/src/webviews/webview-side/integrations/IntegrationPanel.tsx b/src/webviews/webview-side/integrations/IntegrationPanel.tsx index 3a875c84fe..2dbbcfec69 100644 --- a/src/webviews/webview-side/integrations/IntegrationPanel.tsx +++ b/src/webviews/webview-side/integrations/IntegrationPanel.tsx @@ -100,6 +100,11 @@ export const IntegrationPanel: React.FC = ({ baseTheme, }; window.addEventListener('message', handleMessage); + + // The extension answers with the loc strings and the list, which it cannot deliver before this listener exists. + const started: WebviewOutboundMessage = { type: 'started' }; + vscodeApi.postMessage(started); + return () => window.removeEventListener('message', handleMessage); }, []); diff --git a/src/webviews/webview-side/integrations/types.ts b/src/webviews/webview-side/integrations/types.ts index 31978089f6..ca10b87c2a 100644 --- a/src/webviews/webview-side/integrations/types.ts +++ b/src/webviews/webview-side/integrations/types.ts @@ -57,6 +57,7 @@ export interface AuthenticateMessage { // Outbound (webview -> extension); dispatched in `integrationWebview.ts:handleMessage`. Keep exhaustive. export type WebviewOutboundMessage = + | { type: 'started' } | { type: 'configure'; integrationId: string } | { type: 'save'; integrationId: string; config: ConfigurableDatabaseIntegrationConfig } | { type: 'reset'; integrationId: string } From 168959b495f4fba7f76e7e8bf72f1804ebd96c59 Mon Sep 17 00:00:00 2001 From: tomas Date: Thu, 24 Sep 2026 13:40:25 +0000 Subject: [PATCH 34/35] test(e2e): treat a torn read as not yet in the declared-integration poll `awaitDeclaredIntegrationId` read and parsed the project file every 500 ms with no try/catch, while the extension rewrites that file in place (`workspace.fs.writeFile` is not atomic, and a flush save may write it first). A tick that landed mid-write threw out of the `before` hook. Mocha's `retries: 1` retries tests, not hooks, so one torn read failed the whole suite with a parse error unrelated to the feature. A read that fails now counts as "not written yet"; the poll carries on until the deadline, and the timeout message includes the last read error if there was one. Verified: - `npm run compile-e2e` exit 0; prettier clean. - The failure it guards against, reproduced on the suite's own fixture: `deserializeDeepnoteFile` throws on an empty file ("Expected object, received null") and on the first half of quick-notes.deepnote ("Invalid discriminator value"), and parses the whole file. - Not run: this workspace has no display or Electron libraries for ExTester. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DiYErQ9fP3wNhrNaRZAdqv --- .../workspace/addExistingIntegration.e2e.test.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/test/e2e/suite/workspace/addExistingIntegration.e2e.test.ts b/test/e2e/suite/workspace/addExistingIntegration.e2e.test.ts index b72a6dc092..7ed7bc3042 100644 --- a/test/e2e/suite/workspace/addExistingIntegration.e2e.test.ts +++ b/test/e2e/suite/workspace/addExistingIntegration.e2e.test.ts @@ -215,9 +215,16 @@ async function awaitDeclaredIntegrationId(filePath: string, name: string): Promi const driver = VSBrowser.instance.driver; const deadline = Date.now() + FILE_WRITE_TIMEOUT; let declared: DeclaredIntegration[] = []; + let lastReadError: unknown; while (Date.now() < deadline) { - declared = readDeclaredIntegrations(filePath); + // The extension rewrites the file in place, so a poll can land mid-write and parse a torn file: not yet. + try { + declared = readDeclaredIntegrations(filePath); + lastReadError = undefined; + } catch (error) { + lastReadError = error; + } const match = declared.find((entry) => entry.name === name); @@ -230,7 +237,8 @@ async function awaitDeclaredIntegrationId(filePath: string, name: string): Promi throw new Error( `${filePath} never declared an integration named "${name}" within ${FILE_WRITE_TIMEOUT}ms; ` + - `last saw ${JSON.stringify(declared)}` + `last saw ${JSON.stringify(declared)}` + + (lastReadError ? `, and the last read failed: ${lastReadError}` : '') ); } From f801259145b4e9cf6ffa6840ac887a35a7ad0e0b Mon Sep 17 00:00:00 2001 From: tomas Date: Thu, 24 Sep 2026 17:19:59 +0000 Subject: [PATCH 35/35] test(integrations): share one project-file fake across the integration suites Five suites each carried their own copy of the same `workspace.fs` fake: serve `.deepnote` files by `fsPath`, reject any other read, and parse every write into a map. The writer suite's `stubWorkspace` (#429) was the original; this branch added four more copies: the picker's `stubWorkspace`, the manager suite's `setup()`, `stubProjectFileOnDisk` and `stubProjectFiles`. `stubDeepnoteFiles` in `src/test/mocks/vscodeFs.ts` now holds that core. It is built on `stubReadFile`, which now rejects a URI its resolver returns `undefined` for (its two callers always return a string). The options cover what the copies added: `failWriteFor` (writer, manager), `onRead` (picker) and `onWrite` (writer). Each suite keeps its own `findFiles`, workspace-folder and open-notebook wiring, which is where they really differ. The manager suite's failed writes now reject with "write failed for" instead of "write blocked for"; no test reads the message. The helper uses `fsPath`, which `local-rules/dont-use-fspath` bans outside `.node.ts` and `.test.ts` files; like the three `src/test/datascience` helpers, the file disables the rule. Verified: - `npm test`: 2879 passing, 239 pending, as before. In the five suites and the `stubReadFile` callers, the same 175 tests pass, by title. - `npm run typecheck`, `lint` (the same 27 existing warnings as HEAD), `format` and `spell-check` exit 0. - The suites go through the helper: dropping the write capture fails 19 tests across all five suites; ignoring `failWriteFor` fails 3 writer and 2 manager tests; reading it only at stub time fails the 2 manager tests that add a failure after setup; dropping `onWrite` fails the writer's cache-ordering test; dropping `onRead` fails the picker's 2 mid-scan cancellation tests. - Not pinned by a test: serving an unknown path as an empty file instead of rejecting passes everything, because the code under test treats an unparsable file like an unreadable one. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DiYErQ9fP3wNhrNaRZAdqv --- .../existingIntegrationPicker.unit.test.ts | 29 +++------- .../integrationManager.unit.test.ts | 38 ++++--------- .../integrationWebview.unit.test.ts | 24 ++------ .../projectIntegrationsWriter.unit.test.ts | 35 ++++-------- .../sqlCellStatusBarProvider.unit.test.ts | 28 ++-------- src/test/mocks/vscodeFs.ts | 55 +++++++++++++++++-- 6 files changed, 88 insertions(+), 121 deletions(-) diff --git a/src/notebooks/deepnote/integrations/existingIntegrationPicker.unit.test.ts b/src/notebooks/deepnote/integrations/existingIntegrationPicker.unit.test.ts index 9124c8ffe2..2b20ceeffb 100644 --- a/src/notebooks/deepnote/integrations/existingIntegrationPicker.unit.test.ts +++ b/src/notebooks/deepnote/integrations/existingIntegrationPicker.unit.test.ts @@ -1,10 +1,11 @@ -import { deserializeDeepnoteFile, serializeDeepnoteFile, type DeepnoteFile } from '@deepnote/blocks'; +import type { DeepnoteFile } from '@deepnote/blocks'; import { assert, use } from 'chai'; import chaiAsPromised from 'chai-as-promised'; import { anything, instance, mock, when } from 'ts-mockito'; -import { CancellationError, CancellationToken, CancellationTokenSource, Uri, workspace } from 'vscode'; +import { CancellationError, CancellationToken, CancellationTokenSource, Uri } from 'vscode'; import { ConfigurableDatabaseIntegrationConfig } from '../../../platform/notebooks/deepnote/integrationTypes'; +import { stubDeepnoteFiles } from '../../../test/mocks/vscodeFs'; import { mockedVSCodeNamespaces, resetVSCodeMocks } from '../../../test/vscode-mock'; import { IDeepnoteNotebookManager, RawProjectIntegration } from '../../types'; import { @@ -76,26 +77,12 @@ function stubWorkspace(opts: { const byPath = new Map(opts.projects.map((project) => [project.uri.fsPath, projectFile(project)] as const)); const reads: string[] = []; - const writes = new Map(); - const mockFs = mock(); - - when(mockFs.readFile(anything())).thenCall((uri: Uri) => { - reads.push(uri.fsPath); - - const file = byPath.get(uri.fsPath); - - opts.onRead?.(uri); - - return file - ? Promise.resolve(new TextEncoder().encode(serializeDeepnoteFile(file))) - : Promise.reject(new Error(`no readFile stub for ${uri.fsPath}`)); - }); - when(mockFs.writeFile(anything(), anything())).thenCall((uri: Uri, bytes: Uint8Array) => { - writes.set(uri.fsPath, deserializeDeepnoteFile(new TextDecoder().decode(bytes))); - - return Promise.resolve(); + const writes = stubDeepnoteFiles((uri) => byPath.get(uri.fsPath), { + onRead: (uri) => { + reads.push(uri.fsPath); + opts.onRead?.(uri); + } }); - when(mockedVSCodeNamespaces.workspace.fs).thenReturn(instance(mockFs)); return { reads, writes }; } diff --git a/src/notebooks/deepnote/integrations/integrationManager.unit.test.ts b/src/notebooks/deepnote/integrations/integrationManager.unit.test.ts index 1bce5fb4bd..731203cdab 100644 --- a/src/notebooks/deepnote/integrations/integrationManager.unit.test.ts +++ b/src/notebooks/deepnote/integrations/integrationManager.unit.test.ts @@ -1,4 +1,4 @@ -import { deserializeDeepnoteFile, serializeDeepnoteFile, type DeepnoteFile } from '@deepnote/blocks'; +import type { DeepnoteFile } from '@deepnote/blocks'; import { assert } from 'chai'; import sinon from 'sinon'; import { anything, deepEqual, instance, mock, verify, when } from 'ts-mockito'; @@ -8,8 +8,7 @@ import { NotebookDocument, NotebookEditor, QuickPickItem, - Uri, - workspace + Uri } from 'vscode'; import { ITelemetryService } from '../../../platform/analytics/types'; @@ -19,6 +18,7 @@ import { createDeferred } from '../../../platform/common/utils/async'; import { Integrations } from '../../../platform/common/utils/localize'; import { ConfigurableDatabaseIntegrationConfig } from '../../../platform/notebooks/deepnote/integrationTypes'; import { waitForCondition } from '../../../test/common'; +import { stubDeepnoteFiles } from '../../../test/mocks/vscodeFs'; import { mockedVSCodeNamespaces, resetVSCodeMocks } from '../../../test/vscode-mock'; import { IDeepnoteNotebookManager, ProjectIntegration, RawProjectIntegration } from '../../types'; import { @@ -105,7 +105,6 @@ suite('IntegrationManager.addExistingIntegration', () => { onDiskCurrent = undefined; cacheUpdateError = undefined; storedConfigs = [SHARED_CONFIG]; - writes = new Map(); cacheUpdates = []; quickPickItems = undefined; scanProgress = new CancellationTokenSource(); @@ -131,28 +130,15 @@ suite('IntegrationManager.addExistingIntegration', () => { task({ report: () => undefined }, scanProgress.token) ); - const mockFs = mock(); - when(mockFs.readFile(anything())).thenCall((uri: Uri) => { - const file = new Map([ - [CURRENT_URI.fsPath, onDiskCurrent ?? currentProject], - [OTHER_URI.fsPath, onDiskOther], - [SIBLING_URI.fsPath, onDiskSibling] - ]).get(uri.fsPath); - - return file - ? Promise.resolve(new TextEncoder().encode(serializeDeepnoteFile(file))) - : Promise.reject(new Error(`no readFile stub for ${uri.fsPath}`)); - }); - when(mockFs.writeFile(anything(), anything())).thenCall((uri: Uri, bytes: Uint8Array) => { - if (writeFailures.has(uri.fsPath)) { - return Promise.reject(new Error(`write blocked for ${uri.fsPath}`)); - } - - writes.set(uri.fsPath, deserializeDeepnoteFile(new TextDecoder().decode(bytes))); - - return Promise.resolve(); - }); - when(mockedVSCodeNamespaces.workspace.fs).thenReturn(instance(mockFs)); + writes = stubDeepnoteFiles( + (uri) => + new Map([ + [CURRENT_URI.fsPath, onDiskCurrent ?? currentProject], + [OTHER_URI.fsPath, onDiskOther], + [SIBLING_URI.fsPath, onDiskSibling] + ]).get(uri.fsPath), + { failWriteFor: writeFailures } + ); // Picks the first offered item; tests that want a cancel override this. when(mockedVSCodeNamespaces.window.showQuickPick(anything(), anything())).thenCall((items: QuickPickItem[]) => { diff --git a/src/notebooks/deepnote/integrations/integrationWebview.unit.test.ts b/src/notebooks/deepnote/integrations/integrationWebview.unit.test.ts index d977610d72..53205f09dd 100644 --- a/src/notebooks/deepnote/integrations/integrationWebview.unit.test.ts +++ b/src/notebooks/deepnote/integrations/integrationWebview.unit.test.ts @@ -1,7 +1,7 @@ -import { deserializeDeepnoteFile, serializeDeepnoteFile, type DeepnoteFile } from '@deepnote/blocks'; +import type { DeepnoteFile } from '@deepnote/blocks'; import { assert } from 'chai'; import sinon from 'sinon'; -import { EventEmitter, Uri, workspace } from 'vscode'; +import { EventEmitter, Uri } from 'vscode'; import { anyString, anything, deepEqual, instance, mock, reset, resetCalls, verify, when } from 'ts-mockito'; import { ITelemetryService } from '../../../platform/analytics/types'; @@ -29,6 +29,7 @@ import { ConfigurableDatabaseIntegrationConfig, DetectedIntegration } from '../../../platform/notebooks/deepnote/integrationTypes'; +import { stubDeepnoteFiles } from '../../../test/mocks/vscodeFs'; import { mockedVSCodeNamespaces, resetVSCodeMocks } from '../../../test/vscode-mock'; import { buildGoogleOauthIntegration, @@ -249,7 +250,6 @@ suite('IntegrationWebviewProvider', () => { function stubProjectFiles(files: Array<{ file: DeepnoteFile; uri: Uri }>): Map { const onDisk = new Map(files.map(({ file, uri }) => [uri.fsPath, file] as const)); const discovered = files.map(({ uri }) => uri); - const writes = new Map(); when(mockedVSCodeNamespaces.workspace.workspaceFolders).thenReturn([createWorkspaceFolder(Uri.file('/ws'))]); when(mockedVSCodeNamespaces.workspace.findFiles(anything(), anything(), anything(), anything())).thenReturn( @@ -258,23 +258,7 @@ suite('IntegrationWebviewProvider', () => { // The writer enumerates without a token; the scan passes one. when(mockedVSCodeNamespaces.workspace.findFiles(anything())).thenReturn(Promise.resolve(discovered)); - const mockFs = mock(); - - when(mockFs.readFile(anything())).thenCall((uri: Uri) => { - const file = onDisk.get(uri.fsPath); - - return file - ? Promise.resolve(new TextEncoder().encode(serializeDeepnoteFile(file))) - : Promise.reject(new Error(`no readFile stub for ${uri.fsPath}`)); - }); - when(mockFs.writeFile(anything(), anything())).thenCall((uri: Uri, bytes: Uint8Array) => { - writes.set(uri.fsPath, deserializeDeepnoteFile(new TextDecoder().decode(bytes))); - - return Promise.resolve(); - }); - when(mockedVSCodeNamespaces.workspace.fs).thenReturn(instance(mockFs)); - - return writes; + return stubDeepnoteFiles((uri) => onDisk.get(uri.fsPath)); } function successMessages(): CapturedMessage[] { diff --git a/src/notebooks/deepnote/integrations/projectIntegrationsWriter.unit.test.ts b/src/notebooks/deepnote/integrations/projectIntegrationsWriter.unit.test.ts index 17ba5d15e5..672cff748f 100644 --- a/src/notebooks/deepnote/integrations/projectIntegrationsWriter.unit.test.ts +++ b/src/notebooks/deepnote/integrations/projectIntegrationsWriter.unit.test.ts @@ -1,8 +1,9 @@ -import { deserializeDeepnoteFile, serializeDeepnoteFile, type DeepnoteFile } from '@deepnote/blocks'; +import type { DeepnoteFile } from '@deepnote/blocks'; import { assert } from 'chai'; -import { anything, instance, mock, when } from 'ts-mockito'; -import { Uri, workspace, type NotebookDocument } from 'vscode'; +import { anything, when } from 'ts-mockito'; +import { Uri, type NotebookDocument } from 'vscode'; +import { stubDeepnoteFiles } from '../../../test/mocks/vscodeFs'; import { mockedVSCodeNamespaces, resetVSCodeMocks } from '../../../test/vscode-mock'; import { ProjectIntegration, RawProjectIntegration } from '../../types'; import { DeepnoteNotebookManager } from '../deepnoteNotebookManager'; @@ -61,7 +62,6 @@ function stubWorkspace(opts: { when(mockedVSCodeNamespaces.workspace.findFiles(anything())).thenReturn(Promise.resolve(opts.discovered)); const byPath = new Map(opts.onDisk.map((entry) => [entry.uri.fsPath, entry.file] as const)); - const writes = new Map(); const documents = (opts.openNotebooks ?? []).map(({ notebookId, onSave, uri }): NotebookDocument => { const notebook = createMockNotebook({ @@ -83,28 +83,13 @@ function stubWorkspace(opts: { }); when(mockedVSCodeNamespaces.workspace.notebookDocuments).thenReturn(documents); - const mockFs = mock(); - when(mockFs.readFile(anything())).thenCall((uri: Uri) => { - const file = byPath.get(uri.fsPath); - - return file - ? Promise.resolve(new TextEncoder().encode(serializeDeepnoteFile(file))) - : Promise.reject(new Error(`no readFile stub for ${uri.fsPath}`)); - }); - when(mockFs.writeFile(anything(), anything())).thenCall((uri: Uri, bytes: Uint8Array) => { - if (opts.failWriteFor?.has(uri.fsPath)) { - return Promise.reject(new Error(`write failed for ${uri.fsPath}`)); - } - - opts.onWrite?.(uri); - writes.set(uri.fsPath, deserializeDeepnoteFile(new TextDecoder().decode(bytes))); - - return Promise.resolve(); - }); - when(mockedVSCodeNamespaces.workspace.fs).thenReturn(instance(mockFs)); - - return { writes }; + return { + writes: stubDeepnoteFiles((uri) => byPath.get(uri.fsPath), { + failWriteFor: opts.failWriteFor, + onWrite: opts.onWrite + }) + }; } suite('addProjectIntegration', () => { diff --git a/src/notebooks/deepnote/sqlCellStatusBarProvider.unit.test.ts b/src/notebooks/deepnote/sqlCellStatusBarProvider.unit.test.ts index 2d4cdfdfec..147386fc67 100644 --- a/src/notebooks/deepnote/sqlCellStatusBarProvider.unit.test.ts +++ b/src/notebooks/deepnote/sqlCellStatusBarProvider.unit.test.ts @@ -1,21 +1,14 @@ -import { deserializeDeepnoteFile, serializeDeepnoteFile, type DeepnoteFile } from '@deepnote/blocks'; +import type { DeepnoteFile } from '@deepnote/blocks'; import { assert } from 'chai'; import { anything, capture, deepEqual, instance, mock, verify, when } from 'ts-mockito'; -import { - CancellationToken, - CancellationTokenSource, - EventEmitter, - NotebookCell, - NotebookDocument, - Uri, - workspace -} from 'vscode'; +import { CancellationToken, CancellationTokenSource, EventEmitter, NotebookCell, NotebookDocument, Uri } from 'vscode'; import { IDisposableRegistry } from '../../platform/common/types'; import { IIntegrationStorage } from './integrations/types'; import { ITelemetryService } from '../../platform/analytics/types'; import { SqlCellStatusBarProvider } from './sqlCellStatusBarProvider'; import { DATAFRAME_SQL_INTEGRATION_ID } from '../../platform/notebooks/deepnote/integrationTypes'; +import { stubDeepnoteFiles } from '../../test/mocks/vscodeFs'; import { mockedVSCodeNamespaces, resetVSCodeMocks } from '../../test/vscode-mock'; import { createEventHandler } from '../../test/common'; import { Commands } from '../../platform/common/constants'; @@ -30,24 +23,11 @@ import { Integrations } from '../../platform/common/utils/localize'; */ function stubProjectFileOnDisk(notebook: NotebookDocument, projectId: string): Map { const onDisk = createDeepnoteFile({ project: createDeepnoteProject({ id: projectId, name: projectId }) }); - const writes = new Map(); - const mockFs = mock(); when(mockedVSCodeNamespaces.workspace.workspaceFolders).thenReturn(undefined); when(mockedVSCodeNamespaces.workspace.notebookDocuments).thenReturn([notebook]); - when(mockFs.readFile(anything())).thenCall((target: Uri) => - target.fsPath === notebook.uri.fsPath - ? Promise.resolve(new TextEncoder().encode(serializeDeepnoteFile(onDisk))) - : Promise.reject(new Error(`no readFile stub for ${target.fsPath}`)) - ); - when(mockFs.writeFile(anything(), anything())).thenCall((target: Uri, bytes: Uint8Array) => { - writes.set(target.fsPath, deserializeDeepnoteFile(new TextDecoder().decode(bytes))); - - return Promise.resolve(); - }); - when(mockedVSCodeNamespaces.workspace.fs).thenReturn(instance(mockFs)); - return writes; + return stubDeepnoteFiles((target) => (target.fsPath === notebook.uri.fsPath ? onDisk : undefined)); } /** diff --git a/src/test/mocks/vscodeFs.ts b/src/test/mocks/vscodeFs.ts index 4341b60f06..871965ccc2 100644 --- a/src/test/mocks/vscodeFs.ts +++ b/src/test/mocks/vscodeFs.ts @@ -1,3 +1,7 @@ +// These stubs serve Node unit tests, which key files by fsPath. +/* eslint-disable local-rules/dont-use-fspath */ + +import { deserializeDeepnoteFile, serializeDeepnoteFile, type DeepnoteFile } from '@deepnote/blocks'; import { anything, instance, mock, when } from 'ts-mockito'; import { Uri } from 'vscode'; @@ -5,19 +9,60 @@ import { mockedVSCodeNamespaces } from '../vscode-mock'; /** * Stubs `workspace.fs.readFile` to yield `contents` as UTF-8 bytes, and points the mocked - * `workspace.fs` namespace at it. Pass a function to serve different bytes per URI. + * `workspace.fs` namespace at it. Pass a function to serve different bytes per URI; a URI it + * returns `undefined` for rejects, as a missing file would. * * Returns the ts-mockito mock so callers can `verify(mockFs.readFile(anything()))`. * Call after `resetVSCodeMocks()` — the reset replaces the namespace mocks this stubs. */ -export function stubReadFile(contents: string | ((uri: Uri) => string)): typeof import('vscode').workspace.fs { +export function stubReadFile( + contents: string | ((uri: Uri) => string | undefined) +): typeof import('vscode').workspace.fs { const resolveContents = typeof contents === 'function' ? contents : () => contents; const mockFs = mock(); - when(mockFs.readFile(anything())).thenCall((uri: Uri) => - Promise.resolve(new TextEncoder().encode(resolveContents(uri))) - ); + when(mockFs.readFile(anything())).thenCall((uri: Uri) => { + const resolved = resolveContents(uri); + + return resolved === undefined + ? Promise.reject(new Error(`no readFile stub for ${uri.fsPath}`)) + : Promise.resolve(new TextEncoder().encode(resolved)); + }); when(mockedVSCodeNamespaces.workspace.fs).thenReturn(instance(mockFs)); return mockFs; } + +/** + * Serves `.deepnote` files through the mocked `workspace.fs`: a read yields `files(uri)` serialized, rejecting where + * it returns `undefined`, and each write lands in the returned map, parsed, by `fsPath`. Both `files` and + * `failWriteFor` are consulted on every call, so a test can change them after stubbing. + * + * Call after `resetVSCodeMocks()` — the reset replaces the namespace mocks this stubs. + */ +export function stubDeepnoteFiles( + files: (uri: Uri) => DeepnoteFile | undefined, + options: { failWriteFor?: ReadonlySet; onRead?: (uri: Uri) => void; onWrite?: (uri: Uri) => void } = {} +): Map { + const writes = new Map(); + const mockFs = stubReadFile((uri) => { + options.onRead?.(uri); + + const file = files(uri); + + return file ? serializeDeepnoteFile(file) : undefined; + }); + + when(mockFs.writeFile(anything(), anything())).thenCall((uri: Uri, bytes: Uint8Array) => { + if (options.failWriteFor?.has(uri.fsPath)) { + return Promise.reject(new Error(`write failed for ${uri.fsPath}`)); + } + + options.onWrite?.(uri); + writes.set(uri.fsPath, deserializeDeepnoteFile(new TextDecoder().decode(bytes))); + + return Promise.resolve(); + }); + + return writes; +}