Path: blob/main/extensions/ipynb/src/test/notebookModelStoreSync.test.ts
3292 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import * as assert from 'assert';6import * as sinon from 'sinon';7import { CancellationTokenSource, Disposable, EventEmitter, ExtensionContext, NotebookCellKind, NotebookDocumentChangeEvent, NotebookDocumentWillSaveEvent, NotebookEdit, NotebookRange, TextDocumentSaveReason, workspace, type CancellationToken, type NotebookCell, type NotebookDocument, type WorkspaceEdit, type WorkspaceEditMetadata } from 'vscode';8import { activate } from '../notebookModelStoreSync';910suite(`Notebook Model Store Sync`, () => {11let disposables: Disposable[] = [];12let onDidChangeNotebookDocument: EventEmitter<NotebookDocumentChangeEvent>;13let onWillSaveNotebookDocument: AsyncEmitter<NotebookDocumentWillSaveEvent>;14let notebook: NotebookDocument;15let token: CancellationTokenSource;16let editsApplied: WorkspaceEdit[] = [];17let pendingPromises: Promise<void>[] = [];18let cellMetadataUpdates: NotebookEdit[] = [];19let applyEditStub: sinon.SinonStub<[edit: WorkspaceEdit, metadata?: WorkspaceEditMetadata | undefined], Thenable<boolean>>;20setup(() => {21disposables = [];22notebook = {23notebookType: '',24metadata: {}25} as NotebookDocument;26token = new CancellationTokenSource();27disposables.push(token);28sinon.stub(notebook, 'notebookType').get(() => 'jupyter-notebook');29applyEditStub = sinon.stub(workspace, 'applyEdit').callsFake((edit: WorkspaceEdit) => {30editsApplied.push(edit);31return Promise.resolve(true);32});33const context = { subscriptions: [] as Disposable[] } as ExtensionContext;34onDidChangeNotebookDocument = new EventEmitter<NotebookDocumentChangeEvent>();35disposables.push(onDidChangeNotebookDocument);36onWillSaveNotebookDocument = new AsyncEmitter<NotebookDocumentWillSaveEvent>();3738sinon.stub(NotebookEdit, 'updateCellMetadata').callsFake((index, metadata) => {39const edit = (NotebookEdit.updateCellMetadata as any).wrappedMethod.call(NotebookEdit, index, metadata);40cellMetadataUpdates.push(edit);41return edit;42}43);44sinon.stub(workspace, 'onDidChangeNotebookDocument').callsFake(cb =>45onDidChangeNotebookDocument.event(cb)46);47sinon.stub(workspace, 'onWillSaveNotebookDocument').callsFake(cb =>48onWillSaveNotebookDocument.event(cb)49);50activate(context);51});52teardown(async () => {53await Promise.allSettled(pendingPromises);54editsApplied = [];55pendingPromises = [];56cellMetadataUpdates = [];57disposables.forEach(d => d.dispose());58disposables = [];59sinon.restore();60});6162test('Empty cell will not result in any updates', async () => {63const e: NotebookDocumentChangeEvent = {64notebook,65metadata: undefined,66contentChanges: [],67cellChanges: []68};6970onDidChangeNotebookDocument.fire(e);7172assert.strictEqual(editsApplied.length, 0);73});74test('Adding cell for non Jupyter Notebook will not result in any updates', async () => {75sinon.stub(notebook, 'notebookType').get(() => 'some-other-type');76const cell: NotebookCell = {77document: {} as any,78executionSummary: {},79index: 0,80kind: NotebookCellKind.Code,81metadata: {},82notebook,83outputs: []84};85const e: NotebookDocumentChangeEvent = {86notebook,87metadata: undefined,88contentChanges: [89{90range: new NotebookRange(0, 0),91removedCells: [],92addedCells: [cell]93}94],95cellChanges: []96};9798onDidChangeNotebookDocument.fire(e);99100assert.strictEqual(editsApplied.length, 0);101assert.strictEqual(cellMetadataUpdates.length, 0);102});103test('Adding cell to nbformat 4.2 notebook will result in adding empty metadata', async () => {104sinon.stub(notebook, 'metadata').get(() => ({ nbformat: 4, nbformat_minor: 2 }));105const cell: NotebookCell = {106document: {} as any,107executionSummary: {},108index: 0,109kind: NotebookCellKind.Code,110metadata: {},111notebook,112outputs: []113};114const e: NotebookDocumentChangeEvent = {115notebook,116metadata: undefined,117contentChanges: [118{119range: new NotebookRange(0, 0),120removedCells: [],121addedCells: [cell]122}123],124cellChanges: []125};126127onDidChangeNotebookDocument.fire(e);128129assert.strictEqual(editsApplied.length, 1);130assert.strictEqual(cellMetadataUpdates.length, 1);131const newMetadata = cellMetadataUpdates[0].newCellMetadata;132assert.deepStrictEqual(newMetadata, { execution_count: null, metadata: {} });133});134test('Added cell will have a cell id if nbformat is 4.5', async () => {135sinon.stub(notebook, 'metadata').get(() => ({ nbformat: 4, nbformat_minor: 5 }));136const cell: NotebookCell = {137document: {} as any,138executionSummary: {},139index: 0,140kind: NotebookCellKind.Code,141metadata: {},142notebook,143outputs: []144};145const e: NotebookDocumentChangeEvent = {146notebook,147metadata: undefined,148contentChanges: [149{150range: new NotebookRange(0, 0),151removedCells: [],152addedCells: [cell]153}154],155cellChanges: []156};157158onDidChangeNotebookDocument.fire(e);159160assert.strictEqual(editsApplied.length, 1);161assert.strictEqual(cellMetadataUpdates.length, 1);162const newMetadata = cellMetadataUpdates[0].newCellMetadata || {};163assert.strictEqual(Object.keys(newMetadata).length, 3);164assert.deepStrictEqual(newMetadata.execution_count, null);165assert.deepStrictEqual(newMetadata.metadata, {});166assert.ok(newMetadata.id);167});168test('Do not add cell id if one already exists', async () => {169sinon.stub(notebook, 'metadata').get(() => ({ nbformat: 4, nbformat_minor: 5 }));170const cell: NotebookCell = {171document: {} as any,172executionSummary: {},173index: 0,174kind: NotebookCellKind.Code,175metadata: {176id: '1234'177},178notebook,179outputs: []180};181const e: NotebookDocumentChangeEvent = {182notebook,183metadata: undefined,184contentChanges: [185{186range: new NotebookRange(0, 0),187removedCells: [],188addedCells: [cell]189}190],191cellChanges: []192};193194onDidChangeNotebookDocument.fire(e);195196assert.strictEqual(editsApplied.length, 1);197assert.strictEqual(cellMetadataUpdates.length, 1);198const newMetadata = cellMetadataUpdates[0].newCellMetadata || {};199assert.strictEqual(Object.keys(newMetadata).length, 3);200assert.deepStrictEqual(newMetadata.execution_count, null);201assert.deepStrictEqual(newMetadata.metadata, {});202assert.strictEqual(newMetadata.id, '1234');203});204test('Do not perform any updates if cell id and metadata exists', async () => {205sinon.stub(notebook, 'metadata').get(() => ({ nbformat: 4, nbformat_minor: 5 }));206const cell: NotebookCell = {207document: {} as any,208executionSummary: {},209index: 0,210kind: NotebookCellKind.Code,211metadata: {212id: '1234',213metadata: {}214},215notebook,216outputs: []217};218const e: NotebookDocumentChangeEvent = {219notebook,220metadata: undefined,221contentChanges: [222{223range: new NotebookRange(0, 0),224removedCells: [],225addedCells: [cell]226}227],228cellChanges: []229};230231onDidChangeNotebookDocument.fire(e);232233assert.strictEqual(editsApplied.length, 0);234assert.strictEqual(cellMetadataUpdates.length, 0);235});236test('Store language id in custom metadata, whilst preserving existing metadata', async () => {237sinon.stub(notebook, 'metadata').get(() => ({238nbformat: 4, nbformat_minor: 5,239metadata: {240language_info: { name: 'python' }241}242}));243const cell: NotebookCell = {244document: {245languageId: 'javascript'246} as any,247executionSummary: {},248index: 0,249kind: NotebookCellKind.Code,250metadata: {251id: '1234',252metadata: {253collapsed: true, scrolled: true254}255},256notebook,257outputs: []258};259const e: NotebookDocumentChangeEvent = {260notebook,261metadata: undefined,262contentChanges: [],263cellChanges: [264{265cell,266document: {267languageId: 'javascript'268} as any,269metadata: undefined,270outputs: undefined,271executionSummary: undefined272}273]274};275276onDidChangeNotebookDocument.fire(e);277278assert.strictEqual(editsApplied.length, 1);279assert.strictEqual(cellMetadataUpdates.length, 1);280const newMetadata = cellMetadataUpdates[0].newCellMetadata || {};281assert.strictEqual(Object.keys(newMetadata).length, 3);282assert.deepStrictEqual(newMetadata.execution_count, null);283assert.deepStrictEqual(newMetadata.metadata, { collapsed: true, scrolled: true, vscode: { languageId: 'javascript' } });284assert.strictEqual(newMetadata.id, '1234');285});286test('No changes when language is javascript', async () => {287sinon.stub(notebook, 'metadata').get(() => ({288nbformat: 4, nbformat_minor: 5,289metadata: {290language_info: { name: 'javascript' }291}292}));293const cell: NotebookCell = {294document: {295languageId: 'javascript'296} as any,297executionSummary: {},298index: 0,299kind: NotebookCellKind.Code,300metadata: {301id: '1234',302metadata: {303collapsed: true, scrolled: true304}305},306notebook,307outputs: []308};309const e: NotebookDocumentChangeEvent = {310notebook,311metadata: undefined,312contentChanges: [],313cellChanges: [314{315cell,316document: undefined,317metadata: undefined,318outputs: undefined,319executionSummary: undefined320}321]322};323324onDidChangeNotebookDocument.fire(e);325326assert.strictEqual(editsApplied.length, 0);327assert.strictEqual(cellMetadataUpdates.length, 0);328});329test('Remove language from metadata when cell language matches kernel language', async () => {330sinon.stub(notebook, 'metadata').get(() => ({331nbformat: 4, nbformat_minor: 5,332metadata: {333language_info: { name: 'javascript' }334}335}));336const cell: NotebookCell = {337document: {338languageId: 'javascript'339} as any,340executionSummary: {},341index: 0,342kind: NotebookCellKind.Code,343metadata: {344id: '1234',345metadata: {346vscode: { languageId: 'python' },347collapsed: true, scrolled: true348}349},350notebook,351outputs: []352};353const e: NotebookDocumentChangeEvent = {354notebook,355metadata: undefined,356contentChanges: [],357cellChanges: [358{359cell,360document: {361languageId: 'javascript'362} as any,363metadata: undefined,364outputs: undefined,365executionSummary: undefined366}367]368};369370onDidChangeNotebookDocument.fire(e);371372assert.strictEqual(editsApplied.length, 1);373assert.strictEqual(cellMetadataUpdates.length, 1);374const newMetadata = cellMetadataUpdates[0].newCellMetadata || {};375assert.strictEqual(Object.keys(newMetadata).length, 3);376assert.deepStrictEqual(newMetadata.execution_count, null);377assert.deepStrictEqual(newMetadata.metadata, { collapsed: true, scrolled: true });378assert.strictEqual(newMetadata.id, '1234');379});380test('Update language in metadata', async () => {381sinon.stub(notebook, 'metadata').get(() => ({382nbformat: 4, nbformat_minor: 5,383metadata: {384language_info: { name: 'javascript' }385}386}));387const cell: NotebookCell = {388document: {389languageId: 'powershell'390} as any,391executionSummary: {},392index: 0,393kind: NotebookCellKind.Code,394metadata: {395id: '1234',396metadata: {397vscode: { languageId: 'python' },398collapsed: true, scrolled: true399}400},401notebook,402outputs: []403};404const e: NotebookDocumentChangeEvent = {405notebook,406metadata: undefined,407contentChanges: [],408cellChanges: [409{410cell,411document: {412languageId: 'powershell'413} as any,414metadata: undefined,415outputs: undefined,416executionSummary: undefined417}418]419};420421onDidChangeNotebookDocument.fire(e);422423assert.strictEqual(editsApplied.length, 1);424assert.strictEqual(cellMetadataUpdates.length, 1);425const newMetadata = cellMetadataUpdates[0].newCellMetadata || {};426assert.strictEqual(Object.keys(newMetadata).length, 3);427assert.deepStrictEqual(newMetadata.execution_count, null);428assert.deepStrictEqual(newMetadata.metadata, { collapsed: true, scrolled: true, vscode: { languageId: 'powershell' } });429assert.strictEqual(newMetadata.id, '1234');430});431432test('Will save event without any changes', async () => {433await onWillSaveNotebookDocument.fireAsync({ notebook, reason: TextDocumentSaveReason.Manual }, token.token);434});435test('Wait for pending updates to complete when saving', async () => {436let resolveApplyEditPromise: (value: boolean) => void;437const promise = new Promise<boolean>((resolve) => resolveApplyEditPromise = resolve);438applyEditStub.restore();439sinon.stub(workspace, 'applyEdit').callsFake((edit: WorkspaceEdit) => {440editsApplied.push(edit);441return promise;442});443444const cell: NotebookCell = {445document: {} as any,446executionSummary: {},447index: 0,448kind: NotebookCellKind.Code,449metadata: {},450notebook,451outputs: []452};453const e: NotebookDocumentChangeEvent = {454notebook,455metadata: undefined,456contentChanges: [457{458range: new NotebookRange(0, 0),459removedCells: [],460addedCells: [cell]461}462],463cellChanges: []464};465466onDidChangeNotebookDocument.fire(e);467468assert.strictEqual(editsApplied.length, 1);469assert.strictEqual(cellMetadataUpdates.length, 1);470471// Try to save.472let saveCompleted = false;473const saved = onWillSaveNotebookDocument.fireAsync({474notebook,475reason: TextDocumentSaveReason.Manual476}, token.token);477saved.finally(() => saveCompleted = true);478await new Promise((resolve) => setTimeout(resolve, 10));479480// Verify we have not yet completed saving.481assert.strictEqual(saveCompleted, false);482483resolveApplyEditPromise!(true);484await new Promise((resolve) => setTimeout(resolve, 1));485486// Should have completed saving.487saved.finally(() => saveCompleted = true);488});489490interface IWaitUntil {491token: CancellationToken;492waitUntil(thenable: Promise<unknown>): void;493}494495interface IWaitUntil {496token: CancellationToken;497waitUntil(thenable: Promise<unknown>): void;498}499type IWaitUntilData<T> = Omit<Omit<T, 'waitUntil'>, 'token'>;500501class AsyncEmitter<T extends IWaitUntil> {502private listeners: ((d: T) => void)[] = [];503get event(): (listener: (e: T) => any, thisArgs?: any, disposables?: Disposable[]) => Disposable {504505return (listener, thisArgs, _disposables) => {506this.listeners.push(listener.bind(thisArgs));507return {508dispose: () => {509//510}511};512};513}514dispose() {515this.listeners = [];516}517async fireAsync(data: IWaitUntilData<T>, token: CancellationToken): Promise<void> {518if (!this.listeners.length) {519return;520}521522const promises: Promise<unknown>[] = [];523this.listeners.forEach(cb => {524const event = {525...data,526token,527waitUntil: (thenable: Promise<WorkspaceEdit>) => {528promises.push(thenable);529}530} as T;531cb(event);532});533534await Promise.all(promises);535}536}537});538539540