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..0f0e48ff9 100644 --- a/src/notebooks/deepnote/openInDeepnoteHandler.node.unit.test.ts +++ b/src/notebooks/deepnote/openInDeepnoteHandler.node.unit.test.ts @@ -1,26 +1,411 @@ -// 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', () => { + 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.strictEqual( + registeredCommandId, + 'deepnote.openInDeepnote', + 'Command should be registered with correct ID' + ); + assert.isFunction(registeredCallback, '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 () => { + let errorMessage: string | undefined; + + when(mockedVSCodeNamespaces.window.activeNotebookEditor).thenReturn(undefined); + when(mockedVSCodeNamespaces.window.activeTextEditor).thenReturn(undefined); + when(mockedVSCodeNamespaces.window.showErrorMessage(anything())).thenCall((message) => { + errorMessage = message; + return Promise.resolve(undefined); + }); + + await (handler as any).handleOpenInDeepnote(); + + assert.isDefined(errorMessage, 'Should show error message'); + assert.isTrue( + errorMessage!.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'); + 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); + 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', + expiresAt: '2025-12-31T23:59:59Z' + }); + const uploadFileStub = sandbox.stub(importClient, 'uploadFile').resolves(); + sandbox.stub(importClient, 'getDeepnoteDomain').returns('app.deepnote.com'); + 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(withProgressCalled, 'Should show progress'); + assert.isTrue(initImportStub.calledOnce, 'Should initialize import'); + assert.isTrue(uploadFileStub.calledOnce, 'Should upload file'); + }); + + 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); + when(mockedVSCodeNamespaces.window.withProgress(anything(), anything())).thenCall((_options, callback) => { + return callback( + { + report: () => { + /* no-op */ + } + } 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'); + 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'); + }); + + 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); + when(mockedVSCodeNamespaces.window.withProgress(anything(), anything())).thenCall((_options, callback) => { + return callback( + { + report: () => { + /* no-op */ + } + } 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'); + 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'); + }); + + 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); + when(mockedVSCodeNamespaces.window.showErrorMessage(anything())).thenCall((message) => { + errorMessage = message; + return Promise.resolve(undefined); + }); + + await (handler as any).handleOpenInDeepnote(); + + assert.isDefined(errorMessage, 'Should show error message'); + assert.isTrue( + errorMessage!.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); + when(mockedVSCodeNamespaces.window.withProgress(anything(), anything())).thenCall((_options, callback) => { + return callback( + { + report: () => { + /* no-op */ + } + } 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'); + when(mockedVSCodeNamespaces.env.openExternal(anything())).thenReturn(Promise.resolve(true)); + + 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; + let errorMessage: string | undefined; + + when(mockedVSCodeNamespaces.window.activeNotebookEditor).thenReturn(undefined); + when(mockedVSCodeNamespaces.window.activeTextEditor).thenReturn(mockTextEditor); + + const saveStub = sandbox.stub(mockDocument, 'save').resolves(false); + 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.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); + 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.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); + when(mockedVSCodeNamespaces.window.withProgress(anything(), anything())).thenCall((_options, callback) => { + return callback( + { + report: () => { + /* no-op */ + } + } as any, + {} as any + ); + }); + const initImportStub = sandbox.stub(importClient, 'initImport').rejects(new Error('Network error')); + 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.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); + when(mockedVSCodeNamespaces.window.withProgress(anything(), anything())).thenCall((_options, callback) => { + return callback( + { + report: () => { + /* no-op */ + } + } 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')); + 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.isDefined(errorMessage, '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); + when(mockedVSCodeNamespaces.window.withProgress(anything(), anything())).thenCall((_options, callback) => { + return callback( + { + report: () => { + /* no-op */ + } + } 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'); + when(mockedVSCodeNamespaces.env.openExternal(anything())).thenReturn(Promise.resolve(true)); + + 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'); + }); + }); });