diff --git a/packages/server/src/common/command/command-stack.spec.ts b/packages/server/src/common/command/command-stack.spec.ts index 4637181..366a4c1 100644 --- a/packages/server/src/common/command/command-stack.spec.ts +++ b/packages/server/src/common/command/command-stack.spec.ts @@ -16,7 +16,7 @@ import { expect } from 'chai'; import { Container, ContainerModule } from 'inversify'; import * as sinon from 'sinon'; -import { StubCommand, StubLogger } from '../test/mock-util'; +import { expectToThrowAsync, StubCommand, StubLogger } from '../test/mock-util'; import { Logger } from '../utils/logger'; import { DefaultCommandStack } from './command-stack'; @@ -41,17 +41,17 @@ describe('test DefaultCommandStack', () => { }); describe('execute', () => { - it('should execute the given command and become dirty', () => { + it('should execute the given command and become dirty', async () => { expect(commandStack.isDirty).to.be.false; - commandStack.execute(command1); + await commandStack.execute(command1); expect(command1.execute.calledOnce).to.be.true; expect(commandStack.isDirty).to.be.true; }); - it('should execute the given commands in order and become dirty', () => { + it('should execute the given commands in order and become dirty', async () => { expect(commandStack.isDirty).to.be.false; - commandStack.execute(command1); - commandStack.execute(command2); + await commandStack.execute(command1); + await commandStack.execute(command2); expect(command1.execute.calledOnce).to.be.true; expect(command2.execute.calledOnce).to.be.true; @@ -59,112 +59,112 @@ describe('test DefaultCommandStack', () => { expect(commandStack.isDirty).to.be.true; }); - it('should be able to undo after execute', () => { + it('should be able to undo after execute', async () => { expect(commandStack.canUndo()).to.be.false; - commandStack.execute(command1); + await commandStack.execute(command1); expect(commandStack.canUndo()).to.be.true; }); - it('should clear the redo stack after execution', () => { + it('should clear the redo stack after execution', async () => { commandStack['commands'].push(command2); commandStack['top'] = -1; expect(commandStack.canRedo()).to.be.true; - commandStack.execute(command1); + await commandStack.execute(command1); expect(commandStack.canRedo()).to.be.false; }); - it('should flush the stack in case of an execution error', () => { + it('should flush the stack in case of an execution error', async () => { command2.execute.throwsException(); const flushSpy = sandbox.spy(commandStack, 'flush'); - expect(() => commandStack.execute(command2)).to.throw(); + await expectToThrowAsync(() => commandStack.execute(command2)); expect(command2.execute.calledOnce).to.be.true; expect(flushSpy.calledOnce).to.be.true; }); }); describe('undo', () => { - it('should do nothing if the command stack is empty', () => { + it('should do nothing if the command stack is empty', async () => { expect(commandStack.isDirty).to.be.false; - commandStack.undo(); + await commandStack.undo(); expect(commandStack.canUndo()).to.be.false; expect(commandStack.canRedo()).to.be.false; expect(commandStack.isDirty).to.be.false; }); - it('should undo the command and become non-dirty again', () => { + it('should undo the command and become non-dirty again', async () => { commandStack['commands'].push(command1); commandStack['top'] = 0; expect(commandStack.isDirty).to.be.true; expect(commandStack.canUndo()).to.be.true; expect(commandStack.canRedo()).to.be.false; - commandStack.undo(); + await commandStack.undo(); expect(command1.undo.calledOnce).to.be.true; expect(commandStack.isDirty).to.be.false; expect(commandStack.canRedo()).to.be.true; expect(commandStack.canUndo()).to.be.false; }); - it('should undo multiple command and become non-dirty again', () => { + it('should undo multiple command and become non-dirty again', async () => { commandStack['commands'].push(command1, command2); commandStack['top'] = 1; expect(commandStack.isDirty).to.be.true; expect(commandStack.canUndo()).to.be.true; expect(commandStack.canRedo()).to.be.false; - commandStack.undo(); + await commandStack.undo(); expect(command2.undo.calledOnce).to.be.true; expect(commandStack.canRedo()).to.be.true; expect(commandStack.canUndo()).to.be.true; expect(commandStack.isDirty).to.be.true; - commandStack.undo(); + await commandStack.undo(); expect(command1.undo.calledOnce).to.be.true; expect(command1.undo.calledAfter(command2.undo)).to.be.true; expect(commandStack.isDirty).to.be.false; expect(commandStack.canRedo()).to.be.true; expect(commandStack.canUndo()).to.be.false; }); - it('should flush the stack in case of an execution error', () => { + it('should flush the stack in case of an execution error', async () => { command2.undo.throwsException(); const flushSpy = sandbox.spy(commandStack, 'flush'); commandStack['commands'].push(command2); commandStack['top'] = 0; - expect(() => commandStack.undo()).to.throw(); + await expectToThrowAsync(() => commandStack.undo()); expect(command2.undo.calledOnce).to.be.true; expect(flushSpy.calledOnce).to.be.true; }); }); describe('redo', () => { - it('should do nothing if the command stack is empty', () => { + it('should do nothing if the command stack is empty', async () => { expect(commandStack.isDirty).to.be.false; - commandStack.redo(); + await commandStack.redo(); expect(commandStack.canUndo()).to.be.false; expect(commandStack.canRedo()).to.be.false; expect(commandStack.isDirty).to.be.false; }); - it('should redo the command and become dirty again', () => { + it('should redo the command and become dirty again', async () => { commandStack['commands'].push(command1); commandStack['top'] = -1; expect(commandStack.isDirty).to.be.false; expect(commandStack.canUndo()).to.be.false; expect(commandStack.canRedo()).to.be.true; - commandStack.redo(); + await commandStack.redo(); expect(command1.redo.calledOnce).to.be.true; expect(commandStack.isDirty).to.be.true; expect(commandStack.canRedo()).to.be.false; expect(commandStack.canUndo()).to.be.true; }); - it('should undo multiple command and become non-dirty again', () => { + it('should undo multiple command and become non-dirty again', async () => { commandStack['commands'].push(command2, command1); commandStack['top'] = -1; commandStack['saveIndex'] = -1; @@ -172,34 +172,34 @@ describe('test DefaultCommandStack', () => { expect(commandStack.canUndo()).to.be.false; expect(commandStack.canRedo()).to.be.true; - commandStack.redo(); + await commandStack.redo(); expect(command2.redo.calledOnce).to.be.true; expect(commandStack.canRedo()).to.be.true; expect(commandStack.canUndo()).to.be.true; expect(commandStack.isDirty).to.be.true; - commandStack.redo(); + await commandStack.redo(); expect(command1.redo.calledOnce).to.be.true; expect(command1.redo.calledAfter(command2.redo)).to.be.true; expect(commandStack.isDirty).to.be.true; expect(commandStack.canRedo()).to.be.false; expect(commandStack.canUndo()).to.be.true; }); - it('should flush the stack in case of an execution error', () => { + it('should flush the stack in case of an execution error', async () => { command2.redo.throwsException(); const flushSpy = sandbox.spy(commandStack, 'flush'); commandStack['commands'].push(command2); commandStack['top'] = -1; - expect(() => commandStack.redo()).to.throw(); + await expectToThrowAsync(() => commandStack.redo()); expect(command2.redo.calledOnce).to.be.true; expect(flushSpy.calledOnce).to.be.true; }); - it('should be able to undo after redo', () => { + it('should be able to undo after redo', async () => { commandStack['commands'].push(command1); commandStack['top'] = -1; expect(commandStack.canUndo()).to.be.false; - commandStack.redo(); + await commandStack.redo(); expect(commandStack.canUndo()).to.be.true; }); }); diff --git a/packages/server/src/common/command/command-stack.ts b/packages/server/src/common/command/command-stack.ts index 16f5755..2c2d79e 100644 --- a/packages/server/src/common/command/command-stack.ts +++ b/packages/server/src/common/command/command-stack.ts @@ -14,6 +14,7 @@ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ import { inject, injectable } from 'inversify'; +import { MaybePromise } from '..'; import { Logger } from '../utils/logger'; import { Command } from './command'; @@ -31,12 +32,12 @@ export interface CommandStack { * Clears any redoable commands not yet redone, adds the command to the stack and then invokes {@link Command.execute}. * @param command the command to execute. */ - execute(command: Command): void; + execute(command: Command): MaybePromise; /** * Removes the topmost (i.e. last executed) command from the stack and invokes {@link Command.undo}. */ - undo(): void; + undo(): MaybePromise; /** * Returns `true` if the top command on the stack can be undone. @@ -46,7 +47,7 @@ export interface CommandStack { /** * Re-adds the last undo command on top of the stack and invokes {@link Command.redo} */ - redo(): void; + redo(): MaybePromise; /** * Returns `true` if there are redoable commands in the stack. @@ -86,9 +87,9 @@ export class DefaultCommandStack implements CommandStack { */ protected saveIndex = -1; - execute(command: Command): void { + async execute(command: Command): Promise { try { - command.execute(); + await command.execute(); } catch (error) { this.handleError(error); } @@ -104,11 +105,11 @@ export class DefaultCommandStack implements CommandStack { } } - undo(): void { + async undo(): Promise { if (this.canUndo()) { const command = this.commands[this.top--]; try { - command.undo(); + await command.undo(); } catch (error) { this.handleError(error); } @@ -121,11 +122,11 @@ export class DefaultCommandStack implements CommandStack { : false; } - redo(): void { + async redo(): Promise { if (this.canRedo()) { const command = this.commands[++this.top]; try { - command.redo(); + await command.redo(); } catch (error) { this.handleError(error); } diff --git a/packages/server/src/common/command/command.spec.ts b/packages/server/src/common/command/command.spec.ts index 6f8091c..a546f3e 100644 --- a/packages/server/src/common/command/command.spec.ts +++ b/packages/server/src/common/command/command.spec.ts @@ -15,7 +15,7 @@ ********************************************************************************/ import { expect } from 'chai'; import * as sinon from 'sinon'; -import { StubCommand } from '../test/mock-util'; +import { expectToThrowAsync, StubCommand } from '../test/mock-util'; import { CompoundCommand } from './command'; describe('CompoundCommand', () => { @@ -32,18 +32,18 @@ describe('CompoundCommand', () => { }); describe('execute', () => { - it('Should execute the subcommands in order', () => { - compoundCommand.execute(); + it('Should execute the subcommands in order', async () => { + await compoundCommand.execute(); expect(command1.execute.calledOnce).to.be.true; expect(command2.execute.calledOnce).to.be.true; expect(command3.execute.calledOnce).to.be.true; expect(command1.execute.calledBefore(command2.execute)).to.be.true; expect(command2.execute.calledBefore(command3.execute)).to.be.true; }); - it('Should undo partially executed subcommands in case of an error', () => { + it('Should undo partially executed subcommands in case of an error', async () => { command3.execute.throwsException(); - expect(() => compoundCommand.execute()).to.throw(); + await expectToThrowAsync(() => compoundCommand.execute()); expect(command1.execute.calledOnce).to.be.true; expect(command2.execute.calledOnce).to.be.true; @@ -54,8 +54,8 @@ describe('CompoundCommand', () => { }); describe('undo', () => { - it('Should undo the subcommands in reverse order', () => { - compoundCommand.undo(); + it('Should undo the subcommands in reverse order', async () => { + await compoundCommand.undo(); expect(command1.undo.calledOnce).to.be.true; expect(command2.undo.calledOnce).to.be.true; expect(command3.undo.calledOnce).to.be.true; @@ -63,10 +63,9 @@ describe('CompoundCommand', () => { expect(command2.undo.calledAfter(command3.undo)).to.be.true; }); - it('Should redo partially undone subcommands in case of an error', () => { + it('Should redo partially undone subcommands in case of an error', async () => { command1.undo.throwsException(); - - expect(() => compoundCommand.undo()).to.throw(); + await expectToThrowAsync(() => compoundCommand.undo()); expect(command1.undo.calledOnce).to.be.true; expect(command2.undo.calledOnce).to.be.true; diff --git a/packages/server/src/common/command/command.ts b/packages/server/src/common/command/command.ts index 986301c..92e9745 100644 --- a/packages/server/src/common/command/command.ts +++ b/packages/server/src/common/command/command.ts @@ -14,7 +14,7 @@ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ -import { flatPush, MaybeArray } from '@eclipse-glsp/protocol'; +import { flatPush, MaybeArray, MaybePromise } from '@eclipse-glsp/protocol'; /** * A command implements a specific modification of the source model, which can be applied by invoking `execute()`. @@ -30,19 +30,19 @@ export interface Command { /** * Performs the command activity required for the effect (e.g. source model change). */ - execute(): void; + execute(): MaybePromise; /** * Performs the command activity required to `undo` the effects of a preceding `execute` (or `redo`). * The effect, if any, of calling `undo` before `execute` or `redo` have been called, is undefined. */ - undo(): void; + undo(): MaybePromise; /** * Performs the command activity required to `redo` the effect after undoing the effect. * The effect, if any, of calling `redo` before `undo` is called is undefined. */ - redo(): void; + redo(): MaybePromise; /** * Returns whether the command can be undone. @@ -71,18 +71,18 @@ export class CompoundCommand implements Command { flatPush(this.commands, commands); } - execute(): void { + async execute(): Promise { const alreadyExecuted: Command[] = []; try { - this.commands.forEach(command => { - command.execute(); + for (const command of this.commands) { + await command.execute(); alreadyExecuted.unshift(command); - }); + } } catch (err) { for (const command of alreadyExecuted) { if (Command.canUndo(command)) { - command.undo(); + await command.undo(); } else { break; } @@ -91,29 +91,33 @@ export class CompoundCommand implements Command { } } - undo(): void { + async undo(): Promise { const alreadyUndone: Command[] = []; try { - [...this.commands].reverse().forEach(command => { - command.undo(); + for (const command of [...this.commands].reverse()) { + await command.undo(); alreadyUndone.unshift(command); - }); + } } catch (err) { - alreadyUndone.forEach(command => command.redo()); + for (const command of alreadyUndone) { + await command.redo(); + } throw err; } } - redo(): void { + async redo(): Promise { const alreadyRedone: Command[] = []; try { - this.commands.forEach(command => { - command.redo(); + for (const command of this.commands) { + await command.redo(); alreadyRedone.unshift(command); - }); + } } catch (err) { - alreadyRedone.forEach(command => command.undo()); + for (const command of alreadyRedone) { + await command.undo(); + } throw err; } } diff --git a/packages/server/src/common/command/recording-command.spec.ts b/packages/server/src/common/command/recording-command.spec.ts index 8a38f3e..99999bb 100644 --- a/packages/server/src/common/command/recording-command.spec.ts +++ b/packages/server/src/common/command/recording-command.spec.ts @@ -39,36 +39,36 @@ describe('RecordingCommand', () => { beforeState = JSON.parse(JSON.stringify(jsonObject)); }); - it('should be undoable after execution', () => { + it('should be undoable after execution', async () => { // eslint-disable-next-line @typescript-eslint/no-empty-function const command = new RecordingCommand(jsonObject, () => {}); expect(command.canUndo()).to.be.false; - command.execute(); + await command.execute(); expect(command.canUndo()).to.be.true; }); - it('should restore the pre execution state when undo is called', () => { + it('should restore the pre execution state when undo is called', async () => { const command = new RecordingCommand(jsonObject, () => { jsonObject.string = 'bar'; jsonObject.flag = false; jsonObject.maybe = { hello: 'world' }; }); - command.execute(); + await command.execute(); expect(jsonObject).to.not.be.deep.equals(beforeState); - command.undo(); + await command.undo(); expect(jsonObject).to.be.deep.equals(beforeState); }); - it('should restore the post execution state when redo is called', () => { + it('should restore the post execution state when redo is called', async () => { const command = new RecordingCommand(jsonObject, () => { jsonObject.string = 'bar'; jsonObject.flag = false; jsonObject.maybe = { hello: 'world' }; }); - command.execute(); + await command.execute(); const afterState = JSON.parse(JSON.stringify(jsonObject)); jsonObject = JSON.parse(JSON.stringify(afterState)); - command.redo(); + await command.redo(); expect(jsonObject).to.be.deep.equals(afterState); }); }); diff --git a/packages/server/src/common/command/recording-command.ts b/packages/server/src/common/command/recording-command.ts index 2cc4290..13c6e1e 100644 --- a/packages/server/src/common/command/recording-command.ts +++ b/packages/server/src/common/command/recording-command.ts @@ -15,7 +15,7 @@ ********************************************************************************/ import { GModelRootSchema } from '@eclipse-glsp/graph'; -import { AnyObject } from '@eclipse-glsp/protocol'; +import { AnyObject, MaybePromise } from '@eclipse-glsp/protocol'; import * as jsonPatch from 'fast-json-patch'; import { GModelSerializer } from '../features/model/gmodel-serializer'; import { ModelState } from '../features/model/model-state'; @@ -28,9 +28,9 @@ export abstract class AbstractRecordingCommand imp protected undoPatch?: jsonPatch.Operation[]; protected redoPatch?: jsonPatch.Operation[]; - execute(): void { + async execute(): Promise { const beforeState = this.deepClone(this.getJsonObject()); - this.doExecute(); + await this.doExecute(); const afterState = this.getJsonObject(); this.undoPatch = jsonPatch.compare(afterState, beforeState); this.redoPatch = jsonPatch.compare(beforeState, afterState); @@ -46,7 +46,7 @@ export abstract class AbstractRecordingCommand imp /** * The actual execution i.e. series of changes applied to the JSOn object that should be captured. */ - protected abstract doExecute(): void; + protected abstract doExecute(): MaybePromise; protected applyPatch(object: JsonObject, patch: jsonPatch.Operation[]): void { jsonPatch.applyPatch(object, patch, false, true); @@ -84,7 +84,7 @@ export abstract class AbstractRecordingCommand imp * the the given `doExecute` function. */ export class RecordingCommand extends AbstractRecordingCommand { - constructor(protected jsonObject: JsonObject, protected doExecute: () => void) { + constructor(protected jsonObject: JsonObject, protected doExecute: () => MaybePromise) { super(); } @@ -98,12 +98,12 @@ export class RecordingCommand extends * to the `GModelRoot` ({@link ModelState.root}) during the given `doExecute` function */ export class GModelRecordingCommand extends AbstractRecordingCommand { - constructor(protected modelState: ModelState, protected serializer: GModelSerializer, protected doExecute: () => void) { + constructor(protected modelState: ModelState, protected serializer: GModelSerializer, protected doExecute: () => MaybePromise) { super(); } - override execute(): void { - super.execute(); + override async execute(): Promise { + await super.execute(); this.modelState.index.indexRoot(this.modelState.root); } diff --git a/packages/server/src/common/command/undo-redo-action-handler.ts b/packages/server/src/common/command/undo-redo-action-handler.ts index 5b73456..7d107d0 100644 --- a/packages/server/src/common/command/undo-redo-action-handler.ts +++ b/packages/server/src/common/command/undo-redo-action-handler.ts @@ -39,17 +39,17 @@ export class UndoRedoActionHandler implements ActionHandler { return []; } - protected undo(): MaybePromise { + protected async undo(): Promise { if (this.commandStack.canUndo()) { - this.commandStack.undo(); + await this.commandStack.undo(); return this.modelSubmissionHandler.submitModel('undo'); } return []; } - protected redo(): MaybePromise { + protected async redo(): Promise { if (this.commandStack.canRedo()) { - this.commandStack.redo(); + await this.commandStack.redo(); return this.modelSubmissionHandler.submitModel('redo'); } return []; diff --git a/packages/server/src/common/operations/operation-action-handler.ts b/packages/server/src/common/operations/operation-action-handler.ts index 50b9f64..7c57037 100644 --- a/packages/server/src/common/operations/operation-action-handler.ts +++ b/packages/server/src/common/operations/operation-action-handler.ts @@ -66,13 +66,13 @@ export class OperationActionHandler implements ActionHandler { protected async executeHandler(operation: Operation, handler: OperationHandler): Promise { const command = await handler.execute(operation); if (command) { - this.executeCommand(command); + await this.executeCommand(command); } return this.modelSubmissionHandler.submitModel('operation'); } - protected executeCommand(command: Command): void { - this.commandStack.execute(command); + protected async executeCommand(command: Command): Promise { + return this.commandStack.execute(command); } protected submitModel(): MaybePromise { diff --git a/packages/server/src/common/test/mock-util.ts b/packages/server/src/common/test/mock-util.ts index e34af92..02323ce 100644 --- a/packages/server/src/common/test/mock-util.ts +++ b/packages/server/src/common/test/mock-util.ts @@ -25,11 +25,13 @@ import { EdgeTypeHint, InitializeClientSessionParameters, MaybeArray, + MaybePromise, Point, RequestEditValidationAction, ShapeTypeHint, ValidationStatus } from '@eclipse-glsp/protocol'; +import { expect } from 'chai'; import { Container } from 'inversify'; import { MessageConnection } from 'vscode-jsonrpc'; import { ActionDispatcher } from '../actions/action-dispatcher'; @@ -53,6 +55,25 @@ export async function delay(ms: number): Promise { return new Promise(resolve => setTimeout(resolve, ms)); } +/** + * Consumes a maybe async function and checks for error + * @param method - The function to check + * @param message - Optional message to match with error message + */ +export async function expectToThrowAsync(toEvaluate: () => MaybePromise, message?: string): Promise { + let err: Error | undefined = undefined; + try { + await toEvaluate(); + } catch (error: any) { + err = error; + } + if (message) { + expect(err?.message).to.be.equal(message); + } else { + expect(err).to.be.an('Error'); + } +} + export function createClientSession( id: string, diagramType: string,