@eclipse-emfcloud/trigger-engine
Version:
Generic model triggers computation engine.
520 lines • 23.4 kB
JavaScript
;
// *****************************************************************************
// Copyright (C) 2023-2024 STMicroelectronics.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: MIT License which is
// available at https://opensource.org/licenses/MIT.
//
// SPDX-License-Identifier: EPL-2.0 OR MIT
// *****************************************************************************
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
__setModuleDefault(result, mod);
return result;
};
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
const chai_1 = __importStar(require("chai"));
const chai_as_promised_1 = __importDefault(require("chai-as-promised"));
const chai_like_1 = __importDefault(require("chai-like"));
const chai_things_1 = __importDefault(require("chai-things"));
const trigger_engine_1 = require("../trigger-engine");
const fast_json_patch_1 = require("fast-json-patch");
const lodash_1 = require("lodash");
const sinon_1 = __importDefault(require("sinon"));
const sinon_chai_1 = __importDefault(require("sinon-chai"));
const trigger_1 = require("../trigger");
chai_1.default.use(chai_like_1.default);
chai_1.default.use(chai_things_1.default);
chai_1.default.use(chai_as_promised_1.default);
chai_1.default.use(sinon_chai_1.default);
const originalDocument = {
name: 'The Document',
tasks: [{ label: 'a', priority: 'medium' }],
mediumPrioTasks: 1,
};
describe('TriggerEngineImpl', () => {
let engine;
let sandbox;
const suite = (label, testOptions, dependencies) => describe(label, () => {
beforeEach(() => {
sandbox = sinon_1.default.createSandbox();
engine = testOptions
? new trigger_engine_1.TriggerEngineImpl(testOptions)
: new trigger_engine_1.TriggerEngineImpl();
});
afterEach(() => {
sandbox.restore();
});
describe('applyTriggers', () => {
it('no triggers present', async () => {
const result = await runTriggers(engine, addTask('b'));
(0, chai_1.expect)(result).not.to.exist;
});
it('trigger present', async () => {
engine.addTrigger(priorityOfNewTask);
const result = await runTriggers(engine, addTask('b'));
(0, chai_1.expect)(result).to.contain.something.like({
op: 'add',
path: '/tasks/1/priority',
value: 'medium',
});
});
it('cascading triggers present', async () => {
engine.addTrigger(priorityOfNewTask);
engine.addTrigger(recountMediumPrioTasks);
const result = await runTriggers(engine, addTask('b'));
(0, chai_1.expect)(result).to.contain.something.like({
op: 'replace',
path: '/mediumPrioTasks',
value: 2,
});
// Of course, it also still sets the priority of the new task, also
(0, chai_1.expect)(result).to.contain.something.like({
op: 'add',
path: '/tasks/1/priority',
value: 'medium',
});
});
it('subsequent iterations see delta from prior iteration', async () => {
const snoopTrigger = snapshot(sandbox.stub());
engine.addTrigger(priorityOfNewTask);
engine.addTrigger(snoopTrigger);
await runTriggers(engine, addTask('b'));
// Recall that the trigger will always be called at least twice, and we
// need to verify the *second* call only
const secondCall = snoopTrigger.snapshots[1];
(0, chai_1.expect)(secondCall[1]).to.be.an('array').of.length(1);
(0, chai_1.expect)(secondCall[1][0]).to.be.like({
op: 'add',
path: '/tasks/1/priority',
value: 'medium',
});
});
it('subsequent iterations see previous document states', async () => {
const snoopTrigger = snapshot(sandbox.stub());
engine.addTrigger(priorityOfNewTask);
engine.addTrigger(snoopTrigger);
await runTriggers(engine, addTask('b'));
// Recall that the trigger will always be called at least twice, and we
// need to verify the *second* call only
const secondCall = snoopTrigger.snapshots[1];
(0, chai_1.expect)(secondCall[2])
.to.have.property('tasks')
.that.is.an('array')
.of.length(2);
(0, chai_1.expect)(secondCall[2].tasks[1]).to.be.like({
label: 'b',
});
(0, chai_1.expect)(secondCall[2].tasks[1]).not.to.have.property('priority');
});
it('subsequent iterations see changes only from previous iteration', async () => {
engine.addTrigger(createAddTagsTrigger('a', 'b', 'c'));
engine.addTrigger(createAddTaskIfTagAddedTrigger('a'));
const result = await runTriggers(engine, addTask('added task'));
(0, chai_1.expect)(result).to.include.a.thing.like({
op: 'add',
path: '/tasks/1/tags',
value: ['a'],
});
(0, chai_1.expect)(result).to.include.a.thing.like({
op: 'add',
path: '/tasks/2',
value: {
label: "task for tag 'a'",
tags: ['b'],
},
});
// Tag 'c' wasn't added to anything
(0, chai_1.expect)(result).to.have.length(2);
(0, chai_1.expect)(result).not.to.include.a.thing.like({
value: 'c',
});
(0, chai_1.expect)(result).not.to.include.a.thing.like({
value: ['c'],
});
(0, chai_1.expect)(result).not.to.include.a.thing.like({
value: { tags: ['c'] },
});
});
it('triggers never see the original model', async () => {
const trigger1 = sandbox.spy(priorityOfNewTask);
const trigger2 = sandbox.spy(recountMediumPrioTasks);
engine.addTrigger(trigger1);
engine.addTrigger(trigger2);
const [document, delta, previousDocument] = modifyDocument(addTask('b'));
await engine.applyTriggers(document, delta, previousDocument);
(0, chai_1.expect)(trigger1).to.have.been.called;
(0, chai_1.expect)(trigger1).not.to.have.been.calledWith(document);
(0, chai_1.expect)(trigger2).to.have.been.called;
(0, chai_1.expect)(trigger2).not.to.have.been.calledWith(document);
});
});
describe('safePatch mode', () => {
it('protects against modifying patches', async () => {
const triggeringPatch = [
{ op: 'add', path: '/tasks/-', value: { label: 'b' } },
];
const providedPatch = [
{ op: 'add', path: '/tasks/1/nested', value: { nested: true } },
];
const originalImage = (0, lodash_1.cloneDeep)(providedPatch);
const dangerousTrigger = once(() => providedPatch);
const innocentTrigger = (_doc, delta) => {
const op = delta[0];
if (op.op === 'add' && op.path === '/tasks/1/nested') {
return [
{ op: 'add', path: '/tasks/1/nested/result', value: 'Boom!' },
];
}
return undefined;
};
engine = new trigger_engine_1.TriggerEngineImpl({
...(testOptions ?? {}),
safePatches: true,
});
engine.addTrigger(dangerousTrigger);
engine.addTrigger(innocentTrigger);
await runTriggers(engine, triggeringPatch);
(0, chai_1.expect)(providedPatch).to.deep.equal(originalImage);
});
});
describe('iteration limit', () => {
let triggeringPatch;
beforeEach(() => {
triggeringPatch = [
{ op: 'add', path: '/tasks/-', value: { label: 'b' } },
];
});
it('enforces a plausible minimum', () => {
(0, chai_1.expect)(() => new trigger_engine_1.TriggerEngineImpl({
...(testOptions ?? {}),
iterationLimit: 1,
})).to.throw('Iteration limit too low');
});
it('does not exceed the iteration limit', async () => {
const trigger = repeatTrigger();
engine.addTrigger(trigger);
const providedPatch = await runTriggers(engine, triggeringPatch);
(0, chai_1.expect)(providedPatch)
.to.be.an('array')
.of.length.greaterThanOrEqual(trigger.iterationCount);
});
it('correct interim states of previous document', async () => {
const trigger = repeatTrigger();
engine.addTrigger(trigger);
await runTriggers(engine, triggeringPatch);
(0, chai_1.expect)(trigger.interimStates.length).to.be.equal(trigger.iterationCount);
for (let i = 0; i < trigger.iterationCount; i++) {
(0, chai_1.expect)(trigger.interimStates[i].tasks.length).to.be.equal(i + 1);
}
});
it('exceeds the iteration limit', async () => {
engine = new trigger_engine_1.TriggerEngineImpl({
...(testOptions ?? {}),
iterationLimit: 99,
});
const trigger = repeatTrigger(101);
engine.addTrigger(trigger);
await (0, chai_1.expect)(runTriggers(engine, triggeringPatch)).to.eventually.be.rejectedWith('Trigger iteration limit of 99 exceeded');
});
});
if (dependencies) {
describe('Dependencies', dependencies);
}
});
suite('Strictly sequential mode', undefined, () => {
let sandbox;
beforeEach(() => {
sandbox = sinon_1.default.createSandbox();
});
afterEach(() => {
sandbox.restore();
});
it('triggers are run in registration order', async () => {
const trigger1 = sandbox.stub();
const trigger2 = sandbox.stub();
const trigger3 = sandbox.stub();
engine.addTrigger(trigger1);
engine.addTrigger(trigger2);
engine.addTrigger(trigger3);
await runTriggers(engine, addTask('b'));
sinon_1.default.assert.callOrder(trigger1, trigger2, trigger3);
});
it('triggers see effect of previous triggers in the same iteration', async () => {
const nextTrigger = snapshot(sandbox.stub());
engine.addTrigger(priorityOfNewTask);
engine.addTrigger(nextTrigger);
await runTriggers(engine, addTask('b'));
// Recall that the trigger will always be called at least twice, and we
// need to verify the *first* call only
const firstCall = nextTrigger.snapshots[0];
(0, chai_1.expect)(firstCall[0]).to.be.like({
tasks: [
{}, // Don't care about this one
{ label: 'b', priority: 'medium' },
],
});
});
it('triggers get deltas of previous triggers in the same iteration', async () => {
const nextTrigger = snapshot(sandbox.stub());
engine.addTrigger(priorityOfNewTask);
engine.addTrigger(nextTrigger);
await runTriggers(engine, addTask('b'));
// Recall that the trigger will always be called at least twice, and we
// need to verify the *first* call only
const firstCall = nextTrigger.snapshots[0];
// The first trigger added a property to an object that was added
// in the original delta, so the patch seen by the second trigger
// is actually optimized to still have just one operation that adds
// the new object *with* the property value set by the first trigger
(0, chai_1.expect)(firstCall[1]).to.be.an('array').of.length(1);
(0, chai_1.expect)(firstCall[1][0]).to.be.like({
op: 'add',
path: '/tasks/1',
value: { label: 'b', priority: 'medium' },
});
});
it('triggers do not get incremental previous document states', async () => {
const nextTrigger = snapshot(sandbox.stub());
engine.addTrigger(priorityOfNewTask);
engine.addTrigger(nextTrigger);
await runTriggers(engine, addTask('b'));
// Recall that the trigger will always be called at least twice, and we
// need to verify the *first* call only
const firstCall = nextTrigger.snapshots[0];
(0, chai_1.expect)(firstCall[2])
.to.have.property('tasks')
.that.is.an('array')
.of.length(1);
});
});
suite('Parallel mode', { parallel: true }, () => {
let sandbox;
beforeEach(() => {
sandbox = sinon_1.default.createSandbox();
engine = new trigger_engine_1.TriggerEngineImpl({ parallel: true });
});
afterEach(() => {
sandbox.restore();
});
it('triggers all see same model state', async () => {
const nextTrigger = snapshot(sandbox.stub());
engine.addTrigger(priorityOfNewTask);
engine.addTrigger(nextTrigger);
await runTriggers(engine, addTask('b'));
// Recall that the trigger will always be called at least twice, and we
// need to verify the *first* call only
const firstCall = nextTrigger.snapshots[0];
(0, chai_1.expect)(firstCall[0]).to.be.like({
tasks: [
{}, // Don't care about this one
{ label: 'b' },
],
});
// This will be added in the future by the patch from the previous trigger
(0, chai_1.expect)(firstCall[0].tasks[1]).not.to.have.property('priority');
});
it('triggers all get the same deltas', async () => {
const nextTrigger = snapshot(sandbox.stub());
engine.addTrigger(priorityOfNewTask);
engine.addTrigger(nextTrigger);
await runTriggers(engine, addTask('b'));
// Recall that the trigger will always be called at least twice, and we
// need to verify the *first* call only
const firstCall = nextTrigger.snapshots[0];
(0, chai_1.expect)(firstCall[1]).to.be.an('array').of.length(1);
(0, chai_1.expect)(firstCall[1][0]).to.be.like({
op: 'add',
path: '/tasks/1',
value: { label: 'b' },
});
const addOp = firstCall[1][0];
// The property set by the first trigger is not (yet) in the delta
(0, chai_1.expect)(addOp.value).not.to.have.property('priority');
});
it('triggers do not get incremental previous document states', async () => {
const nextTrigger = snapshot(sandbox.stub());
engine.addTrigger(priorityOfNewTask);
engine.addTrigger(nextTrigger);
await runTriggers(engine, addTask('b'));
// Recall that the trigger will always be called at least twice, and we
// need to verify the *first* call only
const firstCall = nextTrigger.snapshots[0];
(0, chai_1.expect)(firstCall[2])
.to.have.property('tasks')
.that.is.an('array')
.of.length(1);
});
});
});
async function runTriggers(engine, patch) {
return engine.applyTriggers(...modifyDocument(patch));
}
function modifyDocument(patch) {
const previousDocument = (0, lodash_1.cloneDeep)(originalDocument);
// This creates a copy, not modifying the `previousDocument`
const document = (0, fast_json_patch_1.applyPatch)(previousDocument, patch, false, false).newDocument;
// Calculate a diff to ensure that we don't have '/-' segments for array appends
const delta = (0, fast_json_patch_1.compare)(previousDocument, document, true);
return [document, delta, previousDocument];
}
function addTask(label) {
return [{ op: 'add', path: '/tasks/-', value: { label } }];
}
function setPriority(document, taskOffset, priority) {
const taskIndex = taskOffset >= 0 ? taskOffset : document.tasks.length + taskOffset;
const task = document.tasks[taskIndex];
if (task.priority !== priority) {
return [
{
op: task.priority ? 'replace' : 'add',
path: `/tasks/${taskIndex}/priority`,
value: priority,
},
];
}
return undefined;
}
function updateMediumPrioTasks(document) {
const oldValue = document.mediumPrioTasks;
const newValue = document.tasks.reduce((count, task) => (task.priority !== 'medium' ? count : count + 1), 0);
if (newValue != oldValue) {
return [{ op: 'replace', path: '/mediumPrioTasks', value: newValue }];
}
return undefined;
}
/** Wrap a trigger to fire at most once. */
function once(trigger) {
let fired = false;
return (doc, delta, prev) => {
if (!fired) {
fired = true;
return trigger(doc, delta, prev);
}
return undefined;
};
}
/** Wrap a trigger to capture a snapshot of its arguments. */
function snapshot(trigger) {
const snapshots = [];
const result = (doc, delta, prev) => {
snapshots.push([(0, lodash_1.cloneDeep)(doc), (0, lodash_1.cloneDeep)(delta), (0, lodash_1.cloneDeep)(prev)]);
return trigger(doc, delta, prev);
};
result.snapshots = snapshots;
return result;
}
/** A trigger that initializes the priority of a newly added task. */
const priorityOfNewTask = async (doc, delta) => {
const op = delta.find((op) => op.op === 'add');
if (!op) {
return undefined;
}
const match = /\/tasks\/(-|\d+)/.exec(op.path);
if (!match) {
return undefined;
}
const taskIndex = match[1] === '-' ? -1 : Number(match[1]);
return setPriority(doc, taskIndex, 'medium');
};
/** A trigger recounts the medium priority tasks when a task's priority is changed. */
const recountMediumPrioTasks = (doc, delta) => {
const op = delta[0];
if ((op.op === 'add' || op.op === 'replace') &&
/\/tasks\/\d+\/priority/.test(op.path)) {
return updateMediumPrioTasks(doc);
}
return undefined;
};
const repeatTrigger = (iterationCount = 100) => {
let performed = 0;
const interimStates = [];
const result = (_document, _delta, previousDocument) => {
if (performed++ >= iterationCount) {
return [];
}
interimStates.push(previousDocument);
// Blindly add another task
return [
{
op: 'add',
path: '/tasks/-',
value: { label: `task ${performed}` },
},
];
};
result.iterationCount = iterationCount;
result.interimStates = interimStates;
return result;
};
function createAddTagsTrigger(...tags) {
const tagsToAdd = [...tags];
return async (_, delta) => {
const result = [];
for (const op of (0, trigger_1.addOrReplaceOperations)(delta)) {
if (/^\/tasks\/[0-9]+$/.test(op.path)) {
// Added a task. Shift and add one of our tags, if any remain
const tag = tagsToAdd.shift();
if (tag) {
if (op.value && typeof op.value === 'object' && 'tags' in op.value) {
result.push({ op: 'add', path: `${op.path}/tags/-`, value: tag });
}
else {
result.push({ op: 'add', path: `${op.path}/tags`, value: [tag] });
}
}
}
}
return result;
};
}
function createAddTaskIfTagAddedTrigger(tag) {
return async (_, delta) => {
const result = [];
for (const op of (0, trigger_1.addOrReplaceOperations)(delta)) {
// Did we either
// (a) add the tag to an existing list of tags?
// (b) add the tag in a new list of tags to a task?
// (c) add a new task having the tag?
if ((/^\/tasks\/[0-9]+\/tags\/[0-9]+$/.test(op.path) && op.value === tag) ||
(/^\/tasks\/[0-9]+\/tags$/.test(op.path) &&
op.value.includes(tag)) ||
(/^\/tasks\/[0-9]+$/.test(op.path) &&
(op.value.tags ?? []).includes(tag))) {
// Added our target tag. Add a task
const task = { label: `task for tag '${tag}'`, priority: 'low' };
result.push({ op: 'add', path: '/tasks/-', value: task });
break; // Only add one
}
}
return result;
};
}
//# sourceMappingURL=trigger-engine.spec.js.map