UNPKG

@eclipse-emfcloud/trigger-engine

Version:

Generic model triggers computation engine.

520 lines 23.4 kB
"use strict"; // ***************************************************************************** // 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