From 660fab82189670dc1a38739f6f7064634f551222 Mon Sep 17 00:00:00 2001 From: Sergey Sergeev Date: Tue, 24 Oct 2023 16:53:01 -0700 Subject: [PATCH] add wait command to CliCore and TemplateProcessor --- README.md | 49 ++++++--- README.test.js | 7 +- example/ex14.json | 6 +- example/ex14.yaml | 2 +- src/CliCore.ts | 31 +++--- src/StatedREPL.ts | 1 + src/TemplateProcessor.ts | 85 ++++++++++++++- src/test/StatedREPL.test.js | 20 +++- src/test/TemplateProcessor.test.js | 160 ++++++++++++++++++----------- 9 files changed, 265 insertions(+), 96 deletions(-) diff --git a/README.md b/README.md index b1815611..d5c0baa5 100644 --- a/README.md +++ b/README.md @@ -249,18 +249,19 @@ fact that State-js is written using ES Module syntax. stated provides a set of REPL commands to interact with the system: -| Command | Description | flags & args | Example | -|----------|----------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------| -| `.init` | Initialize the template from a JSON file. | • `-f `
• `--tags=`
•`--options=`
• `--xf=`
• `--importPath=` | `.init -f "example/hello.json" --tags=FOO,BAR --xf=~/falken/myEnv.json --options={"strict":{"refs":true}} --importPath=~/falken/mytemplates` | -| `.set` | Set data to a JSON pointer path. | ` ` | `.set /to "jsonata"` | -| `.from` | Show the dependents of a given JSON pointer. | `` | `.from /a` | -| `.to` | Show the dependencies of a given JSON pointer. | `` | `.to /b` | -| `.in` | Show the input template. | `None` | `.in` | -| `.out` | Show the current state of the template. | `[]` | `.out`
`.out /data/accounts` | -| `.state` | Show the current state of the template metadata. | `None` | `.state` | -| `.plan` | Show the execution plan for rendering the template. | `None` | `.plan` | -| `.note` | Show a separator line with a comment in the REPL output. | `` | `.note "Example 8"` | -| `.log` | Set the logging level | `[silent, error, warn, info, verbose, debug]` | `.log silent` | +| Command | Description | flags & args | Example | +|----------|----------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------| +| `.init` | Initialize the template from a JSON file. | • `-f `
• `--tags=`
•`--options=`
• `--xf=`
• `--importPath=`
• `-w 'waitConditionJsonata'`
• `-t 'conditionWaitTimeoutMs'`
| `.init -f "example/hello.json" --tags=FOO,BAR --xf=~/falken/myEnv.json --options={"strict":{"refs":true}} --importPath=~/falken/mytemplates` | +| `.set` | Set data to a JSON pointer path. | ` ` | `.set /to "jsonata"` | +| `.from` | Show the dependents of a given JSON pointer. | `` | `.from /a` | +| `.to` | Show the dependencies of a given JSON pointer. | `` | `.to /b` | +| `.in` | Show the input template. | `None` | `.in` | +| `.out` | Show the current state of the template. | `[]` | `.out`
`.out /data/accounts` | +| `.state` | Show the current state of the template metadata. | `None` | `.state` | +| `.plan` | Show the execution plan for rendering the template. | `None` | `.plan` | +| `.note` | Show a separator line with a comment in the REPL output. | `` | `.note "Example 8"` | +| `.log` | Set the logging level | `[silent, error, warn, info, verbose, debug]` | `.log silent` | +| `.wait` | Wait for jsonata condition to occur in the template. | • `-w 'waitConditionJsonata'`
• `-t 'conditionWaitTimeoutMs'`
| `.wait -w foo=bar -t 1500` | The stated repl lets you experiment with templates. The simplest thing to do in the REPL is load a json file. The REPL @@ -1545,6 +1546,30 @@ This can be combined with the `--importPath` option to import files relative to "res": "bar: foo" } ``` +## Waiting for a Jsonata Condition +To wait for a specific condition to become true, use -w option and an optional timeout (default is 10s). +```json ["error.output.status$=\"counting\"", "status$=\"done\""] +> .init -f example/ex14.yaml -w 'status$="done"' -t 10 +{ + "error": { + "message": "wait condition status$=\"done\" timed out in 10ms", + "output": { + "incr$": "{function:}", + "counter": 9, + "upCount$": "--interval/timeout--", + "status$": "counting" + } + } +} +> .init -f example/ex14.yaml -w 'status$="done"' -t 150 +{ + "incr$": "{function:}", + "counter": 11, + "upCount$": "--interval/timeout--", + "status$": "done" +} +``` + # Understanding Plans This information is to explain the planning algorithms to comitters. As a user you do not need to understand how Stated formulates plans. Before explaining how a plan is made, let's show the end-to-end flow of how a plan is used diff --git a/README.test.js b/README.test.js index e003d815..a30c9044 100644 --- a/README.test.js +++ b/README.test.js @@ -102,7 +102,12 @@ testData.forEach(([cmdName, args, expectedResponseString, jsonataExpression], i) const respNormalized = JSON.parse(StatedREPL.stringify(resp)); if(jsonataExpression){ //if we have an optional jsonata expression specified after the codeblock, likje ````json const compiledExpr = jsonata(jsonataExpression); - expect(await compiledExpr.evaluate(respNormalized)).toBe(true); + const result = await compiledExpr.evaluate(respNormalized); + if (result !== true) { + // If the result is not true, throw an error with details + throw new Error(`JSONata Test Failed: Expected JSONata expression to evaluate to true.\nExpression: ${jsonataExpression}\nResponse: ${JSON.stringify(respNormalized, null, 2)}\nEvaluation result: ${result}`); + } + expect(result).toBe(true); }else { if(expectedResponseString){ const _expected = JSON.parse(expectedResponseString); diff --git a/example/ex14.json b/example/ex14.json index 81fd301f..d1689d79 100644 --- a/example/ex14.json +++ b/example/ex14.json @@ -1,6 +1,6 @@ { - "incr": "${function(){$set('/counter',counter+1)}}", + "incr$": "function(){$set('/counter',counter+1)}", "counter": 0, - "upCount": "${ $setInterval(incr, 1000) }", - "status": "${(counter>10?($clearInterval(upCount);'done'):'counting')}" + "upCount$": "$setInterval(incr$, 10)", + "status$": "counter>10?($clearInterval(upCount$);'done'):'counting'" } \ No newline at end of file diff --git a/example/ex14.yaml b/example/ex14.yaml index ab631fab..7d7bfb7d 100644 --- a/example/ex14.yaml +++ b/example/ex14.yaml @@ -3,7 +3,7 @@ incr$: | $set('/counter',counter+1) } counter: 0 -upCount$: $setInterval(incr$, 1000) +upCount$: $setInterval(incr$, 10) status$: | ( counter>10 diff --git a/src/CliCore.ts b/src/CliCore.ts index 9ccdb093..817dd988 100644 --- a/src/CliCore.ts +++ b/src/CliCore.ts @@ -96,7 +96,7 @@ export default class CliCore { return path.join(process.cwd(), filepath); } - //replCmdInoutStr like: -f "example/ex23.json" --tags=["PEACE"] --xf=example/myEnv.json + //replCmdInoutStr like: -f "example/ex23.json" --tags=["PEACE"] --xf=example/myEnv.json -w jsonataExpression -t 1000 async init(replCmdInputStr) { const parsed = CliCore.parseInitArgs(replCmdInputStr); const {filepath, tags,oneshot, options, xf:contextFilePath, importPath} = parsed; @@ -112,20 +112,8 @@ export default class CliCore { this.templateProcessor.logger.level = this.logLevel; this.templateProcessor.logger.debug(`arguments: ${JSON.stringify(parsed)}`); - if (oneshot === true) { - await this.templateProcessor.initialize(); - return this.templateProcessor.output; - } else { - try { - await this.templateProcessor.initialize(); - return this.templateProcessor.input; - } catch (error) { - return { - name: error.name, - message: error.message - }; - } - } + await this.templateProcessor.initialize(); + return await this.wait(replCmdInputStr); } @@ -238,5 +226,18 @@ export default class CliCore { } return this.templateProcessor.errorReport; } + + // .wait -w jsonataExpression -t 1000 + async wait(replCmdInputStr) { + if (!this.templateProcessor) { + throw new Error('Initialize the template first.'); + } + const parsed = CliCore.parseInitArgs(replCmdInputStr); + let { w: waitCondition, t: timeout } = parsed; + + if (!waitCondition) return this.templateProcessor.input; + + return await this.templateProcessor.waitCondition(waitCondition, timeout); + } } diff --git a/src/StatedREPL.ts b/src/StatedREPL.ts index cfa98c7a..63eb8306 100755 --- a/src/StatedREPL.ts +++ b/src/StatedREPL.ts @@ -51,6 +51,7 @@ export default class StatedREPL { ["log", "set the log level [debug, info, warn, error]"], ["debug", "set debug commands (WIP)"], ["errors", "return an error report"], + ["wait", '-w jsonataCondition -t 1000 to wait for the condition to return true within 1000ms'], ].map(c=>{ const [cmdName, helpMsg] = c; diff --git a/src/TemplateProcessor.ts b/src/TemplateProcessor.ts index e59c73c5..a8715c4d 100644 --- a/src/TemplateProcessor.ts +++ b/src/TemplateProcessor.ts @@ -25,6 +25,7 @@ import ConsoleLogger, {StatedLogger} from "./ConsoleLogger.js"; import FancyLogger from "./FancyLogger.js"; import {LOG_LEVELS} from "./ConsoleLogger.js"; import StatedREPL from "./StatedREPL.js"; +import jsonata from "jsonata"; @@ -1084,9 +1085,26 @@ export default class TemplateProcessor { setDataChangeCallback(jsonPtr:JsonPointerString, cbFn:(data, ptr:JsonPointerString, removed?:boolean)=>void) { if(jsonPtr === "/"){ + if (this.commonCallback !== undefined) { + return false; + } this.commonCallback = cbFn; + return true; }else{ + if (this.changeCallbacks.has(jsonPtr)) { + return false + } this.changeCallbacks.set(jsonPtr, cbFn); + return true; + } + return false; + } + + removeDataChangeCallback(jsonPtr) { + if(jsonPtr === "/"){ + this.commonCallback = undefined; + }else if (this.changeCallbacks.has(jsonPtr)) { + this.changeCallbacks.delete(jsonPtr); } } @@ -1111,6 +1129,72 @@ export default class TemplateProcessor { return null; } + + async waitCondition(waitCondition, timeout = 10000) { + return new Promise((resolve, reject) => { + const startTime = Date.now(); + let conditionExpression; + let timeoutId; // Declare timeoutId here + + try { + conditionExpression = jsonata(waitCondition); + } catch (e) { + this.logger.error(`invalid wait condition expression: ${waitCondition}, ${e}`); + resolve({ + "error": { + message: e.message, + name: e.name, + stack: e.stack, + input: this.input + } + }); + } + + const checkTimeout = () => { + if (Date.now() - startTime >= timeout) { + this.removeDataChangeCallback('/'); + this.logger.debug(`wait condition ${waitCondition} timed out in ${timeout}ms`); + resolve({ + "error": { + message: `wait condition ${waitCondition} timed out in ${timeout}ms`, + output: JSON.parse(StatedREPL.stringify(this.output)) // deep copy + } + }); + } else { + timeoutId = setTimeout(checkTimeout, 100); // save timer id so we can cancel it later + } + }; + + let checkConditionCallback = async (data, jsonPtr) => { + let matchedCondition = await conditionExpression.evaluate(this.output); + if (matchedCondition===true) { + this.removeDataChangeCallback('/'); + clearTimeout(timeoutId); // Clear the timeout when condition is met + this.logger.debug(`received data change matching ${waitCondition} for ${jsonPtr} with data ${data}`); + resolve(JSON.parse(StatedREPL.stringify(this.output))); // deep copy + } else { + this.logger.debug(`received data change not matching ${waitCondition} for ${jsonPtr} with data ${data}`); + } + }; + + checkConditionCallback = checkConditionCallback.bind(this); + const callbackIsSet = this.setDataChangeCallback("/", checkConditionCallback); + if(callbackIsSet === false){ + clearTimeout(timeoutId); + resolve({"error": { + message: "can't use wait condition because a callback is already set" + }}); + } + + //execute the callback once to evaluate if the condition is already met + if (this.isInitializing === false) { + checkConditionCallback({}, "/"); + } + + timeoutId = setTimeout(checkTimeout, 100); // Assign the timeout ID here + }); + } + private async localImport(filePathInPackage) { // Resolve the package path const {importPath} = this.options; @@ -1138,6 +1222,5 @@ export default class TemplateProcessor { } return content; } - } diff --git a/src/test/StatedREPL.test.js b/src/test/StatedREPL.test.js index 66859479..43229612 100644 --- a/src/test/StatedREPL.test.js +++ b/src/test/StatedREPL.test.js @@ -34,10 +34,7 @@ test("test stringify custom print function", async () => { /** Test that the onInit function is called when the .init command is called */ test("test onInit", async () => { const repl1 = new StatedREPL(); - await repl1.initialize(); - const repl2 = new StatedREPL(); - await repl2.initialize(); let beenCalled1 = false; repl1.cliCore.onInit = () => { beenCalled1 = true;} @@ -52,5 +49,22 @@ test("test onInit", async () => { }); +test("test wait condition", async () => { + const repl = new StatedREPL(); + + await repl.cliCore.init('-f "example/ex01.yaml"'); + const result = await repl.cliCore.wait(`-w 'c="the answer is: 42" -t 50`); + expect(StatedREPL.stringify(result)).toBe(StatedREPL.stringify({ + "a": 42, + "b": 42, + "c": "the answer is: 42" + })); +}); +test("test wait condition timeout", async () => { + const repl = new StatedREPL(); + await repl.cliCore.init('-f "example/ex14.yaml"'); + const result = await repl.cliCore.wait(`-w 'status$="done" -t 10`); + expect(result.error.message).toBe("wait condition status$=\"done\" timed out in 10ms"); +}); diff --git a/src/test/TemplateProcessor.test.js b/src/test/TemplateProcessor.test.js index 2b45282e..f6625b81 100644 --- a/src/test/TemplateProcessor.test.js +++ b/src/test/TemplateProcessor.test.js @@ -21,6 +21,7 @@ import {fileURLToPath} from 'url'; import {dirname} from 'path'; import MetaInfoProducer from "../../dist/src/MetaInfoProducer.js"; import jsonata from "jsonata"; +import StatedREPL from "../../dist/src/StatedREPL.js"; test("test 1", async () => { @@ -30,9 +31,13 @@ test("test 1", async () => { }); await tp.initialize(); const received = []; - tp.setDataChangeCallback("/a", (data, jsonPtr) => { + expect(tp.setDataChangeCallback("/a", (data, jsonPtr) => { received.push({data, jsonPtr}) - }); + })).toBe(true); + // can't set data change a + expect(tp.setDataChangeCallback("/a", (data, jsonPtr) => { + received.push({data, jsonPtr}) + })).toBe(false); tp.setDataChangeCallback("/b", (data, jsonPtr) => { received.push({data, jsonPtr}) }); @@ -901,39 +906,53 @@ test("Solution Environment Files", async () => { expect(tp.output).toEqual(expected); }); -test("remove all DEFAULT_FUNCTIONS", async () => { - let template = {"fetchFunctionShouldNotExists": "${$fetch('https://raw.githubusercontent.com/geoffhendrey/jsonataplay/main/foobar.json')}"}; - TemplateProcessor.DEFAULT_FUNCTIONS = {}; - const tp = new TemplateProcessor(template); - await tp.initialize(); - expect(tp.output).toStrictEqual({ - "fetchFunctionShouldNotExists": { - "error": { - "message": "Attempted to invoke a non-function", - "name": "JSONata evaluation exception" +describe("Testing DEFAULT_FUNCTIONS manipulation", () => { + let originalDefaultFunctions; + + beforeEach(() => { + // Save the global variable before the test + originalDefaultFunctions = TemplateProcessor.DEFAULT_FUNCTIONS; + }); + + afterEach(() => { + // Restore the global variable after the test + TemplateProcessor.DEFAULT_FUNCTIONS = originalDefaultFunctions; + }); + + test("remove all DEFAULT_FUNCTIONS", async () => { + let template = {"fetchFunctionShouldNotExists": "${$fetch('https://raw.githubusercontent.com/geoffhendrey/jsonataplay/main/foobar.json')}"}; + TemplateProcessor.DEFAULT_FUNCTIONS = {}; + const tp = new TemplateProcessor(template); + await tp.initialize(); + expect(tp.output).toStrictEqual({ + "fetchFunctionShouldNotExists": { + "error": { + "message": "Attempted to invoke a non-function", + "name": "JSONata evaluation exception" + } } - } + }); }); -}); -test("shadow DEFAULT_FUNCTIONS fetch with hello", async () => { - let template = {"fetchFunctionBecomesHello": "${$fetch('https://raw.githubusercontent.com/geoffhendrey/jsonataplay/main/foobar.json')}"}; - TemplateProcessor.DEFAULT_FUNCTIONS['fetch'] = () => 'hello'; - const tp = new TemplateProcessor(template); - await tp.initialize(); - expect(tp.output).toStrictEqual({ - "fetchFunctionBecomesHello": "hello" - }) + test("shadow DEFAULT_FUNCTIONS fetch with hello", async () => { + let template = {"fetchFunctionBecomesHello": "${$fetch('https://raw.githubusercontent.com/geoffhendrey/jsonataplay/main/foobar.json')}"}; + TemplateProcessor.DEFAULT_FUNCTIONS['fetch'] = () => 'hello'; + const tp = new TemplateProcessor(template); + await tp.initialize(); + expect(tp.output).toStrictEqual({ + "fetchFunctionBecomesHello": "hello" + }) + }); }); -test("replace DEFAULT_FUNCTIONS fetch with hello", async () => { - let template = {"fetchFunctionBecomesHello": "${$fetch('https://raw.githubusercontent.com/geoffhendrey/jsonataplay/main/foobar.json')}"}; - const tp = new TemplateProcessor(template, {fetch: () => "hello"}); - await tp.initialize(); - expect(tp.output).toStrictEqual({ - "fetchFunctionBecomesHello": "hello" - }) -}); + test("replace DEFAULT_FUNCTIONS fetch with hello", async () => { + let template = {"fetchFunctionBecomesHello": "${$fetch('https://raw.githubusercontent.com/geoffhendrey/jsonataplay/main/foobar.json')}"}; + const tp = new TemplateProcessor(template, {fetch: () => "hello"}); + await tp.initialize(); + expect(tp.output).toStrictEqual({ + "fetchFunctionBecomesHello": "hello" + }) + }); test("strict.refs", async () => { let template = { @@ -1217,24 +1236,9 @@ test("deep view", async () => { test("test rxLog", async () => { const templateYaml = - `# to run this locally you need to have pulsar running in standalone mode - interval$: ($subscribe(subscribeParams); $setInterval(function(){$publish(pubParams)}, 1000)) - pubParams: - type: /\${ subscribeParams.type} #pub to same type we subscribe on - data: "\${ function(){ {'msg': 'hello', 'rando': $random()}} }" - testData: [ {'msg':'hello'} ] - client: - type: test - subscribeParams: #parameters for subscribing to a cloud event - source: cloudEvent - type: 'my-topic' - to: \${ function($e){$set('/rxLog/-', $e)} } - subscriberId: dingus - initialPosition: latest - client: - type: test + ` rxLog: [ {"default": 42} ] - stop$: ($count(rxLog)=5?$clearInterval(interval$):'still going') + stop$: ($count(rxLog)=5?'done':'still going') `; const template = yaml.load(templateYaml); const tp = new TemplateProcessor(template, {"subscribe": ()=>{}, "publish":()=>{}}); @@ -1357,10 +1361,9 @@ test("ex14.yaml", async () => { expect(tp.to('/counter')).toEqual( [ "/counter" ]); - }); -describe('TemplateProcessor.fromString', () => { +describe('TemplateProcessor.fromString', () => { it('should correctly identify and parse JSON string', () => { const jsonString = '{"key": "value"}'; const instance = TemplateProcessor.fromString(jsonString); @@ -1394,11 +1397,8 @@ key: value`; expect(instance).toBeInstanceOf(TemplateProcessor); expect(instance.output).toEqual({ greeting: "Hello: World" }); }); - - }); - test("test /__META__/tags callback ", async () => { const tp = new TemplateProcessor({ "solutionId": "@INSTALL ${$sys.solutionId}", @@ -1430,8 +1430,6 @@ test("test /__META__/tags callback ", async () => { ]); }); - - test("test /__META__/tags array callback ", async () => { const tp = new TemplateProcessor({ "solutionId": "@INSTALL ${$sys.solutionId}", @@ -1528,11 +1526,53 @@ test("test array with /__META__/tags callback ", async () => { }); +test("wait condition", async () => { + const __filename = fileURLToPath(import.meta.url); + const __dirname = dirname(__filename); + const yamlFilePath = path.join(__dirname, '..','..','example', 'ex14.yaml'); + const templateYaml = fs.readFileSync(yamlFilePath, 'utf8'); + const t = yaml.load(templateYaml); + const tp = new TemplateProcessor(t); + await tp.initialize(); + let conditionMatched = await tp.waitCondition('status$="done"', 500); + expect(StatedREPL.stringify(conditionMatched)).toEqual(StatedREPL.stringify({ + "incr$": "{function:}", + "counter": 11, + "upCount$": "--interval/timeout--", + "status$": "done" + })); +}); +test("wait condition timeout", async () => { + const __filename = fileURLToPath(import.meta.url); + const __dirname = dirname(__filename); + const yamlFilePath = path.join(__dirname, '..','..','example', 'ex14.yaml'); + const templateYaml = fs.readFileSync(yamlFilePath, 'utf8'); + const t = yaml.load(templateYaml); + const tp = new TemplateProcessor(t); + await tp.initialize(); + let conditionMatched = await tp.waitCondition('status$="done"', 50); + expect(StatedREPL.stringify(conditionMatched)).toEqual(StatedREPL.stringify({ + "error": { + "message": "wait condition status$=\"done\" timed out in 50ms", + "output": { + "incr$": "{function:}", + "counter": 9, + "upCount$": "--interval/timeout--", + "status$": "counting" + } + } + })); +}); - - - - - - +test("wait condition with commomCallback already set", async () => { + const tp = new TemplateProcessor({}); + tp.setDataChangeCallback("/", (data, jsonPtr) => {}); + await tp.initialize(); + let conditionMatched = await tp.waitCondition('status$="done"', 500); + expect(StatedREPL.stringify(conditionMatched)).toEqual(StatedREPL.stringify({ + "error": { + "message": "can't use wait condition because a callback is already set", + } + })); +});