diff --git a/src/datascience-ui/interactive-common/cellOutput.tsx b/src/datascience-ui/interactive-common/cellOutput.tsx index a07f46b6afac..9f201f0e8772 100644 --- a/src/datascience-ui/interactive-common/cellOutput.tsx +++ b/src/datascience-ui/interactive-common/cellOutput.tsx @@ -174,7 +174,7 @@ export class CellOutput extends React.Component { const Transform = transforms['text/markdown']; const MarkdownClassName = 'markdown-cell-output'; - return [
]; + return [
]; } // tslint:disable-next-line: max-func-body-length diff --git a/src/datascience-ui/react-common/monacoEditor.tsx b/src/datascience-ui/react-common/monacoEditor.tsx index 28430bc99677..35f5c1c778f4 100644 --- a/src/datascience-ui/react-common/monacoEditor.tsx +++ b/src/datascience-ui/react-common/monacoEditor.tsx @@ -33,7 +33,7 @@ export interface IMonacoEditorProps { lineCountChanged(newCount: number): void; } -interface IMonacoEditorState { +export interface IMonacoEditorState { editor?: monacoEditor.editor.IStandaloneCodeEditor; model: monacoEditor.editor.ITextModel | null; visibleLineCount: number; diff --git a/src/test/datascience/nativeEditor.functional.test.tsx b/src/test/datascience/nativeEditor.functional.test.tsx index 8fcc845afb86..973219361639 100644 --- a/src/test/datascience/nativeEditor.functional.test.tsx +++ b/src/test/datascience/nativeEditor.functional.test.tsx @@ -1,11 +1,14 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. + 'use strict'; + import * as assert from 'assert'; +import { ReactWrapper } from 'enzyme'; +import * as monacoEditor from 'monaco-editor/esm/vs/editor/editor.api'; import * as path from 'path'; import * as TypeMoq from 'typemoq'; import { Disposable, TextDocument, TextEditor, Uri } from 'vscode'; - import { IApplicationShell, IDocumentManager } from '../../client/common/application/types'; import { createDeferred } from '../../client/common/utils/async'; import { noop } from '../../client/common/utils/misc'; @@ -13,288 +16,648 @@ import { Identifiers } from '../../client/datascience/constants'; import { JupyterExecutionFactory } from '../../client/datascience/jupyter/jupyterExecutionFactory'; import { ICell, IJupyterExecution, INotebookEditorProvider, INotebookExporter } from '../../client/datascience/types'; import { PythonInterpreter } from '../../client/interpreter/contracts'; +import { CellInput } from '../../datascience-ui/interactive-common/cellInput'; +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 { 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 { DataScienceIocContainer } from './dataScienceIocContainer'; import { addCell, closeNotebook, createNewEditor, getNativeCellResults, - mountNativeWebView, - openEditor, - runMountedTest -} from './nativeEditorTestHelpers'; + mountNativeWebView, openEditor, runMountedTest, setupWebview } from './nativeEditorTestHelpers'; import { waitForUpdate } from './reactHelpers'; import { addContinuousMockData, addMockData, CellPosition, + createKeyboardEventForCell, escapePath, findButton, getLastOutputCell, + isCellFocused, + isCellSelected, srcDirectory, + verifyCellIndex, verifyHtmlOnCell, waitForMessageResponse } from './testHelpers'; //import { asyncDump } from '../common/asyncDump'; // tslint:disable:max-func-body-length trailing-comma no-any no-multiline-string -suite('DataScience Native Editor tests', () => { - const disposables: Disposable[] = []; - let ioc: DataScienceIocContainer; - - setup(() => { - ioc = new DataScienceIocContainer(); - ioc.registerDataScienceTypes(); - - const appShell = TypeMoq.Mock.ofType(); - appShell.setup(a => a.showErrorMessage(TypeMoq.It.isAnyString())).returns((_e) => Promise.resolve('')); - appShell.setup(a => a.showInformationMessage(TypeMoq.It.isAny(), TypeMoq.It.isAny())).returns(() => Promise.resolve('')); - appShell.setup(a => a.showInformationMessage(TypeMoq.It.isAny(), TypeMoq.It.isAny(), TypeMoq.It.isAny())).returns((_a1: string, a2: string, _a3: string) => Promise.resolve(a2)); - appShell.setup(a => a.showInformationMessage(TypeMoq.It.isAny(), TypeMoq.It.isAny(), TypeMoq.It.isAny(), TypeMoq.It.isAny())).returns((_a1: string, _a2: any, _a3: string, a4: string) => Promise.resolve(a4)); - appShell.setup(a => a.showSaveDialog(TypeMoq.It.isAny())).returns(() => Promise.resolve(Uri.file('foo.ipynb'))); - ioc.serviceManager.rebindInstance(IApplicationShell, appShell.object); +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}; + newCell.data = { cell_type: 'code', execution_count: null, metadata: {}, outputs: [], source: '', ...data }; + + return newCell; + } + suite('Editor tests', () => { + const disposables: Disposable[] = []; + let ioc: DataScienceIocContainer; + + setup(() => { + ioc = new DataScienceIocContainer(); + ioc.registerDataScienceTypes(); + + const appShell = TypeMoq.Mock.ofType(); + appShell.setup(a => a.showErrorMessage(TypeMoq.It.isAnyString())).returns((_e) => Promise.resolve('')); + appShell.setup(a => a.showInformationMessage(TypeMoq.It.isAny(), TypeMoq.It.isAny())).returns(() => Promise.resolve('')); + appShell.setup(a => a.showInformationMessage(TypeMoq.It.isAny(), TypeMoq.It.isAny(), TypeMoq.It.isAny())).returns((_a1: string, a2: string, _a3: string) => Promise.resolve(a2)); + appShell.setup(a => a.showInformationMessage(TypeMoq.It.isAny(), TypeMoq.It.isAny(), TypeMoq.It.isAny(), TypeMoq.It.isAny())).returns((_a1: string, _a2: any, _a3: string, a4: string) => Promise.resolve(a4)); + appShell.setup(a => a.showSaveDialog(TypeMoq.It.isAny())).returns(() => Promise.resolve(Uri.file('foo.ipynb'))); + ioc.serviceManager.rebindInstance(IApplicationShell, appShell.object); + }); + + teardown(async () => { + for (const disposable of disposables) { + if (!disposable) { + continue; + } + // tslint:disable-next-line:no-any + const promise = disposable.dispose() as Promise; + if (promise) { + await promise; + } + } + await ioc.dispose(); + }); + + // Uncomment this to debug hangs on exit + // suiteTeardown(() => { + // asyncDump(); + // }); + + runMountedTest('Simple text', async (wrapper) => { + // Create an editor so something is listening to messages + await createNewEditor(ioc); + + // Add a cell into the UI and wait for it to render + await addCell(wrapper, 'a=1\na'); + + verifyHtmlOnCell(wrapper, 'NativeCell', '1', CellPosition.Last); + }, () => { return ioc; }); + + runMountedTest('Mime Types', async (wrapper) => { + // Create an editor so something is listening to messages + await createNewEditor(ioc); + + const badPanda = `import pandas as pd + df = pd.read("${escapePath(path.join(srcDirectory(), 'DefaultSalesReport.csv'))}") + df.head()`; + const goodPanda = `import pandas as pd + df = pd.read_csv("${escapePath(path.join(srcDirectory(), 'DefaultSalesReport.csv'))}") + df.head()`; + const matPlotLib = 'import matplotlib.pyplot as plt\r\nimport numpy as np\r\nx = np.linspace(0,20,100)\r\nplt.plot(x, np.sin(x))\r\nplt.show()'; + const matPlotLibResults = 'img'; + const spinningCursor = `import sys + import time + def spinning_cursor(): + while True: + for cursor in '|/-\\\\': + yield cursor + spinner = spinning_cursor() + for _ in range(50): + sys.stdout.write(next(spinner)) + sys.stdout.flush() + time.sleep(0.1) + sys.stdout.write('\\r')`; + + addMockData(ioc, badPanda, `pandas has no attribute 'read'`, 'text/html', 'error'); + addMockData(ioc, goodPanda, `A table`, 'text/html'); + addMockData(ioc, matPlotLib, matPlotLibResults, 'text/html'); + const cursors = ['|', '/', '-', '\\']; + let cursorPos = 0; + let loops = 3; + addContinuousMockData(ioc, spinningCursor, async (_c) => { + const result = `${cursors[cursorPos]}\r`; + cursorPos += 1; + if (cursorPos >= cursors.length) { + cursorPos = 0; + loops -= 1; + } + return Promise.resolve({ result: result, haveMore: loops > 0 }); + }); + + await addCell(wrapper, badPanda, true); + verifyHtmlOnCell(wrapper, 'NativeCell', `has no attribute 'read'`, CellPosition.Last); + + await addCell(wrapper, goodPanda, true); + verifyHtmlOnCell(wrapper, 'NativeCell', ``, CellPosition.Last); + + await addCell(wrapper, matPlotLib, true, 6); + verifyHtmlOnCell(wrapper, 'NativeCell', matPlotLibResults, CellPosition.Last); + + await addCell(wrapper, spinningCursor, true, 4 + (ioc.mockJupyter ? (cursors.length * 3) : 50)); + verifyHtmlOnCell(wrapper, 'NativeCell', '
', CellPosition.Last); + }, () => { return ioc; }); + + runMountedTest('Click buttons', async (wrapper) => { + // Goto source should cause the visible editor to be picked as long as its filename matches + const showedEditor = createDeferred(); + const textEditors: TextEditor[] = []; + const docManager = TypeMoq.Mock.ofType(); + const visibleEditor = TypeMoq.Mock.ofType(); + const dummyDocument = TypeMoq.Mock.ofType(); + dummyDocument.setup(d => d.fileName).returns(() => 'foo.py'); + visibleEditor.setup(v => v.show()).returns(() => showedEditor.resolve()); + visibleEditor.setup(v => v.revealRange(TypeMoq.It.isAny())).returns(noop); + visibleEditor.setup(v => v.document).returns(() => dummyDocument.object); + textEditors.push(visibleEditor.object); + docManager.setup(a => a.visibleTextEditors).returns(() => textEditors); + ioc.serviceManager.rebindInstance(IDocumentManager, docManager.object); + // Create an editor so something is listening to messages + await createNewEditor(ioc); + + // Get a cell into the list + await addCell(wrapper, 'a=1\na'); + + // find the buttons on the cell itself + let cell = getLastOutputCell(wrapper, 'NativeCell'); + let ImageButtons = cell.find(ImageButton); + assert.equal(ImageButtons.length, 7, 'Cell buttons not found'); + let deleteButton = ImageButtons.at(6); + + // Make sure delete works + let afterDelete = await getNativeCellResults(wrapper, 1, async () => { + deleteButton.simulate('click'); + return Promise.resolve(); + }); + assert.equal(afterDelete.length, 1, `Delete should remove a cell`); + + // Secondary delete should NOT delete the cell as there should ALWAYS be at + // least one cell in the file. + cell = getLastOutputCell(wrapper, 'NativeCell'); + ImageButtons = cell.find(ImageButton); + assert.equal(ImageButtons.length, 7, 'Cell buttons not found'); + deleteButton = ImageButtons.at(6); + + afterDelete = await getNativeCellResults(wrapper, 1, async () => { + deleteButton.simulate('click'); + return Promise.resolve(); + }); + assert.equal(afterDelete.length, 1, `Delete should NOT remove the last cell`); + }, () => { return ioc; }); + + runMountedTest('Export', async (wrapper) => { + // Export should cause the export dialog to come up. Remap appshell so we can check + const dummyDisposable = { + dispose: () => { return; } + }; + let exportCalled = false; + const appShell = TypeMoq.Mock.ofType(); + appShell.setup(a => a.showErrorMessage(TypeMoq.It.isAnyString())).returns((e) => { throw e; }); + appShell.setup(a => a.showInformationMessage(TypeMoq.It.isAny(), TypeMoq.It.isAny())).returns(() => Promise.resolve('')); + appShell.setup(a => a.showSaveDialog(TypeMoq.It.isAny())).returns(() => { + exportCalled = true; + return Promise.resolve(undefined); + }); + appShell.setup(a => a.setStatusBarMessage(TypeMoq.It.isAny())).returns(() => dummyDisposable); + ioc.serviceManager.rebindInstance(IApplicationShell, appShell.object); + + // Make sure to create the interactive window after the rebind or it gets the wrong application shell. + await createNewEditor(ioc); + await addCell(wrapper, 'a=1\na'); + + // Export should cause exportCalled to change to true + const exportButton = findButton(wrapper, NativeEditor, 6); + await waitForMessageResponse(ioc, () => exportButton!.simulate('click')); + assert.equal(exportCalled, true, 'Export should have been called'); + }, () => { return ioc; }); + + runMountedTest('RunAllCells', async (wrapper) => { + addMockData(ioc, 'b=2\nb', 2); + addMockData(ioc, 'c=3\nc', 3); + + const baseFile = [ {id: 'NotebookImport#0', data: {source: 'a=1\na'}}, + {id: 'NotebookImport#1', data: {source: 'b=2\nb'}}, + {id: 'NotebookImport#2', data: {source: 'c=3\nc'}} ]; + const runAllCells = baseFile.map(cell => { + return createFileCell(cell, cell.data); + }); + const notebook = await ioc.get(INotebookExporter).translateToNotebook(runAllCells, undefined); + await openEditor(ioc, JSON.stringify(notebook)); + + // Export should cause exportCalled to change to true + const runAllButton = findButton(wrapper, NativeEditor, 3); + await waitForMessageResponse(ioc, () => runAllButton!.simulate('click')); + + await waitForUpdate(wrapper, NativeEditor, 16); + + verifyHtmlOnCell(wrapper, 'NativeCell', `1`, 0); + verifyHtmlOnCell(wrapper, 'NativeCell', `2`, 1); + verifyHtmlOnCell(wrapper, 'NativeCell', `3`, 2); + }, () => { return ioc; }); + + runMountedTest('Startup and shutdown', async (wrapper) => { + addMockData(ioc, 'b=2\nb', 2); + addMockData(ioc, 'c=3\nc', 3); + + const baseFile = [ {id: 'NotebookImport#0', data: {source: 'a=1\na'}}, + {id: 'NotebookImport#1', data: {source: 'b=2\nb'}}, + {id: 'NotebookImport#2', data: {source: 'c=3\nc'}} ]; + const runAllCells = baseFile.map(cell => { + return createFileCell(cell, cell.data); + }); + const notebook = await ioc.get(INotebookExporter).translateToNotebook(runAllCells, undefined); + let editor = await openEditor(ioc, JSON.stringify(notebook)); + + // Run everything + let runAllButton = findButton(wrapper, NativeEditor, 3); + await waitForMessageResponse(ioc, () => runAllButton!.simulate('click')); + await waitForUpdate(wrapper, NativeEditor, 16); + + // Close editor. Should still have the server up + await closeNotebook(editor, wrapper); + const jupyterExecution = ioc.serviceManager.get(IJupyterExecution); + const editorProvider = ioc.serviceManager.get(INotebookEditorProvider); + const server = await jupyterExecution.getServer(await editorProvider.getNotebookOptions()); + assert.ok(server, 'Server was destroyed on notebook shutdown'); + + // Reopen, and rerun + editor = await openEditor(ioc, JSON.stringify(notebook)); + runAllButton = findButton(wrapper, NativeEditor, 3); + await waitForMessageResponse(ioc, () => runAllButton!.simulate('click')); + await waitForUpdate(wrapper, NativeEditor, 15); + verifyHtmlOnCell(wrapper, 'NativeCell', `1`, 0); + }, () => { return ioc; }); + + test('Failure', async () => { + // Make a dummy class that will fail during launch + class FailedProcess extends JupyterExecutionFactory { + public getUsableJupyterPython(): Promise { + return Promise.resolve(undefined); + } + } + ioc.serviceManager.rebind(IJupyterExecution, FailedProcess); + ioc.serviceManager.get(IJupyterExecution); + addMockData(ioc, 'a=1\na', 1); + const wrapper = mountNativeWebView(ioc); + await createNewEditor(ioc); + await addCell(wrapper, 'a=1\na', true, 2); + + // Cell should not have the output + verifyHtmlOnCell(wrapper, 'NativeCell', 'Jupyter cannot be started', CellPosition.Last); + }); }); - teardown(async () => { - for (const disposable of disposables) { - if (!disposable) { - continue; + suite('Editor Keyboard tests', () => { + let wrapper: ReactWrapper, React.Component>; + const disposables: Disposable[] = []; + let ioc: DataScienceIocContainer; + const baseFile = [ + { id: 'NotebookImport#0', data: { source: 'a=1\na' } }, + { id: 'NotebookImport#1', data: { source: 'b=2\nb' } }, + { id: 'NotebookImport#2', data: { source: 'c=3\nc' } } + ]; + setup(async function() { + ioc = new DataScienceIocContainer(); + ioc.registerDataScienceTypes(); + const wrapperPossiblyUndefined = await setupWebview(ioc); + if (wrapperPossiblyUndefined) { + wrapper = wrapperPossiblyUndefined; + + addMockData(ioc, 'b=2\nb', 2); + addMockData(ioc, 'c=3\nc', 3); + + const runAllCells = baseFile.map(cell => { + return createFileCell(cell, cell.data); + }); + const notebook = await ioc.get(INotebookExporter).translateToNotebook(runAllCells, undefined); + await Promise.all([waitForUpdate(wrapper, NativeEditor, 1), openEditor(ioc, JSON.stringify(notebook))]); + } else { + // tslint:disable-next-line: no-invalid-this + this.skip(); } - // tslint:disable-next-line:no-any - const promise = disposable.dispose() as Promise; - if (promise) { - await promise; + }); + + teardown(async () => { + for (const disposable of disposables) { + if (!disposable) { + continue; + } + // tslint:disable-next-line:no-any + const promise = disposable.dispose() as Promise; + if (promise) { + await promise; + } } + await ioc.dispose(); + }); + + function clickCell(cellIndex: number) { + wrapper + .find(NativeCell) + .at(cellIndex) + .simulate('click'); + wrapper.update(); + } + function simulateKeyPressOnCell(cellIndex: number, keyboardEvent: Partial & { code: string }) { + const event = { ...createKeyboardEventForCell(keyboardEvent), ...keyboardEvent }; + wrapper + .find(NativeCell) + .at(cellIndex) + .find(CellInput) + .props().keyDown!(baseFile[cellIndex].id, event); + wrapper.update(); } - await ioc.dispose(); - }); - // Uncomment this to debug hangs on exit - // suiteTeardown(() => { - // asyncDump(); - // }); + 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)); + }); - function createFileCell(cell: any, data: any): ICell { - 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 }; + 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)); + }); - return newCell; - } + 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); + }); - runMountedTest('Simple text', async (wrapper) => { - // Create an editor so something is listening to messages - await createNewEditor(ioc); - - // Add a cell into the UI and wait for it to render - await addCell(wrapper, 'a=1\na'); - - verifyHtmlOnCell(wrapper, 'NativeCell', '1', CellPosition.Last); - }, () => { return ioc; }); - - runMountedTest('Mime Types', async (wrapper) => { - // Create an editor so something is listening to messages - await createNewEditor(ioc); - - const badPanda = `import pandas as pd -df = pd.read("${escapePath(path.join(srcDirectory(), 'DefaultSalesReport.csv'))}") -df.head()`; - const goodPanda = `import pandas as pd -df = pd.read_csv("${escapePath(path.join(srcDirectory(), 'DefaultSalesReport.csv'))}") -df.head()`; - const matPlotLib = 'import matplotlib.pyplot as plt\r\nimport numpy as np\r\nx = np.linspace(0,20,100)\r\nplt.plot(x, np.sin(x))\r\nplt.show()'; - const matPlotLibResults = 'img'; - const spinningCursor = `import sys -import time - -def spinning_cursor(): - while True: - for cursor in '|/-\\\\': - yield cursor - -spinner = spinning_cursor() -for _ in range(50): - sys.stdout.write(next(spinner)) - sys.stdout.flush() - time.sleep(0.1) - sys.stdout.write('\\r')`; - - addMockData(ioc, badPanda, `pandas has no attribute 'read'`, 'text/html', 'error'); - addMockData(ioc, goodPanda, `A table`, 'text/html'); - addMockData(ioc, matPlotLib, matPlotLibResults, 'text/html'); - const cursors = ['|', '/', '-', '\\']; - let cursorPos = 0; - let loops = 3; - addContinuousMockData(ioc, spinningCursor, async (_c) => { - const result = `${cursors[cursorPos]}\r`; - cursorPos += 1; - if (cursorPos >= cursors.length) { - cursorPos = 0; - loops -= 1; + 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); } - return Promise.resolve({ result: result, haveMore: loops > 0 }); }); - await addCell(wrapper, badPanda, true); - verifyHtmlOnCell(wrapper, 'NativeCell', `has no attribute 'read'`, CellPosition.Last); - - await addCell(wrapper, goodPanda, true); - verifyHtmlOnCell(wrapper, 'NativeCell', ``, CellPosition.Last); - - await addCell(wrapper, matPlotLib, true, 6); - verifyHtmlOnCell(wrapper, 'NativeCell', matPlotLibResults, CellPosition.Last); - - await addCell(wrapper, spinningCursor, true, 4 + (ioc.mockJupyter ? (cursors.length * 3) : 50)); - verifyHtmlOnCell(wrapper, 'NativeCell', '
', CellPosition.Last); - }, () => { return ioc; }); - - runMountedTest('Click buttons', async (wrapper) => { - // Goto source should cause the visible editor to be picked as long as its filename matches - const showedEditor = createDeferred(); - const textEditors: TextEditor[] = []; - const docManager = TypeMoq.Mock.ofType(); - const visibleEditor = TypeMoq.Mock.ofType(); - const dummyDocument = TypeMoq.Mock.ofType(); - dummyDocument.setup(d => d.fileName).returns(() => 'foo.py'); - visibleEditor.setup(v => v.show()).returns(() => showedEditor.resolve()); - visibleEditor.setup(v => v.revealRange(TypeMoq.It.isAny())).returns(noop); - visibleEditor.setup(v => v.document).returns(() => dummyDocument.object); - textEditors.push(visibleEditor.object); - docManager.setup(a => a.visibleTextEditors).returns(() => textEditors); - ioc.serviceManager.rebindInstance(IDocumentManager, docManager.object); - // Create an editor so something is listening to messages - await createNewEditor(ioc); - - // Get a cell into the list - await addCell(wrapper, 'a=1\na'); - - // find the buttons on the cell itself - let cell = getLastOutputCell(wrapper, 'NativeCell'); - let ImageButtons = cell.find(ImageButton); - assert.equal(ImageButtons.length, 7, 'Cell buttons not found'); - let deleteButton = ImageButtons.at(6); - - // Make sure delete works - let afterDelete = await getNativeCellResults(wrapper, 1, async () => { - deleteButton.simulate('click'); - return Promise.resolve(); + 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); + } }); - assert.equal(afterDelete.length, 1, `Delete should remove a cell`); - - // Secondary delete should NOT delete the cell as there should ALWAYS be at - // least one cell in the file. - cell = getLastOutputCell(wrapper, 'NativeCell'); - ImageButtons = cell.find(ImageButton); - assert.equal(ImageButtons.length, 7, 'Cell buttons not found'); - deleteButton = ImageButtons.at(6); - - afterDelete = await getNativeCellResults(wrapper, 1, async () => { - deleteButton.simulate('click'); - return Promise.resolve(); + + 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)); }); - assert.equal(afterDelete.length, 1, `Delete should NOT remove the last cell`); - }, () => { return ioc; }); - - runMountedTest('Export', async (wrapper) => { - // Export should cause the export dialog to come up. Remap appshell so we can check - const dummyDisposable = { - dispose: () => { return; } - }; - let exportCalled = false; - const appShell = TypeMoq.Mock.ofType(); - appShell.setup(a => a.showErrorMessage(TypeMoq.It.isAnyString())).returns((e) => { throw e; }); - appShell.setup(a => a.showInformationMessage(TypeMoq.It.isAny(), TypeMoq.It.isAny())).returns(() => Promise.resolve('')); - appShell.setup(a => a.showSaveDialog(TypeMoq.It.isAny())).returns(() => { - exportCalled = true; - return Promise.resolve(undefined); + + 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); }); - appShell.setup(a => a.setStatusBarMessage(TypeMoq.It.isAny())).returns(() => dummyDisposable); - ioc.serviceManager.rebindInstance(IApplicationShell, appShell.object); - - // Make sure to create the interactive window after the rebind or it gets the wrong application shell. - await createNewEditor(ioc); - await addCell(wrapper, 'a=1\na'); - - // Export should cause exportCalled to change to true - const exportButton = findButton(wrapper, NativeEditor, 6); - await waitForMessageResponse(ioc, () => exportButton!.simulate('click')); - assert.equal(exportCalled, true, 'Export should have been called'); - }, () => { return ioc; }); - - runMountedTest('RunAllCells', async (wrapper) => { - addMockData(ioc, 'b=2\nb', 2); - addMockData(ioc, 'c=3\nc', 3); - - const baseFile = [ {id: 'NotebookImport#0', data: {source: 'a=1\na'}}, - {id: 'NotebookImport#1', data: {source: 'b=2\nb'}}, - {id: 'NotebookImport#2', data: {source: 'c=3\nc'}} ]; - const runAllCells = baseFile.map(cell => { - return createFileCell(cell, cell.data); + + 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(); + + // Ensure cell was executed. + verifyHtmlOnCell(wrapper, 'NativeCell', '2', 1); + + // The third cell should be selected. + assert.ok(isCellSelected(wrapper, 'NativeCell', 2)); }); - const notebook = await ioc.get(INotebookExporter).translateToNotebook(runAllCells, undefined); - await openEditor(ioc, JSON.stringify(notebook)); - // Export should cause exportCalled to change to true - const runAllButton = findButton(wrapper, NativeEditor, 3); - await waitForMessageResponse(ioc, () => runAllButton!.simulate('click')); + 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; - await waitForUpdate(wrapper, NativeEditor, 16); + // Ensure cell was executed. + verifyHtmlOnCell(wrapper, 'NativeCell', '2', 1); - verifyHtmlOnCell(wrapper, 'NativeCell', `1`, 0); - verifyHtmlOnCell(wrapper, 'NativeCell', `2`, 1); - verifyHtmlOnCell(wrapper, 'NativeCell', `3`, 2); - }, () => { return ioc; }); + // The first cell should be selected. + assert.ok(isCellSelected(wrapper, 'NativeCell', 1)); + }); - runMountedTest('Startup and shutdown', async (wrapper) => { - addMockData(ioc, 'b=2\nb', 2); - addMockData(ioc, 'c=3\nc', 3); + 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 baseFile = [ {id: 'NotebookImport#0', data: {source: 'a=1\na'}}, - {id: 'NotebookImport#1', data: {source: 'b=2\nb'}}, - {id: 'NotebookImport#2', data: {source: 'c=3\nc'}} ]; - const runAllCells = baseFile.map(cell => { - return createFileCell(cell, cell.data); + const update = waitForUpdate(wrapper, NativeEditor, 1); + clickCell(1); + simulateKeyPressOnCell(1, { code: 'Enter', altKey: true, editorInfo: undefined }); + 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); + }); + + test('Pressing \'d\' on a selected cell twice deletes the cell', async () => { + // Initially 3 cells. + assert.equal(wrapper.find('NativeCell').length, 3); + + clickCell(2); + simulateKeyPressOnCell(2, { code: 'd' }); + simulateKeyPressOnCell(2, { code: 'd' }); + + // There should be 2 cells. + assert.equal(wrapper.find('NativeCell').length, 2); + }); + + 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); + + // const secondCell = wrapper.find('NativeCell').at(1); + + 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('Pressing \'b\' on a selected cell adds a cell after the current position', async () => { + // Initially 3 cells. + assert.equal(wrapper.find('NativeCell').length, 3); + + clickCell(1); + const update = waitForUpdate(wrapper, NativeEditor, 1); + simulateKeyPressOnCell(1, { code: 'b' }); + 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"]', 0); + verifyCellIndex(wrapper, 'div[id="NotebookImport#1"]', 1); + 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; + + // Ensure cell was executed. + verifyHtmlOnCell(wrapper, 'NativeCell', '3', 2); + + // Hide the output + update = waitForUpdate(wrapper, NativeEditor, 1); + simulateKeyPressOnCell(2, { code: 'o' }); + await update; + + // Ensure cell output is hidden (looking for cell results will throw an exception). + assert.throws(() => verifyHtmlOnCell(wrapper, 'NativeCell', '3', 2)); + + // 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 oldUpdateOptions = editor.updateOptions.bind(editor); + + let lineNumberSetting: any = ''; + editor.updateOptions = (options: monacoEditor.editor.IEditorConstructionOptions) => { + lineNumberSetting = options.lineNumbers; + oldUpdateOptions(options); + }; + + // Display line numbers. + simulateKeyPressOnCell(1, { code: 'l' }); + assert.equal(lineNumberSetting, 'on'); + + // toggle the display of line numbers. + simulateKeyPressOnCell(1, { code: 'l' }); + assert.equal(lineNumberSetting, 'off'); + }); + + 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); }); - const notebook = await ioc.get(INotebookExporter).translateToNotebook(runAllCells, undefined); - let editor = await openEditor(ioc, JSON.stringify(notebook)); - - // Run everything - let runAllButton = findButton(wrapper, NativeEditor, 3); - await waitForMessageResponse(ioc, () => runAllButton!.simulate('click')); - await waitForUpdate(wrapper, NativeEditor, 16); - - // Close editor. Should still have the server up - await closeNotebook(editor, wrapper); - const jupyterExecution = ioc.serviceManager.get(IJupyterExecution); - const editorProvider = ioc.serviceManager.get(INotebookEditorProvider); - const server = await jupyterExecution.getServer(await editorProvider.getNotebookOptions()); - assert.ok(server, 'Server was destroyed on notebook shutdown'); - - // Reopen, and rerun - editor = await openEditor(ioc, JSON.stringify(notebook)); - runAllButton = findButton(wrapper, NativeEditor, 3); - await waitForMessageResponse(ioc, () => runAllButton!.simulate('click')); - await waitForUpdate(wrapper, NativeEditor, 15); - verifyHtmlOnCell(wrapper, 'NativeCell', `1`, 0); - }, () => { return ioc; }); - - test('Failure', async () => { - // Make a dummy class that will fail during launch - class FailedProcess extends JupyterExecutionFactory { - public getUsableJupyterPython(): Promise { - return Promise.resolve(undefined); - } - } - ioc.serviceManager.rebind(IJupyterExecution, FailedProcess); - ioc.serviceManager.get(IJupyterExecution); - addMockData(ioc, 'a=1\na', 1); - const wrapper = mountNativeWebView(ioc); - await createNewEditor(ioc); - await addCell(wrapper, 'a=1\na', true, 2); - - // Cell should not have the output - verifyHtmlOnCell(wrapper, 'NativeCell', 'Jupyter cannot be started', CellPosition.Last); }); }); diff --git a/src/test/datascience/nativeEditorTestHelpers.tsx b/src/test/datascience/nativeEditorTestHelpers.tsx index 37c4ce84511f..d0421752c580 100644 --- a/src/test/datascience/nativeEditorTestHelpers.tsx +++ b/src/test/datascience/nativeEditorTestHelpers.tsx @@ -48,10 +48,8 @@ export function getNativeCellResults(wrapper: ReactWrapper, Re export function runMountedTest(name: string, testFunc: (wrapper: ReactWrapper, React.Component>) => Promise, getIOC: () => DataScienceIocContainer) { test(name, async () => { const ioc = getIOC(); - const jupyterExecution = ioc.get(IJupyterExecution); - if (await jupyterExecution.isNotebookSupported()) { - addMockData(ioc, 'a=1\na', 1); - const wrapper = mountNativeWebView(ioc); + const wrapper = await setupWebview(ioc); + if (wrapper) { await testFunc(wrapper); } else { // tslint:disable-next-line:no-console @@ -63,6 +61,13 @@ export function runMountedTest(name: string, testFunc: (wrapper: ReactWrapper, React.Component> { return mountWebView(ioc, ); } +export async function setupWebview(ioc: DataScienceIocContainer) { + const jupyterExecution = ioc.get(IJupyterExecution); + if (await jupyterExecution.isNotebookSupported()) { + addMockData(ioc, 'a=1\na', 1); + return mountNativeWebView(ioc); + } +} // tslint:disable-next-line: no-any export async function addCell(wrapper: ReactWrapper, React.Component>, code: string, submit: boolean = true, expectedSubmitRenderCount: number = 5): Promise { diff --git a/src/test/datascience/testHelpers.tsx b/src/test/datascience/testHelpers.tsx index 461a6aac039a..c13b66d11d4a 100644 --- a/src/test/datascience/testHelpers.tsx +++ b/src/test/datascience/testHelpers.tsx @@ -15,8 +15,10 @@ import { InteractiveWindowMessages } from '../../client/datascience/interactive- import { IJupyterExecution } from '../../client/datascience/types'; import { InteractivePanel } from '../../datascience-ui/history-react/interactivePanel'; import { NativeEditor } from '../../datascience-ui/native-editor/nativeEditor'; +import { IKeyboardEvent } from '../../datascience-ui/react-common/event'; import { ImageButton } from '../../datascience-ui/react-common/imageButton'; import { updateSettings } from '../../datascience-ui/react-common/settingsReactSide'; +import { noop } from '../core'; import { DataScienceIocContainer } from './dataScienceIocContainer'; import { createInputEvent, createKeyboardEvent, waitForUpdate } from './reactHelpers'; @@ -33,7 +35,7 @@ export enum CellPosition { Last = 'last' } -export function waitForMessage(ioc: DataScienceIocContainer, message: string) : Promise { +export function waitForMessage(ioc: DataScienceIocContainer, message: string): Promise { // Wait for the mounted web panel to send a message back to the data explorer const promise = createDeferred(); let handler: (m: string, p: any) => void; @@ -48,7 +50,7 @@ export function waitForMessage(ioc: DataScienceIocContainer, message: string) : } export async function waitForMessageResponse(ioc: DataScienceIocContainer, action: () => void): Promise { - ioc.wrapperCreatedPromise = createDeferred(); + ioc.wrapperCreatedPromise = createDeferred(); action(); await ioc.wrapperCreatedPromise.promise; ioc.wrapperCreatedPromise = undefined; @@ -58,7 +60,8 @@ async function testInnerLoop( name: string, mountFunc: (ioc: DataScienceIocContainer) => ReactWrapper, React.Component>, testFunc: (wrapper: ReactWrapper, React.Component>) => Promise, - getIOC: () => DataScienceIocContainer) { + getIOC: () => DataScienceIocContainer +) { const ioc = getIOC(); const jupyterExecution = ioc.get(IJupyterExecution); if (await jupyterExecution.isNotebookSupported()) { @@ -73,8 +76,10 @@ async function testInnerLoop( export function runDoubleTest(name: string, testFunc: (wrapper: ReactWrapper, React.Component>) => Promise, getIOC: () => DataScienceIocContainer) { // Just run the test twice. Originally mounted twice, but too hard trying to figure out disposing. - test(`${name} (interactive)`, async () => testInnerLoop(name, (ioc) => mountWebView(ioc, ), testFunc, getIOC)); - test(`${name} (native)`, async () => testInnerLoop(name, (ioc) => mountWebView(ioc, ), testFunc, getIOC)); + test(`${name} (interactive)`, async () => + testInnerLoop(name, ioc => mountWebView(ioc, ), testFunc, getIOC)); + test(`${name} (native)`, async () => + testInnerLoop(name, ioc => mountWebView(ioc, ), testFunc, getIOC)); } export function mountWebView(ioc: DataScienceIocContainer, node: React.ReactElement): ReactWrapper, React.Component> { @@ -119,7 +124,7 @@ export function verifyHtmlOnCell(wrapper: ReactWrapper, React. let targetCell: ReactWrapper; // Get the correct result that we are dealing with if (typeof cellIndex === 'number') { - if (cellIndex >= 0 && cellIndex <= (foundResult.length - 1)) { + if (cellIndex >= 0 && cellIndex <= foundResult.length - 1) { targetCell = foundResult.at(cellIndex); } } else if (typeof cellIndex === 'string') { @@ -158,8 +163,114 @@ export function verifyHtmlOnCell(wrapper: ReactWrapper, React. } } -export function verifyLastCellInputState(wrapper: ReactWrapper, React.Component>, cellType: string, state: CellInputState) { +/** + * Creates a keyboard event for a cells. + * + * @export + * @param {(Partial & { code: string })} event + * @returns + */ +export function createKeyboardEventForCell(event: Partial & { code: string }) { + const defaultKeyboardEvent: IKeyboardEvent = { + altKey: false, + code: '', + ctrlKey: false, + editorInfo: { + contents: '', + isDirty: false, + isFirstLine: false, + isLastLine: false, + isSuggesting: false + }, + metaKey: false, + preventDefault: noop, + shiftKey: false, + stopPropagation: noop, + target: {} as any + }; + + const defaultEditorInfo = defaultKeyboardEvent.editorInfo!; + const providedEditorInfo = event.editorInfo || {}; + return { + ...defaultKeyboardEvent, + ...event, + editorInfo: { + ...defaultEditorInfo, + ...providedEditorInfo + } + }; +} + +export function isCellSelected(wrapper: ReactWrapper, React.Component>, cellType: string, cellIndex: number | CellPosition): boolean { + try { + verifyCell(wrapper, cellType, { selector: '.cell-wrapper-selected' }, cellIndex); + return true; + } catch { + return false; + } +} + +export function isCellFocused(wrapper: ReactWrapper, React.Component>, cellType: string, cellIndex: number | CellPosition): boolean { + try { + verifyCell(wrapper, cellType, { selector: '.cell-wrapper-focused' }, cellIndex); + return true; + } catch { + return false; + } +} + +export function verifyCellIndex(wrapper: ReactWrapper, React.Component>, cellId: string, expectedCellIndex: number) { + const nativeCell = wrapper + .find(cellId) + .first() + .find('NativeCell'); + const secondCell = wrapper.find('NativeCell').at(expectedCellIndex); + assert.equal(nativeCell.html(), secondCell.html()); +} + +function verifyCell( + wrapper: ReactWrapper, React.Component>, + cellType: string, + options: { selector: string; shouldNotExist?: boolean }, + cellIndex: number | CellPosition +) { + const foundResult = wrapper.find(cellType); + assert.ok(foundResult.length >= 1, 'Didn\'t find any cells being rendered'); + + let targetCell: ReactWrapper; + // Get the correct result that we are dealing with + if (typeof cellIndex === 'number') { + if (cellIndex >= 0 && cellIndex <= foundResult.length - 1) { + targetCell = foundResult.at(cellIndex); + } + } else if (typeof cellIndex === 'string') { + switch (cellIndex) { + case CellPosition.First: + targetCell = foundResult.first(); + break; + + case CellPosition.Last: + // Skip the input cell on these checks. + targetCell = getLastOutputCell(wrapper, cellType); + break; + + default: + // Fall through, targetCell check will fail out + break; + } + } + + // ! is ok here to get rid of undefined type check as we want a fail here if we have not initialized targetCell + assert.ok(targetCell!, 'Target cell doesn\'t exist'); + if (options.shouldNotExist) { + assert.ok(targetCell!.find(options.selector).length === 0, `Found cells with the matching selector '${options.selector}'`); + } else { + assert.ok(targetCell!.find(options.selector).length >= 1, `Didn't find any cells with the matching selector '${options.selector}'`); + } +} + +export function verifyLastCellInputState(wrapper: ReactWrapper, React.Component>, cellType: string, state: CellInputState) { const lastCell = getLastOutputCell(wrapper, cellType); assert.ok(lastCell, 'Last call doesn\'t exist'); @@ -189,8 +300,13 @@ export function verifyLastCellInputState(wrapper: ReactWrapper } } -export async function getCellResults(wrapper: ReactWrapper, React.Component>, mainClass: React.ComponentClass, cellType: string, expectedRenders: number, updater: () => Promise): Promise, React.Component>> { - +export async function getCellResults( + wrapper: ReactWrapper, React.Component>, + mainClass: React.ComponentClass, + cellType: string, + expectedRenders: number, + updater: () => Promise +): Promise, React.Component>> { // Get a render promise with the expected number of renders const renderPromise = waitForUpdate(wrapper, mainClass, expectedRenders); @@ -237,10 +353,9 @@ function simulateKey(domNode: HTMLTextAreaElement, key: string, shiftDown?: bool domNode.dispatchEvent(createInputEvent()); } } - } -async function submitInput(wrapper: ReactWrapper, React.Component>, mainClass: React.ComponentClass, textArea: HTMLTextAreaElement): Promise { +async function submitInput(wrapper: ReactWrapper, React.Component>, mainClass: React.ComponentClass, textArea: HTMLTextAreaElement): Promise { // Get a render promise with the expected number of renders (how many updates a the shift + enter will cause) // Should be 6 - 1 for the shift+enter and 5 for the new cell. const renderPromise = waitForUpdate(wrapper, mainClass, 6); @@ -256,15 +371,14 @@ function enterKey(_wrapper: ReactWrapper, React.Component>, te simulateKey(textArea, key); } -export function getEditor(wrapper: ReactWrapper, React.Component>) : ReactWrapper, React.Component> { +export function getEditor(wrapper: ReactWrapper, React.Component>): ReactWrapper, React.Component> { // Find the last cell. It should have a monacoEditor object const cells = wrapper.find('InteractiveCell'); const lastCell = cells.last(); return lastCell.find('MonacoEditor'); } -export function typeCode(wrapper: ReactWrapper, React.Component>, code: string) : HTMLTextAreaElement | null { - +export function typeCode(wrapper: ReactWrapper, React.Component>, code: string): HTMLTextAreaElement | null { // Find the last cell. It should have a monacoEditor object. We need to search // through its DOM to find the actual textarea to send input to // (we can't actually find it with the enzyme wrappers because they only search @@ -284,8 +398,12 @@ export function typeCode(wrapper: ReactWrapper, React.Componen return textArea; } -export async function enterInput(wrapper: ReactWrapper, React.Component>, mainClass: React.ComponentClass, code: string, resultClass: string): Promise, React.Component>> { - +export async function enterInput( + wrapper: ReactWrapper, React.Component>, + mainClass: React.ComponentClass, + code: string, + resultClass: string +): Promise, React.Component>> { // First we have to type the code into the input box const textArea = typeCode(wrapper, code); @@ -296,7 +414,11 @@ export async function enterInput(wrapper: ReactWrapper, React. return wrapper.find(resultClass); } -export function findButton(wrapper: ReactWrapper, React.Component>, mainClass: React.ComponentClass, index: number): ReactWrapper, React.Component> | undefined { +export function findButton( + wrapper: ReactWrapper, React.Component>, + mainClass: React.ComponentClass, + index: number +): ReactWrapper, React.Component> | undefined { const mainObj = wrapper.find(mainClass); if (mainObj) { const buttons = mainObj.find(ImageButton);