diff --git a/news/1 Enhancements/7831.md b/news/1 Enhancements/7831.md new file mode 100644 index 000000000000..cd8ca8a6c628 --- /dev/null +++ b/news/1 Enhancements/7831.md @@ -0,0 +1 @@ +Added ability to auto-save chagnes made to the notebook. diff --git a/src/client/common/application/applicationShell.ts b/src/client/common/application/applicationShell.ts index 2ab4066f67b4..ea902b332fda 100644 --- a/src/client/common/application/applicationShell.ts +++ b/src/client/common/application/applicationShell.ts @@ -5,11 +5,14 @@ // tslint:disable:no-var-requires no-any unified-signatures import { injectable } from 'inversify'; -import { CancellationToken, Disposable, env, InputBox, InputBoxOptions, MessageItem, MessageOptions, OpenDialogOptions, OutputChannel, Progress, ProgressOptions, QuickPick, QuickPickItem, QuickPickOptions, SaveDialogOptions, StatusBarAlignment, StatusBarItem, TreeView, TreeViewOptions, Uri, window, WorkspaceFolder, WorkspaceFolderPickOptions } from 'vscode'; +import { CancellationToken, Disposable, env, Event, InputBox, InputBoxOptions, MessageItem, MessageOptions, OpenDialogOptions, OutputChannel, Progress, ProgressOptions, QuickPick, QuickPickItem, QuickPickOptions, SaveDialogOptions, StatusBarAlignment, StatusBarItem, TreeView, TreeViewOptions, Uri, window, WindowState, WorkspaceFolder, WorkspaceFolderPickOptions } from 'vscode'; import { IApplicationShell } from './types'; @injectable() export class ApplicationShell implements IApplicationShell { + public get onDidChangeWindowState(): Event { + return window.onDidChangeWindowState; + } public showInformationMessage(message: string, ...items: string[]): Thenable; public showInformationMessage(message: string, options: MessageOptions, ...items: string[]): Thenable; public showInformationMessage(message: string, ...items: T[]): Thenable; @@ -81,5 +84,4 @@ export class ApplicationShell implements IApplicationShell { public createOutputChannel(name: string): OutputChannel { return window.createOutputChannel(name); } - } diff --git a/src/client/common/application/types.ts b/src/client/common/application/types.ts index e3681214ed9a..a63f538e6a8e 100644 --- a/src/client/common/application/types.ts +++ b/src/client/common/application/types.ts @@ -49,6 +49,7 @@ import { TreeViewOptions, Uri, ViewColumn, + WindowState, WorkspaceConfiguration, WorkspaceEdit, WorkspaceFolder, @@ -64,6 +65,12 @@ import { ICommandNameArgumentTypeMapping } from './commands'; export const IApplicationShell = Symbol('IApplicationShell'); export interface IApplicationShell { + /** + * An [event](#Event) which fires when the focus state of the current window + * changes. The value of the event represents whether the window is focused. + */ + readonly onDidChangeWindowState: Event; + showInformationMessage(message: string, ...items: string[]): Thenable; /** diff --git a/src/client/datascience/interactive-common/interactiveWindowTypes.ts b/src/client/datascience/interactive-common/interactiveWindowTypes.ts index 203dbdd470bc..3c438ca51a56 100644 --- a/src/client/datascience/interactive-common/interactiveWindowTypes.ts +++ b/src/client/datascience/interactive-common/interactiveWindowTypes.ts @@ -27,6 +27,8 @@ export namespace InteractiveWindowMessages { export const Interrupt = 'interrupt'; export const SubmitNewCell = 'submit_new_cell'; export const UpdateSettings = SharedMessages.UpdateSettings; + // Message sent to React component from extension asking it to save the notebook. + export const DoSave = 'DoSave'; export const SendInfo = 'send_info'; export const Started = SharedMessages.Started; export const AddedSysInfo = 'added_sys_info'; diff --git a/src/client/datascience/interactive-ipynb/autoSaveService.ts b/src/client/datascience/interactive-ipynb/autoSaveService.ts new file mode 100644 index 000000000000..61f69e225490 --- /dev/null +++ b/src/client/datascience/interactive-ipynb/autoSaveService.ts @@ -0,0 +1,142 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +'use strict'; + +import { inject, injectable } from 'inversify'; +import { ConfigurationChangeEvent, Event, EventEmitter, TextEditor, Uri } from 'vscode'; +import { IApplicationShell, IDocumentManager, IWorkspaceService } from '../../common/application/types'; +import '../../common/extensions'; +import { traceError } from '../../common/logger'; +import { IFileSystem } from '../../common/platform/types'; +import { IDisposable } from '../../common/types'; +import { INotebookIdentity, InteractiveWindowMessages } from '../interactive-common/interactiveWindowTypes'; +import { FileSettings, IInteractiveWindowListener, INotebookEditor, INotebookEditorProvider } from '../types'; + +// tslint:disable: no-any + +/** + * Sends notifications to Notebooks to save the notebook. + * Based on auto save settings, this class will regularly check for changes and send a save requet. + * If window state changes or active editor changes, then notify notebooks (if auto save is configured to do so). + * Monitor save and modified events on editor to determine its current dirty state. + * + * @export + * @class AutoSaveService + * @implements {IInteractiveWindowListener} + */ +@injectable() +export class AutoSaveService implements IInteractiveWindowListener { + private postEmitter: EventEmitter<{ message: string; payload: any }> = new EventEmitter<{ message: string; payload: any }>(); + private disposables: IDisposable[] = []; + private notebookUri?: Uri; + private timeout?: ReturnType; + constructor( + @inject(IApplicationShell) appShell: IApplicationShell, + @inject(IDocumentManager) documentManager: IDocumentManager, + @inject(INotebookEditorProvider) private readonly notebookProvider: INotebookEditorProvider, + @inject(IFileSystem) private readonly fileSystem: IFileSystem, + @inject(IWorkspaceService) private readonly workspace: IWorkspaceService + ) { + this.workspace.onDidChangeConfiguration(this.onSettingsChanded.bind(this), this, this.disposables); + this.disposables.push(appShell.onDidChangeWindowState(this.onDidChangeWindowState.bind(this))); + this.disposables.push(documentManager.onDidChangeActiveTextEditor(this.onDidChangeActiveTextEditor.bind(this))); + } + + public get postMessage(): Event<{ message: string; payload: any }> { + return this.postEmitter.event; + } + + public onMessage(message: string, payload?: any): void { + if (message === InteractiveWindowMessages.NotebookIdentity) { + this.notebookUri = Uri.parse((payload as INotebookIdentity).resource); + } + if (message === InteractiveWindowMessages.LoadAllCellsComplete) { + const notebook = this.getNotebook(); + if (!notebook) { + traceError(`Received message ${message}, but there is no notebook for ${this.notebookUri ? this.notebookUri.fsPath : undefined}`); + return; + } + this.disposables.push(notebook.modified(this.onNotebookModified, this, this.disposables)); + this.disposables.push(notebook.saved(this.onNotebookSaved, this, this.disposables)); + } + } + public dispose(): void | undefined { + this.disposables.filter(item => !!item).forEach(item => item.dispose()); + this.clearTimeout(); + } + private onNotebookModified(_: INotebookEditor) { + // If we haven't started a timer, then start if necessary. + if (!this.timeout) { + this.setTimer(); + } + } + private onNotebookSaved(_: INotebookEditor) { + // If we haven't started a timer, then start if necessary. + if (!this.timeout) { + this.setTimer(); + } + } + private getNotebook(): INotebookEditor | undefined { + const uri = this.notebookUri; + if (!uri) { + return; + } + return this.notebookProvider.editors.find(item => this.fileSystem.arePathsSame(item.file.fsPath, uri.fsPath)); + } + private getAutoSaveSettings(): FileSettings { + const filesConfig = this.workspace.getConfiguration('files', this.notebookUri); + return { + autoSave: filesConfig.get('autoSave', 'off'), + autoSaveDelay: filesConfig.get('autoSaveDelay', 1000) + }; + } + private onSettingsChanded(e: ConfigurationChangeEvent) { + if (e.affectsConfiguration('files.autoSave') || e.affectsConfiguration('files.autoSaveDelay')) { + // Reset the timer, as we may have increased it, turned it off or other. + this.clearTimeout(); + this.setTimer(); + } + } + private setTimer() { + const settings = this.getAutoSaveSettings(); + if (!settings || settings.autoSave === 'off') { + return; + } + if (settings && settings.autoSave === 'afterDelay') { + // Add a timeout to save after n milli seconds. + // Do not use setInterval, as that will cause all handlers to queue up. + this.timeout = setTimeout(() => { + this.save(); + }, settings.autoSaveDelay); + } + } + private clearTimeout() { + if (this.timeout) { + clearTimeout(this.timeout); + this.timeout = undefined; + } + } + private save() { + this.clearTimeout(); + const notebook = this.getNotebook(); + if (notebook && notebook.isDirty) { + // Notify webview to perform a save. + this.postEmitter.fire({ message: InteractiveWindowMessages.DoSave, payload: undefined }); + } else { + this.setTimer(); + } + } + private onDidChangeWindowState() { + const settings = this.getAutoSaveSettings(); + if (settings && settings.autoSave === 'onWindowChange') { + this.save(); + } + } + private onDidChangeActiveTextEditor(_e?: TextEditor) { + const settings = this.getAutoSaveSettings(); + if (settings && settings.autoSave === 'onFocusChange') { + this.save(); + } + } +} diff --git a/src/client/datascience/interactive-ipynb/nativeEditor.ts b/src/client/datascience/interactive-ipynb/nativeEditor.ts index affa86420c7c..29b4fd644ea7 100644 --- a/src/client/datascience/interactive-ipynb/nativeEditor.ts +++ b/src/client/datascience/interactive-ipynb/nativeEditor.ts @@ -9,14 +9,7 @@ import * as path from 'path'; import * as uuid from 'uuid/v4'; import { Event, EventEmitter, Memento, Uri, ViewColumn } from 'vscode'; -import { - IApplicationShell, - ICommandManager, - IDocumentManager, - ILiveShareApi, - IWebPanelProvider, - IWorkspaceService -} from '../../common/application/types'; +import { IApplicationShell, ICommandManager, IDocumentManager, ILiveShareApi, IWebPanelProvider, IWorkspaceService } from '../../common/application/types'; import { ContextKey } from '../../common/contextKey'; import { traceError } from '../../common/logger'; import { IFileSystem, TemporaryFile } from '../../common/platform/types'; @@ -28,21 +21,9 @@ import { EXTENSION_ROOT_DIR } from '../../constants'; import { IInterpreterService } from '../../interpreter/contracts'; import { captureTelemetry, sendTelemetryEvent } from '../../telemetry'; import { concatMultilineString } from '../common'; -import { - EditorContexts, - Identifiers, - NativeKeyboardCommandTelemetryLookup, - NativeMouseCommandTelemetryLookup, - Telemetry -} from '../constants'; +import { EditorContexts, Identifiers, NativeKeyboardCommandTelemetryLookup, NativeMouseCommandTelemetryLookup, Telemetry } from '../constants'; import { InteractiveBase } from '../interactive-common/interactiveBase'; -import { - IEditCell, - INativeCommand, - InteractiveWindowMessages, - ISaveAll, - ISubmitNewCell -} from '../interactive-common/interactiveWindowTypes'; +import { IEditCell, INativeCommand, InteractiveWindowMessages, ISaveAll, ISubmitNewCell } from '../interactive-common/interactiveWindowTypes'; import { CellState, ICell, @@ -74,6 +55,7 @@ export class NativeEditor extends InteractiveBase implements INotebookEditor { private closedEvent: EventEmitter = new EventEmitter(); private executedEvent: EventEmitter = new EventEmitter(); private modifiedEvent: EventEmitter = new EventEmitter(); + private savedEvent: EventEmitter = new EventEmitter(); private loadedPromise: Deferred = createDeferred(); private _file: Uri = Uri.file(''); private _dirty: boolean = false; @@ -129,7 +111,8 @@ export class NativeEditor extends InteractiveBase implements INotebookEditor { errorHandler, path.join(EXTENSION_ROOT_DIR, 'out', 'datascience-ui', 'native-editor', 'index_bundle.js'), localize.DataScience.nativeEditorTitle(), - ViewColumn.Active); + ViewColumn.Active + ); } public get visible(): boolean { @@ -187,6 +170,14 @@ export class NativeEditor extends InteractiveBase implements INotebookEditor { return this.modifiedEvent.event; } + public get saved(): Event { + return this.savedEvent.event; + } + + public get isDirty(): boolean { + return this._dirty; + } + // tslint:disable-next-line: no-any public onMessage(message: string, payload: any) { super.onMessage(message, payload); @@ -269,9 +260,19 @@ export class NativeEditor extends InteractiveBase implements INotebookEditor { this.submitCode(info.code, Identifiers.EmptyFileName, 0, info.id).ignoreErrors(); // Activate the other side, and send as if came from a file - this.ipynbProvider.show(this.file).then(_v => { - this.shareMessage(InteractiveWindowMessages.RemoteAddCode, { code: info.code, file: Identifiers.EmptyFileName, line: 0, id: info.id, originator: this.id, debug: false }); - }).ignoreErrors(); + this.ipynbProvider + .show(this.file) + .then(_v => { + this.shareMessage(InteractiveWindowMessages.RemoteAddCode, { + code: info.code, + file: Identifiers.EmptyFileName, + line: 0, + id: info.id, + originator: this.id, + debug: false + }); + }) + .ignoreErrors(); } } @@ -289,7 +290,14 @@ export class NativeEditor extends InteractiveBase implements INotebookEditor { // Activate the other side, and send as if came from a file await this.ipynbProvider.show(this.file); - this.shareMessage(InteractiveWindowMessages.RemoteReexecuteCode, { code: info.code, file: Identifiers.EmptyFileName, line: 0, id: info.id, originator: this.id, debug: false }); + this.shareMessage(InteractiveWindowMessages.RemoteReexecuteCode, { + code: info.code, + file: Identifiers.EmptyFileName, + line: 0, + id: info.id, + originator: this.id, + debug: false + }); } } catch (exc) { // Make this error our cell output @@ -298,10 +306,12 @@ export class NativeEditor extends InteractiveBase implements INotebookEditor { data: { source: info.code, cell_type: 'code', - outputs: [{ - output_type: 'error', - evalue: exc.toString() - }], + outputs: [ + { + output_type: 'error', + evalue: exc.toString() + } + ], metadata: {}, execution_count: null }, @@ -318,7 +328,6 @@ export class NativeEditor extends InteractiveBase implements INotebookEditor { // Handle an error await this.errorHandler.handleError(exc); - } } @@ -395,8 +404,7 @@ export class NativeEditor extends InteractiveBase implements INotebookEditor { cell_type: 'code', outputs: [], source: [], - metadata: { - }, + metadata: {}, execution_count: null } }; @@ -570,7 +578,6 @@ export class NativeEditor extends InteractiveBase implements INotebookEditor { if (contents) { await this.viewDocument(contents); } - } catch (e) { await this.errorHandler.handleError(e); } finally { @@ -615,8 +622,8 @@ export class NativeEditor extends InteractiveBase implements INotebookEditor { // Update our file name and dirty state this._file = fileToSaveTo; await this.setClean(); + this.savedEvent.fire(this); } - } catch (e) { traceError(e); } diff --git a/src/client/datascience/serviceRegistry.ts b/src/client/datascience/serviceRegistry.ts index ffba92db2bc1..435b6285d99b 100644 --- a/src/client/datascience/serviceRegistry.ts +++ b/src/client/datascience/serviceRegistry.ts @@ -25,6 +25,7 @@ import { DotNetIntellisenseProvider } from './interactive-common/intellisense/do import { JediIntellisenseProvider } from './interactive-common/intellisense/jediIntellisenseProvider'; import { LinkProvider } from './interactive-common/linkProvider'; import { ShowPlotListener } from './interactive-common/showPlotListener'; +import { AutoSaveService } from './interactive-ipynb/autoSaveService'; import { NativeEditor } from './interactive-ipynb/nativeEditor'; import { NativeEditorCommandListener } from './interactive-ipynb/nativeEditorCommandListener'; import { NativeEditorProvider } from './interactive-ipynb/nativeEditorProvider'; @@ -123,6 +124,7 @@ export function registerTypes(serviceManager: IServiceManager) { serviceManager.add(IInteractiveWindowListener, wrapType(ShowPlotListener)); serviceManager.add(IInteractiveWindowListener, wrapType(DebugListener)); serviceManager.add(IInteractiveWindowListener, wrapType(GatherListener)); + serviceManager.add(IInteractiveWindowListener, wrapType(AutoSaveService)); serviceManager.addSingleton(IPlotViewerProvider, wrapType(PlotViewerProvider)); serviceManager.add(IPlotViewer, wrapType(PlotViewer)); serviceManager.addSingleton(IJupyterDebugger, wrapType(JupyterDebugger)); diff --git a/src/client/datascience/types.ts b/src/client/datascience/types.ts index f0edda8be83b..9b4266655e8c 100644 --- a/src/client/datascience/types.ts +++ b/src/client/datascience/types.ts @@ -250,6 +250,11 @@ export interface INotebookEditor extends IInteractiveBase { closed: Event; executed: Event; modified: Event; + saved: Event; + /** + * `true` if there are unpersisted changes. + */ + readonly isDirty: boolean; readonly file: Uri; readonly visible: boolean; readonly active: boolean; @@ -394,6 +399,11 @@ export interface IJupyterCommandFactory { } // Config settings we pass to our react code +export type FileSettings = { + autoSaveDelay: number; + autoSave: 'afterDelay' | 'off' | 'onFocusChange' | 'onWindowChange'; +}; + export interface IDataScienceExtraSettings extends IDataScienceSettings { extraSettings: { editorCursor: string; diff --git a/src/client/datascience/webViewHost.ts b/src/client/datascience/webViewHost.ts index 114e8fece340..a28255d4fec0 100644 --- a/src/client/datascience/webViewHost.ts +++ b/src/client/datascience/webViewHost.ts @@ -246,6 +246,8 @@ export class WebViewHost implements IDisposable { event.affectsConfiguration('editor.fontFamily') || event.affectsConfiguration('editor.cursorStyle') || event.affectsConfiguration('editor.cursorBlinking') || + event.affectsConfiguration('files.autoSave') || + event.affectsConfiguration('files.autoSaveDelay') || event.affectsConfiguration('python.dataScience.enableGather')) { // See if the theme changed const newSettings = this.generateDataScienceExtraSettings(); diff --git a/src/datascience-ui/interactive-common/mainStateController.ts b/src/datascience-ui/interactive-common/mainStateController.ts index d40a34e3eb1a..5d5049ec8a9a 100644 --- a/src/datascience-ui/interactive-common/mainStateController.ts +++ b/src/datascience-ui/interactive-common/mainStateController.ts @@ -56,10 +56,10 @@ export interface IMainStateControllerProps { // tslint:disable-next-line: max-func-body-length export class MainStateController implements IMessageHandler { + protected readonly postOffice: PostOffice = new PostOffice(); private stackLimit = 10; private pendingState: IMainState; private renderedState: IMainState; - private postOffice: PostOffice = new PostOffice(); private intellisenseProvider: IntellisenseProvider; private onigasmPromise: Deferred | undefined; private tmlangugePromise: Deferred | undefined; diff --git a/src/datascience-ui/native-editor/nativeEditorStateController.ts b/src/datascience-ui/native-editor/nativeEditorStateController.ts index 371939f22781..6ceb10dda9a6 100644 --- a/src/datascience-ui/native-editor/nativeEditorStateController.ts +++ b/src/datascience-ui/native-editor/nativeEditorStateController.ts @@ -23,7 +23,6 @@ export class NativeEditorStateController extends MainStateController { constructor(props: IMainStateControllerProps) { super(props); } - // tslint:disable-next-line: no-any public handleMessage(msg: string, payload?: any) { // Handle message before base class so we will @@ -54,6 +53,9 @@ export class NativeEditorStateController extends MainStateController { case InteractiveWindowMessages.NotebookAddCellBelow: this.addNewCell(); break; + case InteractiveWindowMessages.DoSave: + this.save(); + break; default: break; diff --git a/src/datascience-ui/react-common/postOffice.ts b/src/datascience-ui/react-common/postOffice.ts index 017c7def8c0b..afb52319e863 100644 --- a/src/datascience-ui/react-common/postOffice.ts +++ b/src/datascience-ui/react-common/postOffice.ts @@ -14,10 +14,11 @@ export interface IVsCodeApi { getState() : any; } -export interface IMessageHandler { - // tslint:disable-next-line:no-any - handleMessage(type: string, payload?: any) : boolean; -} + export interface IMessageHandler { + // tslint:disable-next-line:no-any + handleMessage(type: string, payload?: any) : boolean; + dispose?(): void; + } // This special function talks to vscode from a web panel export declare function acquireVsCodeApi(): IVsCodeApi; @@ -66,7 +67,7 @@ export class PostOffice implements IDisposable { // Only do this once as it crashes if we ask more than once // tslint:disable-next-line:no-typeof-undefined if (!this.vscodeApi && typeof acquireVsCodeApi !== 'undefined') { - this.vscodeApi = acquireVsCodeApi(); + this.vscodeApi = acquireVsCodeApi(); // NOSONAR } if (!this.registered) { this.registered = true; diff --git a/src/datascience-ui/react-common/settingsReactSide.ts b/src/datascience-ui/react-common/settingsReactSide.ts index 4bd071381afa..ca1a370e78a5 100644 --- a/src/datascience-ui/react-common/settingsReactSide.ts +++ b/src/datascience-ui/react-common/settingsReactSide.ts @@ -28,7 +28,7 @@ export function updateSettings(jsonSettingsString: string) { function load() { // tslint:disable-next-line:no-typeof-undefined if (typeof getInitialSettings !== 'undefined') { - loadedSettings = getInitialSettings(); + loadedSettings = getInitialSettings(); // NOSONAR } else { // Default settings for tests loadedSettings = { diff --git a/src/test/common.ts b/src/test/common.ts index a5172832b799..83807480abf7 100644 --- a/src/test/common.ts +++ b/src/test/common.ts @@ -18,7 +18,7 @@ import { IServiceContainer, IServiceManager } from '../client/ioc/types'; import { EXTENSION_ROOT_DIR_FOR_TESTS, IS_MULTI_ROOT_TEST, IS_PERF_TEST, IS_SMOKE_TEST } from './constants'; -import { noop, sleep } from './core'; +import { noop } from './core'; const StreamZip = require('node-stream-zip'); @@ -410,27 +410,31 @@ export async function unzip(zipFile: string, targetFolder: string): Promise Promise} condition + * @param {number} timeoutMs + * @param {string} errorMessage + * @returns {Promise} + */ export async function waitForCondition(condition: () => Promise, timeoutMs: number, errorMessage: string): Promise { return new Promise(async (resolve, reject) => { - let completed = false; const timeout = setTimeout(() => { - if (!completed) { - reject(new Error(errorMessage)); - } - completed = true; + clearTimeout(timeout); + // tslint:disable-next-line: no-use-before-declare + clearTimeout(timer); + reject(new Error(errorMessage)); }, timeoutMs); - for (let i = 0; i < timeoutMs / 1000; i += 1) { - if (await condition()) { - clearTimeout(timeout); - resolve(); - return; - } - await sleep(500); - if (completed) { + const timer = setInterval(async () => { + if (!await condition().catch(() => false)) { return; } - } + clearTimeout(timeout); + clearTimeout(timer); + resolve(); + }, 10); }); } diff --git a/src/test/datascience/dataScienceIocContainer.ts b/src/test/datascience/dataScienceIocContainer.ts index 9cd482d3023b..1ff5fdb9cf2b 100644 --- a/src/test/datascience/dataScienceIocContainer.ts +++ b/src/test/datascience/dataScienceIocContainer.ts @@ -7,6 +7,7 @@ import { ReactWrapper } from 'enzyme'; import { interfaces } from 'inversify'; import * as path from 'path'; import { SemVer } from 'semver'; +import { anything, instance, mock, when } from 'ts-mockito'; import * as TypeMoq from 'typemoq'; import { ConfigurationChangeEvent, @@ -20,7 +21,6 @@ import { WorkspaceFolder } from 'vscode'; import * as vsls from 'vsls/vscode'; - import { ILanguageServer, ILanguageServerAnalysisOptions } from '../../client/activation/types'; import { TerminalManager } from '../../client/common/application/terminalManager'; import { @@ -37,6 +37,7 @@ import { IWorkspaceService, WebPanelMessage } from '../../client/common/application/types'; +import { WorkspaceService } from '../../client/common/application/workspace'; import { AsyncDisposableRegistry } from '../../client/common/asyncDisposableRegistry'; import { PythonSettings } from '../../client/common/configSettings'; import { EXTENSION_ROOT_DIR } from '../../client/common/constants'; @@ -108,6 +109,7 @@ import { GatherListener } from '../../client/datascience/gather/gatherListener'; import { DotNetIntellisenseProvider } from '../../client/datascience/interactive-common/intellisense/dotNetIntellisenseProvider'; +import { AutoSaveService } from '../../client/datascience/interactive-ipynb/autoSaveService'; import { NativeEditor } from '../../client/datascience/interactive-ipynb/nativeEditor'; import { NativeEditorCommandListener } from '../../client/datascience/interactive-ipynb/nativeEditorCommandListener'; import { InteractiveWindow } from '../../client/datascience/interactive-window/interactiveWindow'; @@ -249,6 +251,7 @@ import { MockJupyterManagerFactory } from './mockJupyterManagerFactory'; import { MockLanguageServer } from './mockLanguageServer'; import { MockLanguageServerAnalysisOptions } from './mockLanguageServerAnalysisOptions'; import { MockLiveShareApi } from './mockLiveShare'; +import { MockWorkspaceConfiguration } from './mockWorkspaceConfig'; import { blurWindow, createMessageEvent } from './reactHelpers'; import { TestInteractiveWindowProvider } from './testInteractiveWindowProvider'; import { TestNativeEditorProvider } from './testNativeEditorProvider'; @@ -259,6 +262,8 @@ export class DataScienceIocContainer extends UnitTestIocContainer { public wrapper: ReactWrapper, React.Component> | undefined; public wrapperCreatedPromise: Deferred | undefined; public postMessage: ((ev: MessageEvent) => void) | undefined; + public mockedWorkspaceConfig!: WorkspaceConfiguration; + public applicationShell!: TypeMoq.IMock; // tslint:disable-next-line:no-any private missedMessages: any[] = []; private pythonSettings = new class extends PythonSettings { @@ -391,6 +396,7 @@ export class DataScienceIocContainer extends UnitTestIocContainer { this.serviceManager.addSingleton(ILanguageServer, MockLanguageServer); this.serviceManager.addSingleton(ILanguageServerAnalysisOptions, MockLanguageServerAnalysisOptions); this.serviceManager.add(IInteractiveWindowListener, DotNetIntellisenseProvider); + this.serviceManager.add(IInteractiveWindowListener, AutoSaveService); this.serviceManager.add(IProtocolParser, ProtocolParser); this.serviceManager.addSingleton(IDebugService, MockDebuggerService); this.serviceManager.addSingleton(ICellHashProvider, CellHashProvider); @@ -416,8 +422,9 @@ export class DataScienceIocContainer extends UnitTestIocContainer { // Also setup a mock execution service and interpreter service const condaService = TypeMoq.Mock.ofType(); - const appShell = TypeMoq.Mock.ofType(); - const workspaceService = TypeMoq.Mock.ofType(); + const appShell = this.applicationShell = TypeMoq.Mock.ofType(); + // const workspaceService = TypeMoq.Mock.ofType(); + const workspaceService = mock(WorkspaceService); const configurationService = TypeMoq.Mock.ofType(); const interpreterDisplay = TypeMoq.Mock.ofType(); const datascience = TypeMoq.Mock.ofType(); @@ -454,18 +461,14 @@ export class DataScienceIocContainer extends UnitTestIocContainer { debugJustMyCode: true }; - const workspaceConfig: TypeMoq.IMock = TypeMoq.Mock.ofType(); - workspaceConfig.setup(ws => ws.has(TypeMoq.It.isAnyString())) - .returns(() => false); - workspaceConfig.setup(ws => ws.get(TypeMoq.It.isAnyString())) - .returns(() => undefined); - workspaceConfig.setup(ws => ws.get(TypeMoq.It.isAnyString(), TypeMoq.It.isAny())) - .returns((_s, d) => d); - + const workspaceConfig = this.mockedWorkspaceConfig = mock(MockWorkspaceConfiguration); configurationService.setup(c => c.getSettings(TypeMoq.It.isAny())).returns(() => this.pythonSettings); - workspaceService.setup(c => c.getConfiguration(TypeMoq.It.isAny())).returns(() => workspaceConfig.object); - workspaceService.setup(c => c.getConfiguration(TypeMoq.It.isAny(), TypeMoq.It.isAny())).returns(() => workspaceConfig.object); - workspaceService.setup(w => w.onDidChangeConfiguration).returns(() => this.configChangeEvent.event); + when(workspaceConfig.get(anything(), anything())).thenCall((_, defaultValue) => defaultValue); + when(workspaceConfig.has(anything())).thenReturn(false); + when((workspaceConfig as any).then).thenReturn(undefined); + when(workspaceService.getConfiguration(anything())).thenReturn(instance(workspaceConfig)); + when(workspaceService.getConfiguration(anything(), anything())).thenReturn(instance(workspaceConfig)); + when(workspaceService.onDidChangeConfiguration).thenReturn(this.configChangeEvent.event); interpreterDisplay.setup(i => i.refresh(TypeMoq.It.isAny())).returns(() => Promise.resolve()); const startTime = Date.now(); datascience.setup(d => d.activationStartTime).returns(() => startTime); @@ -490,18 +493,12 @@ export class DataScienceIocContainer extends UnitTestIocContainer { noop(); } } - workspaceService.setup(w => w.createFileSystemWatcher(TypeMoq.It.isAny(), TypeMoq.It.isAny(), TypeMoq.It.isAny(), TypeMoq.It.isAny())).returns(() => { - return new MockFileSystemWatcher(); - }); - workspaceService - .setup(w => w.hasWorkspaceFolders) - .returns(() => true); + when(workspaceService.createFileSystemWatcher(anything(), anything(), anything(), anything())).thenReturn(new MockFileSystemWatcher()); + when(workspaceService.hasWorkspaceFolders).thenReturn(true); const testWorkspaceFolder = path.join(EXTENSION_ROOT_DIR, 'src', 'test', 'datascience'); const workspaceFolder = this.createMoqWorkspaceFolder(testWorkspaceFolder); - workspaceService - .setup(w => w.workspaceFolders) - .returns(() => [workspaceFolder]); - workspaceService.setup(w => w.rootPath).returns(() => '~'); + when(workspaceService.workspaceFolders).thenReturn([workspaceFolder]); + when(workspaceService.rootPath).thenReturn('~'); // Look on the path for python const pythonPath = this.findPythonPath(); @@ -528,7 +525,7 @@ export class DataScienceIocContainer extends UnitTestIocContainer { this.serviceManager.addSingletonInstance(ICondaService, condaService.object); this.serviceManager.addSingletonInstance(IApplicationShell, appShell.object); this.serviceManager.addSingletonInstance(IDocumentManager, this.documentManager); - this.serviceManager.addSingletonInstance(IWorkspaceService, workspaceService.object); + this.serviceManager.addSingletonInstance(IWorkspaceService, instance(workspaceService)); this.serviceManager.addSingletonInstance(IConfigurationService, configurationService.object); this.serviceManager.addSingletonInstance(IDataScience, datascience.object); this.serviceManager.addSingleton(IBufferDecoder, BufferDecoder); diff --git a/src/test/datascience/mockDocumentManager.ts b/src/test/datascience/mockDocumentManager.ts index 3ef780303f82..3c8052fc2c08 100644 --- a/src/test/datascience/mockDocumentManager.ts +++ b/src/test/datascience/mockDocumentManager.ts @@ -31,7 +31,7 @@ export class MockDocumentManager implements IDocumentManager { public textDocuments: TextDocument[] = []; public activeTextEditor: TextEditor | undefined; public visibleTextEditors: TextEditor[] = []; - private didChangeEmitter = new EventEmitter(); + public didChangeEmitter = new EventEmitter(); private didOpenEmitter = new EventEmitter(); private didChangeVisibleEmitter = new EventEmitter(); private didChangeTextEditorSelectionEmitter = new EventEmitter(); diff --git a/src/test/datascience/mockWorkspaceConfig.ts b/src/test/datascience/mockWorkspaceConfig.ts new file mode 100644 index 000000000000..5a6b6e1fe48e --- /dev/null +++ b/src/test/datascience/mockWorkspaceConfig.ts @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +'use strict'; + +import { ConfigurationTarget, WorkspaceConfiguration } from 'vscode'; + +export class MockWorkspaceConfiguration implements WorkspaceConfiguration { + // tslint:disable: no-any + public get(key: string): any; + public get(section: string): T | undefined; + public get(section: string, defaultValue: T): T; + public get(section: any, defaultValue?: any): any; + public get(_: string, defaultValue?: any): any { + return arguments.length > 1 ? defaultValue : (undefined as any); + } + public has(_section: string): boolean { + return false; + } + public inspect( + _section: string + ): { key: string; defaultValue?: T | undefined; globalValue?: T | undefined; workspaceValue?: T | undefined; workspaceFolderValue?: T | undefined } | undefined { + return; + } + public update(_section: string, _value: any, _configurationTarget?: boolean | ConfigurationTarget | undefined): Promise { + return Promise.resolve(); + } +} diff --git a/src/test/datascience/nativeEditor.functional.test.tsx b/src/test/datascience/nativeEditor.functional.test.tsx index dc0704d81c01..65d8a4624c11 100644 --- a/src/test/datascience/nativeEditor.functional.test.tsx +++ b/src/test/datascience/nativeEditor.functional.test.tsx @@ -3,16 +3,22 @@ 'use strict'; -import * as assert from 'assert'; +import { assert, expect, use } from 'chai'; +import * as chaiAsPromised from 'chai-as-promised'; import { ReactWrapper } from 'enzyme'; -import * as monacoEditor from 'monaco-editor/esm/vs/editor/editor.api'; +import { EventEmitter } from 'events'; +import * as fs from 'fs-extra'; import * as path from 'path'; +import * as sinon from 'sinon'; +import { anything, when } from 'ts-mockito'; import * as TypeMoq from 'typemoq'; -import { Disposable, TextDocument, TextEditor, Uri } from 'vscode'; +import { Disposable, TextDocument, TextEditor, Uri, WindowState } from 'vscode'; import { IApplicationShell, IDocumentManager } from '../../client/common/application/types'; import { createDeferred } from '../../client/common/utils/async'; +import { createTemporaryFile } from '../../client/common/utils/fs'; import { noop } from '../../client/common/utils/misc'; import { Identifiers } from '../../client/datascience/constants'; +import { InteractiveWindowMessages } from '../../client/datascience/interactive-common/interactiveWindowTypes'; import { JupyterExecutionFactory } from '../../client/datascience/jupyter/jupyterExecutionFactory'; import { ICell, IJupyterExecution, INotebookEditorProvider, INotebookExporter } from '../../client/datascience/types'; import { PythonInterpreter } from '../../client/interpreter/contracts'; @@ -21,16 +27,14 @@ import { CellOutput } from '../../datascience-ui/interactive-common/cellOutput'; import { Editor } from '../../datascience-ui/interactive-common/editor'; import { NativeCell } from '../../datascience-ui/native-editor/nativeCell'; import { NativeEditor } from '../../datascience-ui/native-editor/nativeEditor'; +import { NativeEditorStateController } from '../../datascience-ui/native-editor/nativeEditorStateController'; import { IKeyboardEvent } from '../../datascience-ui/react-common/event'; import { ImageButton } from '../../datascience-ui/react-common/imageButton'; import { IMonacoEditorState, MonacoEditor } from '../../datascience-ui/react-common/monacoEditor'; +import { waitForCondition } from '../common'; import { DataScienceIocContainer } from './dataScienceIocContainer'; -import { - addCell, - closeNotebook, - createNewEditor, - getNativeCellResults, - mountNativeWebView, openEditor, runMountedTest, setupWebview } from './nativeEditorTestHelpers'; +import { MockDocumentManager } from './mockDocumentManager'; +import { addCell, closeNotebook, createNewEditor, getNativeCellResults, mountNativeWebView, openEditor, runMountedTest, setupWebview } from './nativeEditorTestHelpers'; import { waitForUpdate } from './reactHelpers'; import { addContinuousMockData, @@ -47,13 +51,13 @@ import { verifyHtmlOnCell, waitForMessageResponse } from './testHelpers'; +use(chaiAsPromised); //import { asyncDump } from '../common/asyncDump'; // tslint:disable:max-func-body-length trailing-comma no-any no-multiline-string suite('DataScience Native Editor', () => { - function createFileCell(cell: any, data: any): ICell { - const newCell = { type: 'preview', id: 'FakeID', file: Identifiers.EmptyFileName, line: 0, state: 2, ...cell}; + const newCell = { type: 'preview', id: 'FakeID', file: Identifiers.EmptyFileName, line: 0, state: 2, ...cell }; newCell.data = { cell_type: 'code', execution_count: null, metadata: {}, outputs: [], source: '', ...data }; return newCell; @@ -287,7 +291,13 @@ suite('DataScience Native Editor', () => { await waitForMessageResponse(ioc, () => runAllButton!.simulate('click')); await waitForUpdate(wrapper, NativeEditor, 15); verifyHtmlOnCell(wrapper, 'NativeCell', `1`, 0); - }, () => { return ioc; }); + }, + () => { + // Disable the warning displayed by nodejs when there are too many listeners. + EventEmitter.defaultMaxListeners = 15; + return ioc; + } + ); test('Failure', async () => { // Make a dummy class that will fail during launch @@ -308,7 +318,7 @@ suite('DataScience Native Editor', () => { }); }); - suite('Editor Keyboard tests', () => { + suite('Editor tests', () => { let wrapper: ReactWrapper, React.Component>; const disposables: Disposable[] = []; let ioc: DataScienceIocContainer; @@ -317,9 +327,15 @@ suite('DataScience Native Editor', () => { { id: 'NotebookImport#1', data: { source: 'b=2\nb' } }, { id: 'NotebookImport#2', data: { source: 'c=3\nc' } } ]; - setup(async function() { + let notebookFile: { + filePath: string; + cleanupCallback: Function; + }; + function initIoc() { ioc = new DataScienceIocContainer(); ioc.registerDataScienceTypes(); + } + async function setupFunction(this: Mocha.Context) { const wrapperPossiblyUndefined = await setupWebview(ioc); if (wrapperPossiblyUndefined) { wrapper = wrapperPossiblyUndefined; @@ -330,13 +346,16 @@ suite('DataScience Native Editor', () => { const runAllCells = baseFile.map(cell => { return createFileCell(cell, cell.data); }); + // Use a real file so we can save notebook to a file. + // This is used in some tests (saving). + notebookFile = await createTemporaryFile('.ipynb'); const notebook = await ioc.get(INotebookExporter).translateToNotebook(runAllCells, undefined); - await Promise.all([waitForUpdate(wrapper, NativeEditor, 1), openEditor(ioc, JSON.stringify(notebook))]); + await Promise.all([waitForUpdate(wrapper, NativeEditor, 1), openEditor(ioc, JSON.stringify(notebook), notebookFile.filePath)]); } else { // tslint:disable-next-line: no-invalid-this this.skip(); } - }); + } teardown(async () => { for (const disposable of disposables) { @@ -350,6 +369,11 @@ suite('DataScience Native Editor', () => { } } await ioc.dispose(); + try { + notebookFile.cleanupCallback(); + } catch { + noop(); + } }); function clickCell(cellIndex: number) { @@ -359,6 +383,7 @@ suite('DataScience Native Editor', () => { .simulate('click'); wrapper.update(); } + function simulateKeyPressOnCell(cellIndex: number, keyboardEvent: Partial & { code: string }) { const event = { ...createKeyboardEventForCell(keyboardEvent), ...keyboardEvent }; wrapper @@ -369,324 +394,595 @@ suite('DataScience Native Editor', () => { wrapper.update(); } - test('None of the cells are selected by default', async () => { - assert.ok(!isCellSelected(wrapper, 'NativeCell', 0)); - assert.ok(!isCellSelected(wrapper, 'NativeCell', 1)); - assert.ok(!isCellSelected(wrapper, 'NativeCell', 2)); - }); + suite('Selection/Focus', () => { + setup(async function() { + initIoc(); + // tslint:disable-next-line: no-invalid-this + await setupFunction.call(this); + }); + test('None of the cells are selected by default', async () => { + assert.ok(!isCellSelected(wrapper, 'NativeCell', 0)); + assert.ok(!isCellSelected(wrapper, 'NativeCell', 1)); + assert.ok(!isCellSelected(wrapper, 'NativeCell', 2)); + }); - test('None of the cells are not focused by default', async () => { - assert.ok(!isCellFocused(wrapper, 'NativeCell', 0)); - assert.ok(!isCellFocused(wrapper, 'NativeCell', 1)); - assert.ok(!isCellFocused(wrapper, 'NativeCell', 2)); - }); + test('None of the cells are not focused by default', async () => { + assert.ok(!isCellFocused(wrapper, 'NativeCell', 0)); + assert.ok(!isCellFocused(wrapper, 'NativeCell', 1)); + assert.ok(!isCellFocused(wrapper, 'NativeCell', 2)); + }); - test('Select cells by clicking them', async () => { - // Click first cell, then second, then third. - clickCell(0); - assert.ok(isCellSelected(wrapper, 'NativeCell', 0)); - assert.equal(isCellSelected(wrapper, 'NativeCell', 1), false); - assert.equal(isCellSelected(wrapper, 'NativeCell', 2), false); - - clickCell(1); - assert.ok(isCellSelected(wrapper, 'NativeCell', 1)); - assert.equal(isCellSelected(wrapper, 'NativeCell', 0), false); - assert.equal(isCellSelected(wrapper, 'NativeCell', 2), false); - - clickCell(2); - assert.ok(isCellSelected(wrapper, 'NativeCell', 2)); - assert.equal(isCellSelected(wrapper, 'NativeCell', 0), false); - assert.equal(isCellSelected(wrapper, 'NativeCell', 1), false); - }); + test('Select cells by clicking them', async () => { + // Click first cell, then second, then third. + clickCell(0); + assert.ok(isCellSelected(wrapper, 'NativeCell', 0)); + assert.equal(isCellSelected(wrapper, 'NativeCell', 1), false); + assert.equal(isCellSelected(wrapper, 'NativeCell', 2), false); - test('Traverse cells by using ArrowUp and ArrowDown, k and j', async () => { - const keyCodesAndPositions = [ - // When we press arrow down in the first cell, then second cell gets selected. - { keyCode: 'ArrowDown', cellIndexToPressKeysOn: 0, expectedSelectedCell: 1 }, - { keyCode: 'ArrowDown', cellIndexToPressKeysOn: 1, expectedSelectedCell: 2 }, - // Arrow down on last cell is a noop. - { keyCode: 'ArrowDown', cellIndexToPressKeysOn: 2, expectedSelectedCell: 2 }, - // When we press arrow up in the last cell, then second cell (from bottom) gets selected. - { keyCode: 'ArrowUp', cellIndexToPressKeysOn: 2, expectedSelectedCell: 1 }, - { keyCode: 'ArrowUp', cellIndexToPressKeysOn: 1, expectedSelectedCell: 0 }, - // Arrow up on last cell is a noop. - { keyCode: 'ArrowUp', cellIndexToPressKeysOn: 0, expectedSelectedCell: 0 }, - - // Same tests as above with k and j. - { keyCode: 'j', cellIndexToPressKeysOn: 0, expectedSelectedCell: 1 }, - { keyCode: 'j', cellIndexToPressKeysOn: 1, expectedSelectedCell: 2 }, - // Arrow down on last cell is a noop. - { keyCode: 'j', cellIndexToPressKeysOn: 2, expectedSelectedCell: 2 }, - { keyCode: 'k', cellIndexToPressKeysOn: 2, expectedSelectedCell: 1 }, - { keyCode: 'k', cellIndexToPressKeysOn: 1, expectedSelectedCell: 0 }, - // Arrow up on last cell is a noop. - { keyCode: 'k', cellIndexToPressKeysOn: 0, expectedSelectedCell: 0 } - ]; - - // keypress on first cell, then second, then third. - // Test navigation through all cells, by traversing up and down. - for (const testItem of keyCodesAndPositions) { - simulateKeyPressOnCell(testItem.cellIndexToPressKeysOn, { code: testItem.keyCode }); - - // Check if it is selected. - // Only the cell at the index should be selected, as that's what we click. - assert.ok(isCellSelected(wrapper, 'NativeCell', testItem.expectedSelectedCell) === true); - } - }); + clickCell(1); + assert.ok(isCellSelected(wrapper, 'NativeCell', 1)); + assert.equal(isCellSelected(wrapper, 'NativeCell', 0), false); + assert.equal(isCellSelected(wrapper, 'NativeCell', 2), false); - test('Traverse cells by using ArrowUp and ArrowDown, k and j', async () => { - const keyCodesAndPositions = [ - // When we press arrow down in the first cell, then second cell gets selected. - { keyCode: 'ArrowDown', cellIndexToPressKeysOn: 0, expectedIndex: 1 }, - { keyCode: 'ArrowDown', cellIndexToPressKeysOn: 1, expectedIndex: 2 }, - // Arrow down on last cell is a noop. - { keyCode: 'ArrowDown', cellIndexToPressKeysOn: 2, expectedIndex: 2 }, - // When we press arrow up in the last cell, then second cell (from bottom) gets selected. - { keyCode: 'ArrowUp', cellIndexToPressKeysOn: 2, expectedIndex: 1 }, - { keyCode: 'ArrowUp', cellIndexToPressKeysOn: 1, expectedIndex: 0 }, - // Arrow up on last cell is a noop. - { keyCode: 'ArrowUp', cellIndexToPressKeysOn: 0, expectedIndex: 0 } - ]; - - // keypress on first cell, then second, then third. - // Test navigation through all cells, by traversing up and down. - for (const testItem of keyCodesAndPositions) { - simulateKeyPressOnCell(testItem.cellIndexToPressKeysOn, { code: testItem.keyCode }); - - // Check if it is selected. - // Only the cell at the index should be selected, as that's what we click. - assert.ok(isCellSelected(wrapper, 'NativeCell', testItem.expectedIndex) === true); - } + clickCell(2); + assert.ok(isCellSelected(wrapper, 'NativeCell', 2)); + assert.equal(isCellSelected(wrapper, 'NativeCell', 0), false); + assert.equal(isCellSelected(wrapper, 'NativeCell', 1), false); + }); }); - test('Pressing \'Enter\' on a selected cell, results in focus being set to the code', async () => { - // For some reason we cannot allow setting focus to monaco editor. - // Tests are known to fall over if allowed. - const editor = wrapper - .find(NativeCell) - .at(1) - .find(Editor) - .first(); - (editor.instance() as Editor).giveFocus = () => editor.props().focused!(); - - const update = waitForUpdate(wrapper, NativeEditor, 1); - clickCell(1); - simulateKeyPressOnCell(1, { code: 'Enter', editorInfo: undefined }); - await update; - - // The second cell should be selected. - assert.ok(isCellFocused(wrapper, 'NativeCell', 1)); - }); + suite('Keyboard Shortcuts', () => { + setup(async function() { + initIoc(); + // tslint:disable-next-line: no-invalid-this + await setupFunction.call(this); + }); - test('Pressing \'Escape\' on a focused cell results in the cell being selected', async () => { - // First focus the cell. - let update = waitForUpdate(wrapper, NativeEditor, 1); - clickCell(1); - simulateKeyPressOnCell(1, { code: 'Enter', editorInfo: undefined }); - await update; - - // The second cell should be selected. - assert.equal(isCellSelected(wrapper, 'NativeCell', 1), false); - assert.equal(isCellFocused(wrapper, 'NativeCell', 1), true); - - // Now hit escape. - update = waitForUpdate(wrapper, NativeEditor, 1); - simulateKeyPressOnCell(1, { code: 'Escape' }); - await update; - - // Confirm it is no longer focused, and it is selected. - assert.equal(isCellSelected(wrapper, 'NativeCell', 1), true); - assert.equal(isCellFocused(wrapper, 'NativeCell', 1), false); - }); + test('Traverse cells by using ArrowUp and ArrowDown, k and j', async () => { + const keyCodesAndPositions = [ + // When we press arrow down in the first cell, then second cell gets selected. + { keyCode: 'ArrowDown', cellIndexToPressKeysOn: 0, expectedSelectedCell: 1 }, + { keyCode: 'ArrowDown', cellIndexToPressKeysOn: 1, expectedSelectedCell: 2 }, + // Arrow down on last cell is a noop. + { keyCode: 'ArrowDown', cellIndexToPressKeysOn: 2, expectedSelectedCell: 2 }, + // When we press arrow up in the last cell, then second cell (from bottom) gets selected. + { keyCode: 'ArrowUp', cellIndexToPressKeysOn: 2, expectedSelectedCell: 1 }, + { keyCode: 'ArrowUp', cellIndexToPressKeysOn: 1, expectedSelectedCell: 0 }, + // Arrow up on last cell is a noop. + { keyCode: 'ArrowUp', cellIndexToPressKeysOn: 0, expectedSelectedCell: 0 }, + + // Same tests as above with k and j. + { keyCode: 'j', cellIndexToPressKeysOn: 0, expectedSelectedCell: 1 }, + { keyCode: 'j', cellIndexToPressKeysOn: 1, expectedSelectedCell: 2 }, + // Arrow down on last cell is a noop. + { keyCode: 'j', cellIndexToPressKeysOn: 2, expectedSelectedCell: 2 }, + { keyCode: 'k', cellIndexToPressKeysOn: 2, expectedSelectedCell: 1 }, + { keyCode: 'k', cellIndexToPressKeysOn: 1, expectedSelectedCell: 0 }, + // Arrow up on last cell is a noop. + { keyCode: 'k', cellIndexToPressKeysOn: 0, expectedSelectedCell: 0 } + ]; + + // keypress on first cell, then second, then third. + // Test navigation through all cells, by traversing up and down. + for (const testItem of keyCodesAndPositions) { + simulateKeyPressOnCell(testItem.cellIndexToPressKeysOn, { code: testItem.keyCode }); + + // Check if it is selected. + // Only the cell at the index should be selected, as that's what we click. + assert.ok(isCellSelected(wrapper, 'NativeCell', testItem.expectedSelectedCell) === true); + } + }); - test('Pressing \'Shift+Enter\' on a selected cell executes the cell and advances to the next cell', async () => { - clickCell(1); - const update = waitForUpdate(wrapper, NativeEditor, 7); - simulateKeyPressOnCell(1, { code: 'Enter', shiftKey: true, editorInfo: undefined }); - await update; - wrapper.update(); + test('Traverse cells by using ArrowUp and ArrowDown, k and j', async () => { + const keyCodesAndPositions = [ + // When we press arrow down in the first cell, then second cell gets selected. + { keyCode: 'ArrowDown', cellIndexToPressKeysOn: 0, expectedIndex: 1 }, + { keyCode: 'ArrowDown', cellIndexToPressKeysOn: 1, expectedIndex: 2 }, + // Arrow down on last cell is a noop. + { keyCode: 'ArrowDown', cellIndexToPressKeysOn: 2, expectedIndex: 2 }, + // When we press arrow up in the last cell, then second cell (from bottom) gets selected. + { keyCode: 'ArrowUp', cellIndexToPressKeysOn: 2, expectedIndex: 1 }, + { keyCode: 'ArrowUp', cellIndexToPressKeysOn: 1, expectedIndex: 0 }, + // Arrow up on last cell is a noop. + { keyCode: 'ArrowUp', cellIndexToPressKeysOn: 0, expectedIndex: 0 } + ]; + + // keypress on first cell, then second, then third. + // Test navigation through all cells, by traversing up and down. + for (const testItem of keyCodesAndPositions) { + simulateKeyPressOnCell(testItem.cellIndexToPressKeysOn, { code: testItem.keyCode }); + + // Check if it is selected. + // Only the cell at the index should be selected, as that's what we click. + assert.ok(isCellSelected(wrapper, 'NativeCell', testItem.expectedIndex) === true); + } + }); - // Ensure cell was executed. - verifyHtmlOnCell(wrapper, 'NativeCell', '2', 1); + test('Pressing \'Enter\' on a selected cell, results in focus being set to the code', async () => { + // For some reason we cannot allow setting focus to monaco editor. + // Tests are known to fall over if allowed. + const editor = wrapper + .find(NativeCell) + .at(1) + .find(Editor) + .first(); + (editor.instance() as Editor).giveFocus = () => editor.props().focused!(); + + const update = waitForUpdate(wrapper, NativeEditor, 1); + clickCell(1); + simulateKeyPressOnCell(1, { code: 'Enter', editorInfo: undefined }); + await update; - // The third cell should be selected. - assert.ok(isCellSelected(wrapper, 'NativeCell', 2)); - }); + // The second cell should be selected. + assert.ok(isCellFocused(wrapper, 'NativeCell', 1)); + }); - test('Pressing \'Ctrl+Enter\' on a selected cell executes the cell and cell selection is not changed', async () => { - const update = waitForUpdate(wrapper, NativeEditor, 7); - clickCell(1); - simulateKeyPressOnCell(1, { code: 'Enter', ctrlKey: true, editorInfo: undefined }); - await update; + test('Pressing \'Escape\' on a focused cell results in the cell being selected', async () => { + // First focus the cell. + let update = waitForUpdate(wrapper, NativeEditor, 1); + clickCell(1); + simulateKeyPressOnCell(1, { code: 'Enter', editorInfo: undefined }); + await update; - // Ensure cell was executed. - verifyHtmlOnCell(wrapper, 'NativeCell', '2', 1); + // The second cell should be selected. + assert.equal(isCellSelected(wrapper, 'NativeCell', 1), false); + assert.equal(isCellFocused(wrapper, 'NativeCell', 1), true); - // The first cell should be selected. - assert.ok(isCellSelected(wrapper, 'NativeCell', 1)); - }); + // Now hit escape. + update = waitForUpdate(wrapper, NativeEditor, 1); + simulateKeyPressOnCell(1, { code: 'Escape' }); + await update; - test('Pressing \'Altr+Enter\' on a selected cell adds a new cell below it', async () => { - // Initially 3 cells. - assert.equal(wrapper.find('NativeCell').length, 3); + // Confirm it is no longer focused, and it is selected. + assert.equal(isCellSelected(wrapper, 'NativeCell', 1), true); + assert.equal(isCellFocused(wrapper, 'NativeCell', 1), false); + }); - const update = waitForUpdate(wrapper, NativeEditor, 1); - clickCell(1); - simulateKeyPressOnCell(1, { code: 'Enter', altKey: true, editorInfo: undefined }); - await update; + test('Pressing \'Shift+Enter\' on a selected cell executes the cell and advances to the next cell', async () => { + clickCell(1); + const update = waitForUpdate(wrapper, NativeEditor, 7); + simulateKeyPressOnCell(1, { code: 'Enter', shiftKey: true, editorInfo: undefined }); + await update; + wrapper.update(); - // The second cell should be selected. - assert.ok(isCellSelected(wrapper, 'NativeCell', 2)); - // There should be 4 cells. - assert.equal(wrapper.find('NativeCell').length, 4); - }); + // Ensure cell was executed. + verifyHtmlOnCell(wrapper, 'NativeCell', '2', 1); - test('Pressing \'d\' on a selected cell twice deletes the cell', async () => { - // Initially 3 cells. - assert.equal(wrapper.find('NativeCell').length, 3); + // The third cell should be selected. + assert.ok(isCellSelected(wrapper, 'NativeCell', 2)); + }); - clickCell(2); - simulateKeyPressOnCell(2, { code: 'd' }); - simulateKeyPressOnCell(2, { code: 'd' }); + test('Pressing \'Ctrl+Enter\' on a selected cell executes the cell and cell selection is not changed', async () => { + const update = waitForUpdate(wrapper, NativeEditor, 7); + clickCell(1); + simulateKeyPressOnCell(1, { code: 'Enter', ctrlKey: true, editorInfo: undefined }); + await update; - // There should be 2 cells. - assert.equal(wrapper.find('NativeCell').length, 2); - }); + // Ensure cell was executed. + verifyHtmlOnCell(wrapper, 'NativeCell', '2', 1); - test('Pressing \'a\' on a selected cell adds a cell at the current position', async () => { - // Initially 3 cells. - assert.equal(wrapper.find('NativeCell').length, 3); + // The first cell should be selected. + assert.ok(isCellSelected(wrapper, 'NativeCell', 1)); + }); + + test('Pressing \'Altr+Enter\' on a selected cell adds a new cell below it', async () => { + // Initially 3 cells. + assert.equal(wrapper.find('NativeCell').length, 3); - // const secondCell = wrapper.find('NativeCell').at(1); + const update = waitForUpdate(wrapper, NativeEditor, 1); + clickCell(1); + simulateKeyPressOnCell(1, { code: 'Enter', altKey: true, editorInfo: undefined }); + await update; - clickCell(0); - const update = waitForUpdate(wrapper, NativeEditor, 1); - simulateKeyPressOnCell(0, { code: 'a' }); - await update; + // The second cell should be selected. + assert.ok(isCellSelected(wrapper, 'NativeCell', 2)); + // There should be 4 cells. + assert.equal(wrapper.find('NativeCell').length, 4); + }); - // There should be 4 cells. - assert.equal(wrapper.find('NativeCell').length, 4); + test('Pressing \'d\' on a selected cell twice deletes the cell', async () => { + // Initially 3 cells. + assert.equal(wrapper.find('NativeCell').length, 3); - // Verify cell indexes of old items. - verifyCellIndex(wrapper, 'div[id="NotebookImport#0"]', 1); - verifyCellIndex(wrapper, 'div[id="NotebookImport#1"]', 2); - verifyCellIndex(wrapper, 'div[id="NotebookImport#2"]', 3); - }); + clickCell(2); + simulateKeyPressOnCell(2, { code: 'd' }); + simulateKeyPressOnCell(2, { code: 'd' }); - test('Pressing \'b\' on a selected cell adds a cell after the current position', async () => { - // Initially 3 cells. - assert.equal(wrapper.find('NativeCell').length, 3); + // There should be 2 cells. + assert.equal(wrapper.find('NativeCell').length, 2); + }); - clickCell(1); - const update = waitForUpdate(wrapper, NativeEditor, 1); - simulateKeyPressOnCell(1, { code: 'b' }); - await update; + test('Pressing \'a\' on a selected cell adds a cell at the current position', async () => { + // Initially 3 cells. + assert.equal(wrapper.find('NativeCell').length, 3); - // There should be 4 cells. - assert.equal(wrapper.find('NativeCell').length, 4); + // const secondCell = wrapper.find('NativeCell').at(1); - // Verify cell indexes of old items. - verifyCellIndex(wrapper, 'div[id="NotebookImport#0"]', 0); - verifyCellIndex(wrapper, 'div[id="NotebookImport#1"]', 1); - verifyCellIndex(wrapper, 'div[id="NotebookImport#2"]', 3); - }); + clickCell(0); + const update = waitForUpdate(wrapper, NativeEditor, 1); + simulateKeyPressOnCell(0, { code: 'a' }); + await update; + + // There should be 4 cells. + assert.equal(wrapper.find('NativeCell').length, 4); + + // Verify cell indexes of old items. + verifyCellIndex(wrapper, 'div[id="NotebookImport#0"]', 1); + verifyCellIndex(wrapper, 'div[id="NotebookImport#1"]', 2); + verifyCellIndex(wrapper, 'div[id="NotebookImport#2"]', 3); + }); - test('Toggle visibility of output', async () => { - // First execute contents of last cell. - let update = waitForUpdate(wrapper, NativeEditor, 7); - clickCell(2); - simulateKeyPressOnCell(2, { code: 'Enter', ctrlKey: true, editorInfo: undefined }); - await update; + test('Pressing \'b\' on a selected cell adds a cell after the current position', async () => { + // Initially 3 cells. + assert.equal(wrapper.find('NativeCell').length, 3); - // Ensure cell was executed. - verifyHtmlOnCell(wrapper, 'NativeCell', '3', 2); + clickCell(1); + const update = waitForUpdate(wrapper, NativeEditor, 1); + simulateKeyPressOnCell(1, { code: 'b' }); + await update; - // Hide the output - update = waitForUpdate(wrapper, NativeEditor, 1); - simulateKeyPressOnCell(2, { code: 'o' }); - await update; + // There should be 4 cells. + assert.equal(wrapper.find('NativeCell').length, 4); - // Ensure cell output is hidden (looking for cell results will throw an exception). - assert.throws(() => verifyHtmlOnCell(wrapper, 'NativeCell', '3', 2)); + // Verify cell indexes of old items. + verifyCellIndex(wrapper, 'div[id="NotebookImport#0"]', 0); + verifyCellIndex(wrapper, 'div[id="NotebookImport#1"]', 1); + verifyCellIndex(wrapper, 'div[id="NotebookImport#2"]', 3); + }); - // Display the output - update = waitForUpdate(wrapper, NativeEditor, 1); - simulateKeyPressOnCell(2, { code: 'o' }); - await update; + test('Toggle visibility of output', async () => { + // First execute contents of last cell. + let update = waitForUpdate(wrapper, NativeEditor, 7); + clickCell(2); + simulateKeyPressOnCell(2, { code: 'Enter', ctrlKey: true, editorInfo: undefined }); + await update; - // Ensure cell output is visible again. - verifyHtmlOnCell(wrapper, 'NativeCell', '3', 2); - }); + // Ensure cell was executed. + verifyHtmlOnCell(wrapper, 'NativeCell', '3', 2); - test('Toggle line numbers using the \'l\' key', async () => { - clickCell(1); + // Hide the output + update = waitForUpdate(wrapper, NativeEditor, 1); + simulateKeyPressOnCell(2, { code: 'o' }); + await update; - const monacoEditorComponent = wrapper.find(NativeCell).at(1).find(MonacoEditor).first(); - const editor = (monacoEditorComponent.instance().state as IMonacoEditorState).editor!; - const oldUpdateOptions = editor.updateOptions.bind(editor); + // Ensure cell output is hidden (looking for cell results will throw an exception). + assert.throws(() => verifyHtmlOnCell(wrapper, 'NativeCell', '3', 2)); - let lineNumberSetting: any = ''; - editor.updateOptions = (options: monacoEditor.editor.IEditorConstructionOptions) => { - lineNumberSetting = options.lineNumbers; - oldUpdateOptions(options); - }; + // Display the output + update = waitForUpdate(wrapper, NativeEditor, 1); + simulateKeyPressOnCell(2, { code: 'o' }); + await update; + + // Ensure cell output is visible again. + verifyHtmlOnCell(wrapper, 'NativeCell', '3', 2); + }); + + test('Toggle line numbers using the \'l\' key', async () => { + clickCell(1); + + const monacoEditorComponent = wrapper + .find(NativeCell) + .at(1) + .find(MonacoEditor) + .first(); + const editor = (monacoEditorComponent.instance().state as IMonacoEditorState).editor!; + const optionsUpdated = sinon.spy(editor, 'updateOptions'); + + // Display line numbers. + simulateKeyPressOnCell(1, { code: 'l' }); + // Confirm monaco editor got updated with line numbers set to turned on. + assert.equal(optionsUpdated.lastCall.args[0].lineNumbers, 'on'); + + // toggle the display of line numbers. + simulateKeyPressOnCell(1, { code: 'l' }); + // Confirm monaco editor got updated with line numbers set to turned ff. + assert.equal(optionsUpdated.lastCall.args[0].lineNumbers, 'off'); + }); - // Display line numbers. - simulateKeyPressOnCell(1, { code: 'l' }); - assert.equal(lineNumberSetting, 'on'); + test('Toggle markdown and code modes using \'y\' and \'m\' keys', async () => { + clickCell(1); + + // Switch to markdown + simulateKeyPressOnCell(1, { code: 'm' }); + + // Confirm output cell is rendered and monaco editor is not. + assert.equal( + wrapper + .find(NativeCell) + .at(1) + .find(CellOutput).length, + 1 + ); + assert.equal( + wrapper + .find(NativeCell) + .at(1) + .find(MonacoEditor).length, + 0 + ); + + // Switch back to code mode. + // At this moment, there's no cell input element, hence send key strokes to the wrapper. + const wrapperElement = wrapper + .find(NativeCell) + .at(1) + .find('.cell-wrapper') + .first(); + wrapperElement.simulate('keyDown', { key: 'y' }); + + // Confirm output cell is not rendered (remember we don't have any output) and monaco editor is rendered. + assert.equal( + wrapper + .find(NativeCell) + .at(1) + .find(CellOutput).length, + 0 + ); + assert.equal( + wrapper + .find(NativeCell) + .at(1) + .find(MonacoEditor).length, + 1 + ); + }); - // toggle the display of line numbers. - simulateKeyPressOnCell(1, { code: 'l' }); - assert.equal(lineNumberSetting, 'off'); + test('Test undo using the key \'z\'', async () => { + clickCell(0); + + // Add, then undo, keep doing at least 3 times and confirm it works as expected. + for (let i = 0; i < 3; i += 1) { + // Add a new cell + let update = waitForUpdate(wrapper, NativeEditor, 1); + simulateKeyPressOnCell(0, { code: 'a' }); + await update; + + // There should be 4 cells and first cell is selected & nothing focused. + assert.equal(isCellSelected(wrapper, 'NativeCell', 0), true); + assert.equal(isCellSelected(wrapper, 'NativeCell', 1), false); + assert.equal(isCellFocused(wrapper, 'NativeCell', 0), false); + assert.equal(isCellFocused(wrapper, 'NativeCell', 1), false); + assert.equal(wrapper.find('NativeCell').length, 4); + + // Press 'z' to undo. + update = waitForUpdate(wrapper, NativeEditor, 1); + simulateKeyPressOnCell(0, { code: 'z' }); + await update; + + // There should be 3 cells and first cell is selected & nothing focused. + assert.equal(isCellSelected(wrapper, 'NativeCell', 0), true); + assert.equal(isCellSelected(wrapper, 'NativeCell', 1), false); + assert.equal(wrapper.find('NativeCell').length, 3); + } + }); }); - test('Toggle markdown and code modes using \'y\' and \'m\' keys', async () => { - clickCell(1); + suite('Auto Save', () => { + let controller: NativeEditorStateController; + let windowStateChangeHandlers: ((e: WindowState) => any)[] = []; + let handleMessageSpy: sinon.SinonSpy<[string, any?], boolean>; + setup(async function() { + handleMessageSpy = sinon.spy(NativeEditorStateController.prototype, 'handleMessage'); + initIoc(); - // Switch to markdown - simulateKeyPressOnCell(1, { code: 'm' }); + windowStateChangeHandlers = []; + // Keep track of all handlers for the onDidChangeWindowState event. + ioc.applicationShell.setup(app => app.onDidChangeWindowState(TypeMoq.It.isAny())).callback(cb => windowStateChangeHandlers.push(cb)); - // Confirm output cell is rendered and monaco editor is not. - assert.equal(wrapper.find(NativeCell).at(1).find(CellOutput).length, 1); - assert.equal(wrapper.find(NativeCell).at(1).find(MonacoEditor).length, 0); + // tslint:disable-next-line: no-invalid-this + await setupFunction.call(this); - // Switch back to code mode. - // At this moment, there's no cell input element, hence send key strokes to the wrapper. - const wrapperElement = wrapper.find(NativeCell).at(1).find('.cell-wrapper').first(); - wrapperElement.simulate('keyDown', {key: 'y'}); + controller = (wrapper + .find(NativeEditor) + .first() + .instance() as NativeEditor).stateController; + }); + teardown(() => sinon.restore()); + + /** + * Wait for a particular message to be received by the editor component. + * If message isn't reiceived within a time out, then reject with a timeout error message. + * + * @param {string} message + * @param {number} timeout + * @returns {Promise} + */ + async function waitForMessageReceivedEditorComponent(message: string, timeout: number = 5000): Promise { + const errorMessage = `Timeout waiting for message ${message}`; + await waitForCondition(async () => handleMessageSpy.calledWith(message, sinon.match.any), timeout, errorMessage); + } - // Confirm output cell is not rendered (remember we don't have any output) and monaco editor is rendered. - assert.equal(wrapper.find(NativeCell).at(1).find(CellOutput).length, 0); - assert.equal(wrapper.find(NativeCell).at(1).find(MonacoEditor).length, 1); - }); + /** + * Wait for notebook to be marked as dirty (within a timeout of 5s). + * + * @param {boolean} [dirty=true] + * @returns {Promise} + */ + async function waitForNotebookToBeDirty(): Promise { + // Wait for the notebook to be marked as dirty (the NotebookDirty message will be sent). + await waitForMessageReceivedEditorComponent(InteractiveWindowMessages.NotebookDirty, 5_000); + // Wait for the state to get updated. + await waitForCondition(async () => controller.getState().dirty === true, 1_000, `Timeout waiting for dirty state to get updated to true`); + } - test('Test undo using the key \'z\'', async () => { - clickCell(0); + /** + * Wait for notebook to be marked as clean (within a timeout of 5s). + * + * @param {boolean} [dirty=true] + * @returns {Promise} + */ + async function waitForNotebookToBeClean(): Promise { + // Wait for the notebook to be marked as dirty (the NotebookDirty message will be sent). + await waitForMessageReceivedEditorComponent(InteractiveWindowMessages.NotebookDirty, 5_000); + + // Wait for the state to get updated. + await waitForCondition(async () => controller.getState().dirty === false, 1_000, `Timeout waiting for dirty state to get updated to false`); + } - // Add, then undo, keep doing at least 3 times and confirm it works as expected. - for (let i = 0; i < 3; i += 1){ - // Add a new cell - let update = waitForUpdate(wrapper, NativeEditor, 1); + /** + * Make some kind of a change to the notebook. + * + * @param {number} cellIndex + */ + async function modifyNotebook() { + // (Add a cell into the UI and wait for it to render) + clickCell(0); + const update = waitForUpdate(wrapper, NativeEditor, 2); simulateKeyPressOnCell(0, { code: 'a' }); await update; + } - // There should be 4 cells and first cell is selected & nothing focused. - assert.equal(isCellSelected(wrapper, 'NativeCell', 0), true); - assert.equal(isCellSelected(wrapper, 'NativeCell', 1), false); - assert.equal(isCellFocused(wrapper, 'NativeCell', 0), false); - assert.equal(isCellFocused(wrapper, 'NativeCell', 1), false); - assert.equal(wrapper.find('NativeCell').length, 4); + test('Auto save notebook every 1s', async () => { + // Configure notebook to save automatically ever 1s. + when(ioc.mockedWorkspaceConfig.get('autoSave', 'off')).thenReturn('afterDelay'); + when(ioc.mockedWorkspaceConfig.get('autoSaveDelay', anything())).thenReturn(1_000); + ioc.forceSettingsChanged(ioc.getSettings().pythonPath); + + /** + * Make some changes to a cell of a notebook, then verify the notebook is auto saved. + * + * @param {number} cellIndex + */ + async function makeChangesAndConfirmFileIsUpdated() { + const notebookFileContents = await fs.readFile(notebookFile.filePath, 'utf8'); + + await modifyNotebook(); + await waitForNotebookToBeDirty(); + + // At this point a message should be sent to extension asking it to save. + // After the save, the extension should send a message to react letting it know that it was saved successfully. + + await waitForNotebookToBeClean(); + // Confirm file has been updated as well. + const newFileContents = await fs.readFile(notebookFile.filePath, 'utf8'); + assert.notEqual(newFileContents, notebookFileContents); + } - // Press 'z' to undo. - update = waitForUpdate(wrapper, NativeEditor, 1); - simulateKeyPressOnCell(0, { code: 'z' }); - await update; + // Make changes & validate (try a couple of times). + await makeChangesAndConfirmFileIsUpdated(); + await makeChangesAndConfirmFileIsUpdated(); + await makeChangesAndConfirmFileIsUpdated(); + }); - // There should be 3 cells and first cell is selected & nothing focused. - assert.equal(isCellSelected(wrapper, 'NativeCell', 0), true); - assert.equal(isCellSelected(wrapper, 'NativeCell', 1), false); - assert.equal(wrapper.find('NativeCell').length, 3); + test('Should not auto save notebook, ever', async () => { + const notebookFileContents = await fs.readFile(notebookFile.filePath, 'utf8'); + + // Configure notebook to to never save. + when(ioc.mockedWorkspaceConfig.get('autoSave', 'off')).thenReturn('off'); + when(ioc.mockedWorkspaceConfig.get('autoSaveDelay', anything())).thenReturn(1000); + // Update the settings and wait for the component to receive it and process it. + const promise = waitForMessageReceivedEditorComponent(InteractiveWindowMessages.UpdateSettings, 1_000); + ioc.forceSettingsChanged(ioc.getSettings().pythonPath); + await promise; + + await modifyNotebook(); + await waitForNotebookToBeDirty(); + + // Now that the notebook is dirty, change the active editor. + const docManager = ioc.get(IDocumentManager) as MockDocumentManager; + docManager.didChangeEmitter.fire(); + // Also, send notification about changes to window state. + windowStateChangeHandlers.forEach(item => item({ focused: false })); + windowStateChangeHandlers.forEach(item => item({ focused: true })); + + // Confirm the message is not clean, trying to wait for it to get saved will timeout (i.e. rejected). + await expect(waitForNotebookToBeClean()).to.eventually.be.rejected; + // Confirm file has not been updated as well. + assert.equal(await fs.readFile(notebookFile.filePath, 'utf8'), notebookFileContents); + }); + + async function testAutoSavingWhenEditorFocusChanges(newEditor: TextEditor | undefined) { + const notebookFileContents = await fs.readFile(notebookFile.filePath, 'utf8'); + + await modifyNotebook(); + await waitForNotebookToBeDirty(); + + // Configure notebook to save when active editor changes. + when(ioc.mockedWorkspaceConfig.get('autoSave', 'off')).thenReturn('onFocusChange'); + ioc.forceSettingsChanged(ioc.getSettings().pythonPath); + + // Now that the notebook is dirty, change the active editor. + const docManager = ioc.get(IDocumentManager) as MockDocumentManager; + docManager.didChangeEmitter.fire(newEditor); + + // At this point a message should be sent to extension asking it to save. + // After the save, the extension should send a message to react letting it know that it was saved successfully. + + await waitForNotebookToBeClean(); + // Confirm file has been updated as well. + assert.notEqual(await fs.readFile(notebookFile.filePath, 'utf8'), notebookFileContents); + } + + test('Auto save notebook when focus changes from active editor to none', () => testAutoSavingWhenEditorFocusChanges(undefined)); + + test('Auto save notebook when focus changes from active editor to something else', () => + testAutoSavingWhenEditorFocusChanges(TypeMoq.Mock.ofType().object)); + + test('Should not auto save notebook when active editor changes', async () => { + const notebookFileContents = await fs.readFile(notebookFile.filePath, 'utf8'); + + await modifyNotebook(); + await waitForNotebookToBeDirty(); + + // Configure notebook to save when window state changes. + when(ioc.mockedWorkspaceConfig.get('autoSave', 'off')).thenReturn('onWindowChange'); + ioc.forceSettingsChanged(ioc.getSettings().pythonPath); + + // Now that the notebook is dirty, change the active editor. + // This should not trigger a save of notebook (as its configured to save only when window state changes). + const docManager = ioc.get(IDocumentManager) as MockDocumentManager; + docManager.didChangeEmitter.fire(); + + // Confirm the message is not clean, trying to wait for it to get saved will timeout (i.e. rejected). + await expect(waitForNotebookToBeClean()).to.eventually.be.rejected; + // Confirm file has not been updated as well. + assert.equal(await fs.readFile(notebookFile.filePath, 'utf8'), notebookFileContents); + }); + + async function testAutoSavingWithChangesToWindowState(focused: boolean) { + const notebookFileContents = await fs.readFile(notebookFile.filePath, 'utf8'); + + await modifyNotebook(); + await waitForNotebookToBeDirty(); + + // Configure notebook to save when active editor changes. + when(ioc.mockedWorkspaceConfig.get('autoSave', 'off')).thenReturn('onWindowChange'); + ioc.forceSettingsChanged(ioc.getSettings().pythonPath); + + // Now that the notebook is dirty, send notification about changes to window state. + windowStateChangeHandlers.forEach(item => item({ focused })); + + // At this point a message should be sent to extension asking it to save. + // After the save, the extension should send a message to react letting it know that it was saved successfully. + + await waitForNotebookToBeClean(); + // Confirm file has been updated as well. + assert.notEqual(await fs.readFile(notebookFile.filePath, 'utf8'), notebookFileContents); } + + test('Auto save notebook when window state changes to being not focused', async () => testAutoSavingWithChangesToWindowState(false)); + test('Auto save notebook when window state changes to being focused', async () => testAutoSavingWithChangesToWindowState(true)); + + test('Should not auto save notebook when window state changes', async () => { + const notebookFileContents = await fs.readFile(notebookFile.filePath, 'utf8'); + + await modifyNotebook(); + await waitForNotebookToBeDirty(); + + // Configure notebook to save when active editor changes. + when(ioc.mockedWorkspaceConfig.get('autoSave', 'off')).thenReturn('onFocusChange'); + ioc.forceSettingsChanged(ioc.getSettings().pythonPath); + + // Now that the notebook is dirty, change window state. + // This should not trigger a save of notebook (as its configured to save only when focus is changed). + windowStateChangeHandlers.forEach(item => item({ focused: false })); + windowStateChangeHandlers.forEach(item => item({ focused: true })); + + // Confirm the message is not clean, trying to wait for it to get saved will timeout (i.e. rejected). + await expect(waitForNotebookToBeClean()).to.eventually.be.rejected; + // Confirm file has not been updated as well. + assert.equal(await fs.readFile(notebookFile.filePath, 'utf8'), notebookFileContents); + }); }); }); }); diff --git a/src/test/datascience/nativeEditorTestHelpers.tsx b/src/test/datascience/nativeEditorTestHelpers.tsx index d0421752c580..0ddb5ee20e18 100644 --- a/src/test/datascience/nativeEditorTestHelpers.tsx +++ b/src/test/datascience/nativeEditorTestHelpers.tsx @@ -31,9 +31,9 @@ export async function createNewEditor(ioc: DataScienceIocContainer): Promise { +export async function openEditor(ioc: DataScienceIocContainer, contents: string, filePath: string = '/usr/home/test.ipynb'): Promise { const loaded = waitForMessage(ioc, InteractiveWindowMessages.LoadAllCellsComplete); - const uri = Uri.parse('file:////usr/home/test.ipynb'); + const uri = Uri.file(filePath); const result = await getOrCreateNativeEditor(ioc, uri, contents); await loaded; return result;