From 87c5210ed0e44870263343129ec152b8866250e6 Mon Sep 17 00:00:00 2001 From: Lukas Saltenas Date: Fri, 31 Oct 2025 17:10:50 +0200 Subject: [PATCH 1/2] feat(open-in-deepnote): button in toolbar --- package.json | 5 + .../deepnote/openInDeepnoteHandler.node.ts | 59 ++- .../openInDeepnoteHandler.node.unit.test.ts | 377 +++++++++++++++++- 3 files changed, 394 insertions(+), 47 deletions(-) diff --git a/package.json b/package.json index e1cf46e97..4aa73d8f4 100644 --- a/package.json +++ b/package.json @@ -857,6 +857,11 @@ } ], "notebook/toolbar": [ + { + "command": "deepnote.openInDeepnote", + "group": "navigation@-1", + "when": "notebookType == 'deepnote'" + }, { "command": "deepnote.manageIntegrations", "group": "navigation@0", diff --git a/src/notebooks/deepnote/openInDeepnoteHandler.node.ts b/src/notebooks/deepnote/openInDeepnoteHandler.node.ts index b5c0e8a89..4a62c634d 100644 --- a/src/notebooks/deepnote/openInDeepnoteHandler.node.ts +++ b/src/notebooks/deepnote/openInDeepnoteHandler.node.ts @@ -11,69 +11,71 @@ import * as fs from 'fs'; import * as path from '../../platform/vscode-path/path'; import { initImport, uploadFile, getErrorMessage, MAX_FILE_SIZE, getDeepnoteDomain } from './importClient.node'; -/** - * Handler for the "Open in Deepnote" command - * Uploads .deepnote files to Deepnote and opens them in the web app - */ @injectable() export class OpenInDeepnoteHandler implements IExtensionSyncActivationService { constructor(@inject(IExtensionContext) private readonly extensionContext: IExtensionContext) {} - /** - * Activates the handler by registering the command - */ public activate(): void { this.extensionContext.subscriptions.push( commands.registerCommand(Commands.OpenInDeepnote, () => this.handleOpenInDeepnote()) ); } - /** - * Main handler for the Open in Deepnote command - */ private async handleOpenInDeepnote(): Promise { try { - // Get the active editor - const activeEditor = window.activeTextEditor; - if (!activeEditor) { - void window.showErrorMessage('Please open a .deepnote file first'); - return; + let fileUri: Uri | undefined; + let isNotebook = false; + + const activeNotebookEditor = window.activeNotebookEditor; + if (activeNotebookEditor) { + const notebook = activeNotebookEditor.notebook; + if (notebook.notebookType === 'deepnote') { + fileUri = notebook.uri.with({ query: '', fragment: '' }); + isNotebook = true; + } } - const fileUri = activeEditor.document.uri; + if (!fileUri) { + const activeEditor = window.activeTextEditor; + if (!activeEditor) { + void window.showErrorMessage('Please open a .deepnote file first'); + return; + } + + fileUri = activeEditor.document.uri; + } - // Validate that it's a .deepnote file if (!fileUri.fsPath.endsWith('.deepnote')) { void window.showErrorMessage('This command only works with .deepnote files'); return; } - // Ensure the file is saved - if (activeEditor.document.isDirty) { - const saved = await activeEditor.document.save(); - if (!saved) { - void window.showErrorMessage('Please save the file before opening in Deepnote'); - return; + if (isNotebook) { + await commands.executeCommand('workbench.action.files.save'); + } else { + const activeEditor = window.activeTextEditor; + if (activeEditor && activeEditor.document.isDirty) { + const saved = await activeEditor.document.save(); + if (!saved) { + void window.showErrorMessage('Please save the file before opening in Deepnote'); + return; + } } } - // Read the file const filePath = fileUri.fsPath; const fileName = path.basename(filePath); logger.info(`Opening in Deepnote: ${fileName}`); - // Check file size const stats = await fs.promises.stat(filePath); if (stats.size > MAX_FILE_SIZE) { void window.showErrorMessage(`File exceeds ${MAX_FILE_SIZE / (1024 * 1024)}MB limit`); return; } - // Read file into buffer const fileBuffer = await fs.promises.readFile(filePath); - // Show progress await window.withProgress( { location: { viewId: 'workbench.view.extension.deepnoteExplorer' }, @@ -82,14 +84,12 @@ export class OpenInDeepnoteHandler implements IExtensionSyncActivationService { }, async (progress) => { try { - // Step 1: Initialize import progress.report({ message: l10n.t('Preparing upload...') }); logger.debug(`Initializing import for ${fileName} (${stats.size} bytes)`); const initResponse = await initImport(fileName, stats.size); logger.debug(`Import initialized: ${initResponse.importId}`); - // Step 2: Upload file progress.report({ message: l10n.t('Uploading file...') }); await uploadFile(initResponse.uploadUrl, fileBuffer, (uploadProgress) => { progress.report({ @@ -98,7 +98,6 @@ export class OpenInDeepnoteHandler implements IExtensionSyncActivationService { }); logger.debug('File uploaded successfully'); - // Step 3: Open in browser progress.report({ message: l10n.t('Opening in Deepnote...') }); const domain = getDeepnoteDomain(); const deepnoteUrl = `https://${domain}/launch?importId=${initResponse.importId}`; diff --git a/src/notebooks/deepnote/openInDeepnoteHandler.node.unit.test.ts b/src/notebooks/deepnote/openInDeepnoteHandler.node.unit.test.ts index 1ee78190e..801daf7dc 100644 --- a/src/notebooks/deepnote/openInDeepnoteHandler.node.unit.test.ts +++ b/src/notebooks/deepnote/openInDeepnoteHandler.node.unit.test.ts @@ -1,26 +1,369 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. +import { assert } from 'chai'; +import * as sinon from 'sinon'; +import { instance, mock, when, anything } from 'ts-mockito'; +import { Uri, TextDocument, TextEditor, NotebookDocument, NotebookEditor } from 'vscode'; +import * as fs from 'fs'; -import * as assert from 'assert'; import { OpenInDeepnoteHandler } from './openInDeepnoteHandler.node'; +import { IExtensionContext } from '../../platform/common/types'; +import { mockedVSCodeNamespaces, resetVSCodeMocks } from '../../test/vscode-mock'; +import * as importClient from './importClient.node'; suite('OpenInDeepnoteHandler', () => { + let handler: OpenInDeepnoteHandler; + let mockExtensionContext: IExtensionContext; + let sandbox: sinon.SinonSandbox; + + setup(() => { + resetVSCodeMocks(); + sandbox = sinon.createSandbox(); + + mockExtensionContext = { + subscriptions: [] + } as any; + + handler = new OpenInDeepnoteHandler(mockExtensionContext); + }); + + teardown(() => { + sandbox.restore(); + }); + suite('activate', () => { - test('should be instantiable', () => { - // This test verifies that the class can be instantiated - // Full testing would require mocking the IExtensionContext - // and VSCode APIs, which is typically done in integration tests - assert.ok(OpenInDeepnoteHandler); + test('should register command when activated', () => { + const registerCommandStub = sandbox.stub(mockedVSCodeNamespaces.commands, 'registerCommand'); + + handler.activate(); + + assert.isTrue(registerCommandStub.calledOnce, 'registerCommand should be called once'); + assert.strictEqual( + registerCommandStub.firstCall.args[0], + 'deepnote.openInDeepnote', + 'Command should be registered with correct ID' + ); + assert.isFunction(registerCommandStub.firstCall.args[1], 'Second argument should be a function'); }); }); - // Note: Full testing of handleOpenInDeepnote would require: - // 1. Mocking window.activeTextEditor - // 2. Mocking file system operations - // 3. Mocking the importClient API calls - // 4. Mocking window.withProgress - // 5. Mocking env.openExternal - // - // These are better suited for integration tests rather than unit tests. - // The core logic is tested through the importClient tests. + suite('handleOpenInDeepnote', () => { + const testFilePath = '/test/notebook.deepnote'; + const testFileUri = Uri.file(testFilePath); + const testFileBuffer = Buffer.from('test content'); + + function createMockTextEditor(uri: Uri, isDirty = false): TextEditor { + const mockDocument = mock(); + when(mockDocument.uri).thenReturn(uri); + when(mockDocument.isDirty).thenReturn(isDirty); + when(mockDocument.save()).thenReturn(Promise.resolve(true)); + + const mockEditor = mock(); + when(mockEditor.document).thenReturn(instance(mockDocument)); + + return instance(mockEditor); + } + + function createMockNotebookEditor(uri: Uri, notebookType: string): NotebookEditor { + const mockNotebook = mock(); + when(mockNotebook.uri).thenReturn(uri); + when(mockNotebook.notebookType).thenReturn(notebookType); + + const mockEditor = mock(); + when(mockEditor.notebook).thenReturn(instance(mockNotebook)); + + return instance(mockEditor); + } + + test('should handle no active editor or notebook', async () => { + when(mockedVSCodeNamespaces.window.activeNotebookEditor).thenReturn(undefined); + when(mockedVSCodeNamespaces.window.activeTextEditor).thenReturn(undefined); + const showErrorStub = sandbox.stub(mockedVSCodeNamespaces.window, 'showErrorMessage'); + + await (handler as any).handleOpenInDeepnote(); + + assert.isTrue(showErrorStub.calledOnce, 'Should show error message'); + assert.isTrue( + showErrorStub.firstCall.args[0].includes('Please open a .deepnote file first'), + 'Error message should mention opening a .deepnote file' + ); + }); + + test('should handle Deepnote notebook editor', async () => { + const notebookUri = testFileUri.with({ query: 'notebook=123' }); + const mockNotebookEditor = createMockNotebookEditor(notebookUri, 'deepnote'); + + when(mockedVSCodeNamespaces.window.activeNotebookEditor).thenReturn(mockNotebookEditor); + when(mockedVSCodeNamespaces.commands.executeCommand(anything())).thenReturn(Promise.resolve(undefined)); + + const statStub = sandbox.stub(fs.promises, 'stat').resolves({ size: 1000 } as fs.Stats); + const readFileStub = sandbox.stub(fs.promises, 'readFile').resolves(testFileBuffer); + const withProgressStub = sandbox + .stub(mockedVSCodeNamespaces.window, 'withProgress') + .callsFake((_options, callback) => { + return callback( + { + report: sinon.stub() + } as any, + {} as any + ); + }); + const initImportStub = sandbox.stub(importClient, 'initImport').resolves({ + importId: 'test-import-id', + uploadUrl: 'https://test.com/upload', + expiresAt: '2025-12-31T23:59:59Z' + }); + const uploadFileStub = sandbox.stub(importClient, 'uploadFile').resolves(); + sandbox.stub(importClient, 'getDeepnoteDomain').returns('app.deepnote.com'); + const openExternalStub = sandbox.stub(mockedVSCodeNamespaces.env, 'openExternal').resolves(); + + await (handler as any).handleOpenInDeepnote(); + + assert.isTrue(statStub.calledOnce, 'Should stat the file'); + assert.isTrue(readFileStub.calledOnce, 'Should read the file'); + assert.isTrue(withProgressStub.calledOnce, 'Should show progress'); + assert.isTrue(initImportStub.calledOnce, 'Should initialize import'); + assert.isTrue(uploadFileStub.calledOnce, 'Should upload file'); + assert.isTrue(openExternalStub.calledOnce, 'Should open external URL'); + }); + + test('should fall back to text editor when notebook is not Deepnote type', async () => { + const notebookUri = Uri.file('/test/other.ipynb'); + const mockNotebookEditor = createMockNotebookEditor(notebookUri, 'jupyter-notebook'); + const mockTextEditor = createMockTextEditor(testFileUri); + + when(mockedVSCodeNamespaces.window.activeNotebookEditor).thenReturn(mockNotebookEditor); + when(mockedVSCodeNamespaces.window.activeTextEditor).thenReturn(mockTextEditor); + + const statStub = sandbox.stub(fs.promises, 'stat').resolves({ size: 1000 } as fs.Stats); + const readFileStub = sandbox.stub(fs.promises, 'readFile').resolves(testFileBuffer); + sandbox.stub(mockedVSCodeNamespaces.window, 'withProgress').callsFake((_options, callback) => { + return callback( + { + report: sinon.stub() + } as any, + {} as any + ); + }); + sandbox.stub(importClient, 'initImport').resolves({ + importId: 'test-import-id', + uploadUrl: 'https://test.com/upload', + expiresAt: '2025-12-31T23:59:59Z' + }); + sandbox.stub(importClient, 'uploadFile').resolves(); + sandbox.stub(importClient, 'getDeepnoteDomain').returns('app.deepnote.com'); + sandbox.stub(mockedVSCodeNamespaces.env, 'openExternal').resolves(); + + await (handler as any).handleOpenInDeepnote(); + + assert.isTrue(statStub.calledOnce, 'Should stat the file'); + assert.isTrue(readFileStub.calledOnce, 'Should read the file'); + }); + + test('should handle text editor with .deepnote file', async () => { + const mockTextEditor = createMockTextEditor(testFileUri); + + when(mockedVSCodeNamespaces.window.activeNotebookEditor).thenReturn(undefined); + when(mockedVSCodeNamespaces.window.activeTextEditor).thenReturn(mockTextEditor); + + const statStub = sandbox.stub(fs.promises, 'stat').resolves({ size: 1000 } as fs.Stats); + const readFileStub = sandbox.stub(fs.promises, 'readFile').resolves(testFileBuffer); + sandbox.stub(mockedVSCodeNamespaces.window, 'withProgress').callsFake((_options, callback) => { + return callback( + { + report: sinon.stub() + } as any, + {} as any + ); + }); + sandbox.stub(importClient, 'initImport').resolves({ + importId: 'test-import-id', + uploadUrl: 'https://test.com/upload', + expiresAt: '2025-12-31T23:59:59Z' + }); + sandbox.stub(importClient, 'uploadFile').resolves(); + sandbox.stub(importClient, 'getDeepnoteDomain').returns('app.deepnote.com'); + sandbox.stub(mockedVSCodeNamespaces.env, 'openExternal').resolves(); + + await (handler as any).handleOpenInDeepnote(); + + assert.isTrue(statStub.calledOnce, 'Should stat the file'); + assert.isTrue(readFileStub.calledOnce, 'Should read the file'); + }); + + test('should reject non-.deepnote files', async () => { + const otherFileUri = Uri.file('/test/notebook.ipynb'); + const mockTextEditor = createMockTextEditor(otherFileUri); + + when(mockedVSCodeNamespaces.window.activeNotebookEditor).thenReturn(undefined); + when(mockedVSCodeNamespaces.window.activeTextEditor).thenReturn(mockTextEditor); + const showErrorStub = sandbox.stub(mockedVSCodeNamespaces.window, 'showErrorMessage'); + + await (handler as any).handleOpenInDeepnote(); + + assert.isTrue(showErrorStub.calledOnce, 'Should show error message'); + assert.isTrue( + showErrorStub.firstCall.args[0].includes('only works with .deepnote files'), + 'Error message should mention .deepnote files' + ); + }); + + test('should save dirty text editor before opening', async () => { + const mockTextEditor = createMockTextEditor(testFileUri, true); + const mockDocument = mockTextEditor.document; + + when(mockedVSCodeNamespaces.window.activeNotebookEditor).thenReturn(undefined); + when(mockedVSCodeNamespaces.window.activeTextEditor).thenReturn(mockTextEditor); + + const saveStub = sandbox.stub(mockDocument, 'save').resolves(true); + sandbox.stub(fs.promises, 'stat').resolves({ size: 1000 } as fs.Stats); + sandbox.stub(fs.promises, 'readFile').resolves(testFileBuffer); + sandbox.stub(mockedVSCodeNamespaces.window, 'withProgress').callsFake((_options, callback) => { + return callback( + { + report: sinon.stub() + } as any, + {} as any + ); + }); + sandbox.stub(importClient, 'initImport').resolves({ + importId: 'test-import-id', + uploadUrl: 'https://test.com/upload', + expiresAt: '2025-12-31T23:59:59Z' + }); + sandbox.stub(importClient, 'uploadFile').resolves(); + sandbox.stub(importClient, 'getDeepnoteDomain').returns('app.deepnote.com'); + sandbox.stub(mockedVSCodeNamespaces.env, 'openExternal').resolves(); + + await (handler as any).handleOpenInDeepnote(); + + assert.isTrue(saveStub.calledOnce, 'Should save dirty document'); + }); + + test('should handle file save failure', async () => { + const mockTextEditor = createMockTextEditor(testFileUri, true); + const mockDocument = mockTextEditor.document; + + when(mockedVSCodeNamespaces.window.activeNotebookEditor).thenReturn(undefined); + when(mockedVSCodeNamespaces.window.activeTextEditor).thenReturn(mockTextEditor); + + const saveStub = sandbox.stub(mockDocument, 'save').resolves(false); + const showErrorStub = sandbox.stub(mockedVSCodeNamespaces.window, 'showErrorMessage'); + + await (handler as any).handleOpenInDeepnote(); + + assert.isTrue(saveStub.calledOnce, 'Should attempt to save'); + assert.isTrue(showErrorStub.calledOnce, 'Should show error message'); + assert.isTrue( + showErrorStub.firstCall.args[0].includes('save the file'), + 'Error message should mention saving' + ); + }); + + test('should reject files exceeding size limit', async () => { + const mockTextEditor = createMockTextEditor(testFileUri); + + when(mockedVSCodeNamespaces.window.activeNotebookEditor).thenReturn(undefined); + when(mockedVSCodeNamespaces.window.activeTextEditor).thenReturn(mockTextEditor); + + const largeSize = importClient.MAX_FILE_SIZE + 1; + const statStub = sandbox.stub(fs.promises, 'stat').resolves({ size: largeSize } as fs.Stats); + const showErrorStub = sandbox.stub(mockedVSCodeNamespaces.window, 'showErrorMessage'); + + await (handler as any).handleOpenInDeepnote(); + + assert.isTrue(statStub.calledOnce, 'Should stat the file'); + assert.isTrue(showErrorStub.calledOnce, 'Should show error message'); + assert.isTrue( + showErrorStub.firstCall.args[0].includes('exceeds'), + 'Error message should mention file size limit' + ); + }); + + test('should handle import initialization error', async () => { + const mockTextEditor = createMockTextEditor(testFileUri); + + when(mockedVSCodeNamespaces.window.activeNotebookEditor).thenReturn(undefined); + when(mockedVSCodeNamespaces.window.activeTextEditor).thenReturn(mockTextEditor); + + sandbox.stub(fs.promises, 'stat').resolves({ size: 1000 } as fs.Stats); + sandbox.stub(fs.promises, 'readFile').resolves(testFileBuffer); + sandbox.stub(mockedVSCodeNamespaces.window, 'withProgress').callsFake((_options, callback) => { + return callback( + { + report: sinon.stub() + } as any, + {} as any + ); + }); + const initImportStub = sandbox.stub(importClient, 'initImport').rejects(new Error('Network error')); + const showErrorStub = sandbox.stub(mockedVSCodeNamespaces.window, 'showErrorMessage'); + + await (handler as any).handleOpenInDeepnote(); + + assert.isTrue(initImportStub.calledOnce, 'Should attempt to initialize import'); + assert.isTrue(showErrorStub.calledOnce, 'Should show error message'); + }); + + test('should handle upload error', async () => { + const mockTextEditor = createMockTextEditor(testFileUri); + + when(mockedVSCodeNamespaces.window.activeNotebookEditor).thenReturn(undefined); + when(mockedVSCodeNamespaces.window.activeTextEditor).thenReturn(mockTextEditor); + + sandbox.stub(fs.promises, 'stat').resolves({ size: 1000 } as fs.Stats); + sandbox.stub(fs.promises, 'readFile').resolves(testFileBuffer); + sandbox.stub(mockedVSCodeNamespaces.window, 'withProgress').callsFake((_options, callback) => { + return callback( + { + report: sinon.stub() + } as any, + {} as any + ); + }); + sandbox.stub(importClient, 'initImport').resolves({ + importId: 'test-import-id', + uploadUrl: 'https://test.com/upload', + expiresAt: '2025-12-31T23:59:59Z' + }); + const uploadFileStub = sandbox.stub(importClient, 'uploadFile').rejects(new Error('Upload failed')); + const showErrorStub = sandbox.stub(mockedVSCodeNamespaces.window, 'showErrorMessage'); + + await (handler as any).handleOpenInDeepnote(); + + assert.isTrue(uploadFileStub.calledOnce, 'Should attempt to upload'); + assert.isTrue(showErrorStub.calledOnce, 'Should show error message'); + }); + + test('should remove query params from notebook URI', async () => { + const notebookUri = testFileUri.with({ query: 'notebook=123', fragment: 'cell0' }); + const mockNotebookEditor = createMockNotebookEditor(notebookUri, 'deepnote'); + + when(mockedVSCodeNamespaces.window.activeNotebookEditor).thenReturn(mockNotebookEditor); + when(mockedVSCodeNamespaces.commands.executeCommand(anything())).thenReturn(Promise.resolve(undefined)); + + const statStub = sandbox.stub(fs.promises, 'stat').resolves({ size: 1000 } as fs.Stats); + sandbox.stub(fs.promises, 'readFile').resolves(testFileBuffer); + sandbox.stub(mockedVSCodeNamespaces.window, 'withProgress').callsFake((_options, callback) => { + return callback( + { + report: sinon.stub() + } as any, + {} as any + ); + }); + sandbox.stub(importClient, 'initImport').resolves({ + importId: 'test-import-id', + uploadUrl: 'https://test.com/upload', + expiresAt: '2025-12-31T23:59:59Z' + }); + sandbox.stub(importClient, 'uploadFile').resolves(); + sandbox.stub(importClient, 'getDeepnoteDomain').returns('app.deepnote.com'); + sandbox.stub(mockedVSCodeNamespaces.env, 'openExternal').resolves(); + + await (handler as any).handleOpenInDeepnote(); + + assert.isTrue(statStub.calledOnce, 'Should stat the file'); + const statPath = statStub.firstCall.args[0]; + assert.strictEqual(statPath, testFilePath, 'Should use base file path without query params'); + }); + }); }); From 4214d582b9306ac525538e3ba16d4c8d30b7a023 Mon Sep 17 00:00:00 2001 From: Lukas Saltenas Date: Fri, 31 Oct 2025 17:48:44 +0200 Subject: [PATCH 2/2] chore: fix tests --- .../openInDeepnoteHandler.node.unit.test.ts | 152 +++++++++++------- 1 file changed, 97 insertions(+), 55 deletions(-) diff --git a/src/notebooks/deepnote/openInDeepnoteHandler.node.unit.test.ts b/src/notebooks/deepnote/openInDeepnoteHandler.node.unit.test.ts index 801daf7dc..0f0e48ff9 100644 --- a/src/notebooks/deepnote/openInDeepnoteHandler.node.unit.test.ts +++ b/src/notebooks/deepnote/openInDeepnoteHandler.node.unit.test.ts @@ -31,17 +31,27 @@ suite('OpenInDeepnoteHandler', () => { suite('activate', () => { test('should register command when activated', () => { - const registerCommandStub = sandbox.stub(mockedVSCodeNamespaces.commands, 'registerCommand'); + let registeredCommandId: string | undefined; + let registeredCallback: Function | undefined; + + when(mockedVSCodeNamespaces.commands.registerCommand(anything(), anything())).thenCall((id, callback) => { + registeredCommandId = id; + registeredCallback = callback; + return { + dispose: () => { + /* no-op */ + } + }; + }); handler.activate(); - assert.isTrue(registerCommandStub.calledOnce, 'registerCommand should be called once'); assert.strictEqual( - registerCommandStub.firstCall.args[0], + registeredCommandId, 'deepnote.openInDeepnote', 'Command should be registered with correct ID' ); - assert.isFunction(registerCommandStub.firstCall.args[1], 'Second argument should be a function'); + assert.isFunction(registeredCallback, 'Second argument should be a function'); }); }); @@ -74,15 +84,20 @@ suite('OpenInDeepnoteHandler', () => { } test('should handle no active editor or notebook', async () => { + let errorMessage: string | undefined; + when(mockedVSCodeNamespaces.window.activeNotebookEditor).thenReturn(undefined); when(mockedVSCodeNamespaces.window.activeTextEditor).thenReturn(undefined); - const showErrorStub = sandbox.stub(mockedVSCodeNamespaces.window, 'showErrorMessage'); + when(mockedVSCodeNamespaces.window.showErrorMessage(anything())).thenCall((message) => { + errorMessage = message; + return Promise.resolve(undefined); + }); await (handler as any).handleOpenInDeepnote(); - assert.isTrue(showErrorStub.calledOnce, 'Should show error message'); + assert.isDefined(errorMessage, 'Should show error message'); assert.isTrue( - showErrorStub.firstCall.args[0].includes('Please open a .deepnote file first'), + errorMessage!.includes('Please open a .deepnote file first'), 'Error message should mention opening a .deepnote file' ); }); @@ -90,22 +105,24 @@ suite('OpenInDeepnoteHandler', () => { test('should handle Deepnote notebook editor', async () => { const notebookUri = testFileUri.with({ query: 'notebook=123' }); const mockNotebookEditor = createMockNotebookEditor(notebookUri, 'deepnote'); + let withProgressCalled = false; when(mockedVSCodeNamespaces.window.activeNotebookEditor).thenReturn(mockNotebookEditor); when(mockedVSCodeNamespaces.commands.executeCommand(anything())).thenReturn(Promise.resolve(undefined)); const statStub = sandbox.stub(fs.promises, 'stat').resolves({ size: 1000 } as fs.Stats); const readFileStub = sandbox.stub(fs.promises, 'readFile').resolves(testFileBuffer); - const withProgressStub = sandbox - .stub(mockedVSCodeNamespaces.window, 'withProgress') - .callsFake((_options, callback) => { - return callback( - { - report: sinon.stub() - } as any, - {} as any - ); - }); + when(mockedVSCodeNamespaces.window.withProgress(anything(), anything())).thenCall((_options, callback) => { + withProgressCalled = true; + return callback( + { + report: () => { + /* no-op */ + } + } as any, + {} as any + ); + }); const initImportStub = sandbox.stub(importClient, 'initImport').resolves({ importId: 'test-import-id', uploadUrl: 'https://test.com/upload', @@ -113,16 +130,15 @@ suite('OpenInDeepnoteHandler', () => { }); const uploadFileStub = sandbox.stub(importClient, 'uploadFile').resolves(); sandbox.stub(importClient, 'getDeepnoteDomain').returns('app.deepnote.com'); - const openExternalStub = sandbox.stub(mockedVSCodeNamespaces.env, 'openExternal').resolves(); + when(mockedVSCodeNamespaces.env.openExternal(anything())).thenReturn(Promise.resolve(true)); await (handler as any).handleOpenInDeepnote(); assert.isTrue(statStub.calledOnce, 'Should stat the file'); assert.isTrue(readFileStub.calledOnce, 'Should read the file'); - assert.isTrue(withProgressStub.calledOnce, 'Should show progress'); + assert.isTrue(withProgressCalled, 'Should show progress'); assert.isTrue(initImportStub.calledOnce, 'Should initialize import'); assert.isTrue(uploadFileStub.calledOnce, 'Should upload file'); - assert.isTrue(openExternalStub.calledOnce, 'Should open external URL'); }); test('should fall back to text editor when notebook is not Deepnote type', async () => { @@ -135,10 +151,12 @@ suite('OpenInDeepnoteHandler', () => { const statStub = sandbox.stub(fs.promises, 'stat').resolves({ size: 1000 } as fs.Stats); const readFileStub = sandbox.stub(fs.promises, 'readFile').resolves(testFileBuffer); - sandbox.stub(mockedVSCodeNamespaces.window, 'withProgress').callsFake((_options, callback) => { + when(mockedVSCodeNamespaces.window.withProgress(anything(), anything())).thenCall((_options, callback) => { return callback( { - report: sinon.stub() + report: () => { + /* no-op */ + } } as any, {} as any ); @@ -150,7 +168,7 @@ suite('OpenInDeepnoteHandler', () => { }); sandbox.stub(importClient, 'uploadFile').resolves(); sandbox.stub(importClient, 'getDeepnoteDomain').returns('app.deepnote.com'); - sandbox.stub(mockedVSCodeNamespaces.env, 'openExternal').resolves(); + when(mockedVSCodeNamespaces.env.openExternal(anything())).thenReturn(Promise.resolve(true)); await (handler as any).handleOpenInDeepnote(); @@ -166,10 +184,12 @@ suite('OpenInDeepnoteHandler', () => { const statStub = sandbox.stub(fs.promises, 'stat').resolves({ size: 1000 } as fs.Stats); const readFileStub = sandbox.stub(fs.promises, 'readFile').resolves(testFileBuffer); - sandbox.stub(mockedVSCodeNamespaces.window, 'withProgress').callsFake((_options, callback) => { + when(mockedVSCodeNamespaces.window.withProgress(anything(), anything())).thenCall((_options, callback) => { return callback( { - report: sinon.stub() + report: () => { + /* no-op */ + } } as any, {} as any ); @@ -181,7 +201,7 @@ suite('OpenInDeepnoteHandler', () => { }); sandbox.stub(importClient, 'uploadFile').resolves(); sandbox.stub(importClient, 'getDeepnoteDomain').returns('app.deepnote.com'); - sandbox.stub(mockedVSCodeNamespaces.env, 'openExternal').resolves(); + when(mockedVSCodeNamespaces.env.openExternal(anything())).thenReturn(Promise.resolve(true)); await (handler as any).handleOpenInDeepnote(); @@ -192,16 +212,20 @@ suite('OpenInDeepnoteHandler', () => { test('should reject non-.deepnote files', async () => { const otherFileUri = Uri.file('/test/notebook.ipynb'); const mockTextEditor = createMockTextEditor(otherFileUri); + let errorMessage: string | undefined; when(mockedVSCodeNamespaces.window.activeNotebookEditor).thenReturn(undefined); when(mockedVSCodeNamespaces.window.activeTextEditor).thenReturn(mockTextEditor); - const showErrorStub = sandbox.stub(mockedVSCodeNamespaces.window, 'showErrorMessage'); + when(mockedVSCodeNamespaces.window.showErrorMessage(anything())).thenCall((message) => { + errorMessage = message; + return Promise.resolve(undefined); + }); await (handler as any).handleOpenInDeepnote(); - assert.isTrue(showErrorStub.calledOnce, 'Should show error message'); + assert.isDefined(errorMessage, 'Should show error message'); assert.isTrue( - showErrorStub.firstCall.args[0].includes('only works with .deepnote files'), + errorMessage!.includes('only works with .deepnote files'), 'Error message should mention .deepnote files' ); }); @@ -216,10 +240,12 @@ suite('OpenInDeepnoteHandler', () => { const saveStub = sandbox.stub(mockDocument, 'save').resolves(true); sandbox.stub(fs.promises, 'stat').resolves({ size: 1000 } as fs.Stats); sandbox.stub(fs.promises, 'readFile').resolves(testFileBuffer); - sandbox.stub(mockedVSCodeNamespaces.window, 'withProgress').callsFake((_options, callback) => { + when(mockedVSCodeNamespaces.window.withProgress(anything(), anything())).thenCall((_options, callback) => { return callback( { - report: sinon.stub() + report: () => { + /* no-op */ + } } as any, {} as any ); @@ -231,7 +257,7 @@ suite('OpenInDeepnoteHandler', () => { }); sandbox.stub(importClient, 'uploadFile').resolves(); sandbox.stub(importClient, 'getDeepnoteDomain').returns('app.deepnote.com'); - sandbox.stub(mockedVSCodeNamespaces.env, 'openExternal').resolves(); + when(mockedVSCodeNamespaces.env.openExternal(anything())).thenReturn(Promise.resolve(true)); await (handler as any).handleOpenInDeepnote(); @@ -241,80 +267,91 @@ suite('OpenInDeepnoteHandler', () => { test('should handle file save failure', async () => { const mockTextEditor = createMockTextEditor(testFileUri, true); const mockDocument = mockTextEditor.document; + let errorMessage: string | undefined; when(mockedVSCodeNamespaces.window.activeNotebookEditor).thenReturn(undefined); when(mockedVSCodeNamespaces.window.activeTextEditor).thenReturn(mockTextEditor); const saveStub = sandbox.stub(mockDocument, 'save').resolves(false); - const showErrorStub = sandbox.stub(mockedVSCodeNamespaces.window, 'showErrorMessage'); + when(mockedVSCodeNamespaces.window.showErrorMessage(anything())).thenCall((message) => { + errorMessage = message; + return Promise.resolve(undefined); + }); await (handler as any).handleOpenInDeepnote(); assert.isTrue(saveStub.calledOnce, 'Should attempt to save'); - assert.isTrue(showErrorStub.calledOnce, 'Should show error message'); - assert.isTrue( - showErrorStub.firstCall.args[0].includes('save the file'), - 'Error message should mention saving' - ); + assert.isDefined(errorMessage, 'Should show error message'); + assert.isTrue(errorMessage!.includes('save the file'), 'Error message should mention saving'); }); test('should reject files exceeding size limit', async () => { const mockTextEditor = createMockTextEditor(testFileUri); + let errorMessage: string | undefined; when(mockedVSCodeNamespaces.window.activeNotebookEditor).thenReturn(undefined); when(mockedVSCodeNamespaces.window.activeTextEditor).thenReturn(mockTextEditor); const largeSize = importClient.MAX_FILE_SIZE + 1; const statStub = sandbox.stub(fs.promises, 'stat').resolves({ size: largeSize } as fs.Stats); - const showErrorStub = sandbox.stub(mockedVSCodeNamespaces.window, 'showErrorMessage'); + when(mockedVSCodeNamespaces.window.showErrorMessage(anything())).thenCall((message) => { + errorMessage = message; + return Promise.resolve(undefined); + }); await (handler as any).handleOpenInDeepnote(); assert.isTrue(statStub.calledOnce, 'Should stat the file'); - assert.isTrue(showErrorStub.calledOnce, 'Should show error message'); - assert.isTrue( - showErrorStub.firstCall.args[0].includes('exceeds'), - 'Error message should mention file size limit' - ); + assert.isDefined(errorMessage, 'Should show error message'); + assert.isTrue(errorMessage!.includes('exceeds'), 'Error message should mention file size limit'); }); test('should handle import initialization error', async () => { const mockTextEditor = createMockTextEditor(testFileUri); + let errorMessage: string | undefined; when(mockedVSCodeNamespaces.window.activeNotebookEditor).thenReturn(undefined); when(mockedVSCodeNamespaces.window.activeTextEditor).thenReturn(mockTextEditor); sandbox.stub(fs.promises, 'stat').resolves({ size: 1000 } as fs.Stats); sandbox.stub(fs.promises, 'readFile').resolves(testFileBuffer); - sandbox.stub(mockedVSCodeNamespaces.window, 'withProgress').callsFake((_options, callback) => { + when(mockedVSCodeNamespaces.window.withProgress(anything(), anything())).thenCall((_options, callback) => { return callback( { - report: sinon.stub() + report: () => { + /* no-op */ + } } as any, {} as any ); }); const initImportStub = sandbox.stub(importClient, 'initImport').rejects(new Error('Network error')); - const showErrorStub = sandbox.stub(mockedVSCodeNamespaces.window, 'showErrorMessage'); + when(mockedVSCodeNamespaces.window.showErrorMessage(anything())).thenCall((message) => { + errorMessage = message; + return Promise.resolve(undefined); + }); await (handler as any).handleOpenInDeepnote(); assert.isTrue(initImportStub.calledOnce, 'Should attempt to initialize import'); - assert.isTrue(showErrorStub.calledOnce, 'Should show error message'); + assert.isDefined(errorMessage, 'Should show error message'); }); test('should handle upload error', async () => { const mockTextEditor = createMockTextEditor(testFileUri); + let errorMessage: string | undefined; when(mockedVSCodeNamespaces.window.activeNotebookEditor).thenReturn(undefined); when(mockedVSCodeNamespaces.window.activeTextEditor).thenReturn(mockTextEditor); sandbox.stub(fs.promises, 'stat').resolves({ size: 1000 } as fs.Stats); sandbox.stub(fs.promises, 'readFile').resolves(testFileBuffer); - sandbox.stub(mockedVSCodeNamespaces.window, 'withProgress').callsFake((_options, callback) => { + when(mockedVSCodeNamespaces.window.withProgress(anything(), anything())).thenCall((_options, callback) => { return callback( { - report: sinon.stub() + report: () => { + /* no-op */ + } } as any, {} as any ); @@ -325,12 +362,15 @@ suite('OpenInDeepnoteHandler', () => { expiresAt: '2025-12-31T23:59:59Z' }); const uploadFileStub = sandbox.stub(importClient, 'uploadFile').rejects(new Error('Upload failed')); - const showErrorStub = sandbox.stub(mockedVSCodeNamespaces.window, 'showErrorMessage'); + when(mockedVSCodeNamespaces.window.showErrorMessage(anything())).thenCall((message) => { + errorMessage = message; + return Promise.resolve(undefined); + }); await (handler as any).handleOpenInDeepnote(); assert.isTrue(uploadFileStub.calledOnce, 'Should attempt to upload'); - assert.isTrue(showErrorStub.calledOnce, 'Should show error message'); + assert.isDefined(errorMessage, 'Should show error message'); }); test('should remove query params from notebook URI', async () => { @@ -342,10 +382,12 @@ suite('OpenInDeepnoteHandler', () => { const statStub = sandbox.stub(fs.promises, 'stat').resolves({ size: 1000 } as fs.Stats); sandbox.stub(fs.promises, 'readFile').resolves(testFileBuffer); - sandbox.stub(mockedVSCodeNamespaces.window, 'withProgress').callsFake((_options, callback) => { + when(mockedVSCodeNamespaces.window.withProgress(anything(), anything())).thenCall((_options, callback) => { return callback( { - report: sinon.stub() + report: () => { + /* no-op */ + } } as any, {} as any ); @@ -357,7 +399,7 @@ suite('OpenInDeepnoteHandler', () => { }); sandbox.stub(importClient, 'uploadFile').resolves(); sandbox.stub(importClient, 'getDeepnoteDomain').returns('app.deepnote.com'); - sandbox.stub(mockedVSCodeNamespaces.env, 'openExternal').resolves(); + when(mockedVSCodeNamespaces.env.openExternal(anything())).thenReturn(Promise.resolve(true)); await (handler as any).handleOpenInDeepnote();