From 076a7e804316a8c83a1db43710c05e053dcb6626 Mon Sep 17 00:00:00 2001 From: Sergey Petushkov Date: Tue, 10 Sep 2024 14:21:46 +0200 Subject: [PATCH] fix(browser-repl): ensure that input can't be modified or evaluated while operation is in progress --- .../src/components/editor.spec.tsx | 36 ++++++++++++++- .../browser-repl/src/components/editor.tsx | 42 +++++++++++++++++- .../src/components/shell.spec.tsx | 44 +++++++++++++++++++ .../browser-repl/src/components/shell.tsx | 23 ++++++++-- 4 files changed, 139 insertions(+), 6 deletions(-) diff --git a/packages/browser-repl/src/components/editor.spec.tsx b/packages/browser-repl/src/components/editor.spec.tsx index b4d91624e..d5155bd78 100644 --- a/packages/browser-repl/src/components/editor.spec.tsx +++ b/packages/browser-repl/src/components/editor.spec.tsx @@ -1,7 +1,9 @@ import sinon from 'sinon'; import { expect } from '../../testing/chai'; -import { createCommands } from './editor'; +import { Editor, createCommands } from './editor'; import type { Command } from '@mongodb-js/compass-editor'; +import { shallow } from '../../testing/enzyme'; +import React from 'react'; describe('', function () { let commandSpies: Parameters[number]; @@ -104,4 +106,36 @@ describe('', function () { expect(commandSpies.onArrowDownOnLastLine).to.not.have.been.called; }); }); + + describe('command callback props', function () { + it('does not call callback props that can modify shell input', function () { + const callbackPropSpies = { + onEnter: sinon.spy(), + onArrowUpOnFirstLine: sinon.stub().resolves(false), + onArrowDownOnLastLine: sinon.stub().resolves(false), + onClearCommand: sinon.spy(), + onSigInt: sinon.spy(), + }; + + const wrapper = shallow( + + ); + + wrapper.instance().onEnter(); + expect(callbackPropSpies.onEnter).to.not.have.been.called; + + wrapper.instance().onArrowUpOnFirstLine(); + expect(callbackPropSpies.onArrowUpOnFirstLine).to.not.have.been.called; + + wrapper.instance().onArrowDownOnLastLine(); + expect(callbackPropSpies.onArrowDownOnLastLine).to.not.have.been.called; + + wrapper.instance().onClearCommand(); + expect(callbackPropSpies.onClearCommand).to.not.have.been.called; + + // Only onSigInt is allowed when operation is in progress + wrapper.instance().onSigInt(); + expect(callbackPropSpies.onSigInt).to.have.been.called; + }); + }); }); diff --git a/packages/browser-repl/src/components/editor.tsx b/packages/browser-repl/src/components/editor.tsx index e212e2311..4e3302c11 100644 --- a/packages/browser-repl/src/components/editor.tsx +++ b/packages/browser-repl/src/components/editor.tsx @@ -168,7 +168,47 @@ export class Editor extends Component { () => null ); }; - this.commands = createCommands(this.props); + this.commands = createCommands(this); + } + + onEnter() { + // Do not allow commands that can modify / evaluate the input while + // operation is in progress + if (this.props.operationInProgress) { + return; + } + return this.props.onEnter(); + } + + onArrowDownOnLastLine() { + // Do not allow commands that can modify / evaluate the input while + // operation is in progress + if (this.props.operationInProgress) { + return Promise.resolve(false); + } + return this.props.onArrowDownOnLastLine(); + } + + onArrowUpOnFirstLine() { + // Do not allow commands that can modify / evaluate the input while + // operation is in progress + if (this.props.operationInProgress) { + return Promise.resolve(false); + } + return this.props.onArrowUpOnFirstLine(); + } + + onClearCommand() { + // Do not allow commands that can modify / evaluate the input while + // operation is in progress + if (this.props.operationInProgress) { + return; + } + return this.props.onClearCommand(); + } + + onSigInt() { + return this.props.onSigInt(); } render(): JSX.Element { diff --git a/packages/browser-repl/src/components/shell.spec.tsx b/packages/browser-repl/src/components/shell.spec.tsx index 61ee3f934..eeed7a0bc 100644 --- a/packages/browser-repl/src/components/shell.spec.tsx +++ b/packages/browser-repl/src/components/shell.spec.tsx @@ -13,6 +13,22 @@ const wait: (ms?: number) => Promise = (ms = 10) => { return new Promise((resolve) => setTimeout(resolve, ms)); }; +const waitFor = async (fn: () => void, timeout = 10_000) => { + const start = Date.now(); + // eslint-disable-next-line no-constant-condition + while (true) { + await wait(); + try { + fn(); + return; + } catch (err) { + if (Date.now() - start >= timeout) { + throw err; + } + } + } +}; + describe('', function () { let onOutputChangedSpy; let onHistoryChangedSpy; @@ -523,4 +539,32 @@ describe('', function () { ); expect(wrapper.find('Editor').prop('value')).to.eq('db.coll.find({})'); }); + + it('evaluates initial lines', async function () { + wrapper = mount( + + ); + + // The `operationInProgress` state should be set to true right away + expect(wrapper.state('operationInProgress')).to.eq(true); + expect(wrapper.state('output')).to.deep.eq([]); + + await waitFor(() => { + // Eventually we can see through output state that initial lines were + // evaluated + expect( + wrapper + .state('output') + .filter((line) => { + return line.format === 'input'; + }) + .map((line) => { + return line.value; + }) + ).to.deep.eq(['use test', 'db.coll.find({})']); + }); + }); }); diff --git a/packages/browser-repl/src/components/shell.tsx b/packages/browser-repl/src/components/shell.tsx index 35c2ad510..d7de84b3d 100644 --- a/packages/browser-repl/src/components/shell.tsx +++ b/packages/browser-repl/src/components/shell.tsx @@ -144,6 +144,23 @@ const noop = (): void => { /* */ }; +const normalizeInitialEvaluate = (initialEvaluate: string | string[]) => { + return ( + Array.isArray(initialEvaluate) ? initialEvaluate : [initialEvaluate] + ).filter((line) => { + // Filter out empty lines if passed by accident + return !!line; + }); +}; + +const isInitialEvaluateEmpty = ( + initialEvaluate?: string | string[] | undefined +) => { + return ( + !initialEvaluate || normalizeInitialEvaluate(initialEvaluate).length === 0 + ); +}; + /** * The browser-repl Shell component */ @@ -166,7 +183,7 @@ export class _Shell extends Component { private onCancelPasswordPrompt: () => void = noop; readonly state: ShellState = { - operationInProgress: false, + operationInProgress: !isInitialEvaluateEmpty(this.props.initialEvaluate), output: this.props.initialOutput.slice(-this.props.maxOutputLength), history: this.props.initialHistory.slice(0, this.props.maxHistoryLength), passwordPrompt: '', @@ -178,9 +195,7 @@ export class _Shell extends Component { // updated one when actually running the lines let evalLines: string[] = []; if (this.props.initialEvaluate) { - evalLines = Array.isArray(this.props.initialEvaluate) - ? this.props.initialEvaluate - : [this.props.initialEvaluate]; + evalLines = normalizeInitialEvaluate(this.props.initialEvaluate); } this.scrollToBottom(); void this.updateShellPrompt().then(async () => {