diff --git a/src/client/activation/languageClientMiddlewareBase.ts b/src/client/activation/languageClientMiddlewareBase.ts index e1aa901c9e68..29a49b8bd35d 100644 --- a/src/client/activation/languageClientMiddlewareBase.ts +++ b/src/client/activation/languageClientMiddlewareBase.ts @@ -171,6 +171,29 @@ export class LanguageClientMiddlewareBase implements Middleware { return this.callNext('willSaveWaitUntil', arguments); } + public async didOpenNotebook() { + return this.callNotebooksNext('didOpen', arguments); + } + + public async didSaveNotebook() { + return this.callNotebooksNext('didSave', arguments); + } + + public async didChangeNotebook() { + return this.callNotebooksNext('didChange', arguments); + } + + public async didCloseNotebook() { + return this.callNotebooksNext('didClose', arguments); + } + + notebooks = { + didOpen: this.didOpenNotebook.bind(this), + didSave: this.didSaveNotebook.bind(this), + didChange: this.didChangeNotebook.bind(this), + didClose: this.didCloseNotebook.bind(this), + }; + public async provideCompletionItem() { if (await this.connected) { return this.callNextAndSendTelemetry( @@ -463,6 +486,17 @@ export class LanguageClientMiddlewareBase implements Middleware { return args[args.length - 1](...args); } + private callNotebooksNext(funcName: 'didOpen' | 'didSave' | 'didChange' | 'didClose', args: IArguments) { + // This function uses the last argument to call the 'next' item. If we're allowing notebook + // middleware, it calls into the notebook middleware first. + if (this.notebookAddon?.notebooks && (this.notebookAddon.notebooks as any)[funcName]) { + // It would be nice to use args.callee, but not supported in strict mode + return (this.notebookAddon.notebooks as any)[funcName](...args); + } + + return args[args.length - 1](...args); + } + private callNextAndSendTelemetry( lspMethod: string, debounceMilliseconds: number, diff --git a/src/client/activation/node/analysisOptions.ts b/src/client/activation/node/analysisOptions.ts index 49573315e1ef..f06ed52b7b54 100644 --- a/src/client/activation/node/analysisOptions.ts +++ b/src/client/activation/node/analysisOptions.ts @@ -24,6 +24,7 @@ export class NodeLanguageServerAnalysisOptions extends LanguageServerAnalysisOpt experimentationSupport: true, trustedWorkspaceSupport: true, lspNotebooksSupport: this.lspNotebooksExperiment.isInNotebooksExperiment(), + lspInteractiveWindowSupport: this.lspNotebooksExperiment.isInNotebooksExperimentWithInteractiveWindowSupport(), } as unknown) as LanguageClientOptions; } } diff --git a/src/client/activation/node/languageClientMiddleware.ts b/src/client/activation/node/languageClientMiddleware.ts index 5c124ef461ba..e1e9cb447bc1 100644 --- a/src/client/activation/node/languageClientMiddleware.ts +++ b/src/client/activation/node/languageClientMiddleware.ts @@ -2,11 +2,13 @@ // Licensed under the MIT License. import { Uri } from 'vscode'; +import { LanguageClient } from 'vscode-languageclient/node'; import { IJupyterExtensionDependencyManager } from '../../common/application/types'; import { IServiceContainer } from '../../ioc/types'; import { JupyterExtensionIntegration } from '../../jupyter/jupyterIntegration'; import { traceLog } from '../../logging'; import { LanguageClientMiddleware } from '../languageClientMiddleware'; +import { LspInteractiveWindowMiddlewareAddon } from './lspInteractiveWindowMiddlewareAddon'; import { LanguageServerType } from '../types'; @@ -15,11 +17,27 @@ import { LspNotebooksExperiment } from './lspNotebooksExperiment'; export class NodeLanguageClientMiddleware extends LanguageClientMiddleware { private readonly lspNotebooksExperiment: LspNotebooksExperiment; - public constructor(serviceContainer: IServiceContainer, serverVersion?: string) { + private readonly jupyterExtensionIntegration: JupyterExtensionIntegration; + + public constructor( + serviceContainer: IServiceContainer, + private getClient: () => LanguageClient | undefined, + serverVersion?: string, + ) { super(serviceContainer, LanguageServerType.Node, serverVersion); this.lspNotebooksExperiment = serviceContainer.get(LspNotebooksExperiment); this.setupHidingMiddleware(serviceContainer); + + this.jupyterExtensionIntegration = serviceContainer.get( + JupyterExtensionIntegration, + ); + if (!this.notebookAddon && this.lspNotebooksExperiment.isInNotebooksExperimentWithInteractiveWindowSupport()) { + this.notebookAddon = new LspInteractiveWindowMiddlewareAddon( + this.getClient, + this.jupyterExtensionIntegration, + ); + } } protected shouldCreateHidingMiddleware(jupyterDependencyManager: IJupyterExtensionDependencyManager): boolean { @@ -34,7 +52,16 @@ export class NodeLanguageClientMiddleware extends LanguageClientMiddleware { await this.lspNotebooksExperiment.onJupyterInstalled(); } - super.onExtensionChange(jupyterDependencyManager); + if (this.lspNotebooksExperiment.isInNotebooksExperimentWithInteractiveWindowSupport()) { + if (!this.notebookAddon) { + this.notebookAddon = new LspInteractiveWindowMiddlewareAddon( + this.getClient, + this.jupyterExtensionIntegration, + ); + } + } else { + super.onExtensionChange(jupyterDependencyManager); + } } protected async getPythonPathOverride(uri: Uri | undefined): Promise { @@ -42,10 +69,7 @@ export class NodeLanguageClientMiddleware extends LanguageClientMiddleware { return undefined; } - const jupyterExtensionIntegration = this.serviceContainer?.get( - JupyterExtensionIntegration, - ); - const jupyterPythonPathFunction = jupyterExtensionIntegration?.getJupyterPythonPathFunction(); + const jupyterPythonPathFunction = this.jupyterExtensionIntegration.getJupyterPythonPathFunction(); if (!jupyterPythonPathFunction) { return undefined; } diff --git a/src/client/activation/node/lspInteractiveWindowMiddlewareAddon.ts b/src/client/activation/node/lspInteractiveWindowMiddlewareAddon.ts new file mode 100644 index 000000000000..c68ebfe5a59c --- /dev/null +++ b/src/client/activation/node/lspInteractiveWindowMiddlewareAddon.ts @@ -0,0 +1,187 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +import { Disposable, NotebookCell, NotebookDocument, TextDocument, TextDocumentChangeEvent, Uri } from 'vscode'; +import { Converter } from 'vscode-languageclient/lib/common/codeConverter'; +import { + DidChangeNotebookDocumentNotification, + LanguageClient, + Middleware, + NotebookCellKind, + NotebookDocumentChangeEvent, +} from 'vscode-languageclient/node'; +import * as proto from 'vscode-languageserver-protocol'; +import { JupyterExtensionIntegration } from '../../jupyter/jupyterIntegration'; + +type TextContent = Required['cells']>['textContent']>[0]; + +/** + * Detects the input box text documents of Interactive Windows and makes them appear to be + * the last cell of their corresponding notebooks. + */ +export class LspInteractiveWindowMiddlewareAddon implements Middleware, Disposable { + constructor( + private readonly getClient: () => LanguageClient | undefined, + private readonly jupyterExtensionIntegration: JupyterExtensionIntegration, + ) { + // Make sure a bunch of functions are bound to this. VS code can call them without a this context + this.didOpen = this.didOpen.bind(this); + this.didChange = this.didChange.bind(this); + this.didClose = this.didClose.bind(this); + } + + public dispose(): void { + // Nothing to dispose at the moment + } + + // Map of document URIs to NotebookDocuments for all known notebooks. + private notebookDocumentMap: Map = new Map(); + + // Map of document URIs to TextDocuments that should be linked to a notebook + // whose didOpen we're expecting to see in the future. + private unlinkedTextDocumentMap: Map = new Map(); + + public async didOpen(document: TextDocument, next: (ev: TextDocument) => Promise): Promise { + const notebookUri = this.getNotebookUriForTextDocumentUri(document.uri); + if (!notebookUri) { + await next(document); + return; + } + + const notebookDocument = this.notebookDocumentMap.get(notebookUri.toString()); + if (!notebookDocument) { + this.unlinkedTextDocumentMap.set(notebookUri.toString(), document); + return; + } + + try { + const result: NotebookDocumentChangeEvent = { + cells: { + structure: { + array: { + start: notebookDocument.cellCount, + deleteCount: 0, + cells: [{ kind: NotebookCellKind.Code, document: document.uri.toString() }], + }, + didOpen: [ + { + uri: document.uri.toString(), + languageId: document.languageId, + version: document.version, + text: document.getText(), + }, + ], + didClose: undefined, + }, + }, + }; + + await this.getClient()?.sendNotification(DidChangeNotebookDocumentNotification.type, { + notebookDocument: { version: notebookDocument.version, uri: notebookUri.toString() }, + change: result, + }); + } catch (error) { + this.getClient()?.error('Sending DidChangeNotebookDocumentNotification failed', error); + throw error; + } + } + + public async didChange( + event: TextDocumentChangeEvent, + next: (ev: TextDocumentChangeEvent) => Promise, + ): Promise { + const notebookUri = this.getNotebookUriForTextDocumentUri(event.document.uri); + if (!notebookUri) { + await next(event); + return; + } + + const notebookDocument = this.notebookDocumentMap.get(notebookUri.toString()); + if (notebookDocument) { + const client = this.getClient(); + if (client) { + client.sendNotification(proto.DidChangeNotebookDocumentNotification.type, { + notebookDocument: { uri: notebookUri.toString(), version: notebookDocument.version }, + change: { + cells: { + textContent: [ + LspInteractiveWindowMiddlewareAddon._asTextContentChange( + event, + client.code2ProtocolConverter, + ), + ], + }, + }, + }); + } + } + } + + private static _asTextContentChange(event: TextDocumentChangeEvent, c2pConverter: Converter): TextContent { + const params = c2pConverter.asChangeTextDocumentParams(event); + return { document: params.textDocument, changes: params.contentChanges }; + } + + public async didClose(document: TextDocument, next: (ev: TextDocument) => Promise): Promise { + const notebookUri = this.getNotebookUriForTextDocumentUri(document.uri); + if (!notebookUri) { + await next(document); + return; + } + + this.unlinkedTextDocumentMap.delete(notebookUri.toString()); + } + + public async didOpenNotebook( + notebookDocument: NotebookDocument, + cells: NotebookCell[], + next: (notebookDocument: NotebookDocument, cells: NotebookCell[]) => Promise, + ): Promise { + this.notebookDocumentMap.set(notebookDocument.uri.toString(), notebookDocument); + + const relatedTextDocument = this.unlinkedTextDocumentMap.get(notebookDocument.uri.toString()); + if (relatedTextDocument) { + const newCells = [ + ...cells, + { + index: notebookDocument.cellCount, + notebook: notebookDocument, + kind: NotebookCellKind.Code, + document: relatedTextDocument, + metadata: {}, + outputs: [], + executionSummary: undefined, + }, + ]; + + this.unlinkedTextDocumentMap.delete(notebookDocument.uri.toString()); + + await next(notebookDocument, newCells); + } else { + await next(notebookDocument, cells); + } + } + + public async didCloseNotebook( + notebookDocument: NotebookDocument, + cells: NotebookCell[], + next: (notebookDocument: NotebookDocument, cells: NotebookCell[]) => Promise, + ): Promise { + this.notebookDocumentMap.delete(notebookDocument.uri.toString()); + + await next(notebookDocument, cells); + } + + notebooks = { + didOpen: this.didOpenNotebook.bind(this), + didClose: this.didCloseNotebook.bind(this), + }; + + private getNotebookUriForTextDocumentUri(textDocumentUri: Uri): Uri | undefined { + const getNotebookUriFunction = this.jupyterExtensionIntegration.getGetNotebookUriForTextDocumentUriFunction(); + if (!getNotebookUriFunction) { + return undefined; + } + + return getNotebookUriFunction(textDocumentUri); + } +} diff --git a/src/client/activation/node/lspNotebooksExperiment.ts b/src/client/activation/node/lspNotebooksExperiment.ts index f3b20c30f300..7cadc1044a2b 100644 --- a/src/client/activation/node/lspNotebooksExperiment.ts +++ b/src/client/activation/node/lspNotebooksExperiment.ts @@ -25,6 +25,8 @@ export class LspNotebooksExperiment implements IExtensionSingleActivationService private isInExperiment: boolean | undefined; + private supportsInteractiveWindow: boolean | undefined; + constructor( @inject(IServiceContainer) private readonly serviceContainer: IServiceContainer, @inject(IConfigurationService) private readonly configurationService: IConfigurationService, @@ -60,6 +62,10 @@ export class LspNotebooksExperiment implements IExtensionSingleActivationService return this.isInExperiment ?? false; } + public isInNotebooksExperimentWithInteractiveWindowSupport(): boolean { + return this.supportsInteractiveWindow ?? false; + } + private updateExperimentSupport(): void { const wasInExperiment = this.isInExperiment; const isInTreatmentGroup = this.configurationService.getSettings().pylanceLspNotebooksEnabled; @@ -87,6 +93,18 @@ export class LspNotebooksExperiment implements IExtensionSingleActivationService sendTelemetryEvent(EventName.PYTHON_EXPERIMENTS_LSP_NOTEBOOKS); } + this.supportsInteractiveWindow = false; + if (!this.isInExperiment) { + traceLog(`LSP Notebooks interactive window support is disabled -- not in LSP Notebooks experiment`); + } else if (!LspNotebooksExperiment.jupyterSupportsLspInteractiveWindow()) { + traceLog(`LSP Notebooks interactive window support is disabled -- Jupyter is not new enough`); + } else if (!LspNotebooksExperiment.pylanceSupportsLspInteractiveWindow()) { + traceLog(`LSP Notebooks interactive window support is disabled -- Pylance is not new enough`); + } else { + this.supportsInteractiveWindow = true; + traceLog(`LSP Notebooks interactive window support is enabled`); + } + // Our "in experiment" status can only change from false to true. That's possible if Pylance // or Jupyter is installed after Python is activated. A true to false transition would require // either Pylance or Jupyter to be uninstalled or downgraded after Python activated, and that @@ -114,6 +132,21 @@ export class LspNotebooksExperiment implements IExtensionSingleActivationService ); } + private static jupyterSupportsLspInteractiveWindow(): boolean { + const jupyterVersion = extensions.getExtension(JUPYTER_EXTENSION_ID)?.packageJSON.version; + return ( + jupyterVersion && (semver.gt(jupyterVersion, '2022.7.1002041057') || semver.patch(jupyterVersion) === 100) + ); + } + + private static pylanceSupportsLspInteractiveWindow(): boolean { + const pylanceVersion = extensions.getExtension(PYLANCE_EXTENSION_ID)?.packageJSON.version; + return ( + pylanceVersion && + (semver.gte(pylanceVersion, '2022.7.51') || semver.prerelease(pylanceVersion)?.includes('dev')) + ); + } + private async waitForJupyterToRegisterPythonPathFunction(): Promise { const jupyterExtensionIntegration = this.serviceContainer.get( JupyterExtensionIntegration, diff --git a/src/client/activation/node/manager.ts b/src/client/activation/node/manager.ts index 63c866748ce6..d9b1b48fe754 100644 --- a/src/client/activation/node/manager.ts +++ b/src/client/activation/node/manager.ts @@ -117,7 +117,11 @@ export class NodeLanguageServerManager implements ILanguageServerManager { @traceDecoratorVerbose('Starting language server') protected async startLanguageServer(): Promise { const options = await this.analysisOptions.getAnalysisOptions(); - this.middleware = new NodeLanguageClientMiddleware(this.serviceContainer, this.lsVersion); + this.middleware = new NodeLanguageClientMiddleware( + this.serviceContainer, + () => this.languageServerProxy.languageClient, + this.lsVersion, + ); options.middleware = this.middleware; // Make sure the middleware is connected if we restart and we we're already connected. diff --git a/src/client/jupyter/jupyterIntegration.ts b/src/client/jupyter/jupyterIntegration.ts index 63e3194891b8..8304d91505eb 100644 --- a/src/client/jupyter/jupyterIntegration.ts +++ b/src/client/jupyter/jupyterIntegration.ts @@ -158,7 +158,20 @@ type PythonApiForJupyterExtension = { interpreter?: PythonEnvironment, ): Promise; + /** + * Call to provide a function that the Python extension can call to request the Python + * path to use for a particular notebook. + * @param func : The function that Python should call when requesting the Python path. + */ registerJupyterPythonPathFunction(func: (uri: Uri) => Promise): void; + + /** + * Call to provide a function that the Python extension can call to request the notebook + * document URI related to a particular text document URI, or undefined if there is no + * associated notebook. + * @param func : The function that Python should call when requesting the notebook URI. + */ + registerGetNotebookUriForTextDocumentUriFunction(func: (textDocumentUri: Uri) => Uri | undefined): void; }; type JupyterExtensionApi = { @@ -186,6 +199,8 @@ export class JupyterExtensionIntegration { private jupyterPythonPathFunction: ((uri: Uri) => Promise) | undefined; + private getNotebookUriForTextDocumentUriFunction: ((textDocumentUri: Uri) => Uri | undefined) | undefined; + constructor( @inject(IExtensions) private readonly extensions: IExtensions, @inject(IInterpreterService) private readonly interpreterService: IInterpreterService, @@ -281,6 +296,8 @@ export class JupyterExtensionIntegration { this.envActivation.getEnvironmentActivationShellCommands(resource, interpreter), registerJupyterPythonPathFunction: (func: (uri: Uri) => Promise) => this.registerJupyterPythonPathFunction(func), + registerGetNotebookUriForTextDocumentUriFunction: (func: (textDocumentUri: Uri) => Uri | undefined) => + this.registerGetNotebookUriForTextDocumentUriFunction(func), }); return undefined; } @@ -334,4 +351,12 @@ export class JupyterExtensionIntegration { public getJupyterPythonPathFunction(): ((uri: Uri) => Promise) | undefined { return this.jupyterPythonPathFunction; } + + public registerGetNotebookUriForTextDocumentUriFunction(func: (textDocumentUri: Uri) => Uri | undefined): void { + this.getNotebookUriForTextDocumentUriFunction = func; + } + + public getGetNotebookUriForTextDocumentUriFunction(): ((textDocumentUri: Uri) => Uri | undefined) | undefined { + return this.getNotebookUriForTextDocumentUriFunction; + } } diff --git a/src/test/activation/node/lspInteractiveWindowMiddlewareAddon.unit.test.ts b/src/test/activation/node/lspInteractiveWindowMiddlewareAddon.unit.test.ts new file mode 100644 index 000000000000..9fd78760804b --- /dev/null +++ b/src/test/activation/node/lspInteractiveWindowMiddlewareAddon.unit.test.ts @@ -0,0 +1,184 @@ +/* eslint-disable @typescript-eslint/no-empty-function */ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +'use strict'; + +import { NotebookCell, NotebookCellKind, NotebookDocument, TextDocument, Uri } from 'vscode'; +import { expect } from 'chai'; +import { anything, capture, instance, mock, verify, when } from 'ts-mockito'; +import { LanguageClient } from 'vscode-languageclient/node'; +import { LspInteractiveWindowMiddlewareAddon } from '../../../client/activation/node/lspInteractiveWindowMiddlewareAddon'; +import { JupyterExtensionIntegration } from '../../../client/jupyter/jupyterIntegration'; +import { IExtensions, IInstaller } from '../../../client/common/types'; +import { + IComponentAdapter, + ICondaService, + IInterpreterDisplay, + IInterpreterService, +} from '../../../client/interpreter/contracts'; +import { IInterpreterSelector } from '../../../client/interpreter/configuration/types'; +import { IEnvironmentActivationService } from '../../../client/interpreter/activation/types'; +import { ILanguageServerCache } from '../../../client/activation/types'; +import { IWorkspaceService } from '../../../client/common/application/types'; +import { MockMemento } from '../../mocks/mementos'; + +suite('Pylance Language Server - Interactive Window LSP Notebooks', () => { + const languageClientMock = mock(); + let languageClient: LanguageClient; + let jupyterApi: JupyterExtensionIntegration; + let middleware: LspInteractiveWindowMiddlewareAddon; + + setup(() => { + languageClient = instance(languageClientMock); + jupyterApi = new JupyterExtensionIntegration( + mock(), + mock(), + mock(), + mock(), + mock(), + mock(), + new MockMemento(), + mock(), + mock(), + mock(), + mock(), + ); + jupyterApi.registerGetNotebookUriForTextDocumentUriFunction(getNotebookUriFunction); + }); + teardown(() => { + middleware?.dispose(); + }); + + test('Unrelated document open should be forwarded to next handler unchanged', async () => { + middleware = makeMiddleware(); + + const uri = Uri.from({ scheme: 'file', path: 'test.py' }); + const textDocument = createTextDocument(uri); + + let nextCalled = false; + await middleware.didOpen(textDocument, async (_) => { + nextCalled = true; + }); + + return expect(nextCalled).to.be.true; + }); + + test('Notebook-related textDocument/didOpen should be swallowed', async () => { + middleware = makeMiddleware(); + + const uri = Uri.from({ scheme: 'test-input', path: 'Test' }); + const textDocument = createTextDocument(uri); + + let nextCalled = false; + await middleware.didOpen(textDocument, async (_) => { + nextCalled = true; + }); + + return expect(nextCalled).to.be.false; + }); + + test('Notebook-related document should be added at end of cells in notebookDocument/didOpen', async () => { + middleware = makeMiddleware(); + + const uri = Uri.from({ scheme: 'test-input', path: 'Test' }); + const textDocument = createTextDocument(uri); + + await middleware.didOpen(textDocument, async (_) => {}); + + const cellCount = 2; + const [notebookDocument, cells] = createNotebookDocument(getNotebookUriFunction(uri)!, cellCount); + await middleware.notebooks.didOpen(notebookDocument, cells, async (_, nextCells) => { + expect(nextCells.length).to.be.equals(cellCount + 1); + expect(nextCells[cellCount]).to.deep.equal({ + index: cellCount, + notebook: notebookDocument, + kind: NotebookCellKind.Code, + document: textDocument, + metadata: {}, + outputs: [], + executionSummary: undefined, + }); + }); + }); + + test('Notebook-related document opened after notebook causes notebookDocument/didChange', async () => { + middleware = makeMiddleware(); + + const uri = Uri.from({ scheme: 'test-input', path: 'Test' }); + const textDocument = createTextDocument(uri); + + const cellCount = 2; + const [notebookDocument, cells] = createNotebookDocument(getNotebookUriFunction(uri)!, cellCount); + await middleware.notebooks.didOpen(notebookDocument, cells, async (_) => {}); + + await middleware.didOpen(textDocument, async (_) => {}); + + verify(languageClientMock.sendNotification(anything(), anything())).once(); + const message = capture(languageClientMock.sendNotification).last()[1]; + + expect(message.notebookDocument.uri).to.equal(notebookDocument.uri.toString()); + expect(message.change.cells.structure).to.deep.equal({ + array: { + start: notebookDocument.cellCount, + deleteCount: 0, + cells: [{ kind: NotebookCellKind.Code, document: textDocument.uri.toString() }], + }, + didOpen: [ + { + uri: textDocument.uri.toString(), + languageId: textDocument.languageId, + version: textDocument.version, + text: textDocument.getText(), + }, + ], + didClose: undefined, + }); + }); + + function makeMiddleware(): LspInteractiveWindowMiddlewareAddon { + return new LspInteractiveWindowMiddlewareAddon(() => languageClient, jupyterApi); + } + + function getNotebookUriFunction(textDocumentUri: Uri): Uri | undefined { + if (textDocumentUri.scheme === 'test-input') { + return textDocumentUri.with({ scheme: 'test-notebook' }); + } + + return undefined; + } + + function createTextDocument(uri: Uri): TextDocument { + const textDocumentMock = mock(); + when(textDocumentMock.uri).thenReturn(uri); + when(textDocumentMock.languageId).thenReturn('python'); + when(textDocumentMock.version).thenReturn(11); + + return instance(textDocumentMock); + } + + function createNotebookDocument(uri: Uri, cellCount: number): [NotebookDocument, NotebookCell[]] { + const notebookDocumentMock = mock(); + when(notebookDocumentMock.uri).thenReturn(uri); + when(notebookDocumentMock.notebookType).thenReturn('jupyter'); + when(notebookDocumentMock.version).thenReturn(20); + when(notebookDocumentMock.cellCount).thenReturn(cellCount); + + const notebookDocument = instance(notebookDocumentMock); + + const cells: NotebookCell[] = []; + for (let i = 0; i < cellCount; i = i + 1) { + cells.push({ + index: i, + notebook: notebookDocument, + kind: NotebookCellKind.Code, + document: createTextDocument(Uri.from({ scheme: 'test-cell', path: `cell${i}` })), + metadata: {}, + outputs: [], + executionSummary: undefined, + }); + } + + return [notebookDocument, cells]; + } +});