diff --git a/extensions/notebook/package.json b/extensions/notebook/package.json index 0b36964491..99472237b7 100644 --- a/extensions/notebook/package.json +++ b/extensions/notebook/package.json @@ -593,6 +593,7 @@ "mocha": "^5.2.0", "mocha-junit-reporter": "^1.17.0", "mocha-multi-reporters": "^1.1.7", + "nock": "^13.0.2", "should": "^13.2.3", "sinon": "^9.0.2", "typemoq": "^2.1.0", diff --git a/extensions/notebook/src/common/constants.ts b/extensions/notebook/src/common/constants.ts index 16d4114587..16eb341f60 100644 --- a/extensions/notebook/src/common/constants.ts +++ b/extensions/notebook/src/common/constants.ts @@ -10,6 +10,8 @@ const localize = nls.loadMessageBundle(); // CONFIG VALUES /////////////////////////////////////////////////////////// export const extensionOutputChannelName = 'Notebooks'; +export const notebookCommandNew = 'notebook.command.new'; + // JUPYTER CONFIG ////////////////////////////////////////////////////////// export const pythonBundleVersion = '0.0.1'; export const pythonVersion = '3.6.6'; diff --git a/extensions/notebook/src/protocol/notebookUriHandler.ts b/extensions/notebook/src/protocol/notebookUriHandler.ts index 51a1fa7c6d..1e10826686 100644 --- a/extensions/notebook/src/protocol/notebookUriHandler.ts +++ b/extensions/notebook/src/protocol/notebookUriHandler.ts @@ -14,6 +14,8 @@ const localize = nls.loadMessageBundle(); import { IQuestion, confirm } from '../prompts/question'; import CodeAdapter from '../prompts/adapter'; import { getErrorMessage, isEditorTitleFree } from '../common/utils'; +import * as constants from '../common/constants'; + export class NotebookUriHandler implements vscode.UriHandler { private prompter = new CodeAdapter(); @@ -24,24 +26,22 @@ export class NotebookUriHandler implements vscode.UriHandler { handleUri(uri: vscode.Uri): vscode.ProviderResult { switch (uri.path) { case '/new': - vscode.commands.executeCommand('notebook.command.new'); - break; + return vscode.commands.executeCommand(constants.notebookCommandNew); case '/open': - this.open(uri); - break; + return this.open(uri); default: vscode.window.showErrorMessage(localize('notebook.unsupportedAction', "Action {0} is not supported for this handler", uri.path)); } } - private open(uri: vscode.Uri): void { + private open(uri: vscode.Uri): Promise { const data = querystring.parse(uri.query); if (!data.url) { console.warn('Failed to open URI:', uri); } - this.openNotebook(data.url); + return this.openNotebook(data.url); } private async openNotebook(url: string | string[]): Promise { diff --git a/extensions/notebook/src/test/protocol/notebookUriHandler.test.ts b/extensions/notebook/src/test/protocol/notebookUriHandler.test.ts new file mode 100644 index 0000000000..a54e119e23 --- /dev/null +++ b/extensions/notebook/src/test/protocol/notebookUriHandler.test.ts @@ -0,0 +1,115 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import 'mocha'; +import * as vscode from 'vscode'; +import * as sinon from 'sinon'; +import * as azdata from 'azdata'; +import * as nock from 'nock'; +import * as loc from '../../common/localizedConstants'; +import * as constants from '../../common/constants'; + +import { NotebookUriHandler } from '../../protocol/notebookUriHandler'; + +describe('Notebook URI Handler', function (): void { + let notebookUriHandler: NotebookUriHandler; + let showErrorMessageSpy: sinon.SinonSpy; + let executeCommandSpy: sinon.SinonSpy; + let showNotebookDocumentStub: sinon.SinonStub; + const notebookUri = vscode.Uri.parse('azuredatastudio://microsoft.notebook/open?url=https%3A%2F%2F127.0.0.1/Hello.ipynb'); + const notebookContent = 'test content'; + + beforeEach(() => { + showErrorMessageSpy = sinon.spy(vscode.window, 'showErrorMessage'); + executeCommandSpy = sinon.spy(vscode.commands, 'executeCommand'); + notebookUriHandler = new NotebookUriHandler(); + showNotebookDocumentStub = sinon.stub(azdata.nb, 'showNotebookDocument'); + }); + + afterEach(function (): void { + sinon.restore(); + nock.cleanAll(); + nock.enableNetConnect(); + }); + + it('should handle empty string gracefully', async function (): Promise { + await notebookUriHandler.handleUri(vscode.Uri.parse('')); + sinon.assert.calledOnce(showErrorMessageSpy); + sinon.assert.neverCalledWith(executeCommandSpy, constants.notebookCommandNew); + sinon.assert.notCalled(showNotebookDocumentStub); + }); + + it('should create new notebook when new passed in', async function (): Promise { + await notebookUriHandler.handleUri(vscode.Uri.parse('azuredatastudio://microsoft.notebook/new')); + sinon.assert.calledWith(executeCommandSpy, constants.notebookCommandNew); + }); + + it('should show error message when no query passed into open', async function (): Promise { + await notebookUriHandler.handleUri(vscode.Uri.parse('azuredatastudio://microsoft.notebook/open')); + sinon.assert.calledOnce(showErrorMessageSpy); + }); + + it('should show error message when file uri scheme is not https or http', async function (): Promise { + await notebookUriHandler.handleUri(vscode.Uri.parse('azuredatastudio://microsoft.notebook/open?file://hello.ipynb')); + sinon.assert.calledOnce(showErrorMessageSpy); + }); + + it('should show error when file is not found given file uri scheme https', async function (): Promise { + let showQuickPickStub = sinon.stub(vscode.window, 'showQuickPick').resolves(Promise.resolve(loc.msgYes) as any); + + await notebookUriHandler.handleUri(notebookUri); + + sinon.assert.calledOnce(showQuickPickStub); + sinon.assert.callCount(showErrorMessageSpy, 1); + }); + + it('should show error when file is not found given file uri scheme http', async function (): Promise { + let notebookUriHttp = vscode.Uri.parse('azuredatastudio://microsoft.notebook/open?url=http%3A%2F%2F127.0.0.1/Hello.ipynb'); + let showQuickPickStub = sinon.stub(vscode.window, 'showQuickPick').resolves(Promise.resolve(loc.msgYes) as any); + + await notebookUriHandler.handleUri(notebookUriHttp); + + sinon.assert.calledOnce(showQuickPickStub); + sinon.assert.callCount(showErrorMessageSpy, 1); + + }); + + it('should open the notebook when file uri is valid', async function (): Promise { + let showQuickPickStub = sinon.stub(vscode.window, 'showQuickPick').resolves(Promise.resolve(loc.msgYes) as any); + nock('https://127.0.0.1') + .get(`/Hello.ipynb`) + .reply(200, notebookContent); + + await notebookUriHandler.handleUri(notebookUri); + + sinon.assert.calledOnce(showQuickPickStub); + sinon.assert.neverCalledWith(showErrorMessageSpy); + sinon.assert.calledWith(showNotebookDocumentStub, sinon.match.any, sinon.match({ initialContent: notebookContent })); + }); + + it('should not download notebook when user declines prompt', async function (): Promise { + let showQuickPickStub = sinon.stub(vscode.window, 'showQuickPick').resolves(Promise.resolve(loc.msgNo) as any); + + await notebookUriHandler.handleUri(notebookUri); + + sinon.assert.calledOnce(showQuickPickStub); + sinon.assert.notCalled(showNotebookDocumentStub); + sinon.assert.callCount(showErrorMessageSpy, 0); + }); + + [403, 404, 500].forEach(httpErrorCode => { + it(`should reject when HTTP error ${httpErrorCode} occurs`, async function (): Promise { + sinon.stub(vscode.window, 'showQuickPick').returns(Promise.resolve(loc.msgYes) as any); + nock('https://127.0.0.1') + .get(`/Hello.ipynb`) + .reply(httpErrorCode); + + await notebookUriHandler.handleUri(notebookUri); + + sinon.assert.callCount(showErrorMessageSpy, 1); + sinon.assert.notCalled(showNotebookDocumentStub); + }); + }); +}); diff --git a/extensions/notebook/yarn.lock b/extensions/notebook/yarn.lock index 9ca1f80bb7..7618ad555b 100644 --- a/extensions/notebook/yarn.lock +++ b/extensions/notebook/yarn.lock @@ -1113,7 +1113,7 @@ json-stable-stringify@^1.0.1: dependencies: jsonify "~0.0.0" -json-stringify-safe@~5.0.1: +json-stringify-safe@^5.0.1, json-stringify-safe@~5.0.1: version "5.0.1" resolved "https://registry.yarnpkg.com/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz#1296a2d58fd45f19a0f6ce01d65701e2c735b6eb" integrity sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus= @@ -1157,6 +1157,11 @@ lodash.get@^4.4.2: resolved "https://registry.yarnpkg.com/lodash.get/-/lodash.get-4.4.2.tgz#2d177f652fa31e939b4438d5341499dfa3825e99" integrity sha1-LRd/ZS+jHpObRDjVNBSZ36OCXpk= +lodash.set@^4.3.2: + version "4.3.2" + resolved "https://registry.yarnpkg.com/lodash.set/-/lodash.set-4.3.2.tgz#d8757b1da807dde24816b0d6a84bea1a76230b23" + integrity sha1-2HV7HagH3eJIFrDWqEvqGnYjCyM= + lodash@^4.16.4, lodash@^4.17.13, lodash@^4.17.4: version "4.17.19" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.19.tgz#e48ddedbe30b3321783c5b4301fbd353bc1e4a4b" @@ -1329,6 +1334,16 @@ nise@^4.0.1: just-extend "^4.0.2" path-to-regexp "^1.7.0" +nock@^13.0.2: + version "13.0.4" + resolved "https://registry.yarnpkg.com/nock/-/nock-13.0.4.tgz#9fb74db35d0aa056322e3c45be14b99105cd7510" + integrity sha512-alqTV8Qt7TUbc74x1pKRLSENzfjp4nywovcJgi/1aXDiUxXdt7TkruSTF5MDWPP7UoPVgea4F9ghVdmX0xxnSA== + dependencies: + debug "^4.1.0" + json-stringify-safe "^5.0.1" + lodash.set "^4.3.2" + propagate "^2.0.0" + node-fetch@^2.3.0: version "2.3.0" resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.3.0.tgz#1a1d940bbfb916a1d3e0219f037e89e71f8c5fa5" @@ -1398,6 +1413,11 @@ postinstall-build@^5.0.1: resolved "https://registry.yarnpkg.com/postinstall-build/-/postinstall-build-5.0.3.tgz#238692f712a481d8f5bc8960e94786036241efc7" integrity sha512-vPvPe8TKgp4FLgY3+DfxCE5PIfoXBK2lyLfNCxsRbDsV6vS4oU5RG/IWxrblMn6heagbnMED3MemUQllQ2bQUg== +propagate@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/propagate/-/propagate-2.0.1.tgz#40cdedab18085c792334e64f0ac17256d38f9a45" + integrity sha512-vGrhOavPSTz4QVNuBNdcNXePNdNMaO1xj9yBeH1ScQPjk/rhg9sSlCXPhMkFuaNNW/syTvYqsnbIJxMBfRbbag== + psl@^1.1.24: version "1.1.31" resolved "https://registry.yarnpkg.com/psl/-/psl-1.1.31.tgz#e9aa86d0101b5b105cbe93ac6b784cd547276184"