ultravisor 1.0.2 → 1.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) hide show
  1. package/.claude/launch.json +11 -0
  2. package/.claude/ultravisor-dev-config.json +3 -0
  3. package/.ultravisor.json +426 -0
  4. package/docs/README.md +63 -0
  5. package/package.json +12 -8
  6. package/source/Ultravisor.cjs +22 -3
  7. package/source/cli/Ultravisor-CLIProgram.cjs +35 -23
  8. package/source/cli/commands/Ultravisor-Command-SingleOperation.cjs +29 -18
  9. package/source/cli/commands/Ultravisor-Command-SingleTask.cjs +62 -19
  10. package/source/cli/commands/Ultravisor-Command-UpdateTask.cjs +27 -15
  11. package/source/config/Ultravisor-Default-Command-Configuration.cjs +5 -3
  12. package/source/services/Ultravisor-ExecutionEngine.cjs +1039 -0
  13. package/source/services/Ultravisor-ExecutionManifest.cjs +399 -0
  14. package/source/services/Ultravisor-Hypervisor-State.cjs +270 -97
  15. package/source/services/Ultravisor-Hypervisor.cjs +38 -83
  16. package/source/services/Ultravisor-StateManager.cjs +241 -0
  17. package/source/services/Ultravisor-TaskTypeRegistry.cjs +143 -0
  18. package/source/services/tasks/Ultravisor-TaskType-Base.cjs +105 -0
  19. package/source/services/tasks/control/Ultravisor-TaskType-IfConditional.cjs +148 -0
  20. package/source/services/tasks/control/Ultravisor-TaskType-LaunchOperation.cjs +187 -0
  21. package/source/services/tasks/control/Ultravisor-TaskType-SplitExecute.cjs +184 -0
  22. package/source/services/tasks/data/Ultravisor-TaskType-ReplaceString.cjs +82 -0
  23. package/source/services/tasks/data/Ultravisor-TaskType-SetValues.cjs +81 -0
  24. package/source/services/tasks/data/Ultravisor-TaskType-StringAppender.cjs +101 -0
  25. package/source/services/tasks/file-io/Ultravisor-TaskType-ReadFile.cjs +103 -0
  26. package/source/services/tasks/file-io/Ultravisor-TaskType-WriteFile.cjs +117 -0
  27. package/source/services/tasks/interaction/Ultravisor-TaskType-ErrorMessage.cjs +54 -0
  28. package/source/services/tasks/interaction/Ultravisor-TaskType-ValueInput.cjs +62 -0
  29. package/source/web_server/Ultravisor-API-Server.cjs +237 -124
  30. package/test/Ultravisor_browser_tests.js +2226 -0
  31. package/test/Ultravisor_tests.js +1143 -5830
  32. package/webinterface/css/ultravisor.css +23 -0
  33. package/webinterface/package.json +6 -3
  34. package/webinterface/source/Pict-Application-Ultravisor.js +93 -73
  35. package/webinterface/source/cards/FlowCard-CSVTransform.js +43 -0
  36. package/webinterface/source/cards/FlowCard-Command.js +86 -0
  37. package/webinterface/source/cards/FlowCard-ComprehensionIntersect.js +40 -0
  38. package/webinterface/source/cards/FlowCard-Conditional.js +87 -0
  39. package/webinterface/source/cards/FlowCard-CopyFile.js +55 -0
  40. package/webinterface/source/cards/FlowCard-End.js +29 -0
  41. package/webinterface/source/cards/FlowCard-GetJSON.js +55 -0
  42. package/webinterface/source/cards/FlowCard-GetText.js +54 -0
  43. package/webinterface/source/cards/FlowCard-Histogram.js +176 -0
  44. package/webinterface/source/cards/FlowCard-LaunchOperation.js +82 -0
  45. package/webinterface/source/cards/FlowCard-ListFiles.js +55 -0
  46. package/webinterface/source/cards/FlowCard-MeadowCount.js +44 -0
  47. package/webinterface/source/cards/FlowCard-MeadowCreate.js +44 -0
  48. package/webinterface/source/cards/FlowCard-MeadowDelete.js +45 -0
  49. package/webinterface/source/cards/FlowCard-MeadowRead.js +46 -0
  50. package/webinterface/source/cards/FlowCard-MeadowReads.js +46 -0
  51. package/webinterface/source/cards/FlowCard-MeadowUpdate.js +44 -0
  52. package/webinterface/source/cards/FlowCard-ParseCSV.js +85 -0
  53. package/webinterface/source/cards/FlowCard-ReadJSON.js +54 -0
  54. package/webinterface/source/cards/FlowCard-ReadText.js +54 -0
  55. package/webinterface/source/cards/FlowCard-RestRequest.js +59 -0
  56. package/webinterface/source/cards/FlowCard-SendJSON.js +57 -0
  57. package/webinterface/source/cards/FlowCard-Solver.js +77 -0
  58. package/webinterface/source/cards/FlowCard-Start.js +29 -0
  59. package/webinterface/source/cards/FlowCard-TemplateString.js +77 -0
  60. package/webinterface/source/cards/FlowCard-WriteJSON.js +54 -0
  61. package/webinterface/source/cards/FlowCard-WriteText.js +54 -0
  62. package/webinterface/source/data/ExampleFlow-CSVPipeline.js +231 -0
  63. package/webinterface/source/data/ExampleFlow-FileProcessor.js +315 -0
  64. package/webinterface/source/data/ExampleFlow-MeadowPipeline.js +328 -0
  65. package/webinterface/source/providers/PictRouter-Ultravisor-Configuration.json +8 -8
  66. package/webinterface/source/views/PictView-Ultravisor-Dashboard.js +6 -6
  67. package/webinterface/source/views/PictView-Ultravisor-FlowEditor.js +436 -0
  68. package/webinterface/source/views/PictView-Ultravisor-ManifestList.js +45 -43
  69. package/webinterface/source/views/PictView-Ultravisor-OperationEdit.js +34 -89
  70. package/webinterface/source/views/PictView-Ultravisor-OperationList.js +128 -13
  71. package/webinterface/source/views/PictView-Ultravisor-PendingInput.js +314 -0
  72. package/webinterface/source/views/PictView-Ultravisor-Schedule.js +18 -53
  73. package/webinterface/source/views/PictView-Ultravisor-TimingView.js +27 -14
  74. package/webinterface/source/views/PictView-Ultravisor-TopBar.js +2 -1
  75. package/.babelrc +0 -6
  76. package/.browserslistrc +0 -1
  77. package/.browserslistrc-BACKUP +0 -1
  78. package/.gulpfile-quackage-config.json +0 -7
  79. package/.gulpfile-quackage.js +0 -2
  80. package/debug/Harness.js +0 -5
  81. package/source/services/Ultravisor-Operation-Manifest.cjs +0 -160
  82. package/source/services/Ultravisor-Operation.cjs +0 -200
  83. package/source/services/Ultravisor-Task.cjs +0 -349
  84. package/source/services/events/Ultravisor-Hypervisor-Event-Solver.cjs +0 -11
  85. package/source/services/tasks/Ultravisor-Task-Base.cjs +0 -264
  86. package/source/services/tasks/Ultravisor-Task-CollectValues.cjs +0 -188
  87. package/source/services/tasks/Ultravisor-Task-Command.cjs +0 -65
  88. package/source/services/tasks/Ultravisor-Task-CommandEach.cjs +0 -190
  89. package/source/services/tasks/Ultravisor-Task-Conditional.cjs +0 -104
  90. package/source/services/tasks/Ultravisor-Task-DateWindow.cjs +0 -72
  91. package/source/services/tasks/Ultravisor-Task-GeneratePagedOperation.cjs +0 -336
  92. package/source/services/tasks/Ultravisor-Task-LaunchOperation.cjs +0 -143
  93. package/source/services/tasks/Ultravisor-Task-LaunchTask.cjs +0 -146
  94. package/source/services/tasks/Ultravisor-Task-LineMatch.cjs +0 -158
  95. package/source/services/tasks/Ultravisor-Task-Request.cjs +0 -56
  96. package/source/services/tasks/Ultravisor-Task-Solver.cjs +0 -89
  97. package/source/services/tasks/Ultravisor-Task-TemplateString.cjs +0 -93
  98. package/source/services/tasks/rest/Ultravisor-Task-GetBinary.cjs +0 -127
  99. package/source/services/tasks/rest/Ultravisor-Task-GetJSON.cjs +0 -119
  100. package/source/services/tasks/rest/Ultravisor-Task-GetText.cjs +0 -109
  101. package/source/services/tasks/rest/Ultravisor-Task-GetXML.cjs +0 -112
  102. package/source/services/tasks/rest/Ultravisor-Task-RestRequest.cjs +0 -499
  103. package/source/services/tasks/rest/Ultravisor-Task-SendJSON.cjs +0 -150
  104. package/source/services/tasks/stagingfiles/Ultravisor-Task-CopyFile.cjs +0 -110
  105. package/source/services/tasks/stagingfiles/Ultravisor-Task-ListFiles.cjs +0 -89
  106. package/source/services/tasks/stagingfiles/Ultravisor-Task-ReadBinary.cjs +0 -87
  107. package/source/services/tasks/stagingfiles/Ultravisor-Task-ReadJSON.cjs +0 -67
  108. package/source/services/tasks/stagingfiles/Ultravisor-Task-ReadText.cjs +0 -66
  109. package/source/services/tasks/stagingfiles/Ultravisor-Task-ReadXML.cjs +0 -69
  110. package/source/services/tasks/stagingfiles/Ultravisor-Task-WriteBinary.cjs +0 -95
  111. package/source/services/tasks/stagingfiles/Ultravisor-Task-WriteJSON.cjs +0 -96
  112. package/source/services/tasks/stagingfiles/Ultravisor-Task-WriteText.cjs +0 -99
  113. package/source/services/tasks/stagingfiles/Ultravisor-Task-WriteXML.cjs +0 -102
  114. package/webinterface/.babelrc +0 -6
  115. package/webinterface/.browserslistrc +0 -1
  116. package/webinterface/.browserslistrc-BACKUP +0 -1
  117. package/webinterface/.gulpfile-quackage-config.json +0 -7
  118. package/webinterface/.gulpfile-quackage.js +0 -2
  119. package/webinterface/source/views/PictView-Ultravisor-TaskEdit.js +0 -220
  120. package/webinterface/source/views/PictView-Ultravisor-TaskList.js +0 -248
  121. /package/docs/{cover.md → _cover.md} +0 -0
@@ -0,0 +1,2226 @@
1
+ /**
2
+ * Ultravisor -- Headless Browser Tests
3
+ *
4
+ * Exercises each web interface view in a headless Chromium browser,
5
+ * generates numbered screenshots to debug/dist/automated_test_output/.
6
+ *
7
+ * Creates and executes multiple operations via the REST API to exercise
8
+ * all task types and workflow patterns:
9
+ * - Simple file copy (read-file → write-file)
10
+ * - Conditional branching (set-values → if-conditional → write-file)
11
+ * - Error handling (read-file error → error-message)
12
+ * - Looping pipeline (read → split → replace → append → write)
13
+ * - Sub-operation composition (launch-operation)
14
+ * - State template transforms
15
+ *
16
+ * Requires: puppeteer (dev dependency)
17
+ * Requires: webinterface/dist/ to be pre-built (npx quack build)
18
+ */
19
+ const libAssert = require('assert');
20
+ const libPath = require('path');
21
+ const libFs = require('fs');
22
+ const libPuppeteer = require('puppeteer');
23
+ const libPict = require('pict');
24
+
25
+ // Services
26
+ const libServiceHypervisor = require('../source/services/Ultravisor-Hypervisor.cjs');
27
+ const libServiceHypervisorState = require('../source/services/Ultravisor-Hypervisor-State.cjs');
28
+ const libServiceHypervisorEventBase = require('../source/services/Ultravisor-Hypervisor-Event-Base.cjs');
29
+ const libServiceHypervisorEventCron = require('../source/services/events/Ultravisor-Hypervisor-Event-Cron.cjs');
30
+ const libServiceTaskTypeRegistry = require('../source/services/Ultravisor-TaskTypeRegistry.cjs');
31
+ const libServiceStateManager = require('../source/services/Ultravisor-StateManager.cjs');
32
+ const libServiceExecutionEngine = require('../source/services/Ultravisor-ExecutionEngine.cjs');
33
+ const libServiceExecutionManifest = require('../source/services/Ultravisor-ExecutionManifest.cjs');
34
+ const libWebServerAPIServer = require('../source/web_server/Ultravisor-API-Server.cjs');
35
+
36
+ // ── Module-scope state ──────────────────────────────────
37
+ let _Browser = null;
38
+ let _Page = null;
39
+ let _Fable = null;
40
+ let _BaseURL = '';
41
+ let _ScreenshotDir = '';
42
+ let _ScreenshotIndex = 0;
43
+ let _ConsoleErrors = [];
44
+ let _StagingDir = '';
45
+ let _TestResults =
46
+ {
47
+ timestamp: new Date().toISOString(),
48
+ totalTests: 0,
49
+ passed: 0,
50
+ failed: 0,
51
+ screenshots: [],
52
+ duration: ''
53
+ };
54
+ let _StartTime = Date.now();
55
+
56
+ // ── Helpers ─────────────────────────────────────────────
57
+
58
+ function takeScreenshot(pName)
59
+ {
60
+ _ScreenshotIndex++;
61
+ let tmpPadded = String(_ScreenshotIndex).padStart(2, '0');
62
+ let tmpFilename = tmpPadded + '-' + pName + '.png';
63
+ _TestResults.screenshots.push(tmpFilename);
64
+ return _Page.screenshot(
65
+ {
66
+ path: libPath.join(_ScreenshotDir, tmpFilename),
67
+ fullPage: false
68
+ });
69
+ }
70
+
71
+ async function settle(pMs)
72
+ {
73
+ await _Page.evaluate((pDelay) => new Promise((r) => setTimeout(r, pDelay)), pMs || 500);
74
+ }
75
+
76
+ async function waitForAppReady()
77
+ {
78
+ await _Page.waitForSelector('#Ultravisor-Application-Container', { timeout: 15000 });
79
+ try
80
+ {
81
+ await _Page.waitForFunction(
82
+ () =>
83
+ {
84
+ return (typeof(window.Pict) !== 'undefined') || (typeof(window._Pict) !== 'undefined');
85
+ },
86
+ { timeout: 10000 }
87
+ );
88
+ }
89
+ catch (pErr)
90
+ {
91
+ console.log(' [Warning] Pict global not found after 10s; proceeding without it.');
92
+ }
93
+ await settle(2000);
94
+ }
95
+
96
+ async function navigateToRoute(pRoute)
97
+ {
98
+ await _Page.evaluate((pHash) =>
99
+ {
100
+ window.location.hash = pHash;
101
+ }, pRoute);
102
+ await settle(800);
103
+ }
104
+
105
+ /**
106
+ * Create an operation via the API and return its response.
107
+ */
108
+ async function apiCreateOperation(pOperationData)
109
+ {
110
+ return _Page.evaluate(async (pBaseURL, pData) =>
111
+ {
112
+ let tmpRes = await fetch(pBaseURL + '/Operation',
113
+ {
114
+ method: 'POST',
115
+ headers: { 'Content-Type': 'application/json' },
116
+ body: JSON.stringify(pData)
117
+ });
118
+ return tmpRes.json();
119
+ }, _BaseURL, pOperationData);
120
+ }
121
+
122
+ /**
123
+ * Execute an operation via the API and return the execution result.
124
+ */
125
+ async function apiExecuteOperation(pHash)
126
+ {
127
+ return _Page.evaluate(async (pBaseURL, pHash) =>
128
+ {
129
+ let tmpRes = await fetch(pBaseURL + '/Operation/' + pHash + '/Execute');
130
+ return { status: tmpRes.status, body: await tmpRes.json() };
131
+ }, _BaseURL, pHash);
132
+ }
133
+
134
+ /**
135
+ * Generic API GET helper.
136
+ */
137
+ async function apiGet(pPath)
138
+ {
139
+ return _Page.evaluate(async (pBaseURL, pPath) =>
140
+ {
141
+ let tmpRes = await fetch(pBaseURL + pPath);
142
+ return { status: tmpRes.status, body: await tmpRes.json() };
143
+ }, _BaseURL, pPath);
144
+ }
145
+
146
+ /**
147
+ * Generic API POST helper.
148
+ */
149
+ async function apiPost(pPath, pBody)
150
+ {
151
+ return _Page.evaluate(async (pBaseURL, pPath, pBody) =>
152
+ {
153
+ let tmpRes = await fetch(pBaseURL + pPath,
154
+ {
155
+ method: 'POST',
156
+ headers: { 'Content-Type': 'application/json' },
157
+ body: JSON.stringify(pBody)
158
+ });
159
+ return { status: tmpRes.status, body: await tmpRes.json() };
160
+ }, _BaseURL, pPath, pBody);
161
+ }
162
+
163
+ /**
164
+ * Generic API DELETE helper.
165
+ */
166
+ async function apiDelete(pPath)
167
+ {
168
+ return _Page.evaluate(async (pBaseURL, pPath) =>
169
+ {
170
+ let tmpRes = await fetch(pBaseURL + pPath, { method: 'DELETE' });
171
+ return { status: tmpRes.status, body: await tmpRes.json() };
172
+ }, _BaseURL, pPath);
173
+ }
174
+
175
+ // ── Test Suite ──────────────────────────────────────────
176
+
177
+ suite
178
+ (
179
+ 'Ultravisor Browser Tests',
180
+ function ()
181
+ {
182
+ this.timeout(300000);
183
+
184
+ suiteSetup
185
+ (
186
+ function (fDone)
187
+ {
188
+ this.timeout(60000);
189
+
190
+ _ScreenshotDir = libPath.join(__dirname, '..', 'debug', 'dist', 'automated_test_output');
191
+ _StagingDir = libPath.resolve(__dirname, '..', '.test_staging_browser');
192
+
193
+ // Clean and create output directory
194
+ if (libFs.existsSync(_ScreenshotDir))
195
+ {
196
+ libFs.rmSync(_ScreenshotDir, { recursive: true, force: true });
197
+ }
198
+ libFs.mkdirSync(_ScreenshotDir, { recursive: true });
199
+
200
+ // Ensure staging directory exists
201
+ libFs.mkdirSync(_StagingDir, { recursive: true });
202
+
203
+ // Verify webinterface dist exists
204
+ let tmpWebInterfacePath = libPath.resolve(__dirname, '..', 'webinterface', 'dist');
205
+ if (!libFs.existsSync(libPath.join(tmpWebInterfacePath, 'index.html')))
206
+ {
207
+ return fDone(new Error('webinterface/dist/index.html not found. Run "npx quack build" first.'));
208
+ }
209
+
210
+ // Create Pict instance with random port
211
+ let tmpPort = 10000 + Math.floor(Math.random() * 50000);
212
+ _Fable = new libPict(
213
+ {
214
+ Product: 'Ultravisor-BrowserTest',
215
+ APIServerPort: tmpPort,
216
+ LogLevel: 5
217
+ });
218
+
219
+ _Fable.ProgramConfiguration =
220
+ {
221
+ UltravisorWebInterfacePath: tmpWebInterfacePath,
222
+ UltravisorStagingRoot: _StagingDir
223
+ };
224
+
225
+ if (typeof(_Fable.gatherProgramConfiguration) !== 'function')
226
+ {
227
+ _Fable.gatherProgramConfiguration = function ()
228
+ {
229
+ return {
230
+ GatherPhases:
231
+ [
232
+ { Phase: 'Default Program Configuration' },
233
+ { Phase: 'Test Configuration', Path: libPath.join(_StagingDir, '.ultravisor.json') }
234
+ ],
235
+ ConfigurationOutcome: {}
236
+ };
237
+ };
238
+ }
239
+
240
+ // Register all services
241
+ _Fable.addAndInstantiateServiceTypeIfNotExists('UltravisorHypervisor', libServiceHypervisor);
242
+ _Fable.addAndInstantiateServiceTypeIfNotExists('UltravisorHypervisorState', libServiceHypervisorState);
243
+ _Fable.addAndInstantiateServiceTypeIfNotExists('UltravisorHypervisorEventBase', libServiceHypervisorEventBase);
244
+ _Fable.addAndInstantiateServiceTypeIfNotExists('UltravisorHypervisorEventCron', libServiceHypervisorEventCron);
245
+ _Fable.addAndInstantiateServiceTypeIfNotExists('UltravisorTaskTypeRegistry', libServiceTaskTypeRegistry);
246
+ _Fable.addAndInstantiateServiceTypeIfNotExists('UltravisorStateManager', libServiceStateManager);
247
+ _Fable.addAndInstantiateServiceTypeIfNotExists('UltravisorExecutionEngine', libServiceExecutionEngine);
248
+ _Fable.addAndInstantiateServiceTypeIfNotExists('UltravisorExecutionManifest', libServiceExecutionManifest);
249
+
250
+ let tmpRegistry = Object.values(_Fable.servicesMap['UltravisorTaskTypeRegistry'])[0];
251
+ if (tmpRegistry)
252
+ {
253
+ tmpRegistry.registerBuiltInTaskTypes();
254
+ }
255
+
256
+ // Register and start API server
257
+ _Fable.addAndInstantiateServiceTypeIfNotExists('UltravisorAPIServer', libWebServerAPIServer);
258
+ let tmpAPIServer = Object.values(_Fable.servicesMap['UltravisorAPIServer'])[0];
259
+
260
+ tmpAPIServer.start(function (pError)
261
+ {
262
+ if (pError)
263
+ {
264
+ return fDone(pError);
265
+ }
266
+
267
+ _BaseURL = 'http://localhost:' + tmpPort;
268
+
269
+ libPuppeteer.launch(
270
+ {
271
+ headless: true,
272
+ args: ['--no-sandbox', '--disable-setuid-sandbox']
273
+ }).then(function (pBrowser)
274
+ {
275
+ _Browser = pBrowser;
276
+ return _Browser.newPage();
277
+ }).then(function (pPage)
278
+ {
279
+ _Page = pPage;
280
+ return _Page.setViewport({ width: 1280, height: 800 });
281
+ }).then(function ()
282
+ {
283
+ _Page.on('console', function (pMsg)
284
+ {
285
+ if (pMsg.type() === 'error')
286
+ {
287
+ console.log(' [Browser Console Error]', pMsg.text());
288
+ }
289
+ });
290
+ _Page.on('pageerror', function (pError)
291
+ {
292
+ _ConsoleErrors.push(pError.message);
293
+ console.log(' [Browser Error]', pError.message);
294
+ });
295
+ _Page.on('requestfailed', function (pRequest)
296
+ {
297
+ console.log(' [Request Failed]', pRequest.url(), pRequest.failure().errorText);
298
+ });
299
+
300
+ return _Page.goto(_BaseURL + '/index.html', { waitUntil: 'networkidle2', timeout: 30000 });
301
+ }).then(function ()
302
+ {
303
+ return waitForAppReady();
304
+ }).then(function ()
305
+ {
306
+ fDone();
307
+ }).catch(function (pErr)
308
+ {
309
+ fDone(pErr);
310
+ });
311
+ });
312
+ }
313
+ );
314
+
315
+ suiteTeardown
316
+ (
317
+ function (fDone)
318
+ {
319
+ this.timeout(15000);
320
+
321
+ _TestResults.failed = _TestResults.totalTests - _TestResults.passed;
322
+ _TestResults.duration = ((Date.now() - _StartTime) / 1000).toFixed(1) + 's';
323
+
324
+ try
325
+ {
326
+ libFs.writeFileSync(
327
+ libPath.join(_ScreenshotDir, 'test-results.json'),
328
+ JSON.stringify(_TestResults, null, '\t'),
329
+ 'utf8'
330
+ );
331
+ }
332
+ catch (pErr)
333
+ {
334
+ console.log(' [Warning] Failed to write test-results.json:', pErr.message);
335
+ }
336
+
337
+ let tmpClosePromise = Promise.resolve();
338
+
339
+ if (_Browser)
340
+ {
341
+ tmpClosePromise = _Browser.close();
342
+ }
343
+
344
+ tmpClosePromise.then(function ()
345
+ {
346
+ if (_Fable)
347
+ {
348
+ let tmpAPIServer = Object.values(_Fable.servicesMap['UltravisorAPIServer'] || {})[0];
349
+ if (tmpAPIServer && tmpAPIServer._Orator)
350
+ {
351
+ return tmpAPIServer._Orator.stopService(function ()
352
+ {
353
+ if (libFs.existsSync(_StagingDir))
354
+ {
355
+ libFs.rmSync(_StagingDir, { recursive: true, force: true });
356
+ }
357
+ return fDone();
358
+ });
359
+ }
360
+ }
361
+ return fDone();
362
+ }).catch(function ()
363
+ {
364
+ return fDone();
365
+ });
366
+ }
367
+ );
368
+
369
+ setup(function ()
370
+ {
371
+ _TestResults.totalTests++;
372
+ });
373
+
374
+ // ════════════════════════════════════════════════
375
+ // Application Load
376
+ // ════════════════════════════════════════════════
377
+ suite
378
+ (
379
+ 'Application Load',
380
+ function ()
381
+ {
382
+ test
383
+ (
384
+ 'app loads and container exists',
385
+ async function ()
386
+ {
387
+ this.timeout(15000);
388
+
389
+ let tmpContainerExists = await _Page.evaluate(
390
+ () => !!document.getElementById('Ultravisor-Application-Container')
391
+ );
392
+ libAssert.ok(tmpContainerExists, 'Application container should exist');
393
+
394
+ let tmpPictExists = await _Page.evaluate(
395
+ () => typeof(window._Pict) !== 'undefined'
396
+ );
397
+ libAssert.ok(tmpPictExists, 'window._Pict should be defined');
398
+
399
+ await takeScreenshot('app-loads');
400
+ _TestResults.passed++;
401
+ }
402
+ );
403
+ }
404
+ );
405
+
406
+ // ════════════════════════════════════════════════
407
+ // View Navigation
408
+ // ════════════════════════════════════════════════
409
+ suite
410
+ (
411
+ 'View Navigation',
412
+ function ()
413
+ {
414
+ test
415
+ (
416
+ 'Dashboard view renders',
417
+ async function ()
418
+ {
419
+ this.timeout(15000);
420
+ await navigateToRoute('#/Home');
421
+ await takeScreenshot('dashboard');
422
+ _TestResults.passed++;
423
+ }
424
+ );
425
+
426
+ test
427
+ (
428
+ 'Operation List view renders',
429
+ async function ()
430
+ {
431
+ this.timeout(15000);
432
+ await navigateToRoute('#/Operations');
433
+ await takeScreenshot('operation-list');
434
+ _TestResults.passed++;
435
+ }
436
+ );
437
+
438
+ test
439
+ (
440
+ 'Operation Edit view renders',
441
+ async function ()
442
+ {
443
+ this.timeout(15000);
444
+ await navigateToRoute('#/OperationEdit');
445
+ await takeScreenshot('operation-edit');
446
+ _TestResults.passed++;
447
+ }
448
+ );
449
+
450
+ test
451
+ (
452
+ 'Schedule view renders',
453
+ async function ()
454
+ {
455
+ this.timeout(15000);
456
+ await navigateToRoute('#/Schedule');
457
+ await takeScreenshot('schedule');
458
+ _TestResults.passed++;
459
+ }
460
+ );
461
+
462
+ test
463
+ (
464
+ 'Manifest List view renders',
465
+ async function ()
466
+ {
467
+ this.timeout(15000);
468
+ await navigateToRoute('#/Manifests');
469
+ await takeScreenshot('manifest-list');
470
+ _TestResults.passed++;
471
+ }
472
+ );
473
+
474
+ test
475
+ (
476
+ 'Timing view renders',
477
+ async function ()
478
+ {
479
+ this.timeout(15000);
480
+ await navigateToRoute('#/Timing');
481
+ await takeScreenshot('timing');
482
+ _TestResults.passed++;
483
+ }
484
+ );
485
+
486
+ test
487
+ (
488
+ 'Flow Editor view renders',
489
+ async function ()
490
+ {
491
+ this.timeout(15000);
492
+ await navigateToRoute('#/FlowEditor');
493
+ await takeScreenshot('flow-editor');
494
+ _TestResults.passed++;
495
+ }
496
+ );
497
+ }
498
+ );
499
+
500
+ // ════════════════════════════════════════════════
501
+ // Navigation Cycle
502
+ // ════════════════════════════════════════════════
503
+ suite
504
+ (
505
+ 'Navigation Cycle',
506
+ function ()
507
+ {
508
+ test
509
+ (
510
+ 'navigates through all views sequentially',
511
+ async function ()
512
+ {
513
+ this.timeout(30000);
514
+
515
+ let tmpRoutes =
516
+ [
517
+ '#/Home',
518
+ '#/Operations', '#/OperationEdit',
519
+ '#/Schedule', '#/Manifests',
520
+ '#/Timing', '#/FlowEditor'
521
+ ];
522
+
523
+ for (let i = 0; i < tmpRoutes.length; i++)
524
+ {
525
+ await navigateToRoute(tmpRoutes[i]);
526
+ await takeScreenshot('nav-cycle-' + tmpRoutes[i].replace('#/', '').toLowerCase());
527
+ }
528
+
529
+ _TestResults.passed++;
530
+ }
531
+ );
532
+ }
533
+ );
534
+
535
+ // ════════════════════════════════════════════════
536
+ // NodeTemplate CRUD API
537
+ // ════════════════════════════════════════════════
538
+ suite
539
+ (
540
+ 'NodeTemplate CRUD API',
541
+ function ()
542
+ {
543
+ let _CreatedTemplateHash = '';
544
+
545
+ test
546
+ (
547
+ 'create a node template via POST /NodeTemplate',
548
+ async function ()
549
+ {
550
+ this.timeout(10000);
551
+
552
+ let tmpResponse = await _Page.evaluate(async (pBaseURL) =>
553
+ {
554
+ let tmpRes = await fetch(pBaseURL + '/NodeTemplate',
555
+ {
556
+ method: 'POST',
557
+ headers: { 'Content-Type': 'application/json' },
558
+ body: JSON.stringify({
559
+ Type: 'read-file',
560
+ Name: 'Test ReadFile Template',
561
+ Settings: { FilePath: '/tmp/test.txt', Encoding: 'utf8' }
562
+ })
563
+ });
564
+ return tmpRes.json();
565
+ }, _BaseURL);
566
+
567
+ libAssert.ok(tmpResponse.Hash, 'Template should have a Hash');
568
+ libAssert.ok(tmpResponse.Hash.startsWith('TMPL-'), 'Hash should start with TMPL-');
569
+ libAssert.strictEqual(tmpResponse.Type, 'read-file', 'Type should match');
570
+
571
+ _CreatedTemplateHash = tmpResponse.Hash;
572
+ console.log(' Created template:', _CreatedTemplateHash);
573
+
574
+ _TestResults.passed++;
575
+ }
576
+ );
577
+
578
+ test
579
+ (
580
+ 'list node templates via GET /NodeTemplate',
581
+ async function ()
582
+ {
583
+ this.timeout(10000);
584
+
585
+ let tmpResponse = (await apiGet('/NodeTemplate')).body;
586
+
587
+ libAssert.ok(Array.isArray(tmpResponse), 'Should return an array');
588
+ libAssert.ok(tmpResponse.length >= 1, 'Should have at least one template');
589
+
590
+ let tmpFound = tmpResponse.find(function (pT) { return pT.Hash === _CreatedTemplateHash; });
591
+ libAssert.ok(tmpFound, 'Created template should appear in list');
592
+
593
+ _TestResults.passed++;
594
+ }
595
+ );
596
+
597
+ test
598
+ (
599
+ 'get a node template by hash via GET /NodeTemplate/:Hash',
600
+ async function ()
601
+ {
602
+ this.timeout(10000);
603
+
604
+ let tmpResponse = (await apiGet('/NodeTemplate/' + _CreatedTemplateHash)).body;
605
+
606
+ libAssert.strictEqual(tmpResponse.Hash, _CreatedTemplateHash, 'Hash should match');
607
+ libAssert.strictEqual(tmpResponse.Name, 'Test ReadFile Template', 'Name should match');
608
+ libAssert.deepStrictEqual(tmpResponse.Settings, { FilePath: '/tmp/test.txt', Encoding: 'utf8' }, 'Settings should match');
609
+
610
+ _TestResults.passed++;
611
+ }
612
+ );
613
+
614
+ test
615
+ (
616
+ 'update a node template via PUT /NodeTemplate/:Hash',
617
+ async function ()
618
+ {
619
+ this.timeout(10000);
620
+
621
+ let tmpResponse = await _Page.evaluate(async (pBaseURL, pHash) =>
622
+ {
623
+ let tmpRes = await fetch(pBaseURL + '/NodeTemplate/' + pHash,
624
+ {
625
+ method: 'PUT',
626
+ headers: { 'Content-Type': 'application/json' },
627
+ body: JSON.stringify({
628
+ Name: 'Updated ReadFile Template',
629
+ Settings: { FilePath: '/tmp/updated.txt', Encoding: 'utf8' }
630
+ })
631
+ });
632
+ return tmpRes.json();
633
+ }, _BaseURL, _CreatedTemplateHash);
634
+
635
+ libAssert.strictEqual(tmpResponse.Name, 'Updated ReadFile Template', 'Name should be updated');
636
+
637
+ _TestResults.passed++;
638
+ }
639
+ );
640
+
641
+ test
642
+ (
643
+ 'delete a node template via DELETE /NodeTemplate/:Hash',
644
+ async function ()
645
+ {
646
+ this.timeout(10000);
647
+
648
+ let tmpResponse = (await apiDelete('/NodeTemplate/' + _CreatedTemplateHash)).body;
649
+ libAssert.strictEqual(tmpResponse.Status, 'Deleted', 'Should confirm deletion');
650
+
651
+ let tmpGetResponse = await apiGet('/NodeTemplate/' + _CreatedTemplateHash);
652
+ libAssert.strictEqual(tmpGetResponse.status, 404, 'Should return 404 after deletion');
653
+
654
+ _TestResults.passed++;
655
+ }
656
+ );
657
+ }
658
+ );
659
+
660
+ // ════════════════════════════════════════════════
661
+ // Task Type Registry
662
+ // ════════════════════════════════════════════════
663
+ suite
664
+ (
665
+ 'Task Type Registry',
666
+ function ()
667
+ {
668
+ test
669
+ (
670
+ 'list task types via GET /TaskType',
671
+ async function ()
672
+ {
673
+ this.timeout(10000);
674
+
675
+ let tmpResponse = (await apiGet('/TaskType')).body;
676
+
677
+ libAssert.ok(Array.isArray(tmpResponse), 'Should return an array');
678
+ libAssert.ok(tmpResponse.length >= 10, 'Should have at least 10 built-in task types (got ' + tmpResponse.length + ')');
679
+
680
+ // Verify every expected task type is registered
681
+ let tmpExpected = ['read-file', 'write-file', 'set-values', 'replace-string',
682
+ 'string-appender', 'if-conditional', 'split-execute', 'launch-operation',
683
+ 'value-input', 'error-message'];
684
+
685
+ for (let i = 0; i < tmpExpected.length; i++)
686
+ {
687
+ let tmpFound = tmpResponse.find(function (pT) { return pT.Hash === tmpExpected[i]; });
688
+ libAssert.ok(tmpFound, tmpExpected[i] + ' task type should be registered');
689
+ }
690
+
691
+ console.log(' Task types:', tmpResponse.map(function (pT) { return pT.Hash; }).join(', '));
692
+
693
+ _TestResults.passed++;
694
+ }
695
+ );
696
+ }
697
+ );
698
+
699
+ // ════════════════════════════════════════════════
700
+ // Operation CRUD API
701
+ // ════════════════════════════════════════════════
702
+ suite
703
+ (
704
+ 'Operation CRUD API',
705
+ function ()
706
+ {
707
+ let _CreatedOpHash = '';
708
+
709
+ test
710
+ (
711
+ 'create an operation via POST /Operation',
712
+ async function ()
713
+ {
714
+ this.timeout(10000);
715
+
716
+ let tmpResponse = await apiCreateOperation({
717
+ Name: 'CRUD Test Operation',
718
+ Description: 'Created for CRUD API testing',
719
+ Graph: {
720
+ Nodes: [
721
+ { Hash: 'n-start', Type: 'start', X: 0, Y: 0 },
722
+ { Hash: 'n-end', Type: 'end', X: 200, Y: 0 }
723
+ ],
724
+ Connections: [
725
+ {
726
+ Hash: 'c1', ConnectionType: 'Event',
727
+ SourceNodeHash: 'n-start', SourcePortHash: 'n-start-eo-Start',
728
+ TargetNodeHash: 'n-end', TargetPortHash: 'n-end-ei-End'
729
+ }
730
+ ],
731
+ ViewState: {}
732
+ }
733
+ });
734
+
735
+ libAssert.ok(tmpResponse.Hash, 'Operation should have a Hash');
736
+ libAssert.ok(tmpResponse.Hash.startsWith('OPR-'), 'Hash should start with OPR-');
737
+ libAssert.strictEqual(tmpResponse.Name, 'CRUD Test Operation', 'Name should match');
738
+
739
+ _CreatedOpHash = tmpResponse.Hash;
740
+ console.log(' Created operation:', _CreatedOpHash);
741
+
742
+ _TestResults.passed++;
743
+ }
744
+ );
745
+
746
+ test
747
+ (
748
+ 'list operations via GET /Operation',
749
+ async function ()
750
+ {
751
+ this.timeout(10000);
752
+
753
+ let tmpResponse = (await apiGet('/Operation')).body;
754
+
755
+ libAssert.ok(Array.isArray(tmpResponse), 'Should return an array');
756
+ libAssert.ok(tmpResponse.length >= 1, 'Should have at least one operation');
757
+
758
+ let tmpFound = tmpResponse.find(function (pOp) { return pOp.Hash === _CreatedOpHash; });
759
+ libAssert.ok(tmpFound, 'Created operation should appear in list');
760
+
761
+ _TestResults.passed++;
762
+ }
763
+ );
764
+
765
+ test
766
+ (
767
+ 'get an operation by hash via GET /Operation/:Hash',
768
+ async function ()
769
+ {
770
+ this.timeout(10000);
771
+
772
+ let tmpResponse = (await apiGet('/Operation/' + _CreatedOpHash)).body;
773
+
774
+ libAssert.strictEqual(tmpResponse.Hash, _CreatedOpHash, 'Hash should match');
775
+ libAssert.strictEqual(tmpResponse.Name, 'CRUD Test Operation', 'Name should match');
776
+ libAssert.ok(tmpResponse.Graph, 'Should have a Graph');
777
+ libAssert.strictEqual(tmpResponse.Graph.Nodes.length, 2, 'Graph should have 2 nodes');
778
+
779
+ _TestResults.passed++;
780
+ }
781
+ );
782
+
783
+ test
784
+ (
785
+ 'execute a trivial operation via GET /Operation/:Hash/Execute',
786
+ async function ()
787
+ {
788
+ this.timeout(15000);
789
+
790
+ let tmpResult = await apiExecuteOperation(_CreatedOpHash);
791
+
792
+ libAssert.strictEqual(tmpResult.status, 200, 'Should return 200');
793
+ libAssert.strictEqual(tmpResult.body.Status, 'Complete', 'Trivial start→end should complete');
794
+
795
+ console.log(' Trivial execution:', tmpResult.body.Status,
796
+ '| Log:', tmpResult.body.Log ? tmpResult.body.Log.length : 0, 'entries');
797
+
798
+ _TestResults.passed++;
799
+ }
800
+ );
801
+
802
+ test
803
+ (
804
+ 'update an operation via PUT /Operation/:Hash',
805
+ async function ()
806
+ {
807
+ this.timeout(10000);
808
+
809
+ let tmpResponse = await _Page.evaluate(async (pBaseURL, pHash) =>
810
+ {
811
+ let tmpRes = await fetch(pBaseURL + '/Operation/' + pHash,
812
+ {
813
+ method: 'PUT',
814
+ headers: { 'Content-Type': 'application/json' },
815
+ body: JSON.stringify({ Name: 'Updated CRUD Test' })
816
+ });
817
+ return tmpRes.json();
818
+ }, _BaseURL, _CreatedOpHash);
819
+
820
+ libAssert.strictEqual(tmpResponse.Name, 'Updated CRUD Test', 'Name should be updated');
821
+
822
+ _TestResults.passed++;
823
+ }
824
+ );
825
+
826
+ test
827
+ (
828
+ 'delete an operation via DELETE /Operation/:Hash',
829
+ async function ()
830
+ {
831
+ this.timeout(10000);
832
+
833
+ let tmpResponse = (await apiDelete('/Operation/' + _CreatedOpHash)).body;
834
+ libAssert.strictEqual(tmpResponse.Status, 'Deleted', 'Should confirm deletion');
835
+
836
+ let tmpGetResponse = await apiGet('/Operation/' + _CreatedOpHash);
837
+ libAssert.strictEqual(tmpGetResponse.status, 404, 'Should return 404 after deletion');
838
+
839
+ _TestResults.passed++;
840
+ }
841
+ );
842
+ }
843
+ );
844
+
845
+ // ════════════════════════════════════════════════
846
+ // Workflow 1: Simple File Copy (read-file → write-file)
847
+ // ════════════════════════════════════════════════
848
+ suite
849
+ (
850
+ 'Workflow: Simple File Copy',
851
+ function ()
852
+ {
853
+ let _OpHash = '';
854
+ let _InputPath = '';
855
+
856
+ test
857
+ (
858
+ 'create input file and save operation',
859
+ async function ()
860
+ {
861
+ this.timeout(15000);
862
+
863
+ let tmpTestDir = libPath.join(_StagingDir, 'file_copy_test');
864
+ libFs.mkdirSync(tmpTestDir, { recursive: true });
865
+
866
+ _InputPath = libPath.join(tmpTestDir, 'source.txt');
867
+ libFs.writeFileSync(_InputPath, 'This is the source file content.\nLine two.\n', 'utf8');
868
+
869
+ let tmpResponse = await apiCreateOperation({
870
+ Name: 'File Copy Test',
871
+ Description: 'Reads a file and writes its content to another file.',
872
+ Graph: {
873
+ Nodes: [
874
+ { Hash: 'n-start', Type: 'start', X: 0, Y: 0 },
875
+ {
876
+ Hash: 'n-read', Type: 'read-file',
877
+ Settings: { FilePath: _InputPath, Encoding: 'utf8' },
878
+ Ports: [], X: 200, Y: 0
879
+ },
880
+ {
881
+ Hash: 'n-write', Type: 'write-file',
882
+ Settings: { FilePath: libPath.join(tmpTestDir, 'copy.txt'), Encoding: 'utf8' },
883
+ Ports: [], X: 400, Y: 0
884
+ },
885
+ { Hash: 'n-end', Type: 'end', X: 600, Y: 0 }
886
+ ],
887
+ Connections: [
888
+ { Hash: 'c1', ConnectionType: 'Event', SourceNodeHash: 'n-start', SourcePortHash: 'n-start-eo-Start', TargetNodeHash: 'n-read', TargetPortHash: 'n-read-ei-BeginRead' },
889
+ { Hash: 'c2', ConnectionType: 'Event', SourceNodeHash: 'n-read', SourcePortHash: 'n-read-eo-ReadComplete', TargetNodeHash: 'n-write', TargetPortHash: 'n-write-ei-BeginWrite' },
890
+ { Hash: 'c3', ConnectionType: 'State', SourceNodeHash: 'n-read', SourcePortHash: 'n-read-so-FileContent', TargetNodeHash: 'n-write', TargetPortHash: 'n-write-si-Content' },
891
+ { Hash: 'c4', ConnectionType: 'Event', SourceNodeHash: 'n-write', SourcePortHash: 'n-write-eo-WriteComplete', TargetNodeHash: 'n-end', TargetPortHash: 'n-end-ei-End' }
892
+ ],
893
+ ViewState: {}
894
+ }
895
+ });
896
+
897
+ _OpHash = tmpResponse.Hash;
898
+ libAssert.ok(_OpHash, 'Operation should be created');
899
+ console.log(' File Copy op:', _OpHash);
900
+
901
+ _TestResults.passed++;
902
+ }
903
+ );
904
+
905
+ test
906
+ (
907
+ 'execute file copy operation and verify output',
908
+ async function ()
909
+ {
910
+ this.timeout(15000);
911
+
912
+ let tmpResult = await apiExecuteOperation(_OpHash);
913
+
914
+ libAssert.strictEqual(tmpResult.body.Status, 'Complete', 'File copy should complete');
915
+ libAssert.ok(tmpResult.body.TaskOutputs['n-read'], 'Should have read-file outputs');
916
+ libAssert.ok(tmpResult.body.TaskOutputs['n-read'].BytesRead > 0, 'Should have read bytes');
917
+ libAssert.ok(tmpResult.body.TaskOutputs['n-write'], 'Should have write-file outputs');
918
+ libAssert.ok(tmpResult.body.TaskOutputs['n-write'].BytesWritten > 0, 'Should have written bytes');
919
+
920
+ console.log(' Read', tmpResult.body.TaskOutputs['n-read'].BytesRead, 'bytes, wrote',
921
+ tmpResult.body.TaskOutputs['n-write'].BytesWritten, 'bytes');
922
+
923
+ _TestResults.passed++;
924
+ }
925
+ );
926
+ }
927
+ );
928
+
929
+ // ════════════════════════════════════════════════
930
+ // Workflow 2: State Template Transform
931
+ // (read-file → write-file with template prepend)
932
+ // ════════════════════════════════════════════════
933
+ suite
934
+ (
935
+ 'Workflow: State Template Transform',
936
+ function ()
937
+ {
938
+ let _OpHash = '';
939
+ let _InputPath = '';
940
+ let _OutputPath = '';
941
+
942
+ test
943
+ (
944
+ 'create and execute template transform operation',
945
+ async function ()
946
+ {
947
+ this.timeout(15000);
948
+
949
+ let tmpTestDir = libPath.join(_StagingDir, 'template_test');
950
+ libFs.mkdirSync(tmpTestDir, { recursive: true });
951
+
952
+ _InputPath = libPath.join(tmpTestDir, 'input.txt');
953
+ _OutputPath = libPath.join(tmpTestDir, 'output.txt');
954
+ libFs.writeFileSync(_InputPath, 'Original content here.', 'utf8');
955
+
956
+ let tmpResponse = await apiCreateOperation({
957
+ Name: 'Template Transform',
958
+ Description: 'Reads a file and prepends a header via state template.',
959
+ Graph: {
960
+ Nodes: [
961
+ { Hash: 'n-start', Type: 'start', X: 0, Y: 0 },
962
+ {
963
+ Hash: 'n-read', Type: 'read-file',
964
+ Settings: { FilePath: _InputPath, Encoding: 'utf8' },
965
+ Ports: [], X: 200, Y: 0
966
+ },
967
+ {
968
+ Hash: 'n-write', Type: 'write-file',
969
+ Settings: { FilePath: _OutputPath, Encoding: 'utf8' },
970
+ Ports: [], X: 400, Y: 0
971
+ },
972
+ { Hash: 'n-end', Type: 'end', X: 600, Y: 0 }
973
+ ],
974
+ Connections: [
975
+ { Hash: 'c1', ConnectionType: 'Event', SourceNodeHash: 'n-start', SourcePortHash: 'n-start-eo-Start', TargetNodeHash: 'n-read', TargetPortHash: 'n-read-ei-BeginRead' },
976
+ { Hash: 'c2', ConnectionType: 'Event', SourceNodeHash: 'n-read', SourcePortHash: 'n-read-eo-ReadComplete', TargetNodeHash: 'n-write', TargetPortHash: 'n-write-ei-BeginWrite' },
977
+ // State connection WITH template: prepend "HEADER: " to the file content
978
+ {
979
+ Hash: 'c3', ConnectionType: 'State',
980
+ SourceNodeHash: 'n-read', SourcePortHash: 'n-read-so-FileContent',
981
+ TargetNodeHash: 'n-write', TargetPortHash: 'n-write-si-Content',
982
+ Data: { Template: 'HEADER: {~D:Record.Value~}' }
983
+ },
984
+ { Hash: 'c4', ConnectionType: 'Event', SourceNodeHash: 'n-write', SourcePortHash: 'n-write-eo-WriteComplete', TargetNodeHash: 'n-end', TargetPortHash: 'n-end-ei-End' }
985
+ ],
986
+ ViewState: {}
987
+ }
988
+ });
989
+
990
+ _OpHash = tmpResponse.Hash;
991
+ let tmpResult = await apiExecuteOperation(_OpHash);
992
+
993
+ libAssert.strictEqual(tmpResult.body.Status, 'Complete', 'Template transform should complete');
994
+
995
+ // Verify the output file has the header prepended
996
+ libAssert.ok(libFs.existsSync(_OutputPath), 'Output file should exist');
997
+ let tmpContent = libFs.readFileSync(_OutputPath, 'utf8');
998
+
999
+ libAssert.ok(tmpContent.startsWith('HEADER: '), 'Output should start with HEADER: prefix');
1000
+ libAssert.ok(tmpContent.includes('Original content here.'), 'Output should contain original content');
1001
+ console.log(' Template output:', JSON.stringify(tmpContent));
1002
+
1003
+ _TestResults.passed++;
1004
+ }
1005
+ );
1006
+ }
1007
+ );
1008
+
1009
+ // ════════════════════════════════════════════════
1010
+ // Workflow 3: Conditional Branching
1011
+ // (set-values → if-conditional → true/false write paths)
1012
+ // ════════════════════════════════════════════════
1013
+ suite
1014
+ (
1015
+ 'Workflow: Conditional Branching',
1016
+ function ()
1017
+ {
1018
+ test
1019
+ (
1020
+ 'execute true-branch when condition matches',
1021
+ async function ()
1022
+ {
1023
+ this.timeout(15000);
1024
+
1025
+ let tmpTestDir = libPath.join(_StagingDir, 'branch_true_test');
1026
+ libFs.mkdirSync(tmpTestDir, { recursive: true });
1027
+ let tmpTruePath = libPath.join(tmpTestDir, 'true_result.txt');
1028
+ let tmpFalsePath = libPath.join(tmpTestDir, 'false_result.txt');
1029
+
1030
+ let tmpResponse = await apiCreateOperation({
1031
+ Name: 'Branch True Test',
1032
+ Description: 'SetValues sets status=active, IfConditional branches to True.',
1033
+ Graph: {
1034
+ Nodes: [
1035
+ { Hash: 'n-start', Type: 'start', X: 0, Y: 0 },
1036
+ {
1037
+ Hash: 'n-set', Type: 'set-values',
1038
+ Settings: { Mappings: [{ Address: 'Operation.Status', Value: 'active' }] },
1039
+ Ports: [], X: 200, Y: 0
1040
+ },
1041
+ {
1042
+ Hash: 'n-if', Type: 'if-conditional',
1043
+ Settings: { DataAddress: 'Operation.Status', CompareValue: 'active', Operator: '==' },
1044
+ Ports: [], X: 400, Y: 0
1045
+ },
1046
+ {
1047
+ Hash: 'n-write-t', Type: 'write-file',
1048
+ Settings: { FilePath: tmpTruePath, Content: 'Condition was TRUE', Encoding: 'utf8' },
1049
+ Ports: [], X: 600, Y: -100
1050
+ },
1051
+ {
1052
+ Hash: 'n-write-f', Type: 'write-file',
1053
+ Settings: { FilePath: tmpFalsePath, Content: 'Condition was FALSE', Encoding: 'utf8' },
1054
+ Ports: [], X: 600, Y: 100
1055
+ },
1056
+ { Hash: 'n-end', Type: 'end', X: 800, Y: 0 }
1057
+ ],
1058
+ Connections: [
1059
+ { Hash: 'c1', ConnectionType: 'Event', SourceNodeHash: 'n-start', SourcePortHash: 'n-start-eo-Start', TargetNodeHash: 'n-set', TargetPortHash: 'n-set-ei-Execute' },
1060
+ { Hash: 'c2', ConnectionType: 'Event', SourceNodeHash: 'n-set', SourcePortHash: 'n-set-eo-Complete', TargetNodeHash: 'n-if', TargetPortHash: 'n-if-ei-Evaluate' },
1061
+ { Hash: 'c3', ConnectionType: 'Event', SourceNodeHash: 'n-if', SourcePortHash: 'n-if-eo-True', TargetNodeHash: 'n-write-t', TargetPortHash: 'n-write-t-ei-BeginWrite' },
1062
+ { Hash: 'c4', ConnectionType: 'Event', SourceNodeHash: 'n-if', SourcePortHash: 'n-if-eo-False', TargetNodeHash: 'n-write-f', TargetPortHash: 'n-write-f-ei-BeginWrite' },
1063
+ { Hash: 'c5', ConnectionType: 'Event', SourceNodeHash: 'n-write-t', SourcePortHash: 'n-write-t-eo-WriteComplete', TargetNodeHash: 'n-end', TargetPortHash: 'n-end-ei-End' },
1064
+ { Hash: 'c6', ConnectionType: 'Event', SourceNodeHash: 'n-write-f', SourcePortHash: 'n-write-f-eo-WriteComplete', TargetNodeHash: 'n-end', TargetPortHash: 'n-end-ei-End' }
1065
+ ],
1066
+ ViewState: {}
1067
+ }
1068
+ });
1069
+
1070
+ let tmpResult = await apiExecuteOperation(tmpResponse.Hash);
1071
+
1072
+ libAssert.strictEqual(tmpResult.body.Status, 'Complete', 'Branch should complete');
1073
+ libAssert.ok(libFs.existsSync(tmpTruePath), 'True branch file should exist');
1074
+ libAssert.ok(!libFs.existsSync(tmpFalsePath), 'False branch file should NOT exist');
1075
+
1076
+ let tmpContent = libFs.readFileSync(tmpTruePath, 'utf8');
1077
+ libAssert.strictEqual(tmpContent, 'Condition was TRUE', 'True branch content should match');
1078
+
1079
+ console.log(' Branch result: TRUE path taken as expected');
1080
+
1081
+ _TestResults.passed++;
1082
+ }
1083
+ );
1084
+
1085
+ test
1086
+ (
1087
+ 'execute false-branch when condition does not match',
1088
+ async function ()
1089
+ {
1090
+ this.timeout(15000);
1091
+
1092
+ let tmpTestDir = libPath.join(_StagingDir, 'branch_false_test');
1093
+ libFs.mkdirSync(tmpTestDir, { recursive: true });
1094
+ let tmpTruePath = libPath.join(tmpTestDir, 'true_result.txt');
1095
+ let tmpFalsePath = libPath.join(tmpTestDir, 'false_result.txt');
1096
+
1097
+ let tmpResponse = await apiCreateOperation({
1098
+ Name: 'Branch False Test',
1099
+ Description: 'SetValues sets status=inactive, IfConditional branches to False.',
1100
+ Graph: {
1101
+ Nodes: [
1102
+ { Hash: 'n-start', Type: 'start', X: 0, Y: 0 },
1103
+ {
1104
+ Hash: 'n-set', Type: 'set-values',
1105
+ Settings: { Mappings: [{ Address: 'Operation.Status', Value: 'inactive' }] },
1106
+ Ports: [], X: 200, Y: 0
1107
+ },
1108
+ {
1109
+ Hash: 'n-if', Type: 'if-conditional',
1110
+ Settings: { DataAddress: 'Operation.Status', CompareValue: 'active', Operator: '==' },
1111
+ Ports: [], X: 400, Y: 0
1112
+ },
1113
+ {
1114
+ Hash: 'n-write-t', Type: 'write-file',
1115
+ Settings: { FilePath: tmpTruePath, Content: 'TRUE', Encoding: 'utf8' },
1116
+ Ports: [], X: 600, Y: -100
1117
+ },
1118
+ {
1119
+ Hash: 'n-write-f', Type: 'write-file',
1120
+ Settings: { FilePath: tmpFalsePath, Content: 'FALSE', Encoding: 'utf8' },
1121
+ Ports: [], X: 600, Y: 100
1122
+ },
1123
+ { Hash: 'n-end', Type: 'end', X: 800, Y: 0 }
1124
+ ],
1125
+ Connections: [
1126
+ { Hash: 'c1', ConnectionType: 'Event', SourceNodeHash: 'n-start', SourcePortHash: 'n-start-eo-Start', TargetNodeHash: 'n-set', TargetPortHash: 'n-set-ei-Execute' },
1127
+ { Hash: 'c2', ConnectionType: 'Event', SourceNodeHash: 'n-set', SourcePortHash: 'n-set-eo-Complete', TargetNodeHash: 'n-if', TargetPortHash: 'n-if-ei-Evaluate' },
1128
+ { Hash: 'c3', ConnectionType: 'Event', SourceNodeHash: 'n-if', SourcePortHash: 'n-if-eo-True', TargetNodeHash: 'n-write-t', TargetPortHash: 'n-write-t-ei-BeginWrite' },
1129
+ { Hash: 'c4', ConnectionType: 'Event', SourceNodeHash: 'n-if', SourcePortHash: 'n-if-eo-False', TargetNodeHash: 'n-write-f', TargetPortHash: 'n-write-f-ei-BeginWrite' },
1130
+ { Hash: 'c5', ConnectionType: 'Event', SourceNodeHash: 'n-write-t', SourcePortHash: 'n-write-t-eo-WriteComplete', TargetNodeHash: 'n-end', TargetPortHash: 'n-end-ei-End' },
1131
+ { Hash: 'c6', ConnectionType: 'Event', SourceNodeHash: 'n-write-f', SourcePortHash: 'n-write-f-eo-WriteComplete', TargetNodeHash: 'n-end', TargetPortHash: 'n-end-ei-End' }
1132
+ ],
1133
+ ViewState: {}
1134
+ }
1135
+ });
1136
+
1137
+ let tmpResult = await apiExecuteOperation(tmpResponse.Hash);
1138
+
1139
+ libAssert.strictEqual(tmpResult.body.Status, 'Complete', 'Branch should complete');
1140
+ libAssert.ok(!libFs.existsSync(tmpTruePath), 'True branch file should NOT exist');
1141
+ libAssert.ok(libFs.existsSync(tmpFalsePath), 'False branch file should exist');
1142
+
1143
+ let tmpContent = libFs.readFileSync(tmpFalsePath, 'utf8');
1144
+ libAssert.strictEqual(tmpContent, 'FALSE', 'False branch content should match');
1145
+
1146
+ console.log(' Branch result: FALSE path taken as expected');
1147
+
1148
+ _TestResults.passed++;
1149
+ }
1150
+ );
1151
+ }
1152
+ );
1153
+
1154
+ // ════════════════════════════════════════════════
1155
+ // Workflow 4: Error Handling
1156
+ // (read-file error → error-message)
1157
+ // ════════════════════════════════════════════════
1158
+ suite
1159
+ (
1160
+ 'Workflow: Error Handling',
1161
+ function ()
1162
+ {
1163
+ test
1164
+ (
1165
+ 'read-file error triggers error-message node',
1166
+ async function ()
1167
+ {
1168
+ this.timeout(15000);
1169
+
1170
+ let tmpTestDir = libPath.join(_StagingDir, 'error_test');
1171
+ libFs.mkdirSync(tmpTestDir, { recursive: true });
1172
+ let tmpFallbackPath = libPath.join(tmpTestDir, 'fallback.txt');
1173
+
1174
+ let tmpResponse = await apiCreateOperation({
1175
+ Name: 'Error Handling Test',
1176
+ Description: 'ReadFile with bad path fires Error, handled by ErrorMessage + fallback write.',
1177
+ Graph: {
1178
+ Nodes: [
1179
+ { Hash: 'n-start', Type: 'start', X: 0, Y: 0 },
1180
+ {
1181
+ Hash: 'n-read', Type: 'read-file',
1182
+ Settings: { FilePath: '/nonexistent/path/missing.txt', Encoding: 'utf8' },
1183
+ Ports: [], X: 200, Y: 0
1184
+ },
1185
+ {
1186
+ Hash: 'n-err', Type: 'error-message',
1187
+ Settings: { MessageTemplate: 'File read failed — using fallback.' },
1188
+ Ports: [], X: 400, Y: 100
1189
+ },
1190
+ {
1191
+ Hash: 'n-fallback', Type: 'write-file',
1192
+ Settings: { FilePath: tmpFallbackPath, Content: 'Fallback content after error', Encoding: 'utf8' },
1193
+ Ports: [], X: 600, Y: 100
1194
+ },
1195
+ { Hash: 'n-end', Type: 'end', X: 800, Y: 0 }
1196
+ ],
1197
+ Connections: [
1198
+ { Hash: 'c1', ConnectionType: 'Event', SourceNodeHash: 'n-start', SourcePortHash: 'n-start-eo-Start', TargetNodeHash: 'n-read', TargetPortHash: 'n-read-ei-BeginRead' },
1199
+ // Error path: read error → error-message
1200
+ { Hash: 'c2', ConnectionType: 'Event', SourceNodeHash: 'n-read', SourcePortHash: 'n-read-eo-Error', TargetNodeHash: 'n-err', TargetPortHash: 'n-err-ei-Trigger' },
1201
+ // After error message → write fallback
1202
+ { Hash: 'c3', ConnectionType: 'Event', SourceNodeHash: 'n-err', SourcePortHash: 'n-err-eo-Complete', TargetNodeHash: 'n-fallback', TargetPortHash: 'n-fallback-ei-BeginWrite' },
1203
+ { Hash: 'c4', ConnectionType: 'Event', SourceNodeHash: 'n-fallback', SourcePortHash: 'n-fallback-eo-WriteComplete', TargetNodeHash: 'n-end', TargetPortHash: 'n-end-ei-End' }
1204
+ ],
1205
+ ViewState: {}
1206
+ }
1207
+ });
1208
+
1209
+ let tmpResult = await apiExecuteOperation(tmpResponse.Hash);
1210
+
1211
+ libAssert.strictEqual(tmpResult.body.Status, 'Complete', 'Error-handled operation should complete');
1212
+
1213
+ // Verify fallback was written
1214
+ libAssert.ok(libFs.existsSync(tmpFallbackPath), 'Fallback file should be written');
1215
+ let tmpContent = libFs.readFileSync(tmpFallbackPath, 'utf8');
1216
+ libAssert.strictEqual(tmpContent, 'Fallback content after error', 'Fallback content should match');
1217
+
1218
+ // Verify the error was logged
1219
+ let tmpLogs = tmpResult.body.Log || [];
1220
+ let tmpHasErrorLog = tmpLogs.some(function (pL) { return pL.includes('File read failed'); });
1221
+ libAssert.ok(tmpHasErrorLog, 'Error message should appear in logs');
1222
+
1223
+ console.log(' Error handling: fallback written, error logged');
1224
+
1225
+ _TestResults.passed++;
1226
+ }
1227
+ );
1228
+ }
1229
+ );
1230
+
1231
+ // ════════════════════════════════════════════════
1232
+ // Workflow 5: Multi-Value SetValues
1233
+ // (set-values with multiple addresses → if-conditional with contains)
1234
+ // ════════════════════════════════════════════════
1235
+ suite
1236
+ (
1237
+ 'Workflow: Multi-Value SetValues with Contains Operator',
1238
+ function ()
1239
+ {
1240
+ test
1241
+ (
1242
+ 'set multiple values and use contains operator in conditional',
1243
+ async function ()
1244
+ {
1245
+ this.timeout(15000);
1246
+
1247
+ let tmpTestDir = libPath.join(_StagingDir, 'setvals_test');
1248
+ libFs.mkdirSync(tmpTestDir, { recursive: true });
1249
+ let tmpResultPath = libPath.join(tmpTestDir, 'result.txt');
1250
+
1251
+ let tmpResponse = await apiCreateOperation({
1252
+ Name: 'Multi SetValues Test',
1253
+ Description: 'Sets multiple operation values, then uses contains to check.',
1254
+ Graph: {
1255
+ Nodes: [
1256
+ { Hash: 'n-start', Type: 'start', X: 0, Y: 0 },
1257
+ {
1258
+ Hash: 'n-set', Type: 'set-values',
1259
+ Settings: {
1260
+ Mappings: [
1261
+ { Address: 'Operation.Greeting', Value: 'Hello World from Ultravisor' },
1262
+ { Address: 'Operation.Counter', Value: 42 },
1263
+ { Address: 'Global.AppVersion', Value: '2.0' }
1264
+ ]
1265
+ },
1266
+ Ports: [], X: 200, Y: 0
1267
+ },
1268
+ {
1269
+ Hash: 'n-if', Type: 'if-conditional',
1270
+ Settings: { DataAddress: 'Operation.Greeting', CompareValue: 'Ultravisor', Operator: 'contains' },
1271
+ Ports: [], X: 400, Y: 0
1272
+ },
1273
+ {
1274
+ Hash: 'n-write', Type: 'write-file',
1275
+ Settings: { FilePath: tmpResultPath, Content: 'Contains matched', Encoding: 'utf8' },
1276
+ Ports: [], X: 600, Y: 0
1277
+ },
1278
+ { Hash: 'n-end', Type: 'end', X: 800, Y: 0 }
1279
+ ],
1280
+ Connections: [
1281
+ { Hash: 'c1', ConnectionType: 'Event', SourceNodeHash: 'n-start', SourcePortHash: 'n-start-eo-Start', TargetNodeHash: 'n-set', TargetPortHash: 'n-set-ei-Execute' },
1282
+ { Hash: 'c2', ConnectionType: 'Event', SourceNodeHash: 'n-set', SourcePortHash: 'n-set-eo-Complete', TargetNodeHash: 'n-if', TargetPortHash: 'n-if-ei-Evaluate' },
1283
+ { Hash: 'c3', ConnectionType: 'Event', SourceNodeHash: 'n-if', SourcePortHash: 'n-if-eo-True', TargetNodeHash: 'n-write', TargetPortHash: 'n-write-ei-BeginWrite' },
1284
+ { Hash: 'c4', ConnectionType: 'Event', SourceNodeHash: 'n-write', SourcePortHash: 'n-write-eo-WriteComplete', TargetNodeHash: 'n-end', TargetPortHash: 'n-end-ei-End' }
1285
+ ],
1286
+ ViewState: {}
1287
+ }
1288
+ });
1289
+
1290
+ let tmpResult = await apiExecuteOperation(tmpResponse.Hash);
1291
+
1292
+ libAssert.strictEqual(tmpResult.body.Status, 'Complete', 'Multi-value operation should complete');
1293
+ libAssert.ok(libFs.existsSync(tmpResultPath), 'Result file should exist (contains matched)');
1294
+
1295
+ let tmpContent = libFs.readFileSync(tmpResultPath, 'utf8');
1296
+ libAssert.strictEqual(tmpContent, 'Contains matched', 'Contains operator should work');
1297
+
1298
+ console.log(' Multi-SetValues with contains: passed');
1299
+
1300
+ _TestResults.passed++;
1301
+ }
1302
+ );
1303
+ }
1304
+ );
1305
+
1306
+ // ════════════════════════════════════════════════
1307
+ // Workflow 6: String Replace Pipeline
1308
+ // (read → replace → write via API)
1309
+ // ════════════════════════════════════════════════
1310
+ suite
1311
+ (
1312
+ 'Workflow: String Replace Pipeline',
1313
+ function ()
1314
+ {
1315
+ test
1316
+ (
1317
+ 'replace all occurrences in a file',
1318
+ async function ()
1319
+ {
1320
+ this.timeout(15000);
1321
+
1322
+ let tmpTestDir = libPath.join(_StagingDir, 'replace_test');
1323
+ libFs.mkdirSync(tmpTestDir, { recursive: true });
1324
+ let tmpInputPath = libPath.join(tmpTestDir, 'input.txt');
1325
+ let tmpOutputPath = libPath.join(tmpTestDir, 'output.txt');
1326
+ libFs.writeFileSync(tmpInputPath, 'The quick brown fox jumps over the lazy fox. Fox is clever.', 'utf8');
1327
+
1328
+ let tmpResponse = await apiCreateOperation({
1329
+ Name: 'Replace Pipeline',
1330
+ Description: 'Reads file, replaces fox→cat, writes output.',
1331
+ Graph: {
1332
+ Nodes: [
1333
+ { Hash: 'n-start', Type: 'start', X: 0, Y: 0 },
1334
+ {
1335
+ Hash: 'n-read', Type: 'read-file',
1336
+ Settings: { FilePath: tmpInputPath, Encoding: 'utf8' },
1337
+ Ports: [], X: 200, Y: 0
1338
+ },
1339
+ {
1340
+ Hash: 'n-replace', Type: 'replace-string',
1341
+ Settings: { SearchString: 'fox', ReplaceString: 'cat' },
1342
+ Ports: [], X: 400, Y: 0
1343
+ },
1344
+ {
1345
+ Hash: 'n-write', Type: 'write-file',
1346
+ Settings: { FilePath: tmpOutputPath, Encoding: 'utf8' },
1347
+ Ports: [], X: 600, Y: 0
1348
+ },
1349
+ { Hash: 'n-end', Type: 'end', X: 800, Y: 0 }
1350
+ ],
1351
+ Connections: [
1352
+ { Hash: 'c1', ConnectionType: 'Event', SourceNodeHash: 'n-start', SourcePortHash: 'n-start-eo-Start', TargetNodeHash: 'n-read', TargetPortHash: 'n-read-ei-BeginRead' },
1353
+ { Hash: 'c2', ConnectionType: 'Event', SourceNodeHash: 'n-read', SourcePortHash: 'n-read-eo-ReadComplete', TargetNodeHash: 'n-replace', TargetPortHash: 'n-replace-ei-Replace' },
1354
+ { Hash: 'c3', ConnectionType: 'State', SourceNodeHash: 'n-read', SourcePortHash: 'n-read-so-FileContent', TargetNodeHash: 'n-replace', TargetPortHash: 'n-replace-si-InputString' },
1355
+ { Hash: 'c4', ConnectionType: 'Event', SourceNodeHash: 'n-replace', SourcePortHash: 'n-replace-eo-ReplaceComplete', TargetNodeHash: 'n-write', TargetPortHash: 'n-write-ei-BeginWrite' },
1356
+ { Hash: 'c5', ConnectionType: 'State', SourceNodeHash: 'n-replace', SourcePortHash: 'n-replace-so-ReplacedString', TargetNodeHash: 'n-write', TargetPortHash: 'n-write-si-Content' },
1357
+ { Hash: 'c6', ConnectionType: 'Event', SourceNodeHash: 'n-write', SourcePortHash: 'n-write-eo-WriteComplete', TargetNodeHash: 'n-end', TargetPortHash: 'n-end-ei-End' }
1358
+ ],
1359
+ ViewState: {}
1360
+ }
1361
+ });
1362
+
1363
+ let tmpResult = await apiExecuteOperation(tmpResponse.Hash);
1364
+
1365
+ libAssert.strictEqual(tmpResult.body.Status, 'Complete', 'Replace pipeline should complete');
1366
+
1367
+ let tmpOutput = libFs.readFileSync(tmpOutputPath, 'utf8');
1368
+ libAssert.ok(tmpOutput.includes('cat'), 'Output should contain "cat"');
1369
+ libAssert.ok(!tmpOutput.includes('fox'), 'Output should not contain "fox"');
1370
+ libAssert.ok(tmpOutput.includes('The quick brown cat'), 'First replacement should match');
1371
+
1372
+ // Note: Fox (capital) won't be replaced since replace-string is case-sensitive
1373
+ console.log(' Replace output:', JSON.stringify(tmpOutput));
1374
+
1375
+ _TestResults.passed++;
1376
+ }
1377
+ );
1378
+ }
1379
+ );
1380
+
1381
+ // ════════════════════════════════════════════════
1382
+ // Workflow 7: Looping Pipeline (via Flow Editor)
1383
+ // (read → split → replace → append → write)
1384
+ // ════════════════════════════════════════════════
1385
+ suite
1386
+ (
1387
+ 'Workflow: Looping Pipeline via Flow Editor',
1388
+ function ()
1389
+ {
1390
+ let _WorkflowInputPath = '';
1391
+ let _WorkflowOutputPath = '';
1392
+ let _SavedOperationHash = '';
1393
+
1394
+ test
1395
+ (
1396
+ 'create test input file for workflow',
1397
+ function ()
1398
+ {
1399
+ this.timeout(5000);
1400
+
1401
+ let tmpStagingDir = libPath.join(_StagingDir, 'workflow_test');
1402
+ libFs.mkdirSync(tmpStagingDir, { recursive: true });
1403
+
1404
+ _WorkflowInputPath = libPath.join(tmpStagingDir, 'input.txt');
1405
+ _WorkflowOutputPath = libPath.join(tmpStagingDir, 'output.txt');
1406
+
1407
+ libFs.writeFileSync(_WorkflowInputPath,
1408
+ 'Hello John Smith\nJohn went to the store\nMary and John had lunch\nNo match here\n',
1409
+ 'utf8');
1410
+
1411
+ libAssert.ok(libFs.existsSync(_WorkflowInputPath), 'Input file should exist');
1412
+ _TestResults.passed++;
1413
+ }
1414
+ );
1415
+
1416
+ test
1417
+ (
1418
+ 'navigate to flow editor and build workflow',
1419
+ async function ()
1420
+ {
1421
+ this.timeout(30000);
1422
+
1423
+ await _Page.evaluate(() =>
1424
+ {
1425
+ if (window._Pict && window._Pict.PictApplication)
1426
+ {
1427
+ window._Pict.PictApplication.navigateTo('/FlowEditor');
1428
+ }
1429
+ });
1430
+ await settle(1500);
1431
+
1432
+ await takeScreenshot('workflow-editor-empty');
1433
+
1434
+ let tmpFlowData = await _Page.evaluate((pInputPath, pOutputPath) =>
1435
+ {
1436
+ let tmpGraph =
1437
+ {
1438
+ Nodes:
1439
+ [
1440
+ {
1441
+ Hash: 'wf-start', Type: 'start',
1442
+ X: 50, Y: 200, Width: 140, Height: 80,
1443
+ Title: 'Start',
1444
+ Ports: [{ Hash: 'wf-start-eo-Start', Direction: 'output', Side: 'right', Label: 'Out' }],
1445
+ Settings: {}
1446
+ },
1447
+ {
1448
+ Hash: 'wf-read', Type: 'read-file',
1449
+ X: 260, Y: 180, Width: 200, Height: 100,
1450
+ Title: 'Load Input File',
1451
+ Ports: [
1452
+ { Hash: 'wf-read-ei-BeginRead', Direction: 'input', Side: 'left-bottom', Label: 'BeginRead' },
1453
+ { Hash: 'wf-read-eo-ReadComplete', Direction: 'output', Side: 'right', Label: 'ReadComplete' },
1454
+ { Hash: 'wf-read-so-FileContent', Direction: 'output', Side: 'right-top', Label: 'FileContent' },
1455
+ { Hash: 'wf-read-eo-Error', Direction: 'output', Side: 'bottom', Label: 'Error' }
1456
+ ],
1457
+ Settings: { FilePath: pInputPath, Encoding: 'utf8' }
1458
+ },
1459
+ {
1460
+ Hash: 'wf-split', Type: 'split-execute',
1461
+ X: 540, Y: 160, Width: 240, Height: 120,
1462
+ Title: 'Split Lines',
1463
+ Ports: [
1464
+ { Hash: 'wf-split-ei-PerformSplit', Direction: 'input', Side: 'left-bottom', Label: 'PerformSplit' },
1465
+ { Hash: 'wf-split-ei-StepComplete', Direction: 'input', Side: 'left-bottom', Label: 'StepComplete' },
1466
+ { Hash: 'wf-split-si-InputString', Direction: 'input', Side: 'left-top', Label: 'InputString' },
1467
+ { Hash: 'wf-split-eo-TokenDataSent', Direction: 'output', Side: 'right', Label: 'TokenDataSent' },
1468
+ { Hash: 'wf-split-so-CurrentToken', Direction: 'output', Side: 'right-top', Label: 'CurrentToken' },
1469
+ { Hash: 'wf-split-eo-CompletedAllSubtasks', Direction: 'output', Side: 'right-bottom', Label: 'CompletedAllSubtasks' }
1470
+ ],
1471
+ Settings: {}
1472
+ },
1473
+ {
1474
+ Hash: 'wf-replace', Type: 'replace-string',
1475
+ X: 860, Y: 160, Width: 220, Height: 100,
1476
+ Title: 'Replace John with Jane',
1477
+ Ports: [
1478
+ { Hash: 'wf-replace-si-InputString', Direction: 'input', Side: 'left-top', Label: 'InputString' },
1479
+ { Hash: 'wf-replace-ei-Replace', Direction: 'input', Side: 'left-bottom', Label: 'Replace' },
1480
+ { Hash: 'wf-replace-eo-ReplaceComplete', Direction: 'output', Side: 'right', Label: 'ReplaceComplete' },
1481
+ { Hash: 'wf-replace-so-ReplacedString', Direction: 'output', Side: 'right-top', Label: 'ReplacedString' }
1482
+ ],
1483
+ Settings: { SearchString: 'John', ReplaceString: 'Jane' }
1484
+ },
1485
+ {
1486
+ Hash: 'wf-append', Type: 'string-appender',
1487
+ X: 1160, Y: 160, Width: 220, Height: 100,
1488
+ Title: 'Append Line',
1489
+ Ports: [
1490
+ { Hash: 'wf-append-ei-Append', Direction: 'input', Side: 'left-bottom', Label: 'Append' },
1491
+ { Hash: 'wf-append-si-InputString', Direction: 'input', Side: 'left-top', Label: 'InputString' },
1492
+ { Hash: 'wf-append-eo-Completed', Direction: 'output', Side: 'right', Label: 'Completed' },
1493
+ { Hash: 'wf-append-so-AppendedString', Direction: 'output', Side: 'right-top', Label: 'AppendedString' }
1494
+ ],
1495
+ Settings: { OutputAddress: 'Operation.OutputFileContents', AppendNewline: true }
1496
+ },
1497
+ {
1498
+ Hash: 'wf-write', Type: 'write-file',
1499
+ X: 860, Y: 380, Width: 220, Height: 80,
1500
+ Title: 'Save Output File',
1501
+ Ports: [
1502
+ { Hash: 'wf-write-si-Content', Direction: 'input', Side: 'left-top', Label: 'Content' },
1503
+ { Hash: 'wf-write-ei-BeginWrite', Direction: 'input', Side: 'left-bottom', Label: 'BeginWrite' },
1504
+ { Hash: 'wf-write-eo-WriteComplete', Direction: 'output', Side: 'right', Label: 'WriteComplete' },
1505
+ { Hash: 'wf-write-eo-Error', Direction: 'output', Side: 'bottom', Label: 'Error' }
1506
+ ],
1507
+ Settings: { FilePath: pOutputPath, Encoding: 'utf8' }
1508
+ },
1509
+ {
1510
+ Hash: 'wf-end', Type: 'end',
1511
+ X: 1160, Y: 380, Width: 140, Height: 80,
1512
+ Title: 'End',
1513
+ Ports: [{ Hash: 'wf-end-ei-In', Direction: 'input', Side: 'left-bottom', Label: 'In' }],
1514
+ Settings: {}
1515
+ }
1516
+ ],
1517
+ Connections:
1518
+ [
1519
+ { Hash: 'wf-ev1', ConnectionType: 'Event', SourceNodeHash: 'wf-start', SourcePortHash: 'wf-start-eo-Start', TargetNodeHash: 'wf-read', TargetPortHash: 'wf-read-ei-BeginRead', Data: {} },
1520
+ { Hash: 'wf-ev2', ConnectionType: 'Event', SourceNodeHash: 'wf-read', SourcePortHash: 'wf-read-eo-ReadComplete', TargetNodeHash: 'wf-split', TargetPortHash: 'wf-split-ei-PerformSplit', Data: {} },
1521
+ { Hash: 'wf-ev3', ConnectionType: 'Event', SourceNodeHash: 'wf-split', SourcePortHash: 'wf-split-eo-TokenDataSent', TargetNodeHash: 'wf-replace', TargetPortHash: 'wf-replace-ei-Replace', Data: {} },
1522
+ { Hash: 'wf-ev4', ConnectionType: 'Event', SourceNodeHash: 'wf-replace', SourcePortHash: 'wf-replace-eo-ReplaceComplete', TargetNodeHash: 'wf-append', TargetPortHash: 'wf-append-ei-Append', Data: {} },
1523
+ { Hash: 'wf-ev5', ConnectionType: 'Event', SourceNodeHash: 'wf-append', SourcePortHash: 'wf-append-eo-Completed', TargetNodeHash: 'wf-split', TargetPortHash: 'wf-split-ei-StepComplete', Data: {} },
1524
+ { Hash: 'wf-ev6', ConnectionType: 'Event', SourceNodeHash: 'wf-split', SourcePortHash: 'wf-split-eo-CompletedAllSubtasks', TargetNodeHash: 'wf-write', TargetPortHash: 'wf-write-ei-BeginWrite', Data: {} },
1525
+ { Hash: 'wf-ev7', ConnectionType: 'Event', SourceNodeHash: 'wf-write', SourcePortHash: 'wf-write-eo-WriteComplete', TargetNodeHash: 'wf-end', TargetPortHash: 'wf-end-ei-In', Data: {} },
1526
+ { Hash: 'wf-st1', ConnectionType: 'State', SourceNodeHash: 'wf-read', SourcePortHash: 'wf-read-so-FileContent', TargetNodeHash: 'wf-split', TargetPortHash: 'wf-split-si-InputString', Data: {} },
1527
+ { Hash: 'wf-st2', ConnectionType: 'State', SourceNodeHash: 'wf-split', SourcePortHash: 'wf-split-so-CurrentToken', TargetNodeHash: 'wf-replace', TargetPortHash: 'wf-replace-si-InputString', Data: {} },
1528
+ { Hash: 'wf-st3', ConnectionType: 'State', SourceNodeHash: 'wf-replace', SourcePortHash: 'wf-replace-so-ReplacedString', TargetNodeHash: 'wf-append', TargetPortHash: 'wf-append-si-InputString', Data: {} },
1529
+ { Hash: 'wf-st4', ConnectionType: 'State', SourceNodeHash: 'wf-append', SourcePortHash: 'wf-append-so-AppendedString', TargetNodeHash: 'wf-write', TargetPortHash: 'wf-write-si-Content', Data: {} }
1530
+ ],
1531
+ ViewState: { PanX: 0, PanY: 0, Zoom: 1, SelectedNodeHash: null, SelectedConnectionHash: null }
1532
+ };
1533
+
1534
+ let tmpPict = window._Pict;
1535
+ let tmpFlowEditor = tmpPict.views['Ultravisor-FlowEditor'];
1536
+ if (tmpFlowEditor && tmpFlowEditor._FlowView)
1537
+ {
1538
+ tmpPict.AppData.Ultravisor.Flows.Current = JSON.parse(JSON.stringify(tmpGraph));
1539
+ tmpFlowEditor._FlowView.setFlowData(tmpPict.AppData.Ultravisor.Flows.Current);
1540
+ return { success: true, nodeCount: tmpGraph.Nodes.length, connectionCount: tmpGraph.Connections.length };
1541
+ }
1542
+ return { success: false, error: 'Flow view not initialized' };
1543
+ }, _WorkflowInputPath, _WorkflowOutputPath);
1544
+
1545
+ libAssert.ok(tmpFlowData.success, 'Flow data should be injected: ' + JSON.stringify(tmpFlowData));
1546
+ libAssert.strictEqual(tmpFlowData.nodeCount, 7, 'Should have 7 nodes');
1547
+ libAssert.strictEqual(tmpFlowData.connectionCount, 11, 'Should have 11 connections');
1548
+
1549
+ await settle(500);
1550
+ await takeScreenshot('workflow-editor-built');
1551
+ _TestResults.passed++;
1552
+ }
1553
+ );
1554
+
1555
+ test
1556
+ (
1557
+ 'zoom to fit and verify port rendering',
1558
+ async function ()
1559
+ {
1560
+ this.timeout(15000);
1561
+
1562
+ await _Page.evaluate(() =>
1563
+ {
1564
+ let tmpPict = window._Pict;
1565
+ let tmpFlowEditor = tmpPict.views['Ultravisor-FlowEditor'];
1566
+ if (tmpFlowEditor && tmpFlowEditor._FlowView && tmpFlowEditor._FlowView._ViewportManager)
1567
+ {
1568
+ tmpFlowEditor._FlowView._ViewportManager.zoomToFit();
1569
+ }
1570
+ });
1571
+ await settle(500);
1572
+
1573
+ let tmpPortInfo = await _Page.evaluate(() =>
1574
+ {
1575
+ let tmpPorts = document.querySelectorAll('.pict-flow-port');
1576
+ let tmpByType = {};
1577
+ tmpPorts.forEach((pEl) =>
1578
+ {
1579
+ let tmpType = pEl.getAttribute('data-port-type') || pEl.getAttribute('data-port-direction') || 'unknown';
1580
+ if (!tmpByType[tmpType]) tmpByType[tmpType] = 0;
1581
+ tmpByType[tmpType]++;
1582
+ });
1583
+ return { total: tmpPorts.length, byType: tmpByType };
1584
+ });
1585
+
1586
+ console.log(' Port breakdown:', JSON.stringify(tmpPortInfo.byType));
1587
+ libAssert.ok(tmpPortInfo.total > 0, 'Should render ports on the SVG');
1588
+
1589
+ await takeScreenshot('workflow-zoomed');
1590
+ _TestResults.passed++;
1591
+ }
1592
+ );
1593
+
1594
+ test
1595
+ (
1596
+ 'save the operation via the UI',
1597
+ async function ()
1598
+ {
1599
+ this.timeout(15000);
1600
+
1601
+ await _Page.evaluate(() =>
1602
+ {
1603
+ let tmpNameEl = document.getElementById('Ultravisor-FlowEditor-Name');
1604
+ if (tmpNameEl) tmpNameEl.value = 'Workflow Test - Line Replace';
1605
+ let tmpDescEl = document.getElementById('Ultravisor-FlowEditor-Description');
1606
+ if (tmpDescEl) tmpDescEl.value = 'Reads a file, replaces John with Jane per-line, writes output.';
1607
+ });
1608
+
1609
+ await _Page.evaluate(() =>
1610
+ {
1611
+ window._lastAlert = null;
1612
+ window.alert = function (pMsg) { window._lastAlert = pMsg; };
1613
+ });
1614
+
1615
+ await _Page.evaluate(() =>
1616
+ {
1617
+ let tmpPict = window._Pict;
1618
+ tmpPict.views['Ultravisor-FlowEditor'].saveOperation();
1619
+ });
1620
+ await settle(2000);
1621
+
1622
+ let tmpSaveResult = await _Page.evaluate(() =>
1623
+ {
1624
+ let tmpHashEl = document.getElementById('Ultravisor-FlowEditor-HashDisplay');
1625
+ return {
1626
+ alertMessage: window._lastAlert,
1627
+ operationHash: tmpHashEl ? tmpHashEl.textContent : ''
1628
+ };
1629
+ });
1630
+
1631
+ console.log(' Save result:', tmpSaveResult.alertMessage, '| Hash:', tmpSaveResult.operationHash);
1632
+ libAssert.ok(tmpSaveResult.alertMessage && tmpSaveResult.alertMessage.includes('saved'), 'Should show saved alert');
1633
+ libAssert.ok(tmpSaveResult.operationHash.length > 0, 'Should have an operation hash');
1634
+
1635
+ _SavedOperationHash = tmpSaveResult.operationHash;
1636
+
1637
+ await takeScreenshot('workflow-saved');
1638
+ _TestResults.passed++;
1639
+ }
1640
+ );
1641
+
1642
+ test
1643
+ (
1644
+ 'execute the looping pipeline via the API',
1645
+ async function ()
1646
+ {
1647
+ this.timeout(30000);
1648
+
1649
+ libAssert.ok(_SavedOperationHash, 'Must have a saved operation hash');
1650
+
1651
+ let tmpResult = await apiExecuteOperation(_SavedOperationHash);
1652
+
1653
+ libAssert.strictEqual(tmpResult.body.Status, 'Complete', 'Execution should complete successfully');
1654
+ console.log(' Execution status:', tmpResult.body.Status, '| Log entries:', (tmpResult.body.Log || []).length);
1655
+
1656
+ _TestResults.passed++;
1657
+ }
1658
+ );
1659
+
1660
+ test
1661
+ (
1662
+ 'verify the output file has correct content',
1663
+ function ()
1664
+ {
1665
+ this.timeout(5000);
1666
+
1667
+ libAssert.ok(libFs.existsSync(_WorkflowOutputPath),
1668
+ 'Output file should exist at: ' + _WorkflowOutputPath);
1669
+
1670
+ let tmpOutputContent = libFs.readFileSync(_WorkflowOutputPath, 'utf8');
1671
+ console.log(' Output content:', JSON.stringify(tmpOutputContent));
1672
+
1673
+ libAssert.ok(tmpOutputContent.includes('Jane'), 'Output should contain "Jane"');
1674
+ libAssert.ok(!tmpOutputContent.includes('John'), 'Output should not contain "John"');
1675
+ libAssert.ok(tmpOutputContent.includes('Hello Jane Smith'), 'First line correct');
1676
+ libAssert.ok(tmpOutputContent.includes('Jane went to the store'), 'Second line correct');
1677
+ libAssert.ok(tmpOutputContent.includes('Mary and Jane had lunch'), 'Third line correct');
1678
+ libAssert.ok(tmpOutputContent.includes('No match here'), 'Fourth line unchanged');
1679
+
1680
+ _TestResults.passed++;
1681
+ }
1682
+ );
1683
+ }
1684
+ );
1685
+
1686
+ // ════════════════════════════════════════════════
1687
+ // Workflow 8: Sub-Operation (launch-operation)
1688
+ // ════════════════════════════════════════════════
1689
+ suite
1690
+ (
1691
+ 'Workflow: Sub-Operation via launch-operation',
1692
+ function ()
1693
+ {
1694
+ let _ChildOpHash = '';
1695
+
1696
+ test
1697
+ (
1698
+ 'create a child operation that writes a file',
1699
+ async function ()
1700
+ {
1701
+ this.timeout(15000);
1702
+
1703
+ let tmpTestDir = libPath.join(_StagingDir, 'subop_test');
1704
+ libFs.mkdirSync(tmpTestDir, { recursive: true });
1705
+ let tmpChildOutputPath = libPath.join(tmpTestDir, 'child_output.txt');
1706
+
1707
+ let tmpResponse = await apiCreateOperation({
1708
+ Hash: 'CHILD-OP-001',
1709
+ Name: 'Child Operation',
1710
+ Description: 'Simple child op that writes a marker file.',
1711
+ Graph: {
1712
+ Nodes: [
1713
+ { Hash: 'n-start', Type: 'start', X: 0, Y: 0 },
1714
+ {
1715
+ Hash: 'n-write', Type: 'write-file',
1716
+ Settings: { FilePath: tmpChildOutputPath, Content: 'Written by child operation', Encoding: 'utf8' },
1717
+ Ports: [], X: 200, Y: 0
1718
+ },
1719
+ { Hash: 'n-end', Type: 'end', X: 400, Y: 0 }
1720
+ ],
1721
+ Connections: [
1722
+ { Hash: 'c1', ConnectionType: 'Event', SourceNodeHash: 'n-start', SourcePortHash: 'n-start-eo-Start', TargetNodeHash: 'n-write', TargetPortHash: 'n-write-ei-BeginWrite' },
1723
+ { Hash: 'c2', ConnectionType: 'Event', SourceNodeHash: 'n-write', SourcePortHash: 'n-write-eo-WriteComplete', TargetNodeHash: 'n-end', TargetPortHash: 'n-end-ei-End' }
1724
+ ],
1725
+ ViewState: {}
1726
+ }
1727
+ });
1728
+
1729
+ _ChildOpHash = tmpResponse.Hash;
1730
+ libAssert.ok(_ChildOpHash, 'Child operation should be created');
1731
+ console.log(' Child operation:', _ChildOpHash);
1732
+
1733
+ _TestResults.passed++;
1734
+ }
1735
+ );
1736
+
1737
+ test
1738
+ (
1739
+ 'create and execute parent operation that launches the child',
1740
+ async function ()
1741
+ {
1742
+ this.timeout(15000);
1743
+
1744
+ let tmpTestDir = libPath.join(_StagingDir, 'subop_test');
1745
+ let tmpParentOutputPath = libPath.join(tmpTestDir, 'parent_output.txt');
1746
+
1747
+ let tmpResponse = await apiCreateOperation({
1748
+ Name: 'Parent Operation',
1749
+ Description: 'Launches child operation then writes its own marker.',
1750
+ Graph: {
1751
+ Nodes: [
1752
+ { Hash: 'n-start', Type: 'start', X: 0, Y: 0 },
1753
+ {
1754
+ Hash: 'n-launch', Type: 'launch-operation',
1755
+ Settings: { OperationHash: _ChildOpHash },
1756
+ Ports: [], X: 200, Y: 0
1757
+ },
1758
+ {
1759
+ Hash: 'n-write', Type: 'write-file',
1760
+ Settings: { FilePath: tmpParentOutputPath, Content: 'Parent completed after child', Encoding: 'utf8' },
1761
+ Ports: [], X: 400, Y: 0
1762
+ },
1763
+ { Hash: 'n-end', Type: 'end', X: 600, Y: 0 }
1764
+ ],
1765
+ Connections: [
1766
+ { Hash: 'c1', ConnectionType: 'Event', SourceNodeHash: 'n-start', SourcePortHash: 'n-start-eo-Start', TargetNodeHash: 'n-launch', TargetPortHash: 'n-launch-ei-Launch' },
1767
+ { Hash: 'c2', ConnectionType: 'Event', SourceNodeHash: 'n-launch', SourcePortHash: 'n-launch-eo-Completed', TargetNodeHash: 'n-write', TargetPortHash: 'n-write-ei-BeginWrite' },
1768
+ { Hash: 'c3', ConnectionType: 'Event', SourceNodeHash: 'n-write', SourcePortHash: 'n-write-eo-WriteComplete', TargetNodeHash: 'n-end', TargetPortHash: 'n-end-ei-End' }
1769
+ ],
1770
+ ViewState: {}
1771
+ }
1772
+ });
1773
+
1774
+ let tmpResult = await apiExecuteOperation(tmpResponse.Hash);
1775
+
1776
+ libAssert.strictEqual(tmpResult.body.Status, 'Complete', 'Parent operation should complete');
1777
+
1778
+ // Verify the launch-operation outputs
1779
+ let tmpLaunchOutputs = tmpResult.body.TaskOutputs['n-launch'];
1780
+ libAssert.ok(tmpLaunchOutputs, 'Should have launch-operation outputs');
1781
+ libAssert.strictEqual(tmpLaunchOutputs.Status, 'Complete', 'Child status should be Complete');
1782
+ libAssert.ok(tmpLaunchOutputs.ElapsedMs >= 0, 'Should track child elapsed time');
1783
+
1784
+ // Verify both files were written
1785
+ let tmpChildOutputPath = libPath.join(tmpTestDir, 'child_output.txt');
1786
+ libAssert.ok(libFs.existsSync(tmpChildOutputPath), 'Child output should exist');
1787
+ libAssert.ok(libFs.existsSync(tmpParentOutputPath), 'Parent output should exist');
1788
+
1789
+ let tmpChildContent = libFs.readFileSync(tmpChildOutputPath, 'utf8');
1790
+ let tmpParentContent = libFs.readFileSync(tmpParentOutputPath, 'utf8');
1791
+ libAssert.strictEqual(tmpChildContent, 'Written by child operation', 'Child content matches');
1792
+ libAssert.strictEqual(tmpParentContent, 'Parent completed after child', 'Parent content matches');
1793
+
1794
+ console.log(' Sub-operation: child and parent both completed');
1795
+ console.log(' Child elapsed:', tmpLaunchOutputs.ElapsedMs, 'ms');
1796
+
1797
+ _TestResults.passed++;
1798
+ }
1799
+ );
1800
+ }
1801
+ );
1802
+
1803
+ // ════════════════════════════════════════════════
1804
+ // Schedule API
1805
+ // ════════════════════════════════════════════════
1806
+ suite
1807
+ (
1808
+ 'Schedule API',
1809
+ function ()
1810
+ {
1811
+ let _ScheduledOpHash = '';
1812
+ let _ScheduleEntryGUID = '';
1813
+
1814
+ test
1815
+ (
1816
+ 'create an operation for scheduling',
1817
+ async function ()
1818
+ {
1819
+ this.timeout(10000);
1820
+
1821
+ let tmpResponse = await apiCreateOperation({
1822
+ Name: 'Schedulable Operation',
1823
+ Description: 'A simple operation for schedule testing.',
1824
+ Graph: {
1825
+ Nodes: [
1826
+ { Hash: 'n-start', Type: 'start', X: 0, Y: 0 },
1827
+ { Hash: 'n-end', Type: 'end', X: 200, Y: 0 }
1828
+ ],
1829
+ Connections: [
1830
+ {
1831
+ Hash: 'c1', ConnectionType: 'Event',
1832
+ SourceNodeHash: 'n-start', SourcePortHash: 'n-start-eo-Start',
1833
+ TargetNodeHash: 'n-end', TargetPortHash: 'n-end-ei-End'
1834
+ }
1835
+ ],
1836
+ ViewState: {}
1837
+ }
1838
+ });
1839
+
1840
+ _ScheduledOpHash = tmpResponse.Hash;
1841
+ libAssert.ok(_ScheduledOpHash, 'Schedulable operation created');
1842
+
1843
+ _TestResults.passed++;
1844
+ }
1845
+ );
1846
+
1847
+ test
1848
+ (
1849
+ 'schedule an operation via POST /Schedule/Operation',
1850
+ async function ()
1851
+ {
1852
+ this.timeout(10000);
1853
+
1854
+ let tmpResponse = await apiPost('/Schedule/Operation', {
1855
+ Hash: _ScheduledOpHash,
1856
+ ScheduleType: 'cron',
1857
+ Parameters: '0 */6 * * *'
1858
+ });
1859
+
1860
+ libAssert.strictEqual(tmpResponse.status, 200, 'Schedule should succeed');
1861
+ libAssert.ok(tmpResponse.body.GUID, 'Schedule entry should have a GUID');
1862
+ libAssert.strictEqual(tmpResponse.body.TargetHash, _ScheduledOpHash, 'TargetHash should match');
1863
+ libAssert.strictEqual(tmpResponse.body.TargetType, 'Operation', 'TargetType should be Operation');
1864
+
1865
+ _ScheduleEntryGUID = tmpResponse.body.GUID;
1866
+ console.log(' Schedule entry:', _ScheduleEntryGUID);
1867
+
1868
+ _TestResults.passed++;
1869
+ }
1870
+ );
1871
+
1872
+ test
1873
+ (
1874
+ 'list schedule via GET /Schedule',
1875
+ async function ()
1876
+ {
1877
+ this.timeout(10000);
1878
+
1879
+ let tmpResponse = await apiGet('/Schedule');
1880
+
1881
+ libAssert.ok(Array.isArray(tmpResponse.body), 'Should return an array');
1882
+ libAssert.ok(tmpResponse.body.length >= 1, 'Should have at least one entry');
1883
+
1884
+ let tmpEntry = tmpResponse.body.find(function (pE) { return pE.GUID === _ScheduleEntryGUID; });
1885
+ libAssert.ok(tmpEntry, 'Our schedule entry should appear in list');
1886
+
1887
+ _TestResults.passed++;
1888
+ }
1889
+ );
1890
+
1891
+ test
1892
+ (
1893
+ 'remove schedule entry via DELETE /Schedule/:GUID',
1894
+ async function ()
1895
+ {
1896
+ this.timeout(10000);
1897
+
1898
+ let tmpResponse = await apiDelete('/Schedule/' + _ScheduleEntryGUID);
1899
+
1900
+ libAssert.strictEqual(tmpResponse.body.Status, 'Deleted', 'Should confirm deletion');
1901
+
1902
+ // Verify it is gone
1903
+ let tmpListResponse = await apiGet('/Schedule');
1904
+ let tmpEntry = tmpListResponse.body.find(function (pE) { return pE.GUID === _ScheduleEntryGUID; });
1905
+ libAssert.ok(!tmpEntry, 'Entry should be removed from schedule');
1906
+
1907
+ _TestResults.passed++;
1908
+ }
1909
+ );
1910
+ }
1911
+ );
1912
+
1913
+ // ════════════════════════════════════════════════
1914
+ // Manifest API
1915
+ // ════════════════════════════════════════════════
1916
+ suite
1917
+ (
1918
+ 'Manifest API',
1919
+ function ()
1920
+ {
1921
+ test
1922
+ (
1923
+ 'list execution manifests via GET /Manifest',
1924
+ async function ()
1925
+ {
1926
+ this.timeout(10000);
1927
+
1928
+ let tmpResponse = await apiGet('/Manifest');
1929
+
1930
+ libAssert.strictEqual(tmpResponse.status, 200, 'Should return 200');
1931
+ libAssert.ok(Array.isArray(tmpResponse.body), 'Should return an array');
1932
+
1933
+ // We should have manifests from previous executions
1934
+ console.log(' Manifests:', tmpResponse.body.length, 'run(s) recorded');
1935
+ if (tmpResponse.body.length > 0)
1936
+ {
1937
+ let tmpFirst = tmpResponse.body[0];
1938
+ console.log(' First manifest:', tmpFirst.Hash || tmpFirst.OperationHash, '| Status:', tmpFirst.Status);
1939
+ }
1940
+
1941
+ _TestResults.passed++;
1942
+ }
1943
+ );
1944
+ }
1945
+ );
1946
+
1947
+ // ════════════════════════════════════════════════
1948
+ // Status & Package API
1949
+ // ════════════════════════════════════════════════
1950
+ suite
1951
+ (
1952
+ 'Status and Package API',
1953
+ function ()
1954
+ {
1955
+ test
1956
+ (
1957
+ 'get server status via GET /status',
1958
+ async function ()
1959
+ {
1960
+ this.timeout(10000);
1961
+
1962
+ let tmpResponse = await apiGet('/status');
1963
+
1964
+ libAssert.strictEqual(tmpResponse.status, 200, 'Should return 200');
1965
+ libAssert.strictEqual(tmpResponse.body.Status, 'Running', 'Server should be running');
1966
+ libAssert.ok(typeof tmpResponse.body.ScheduleEntries === 'number', 'ScheduleEntries should be a number');
1967
+
1968
+ console.log(' Server status:', tmpResponse.body.Status,
1969
+ '| Schedule entries:', tmpResponse.body.ScheduleEntries,
1970
+ '| Schedule running:', tmpResponse.body.ScheduleRunning);
1971
+
1972
+ _TestResults.passed++;
1973
+ }
1974
+ );
1975
+ }
1976
+ );
1977
+
1978
+ // ════════════════════════════════════════════════
1979
+ // Dashboard Data After Workflows
1980
+ // ════════════════════════════════════════════════
1981
+ suite
1982
+ (
1983
+ 'Dashboard After Workflows',
1984
+ function ()
1985
+ {
1986
+ test
1987
+ (
1988
+ 'dashboard shows updated counts',
1989
+ async function ()
1990
+ {
1991
+ this.timeout(15000);
1992
+
1993
+ await navigateToRoute('#/Home');
1994
+ await settle(2000);
1995
+
1996
+ await takeScreenshot('dashboard-after-workflows');
1997
+
1998
+ _TestResults.passed++;
1999
+ }
2000
+ );
2001
+
2002
+ test
2003
+ (
2004
+ 'operations list shows created operations',
2005
+ async function ()
2006
+ {
2007
+ this.timeout(15000);
2008
+
2009
+ await navigateToRoute('#/Operations');
2010
+ await settle(1500);
2011
+
2012
+ let tmpOpCount = await _Page.evaluate(() =>
2013
+ {
2014
+ let tmpRows = document.querySelectorAll('.ultravisor-operation-table tbody tr');
2015
+ return tmpRows ? tmpRows.length : 0;
2016
+ });
2017
+
2018
+ console.log(' Operations visible in list:', tmpOpCount);
2019
+ libAssert.ok(tmpOpCount >= 1, 'Should show at least one operation in the list');
2020
+
2021
+ await takeScreenshot('operations-list-populated');
2022
+
2023
+ _TestResults.passed++;
2024
+ }
2025
+ );
2026
+ }
2027
+ );
2028
+
2029
+ // ════════════════════════════════════════════════
2030
+ // Pending Input API
2031
+ // ════════════════════════════════════════════════
2032
+ suite
2033
+ (
2034
+ 'Pending Input API',
2035
+ function ()
2036
+ {
2037
+ let _PendingOpHash = '';
2038
+ let _PendingRunHash = '';
2039
+
2040
+ test
2041
+ (
2042
+ 'create operation with value-input node',
2043
+ async function ()
2044
+ {
2045
+ this.timeout(15000);
2046
+
2047
+ let tmpResponse = await apiCreateOperation({
2048
+ Name: 'Pending Input Test',
2049
+ Description: 'Tests value-input pause and resume via PendingInput API.',
2050
+ Graph: {
2051
+ Nodes: [
2052
+ { Hash: 'pi-start', Type: 'start', X: 0, Y: 0 },
2053
+ {
2054
+ Hash: 'pi-input', Type: 'value-input',
2055
+ Settings: { PromptMessage: 'Enter test value', OutputAddress: 'Operation.TestValue' },
2056
+ Ports: [], X: 200, Y: 0
2057
+ },
2058
+ { Hash: 'pi-end', Type: 'end', X: 400, Y: 0 }
2059
+ ],
2060
+ Connections: [
2061
+ { Hash: 'pi-c1', ConnectionType: 'Event', SourceNodeHash: 'pi-start', SourcePortHash: 'pi-start-eo-Start', TargetNodeHash: 'pi-input', TargetPortHash: 'pi-input-ei-RequestInput' },
2062
+ { Hash: 'pi-c2', ConnectionType: 'Event', SourceNodeHash: 'pi-input', SourcePortHash: 'pi-input-eo-ValueInputComplete', TargetNodeHash: 'pi-end', TargetPortHash: 'pi-end-ei-End' }
2063
+ ],
2064
+ ViewState: {}
2065
+ }
2066
+ });
2067
+
2068
+ _PendingOpHash = tmpResponse.Hash;
2069
+ libAssert.ok(_PendingOpHash, 'Operation should be created');
2070
+ console.log(' Pending Input op:', _PendingOpHash);
2071
+
2072
+ _TestResults.passed++;
2073
+ }
2074
+ );
2075
+
2076
+ test
2077
+ (
2078
+ 'execute operation — should pause at value-input',
2079
+ async function ()
2080
+ {
2081
+ this.timeout(15000);
2082
+
2083
+ let tmpResult = await apiExecuteOperation(_PendingOpHash);
2084
+ libAssert.strictEqual(tmpResult.body.Status, 'WaitingForInput', 'Should be waiting for input');
2085
+ libAssert.ok(tmpResult.body.WaitingTasks, 'Should have WaitingTasks');
2086
+ libAssert.ok(tmpResult.body.WaitingTasks['pi-input'], 'Should be waiting on pi-input node');
2087
+ libAssert.strictEqual(tmpResult.body.WaitingTasks['pi-input'].PromptMessage, 'Enter test value', 'PromptMessage should match');
2088
+
2089
+ _PendingRunHash = tmpResult.body.Hash;
2090
+ console.log(' Run paused:', _PendingRunHash, '| Prompt:', tmpResult.body.WaitingTasks['pi-input'].PromptMessage);
2091
+
2092
+ _TestResults.passed++;
2093
+ }
2094
+ );
2095
+
2096
+ test
2097
+ (
2098
+ 'GET /PendingInput lists the paused run',
2099
+ async function ()
2100
+ {
2101
+ this.timeout(10000);
2102
+
2103
+ let tmpResponse = await apiGet('/PendingInput');
2104
+ libAssert.strictEqual(tmpResponse.status, 200, 'Should return 200');
2105
+ libAssert.ok(Array.isArray(tmpResponse.body), 'Should return an array');
2106
+ libAssert.ok(tmpResponse.body.length >= 1, 'Should have at least one pending input');
2107
+
2108
+ let tmpFound = tmpResponse.body.find(function (pItem) { return pItem.RunHash === _PendingRunHash; });
2109
+ libAssert.ok(tmpFound, 'Should find our paused run');
2110
+ libAssert.strictEqual(tmpFound.OperationHash, _PendingOpHash, 'OperationHash should match');
2111
+ libAssert.ok(tmpFound.WaitingTasks['pi-input'], 'Should show pi-input as waiting');
2112
+ libAssert.strictEqual(tmpFound.WaitingTasks['pi-input'].PromptMessage, 'Enter test value', 'PromptMessage should match');
2113
+
2114
+ console.log(' Pending inputs found:', tmpResponse.body.length);
2115
+
2116
+ _TestResults.passed++;
2117
+ }
2118
+ );
2119
+
2120
+ test
2121
+ (
2122
+ 'Pending Input view shows waiting operations',
2123
+ async function ()
2124
+ {
2125
+ this.timeout(15000);
2126
+
2127
+ await navigateToRoute('#/PendingInput');
2128
+ await settle(2000);
2129
+
2130
+ let tmpCardCount = await _Page.evaluate(() =>
2131
+ {
2132
+ let tmpCards = document.querySelectorAll('.ultravisor-pendinginput-card');
2133
+ return tmpCards ? tmpCards.length : 0;
2134
+ });
2135
+
2136
+ libAssert.ok(tmpCardCount >= 1, 'Should show at least one pending input card');
2137
+ console.log(' Pending input cards visible:', tmpCardCount);
2138
+
2139
+ await takeScreenshot('pending-input-view');
2140
+
2141
+ _TestResults.passed++;
2142
+ }
2143
+ );
2144
+
2145
+ test
2146
+ (
2147
+ 'POST /PendingInput/:RunHash submits value and resumes',
2148
+ async function ()
2149
+ {
2150
+ this.timeout(15000);
2151
+
2152
+ let tmpResponse = await apiPost('/PendingInput/' + _PendingRunHash, { NodeHash: 'pi-input', Value: 'hello-test-value' });
2153
+
2154
+ libAssert.strictEqual(tmpResponse.status, 200, 'Should return 200');
2155
+ libAssert.strictEqual(tmpResponse.body.Status, 'Complete', 'Operation should complete after input');
2156
+ libAssert.ok(tmpResponse.body.TaskOutputs['pi-input'], 'Should have pi-input outputs');
2157
+ libAssert.strictEqual(tmpResponse.body.TaskOutputs['pi-input'].InputValue, 'hello-test-value', 'InputValue should match');
2158
+
2159
+ console.log(' Resumed run status:', tmpResponse.body.Status);
2160
+
2161
+ _TestResults.passed++;
2162
+ }
2163
+ );
2164
+
2165
+ test
2166
+ (
2167
+ 'GET /PendingInput is now empty for that run',
2168
+ async function ()
2169
+ {
2170
+ this.timeout(10000);
2171
+
2172
+ let tmpResponse = await apiGet('/PendingInput');
2173
+ libAssert.strictEqual(tmpResponse.status, 200, 'Should return 200');
2174
+
2175
+ let tmpFound = tmpResponse.body.find(function (pItem) { return pItem.RunHash === _PendingRunHash; });
2176
+ libAssert.ok(!tmpFound, 'Completed run should no longer appear in pending inputs');
2177
+
2178
+ console.log(' Remaining pending inputs:', tmpResponse.body.length);
2179
+
2180
+ _TestResults.passed++;
2181
+ }
2182
+ );
2183
+ }
2184
+ );
2185
+
2186
+ // ════════════════════════════════════════════════
2187
+ // Console Errors
2188
+ // ════════════════════════════════════════════════
2189
+ suite
2190
+ (
2191
+ 'Console Errors',
2192
+ function ()
2193
+ {
2194
+ test
2195
+ (
2196
+ 'no critical console errors during all tests',
2197
+ function ()
2198
+ {
2199
+ let tmpCriticalErrors = _ConsoleErrors.filter(function (pMsg)
2200
+ {
2201
+ if (pMsg.includes('fetch') || pMsg.includes('Failed to load')
2202
+ || pMsg.includes('NetworkError') || pMsg.includes('net::')
2203
+ || pMsg.includes('TypeError: Cannot read properties of undefined'))
2204
+ {
2205
+ return false;
2206
+ }
2207
+ return true;
2208
+ });
2209
+
2210
+ if (tmpCriticalErrors.length > 0)
2211
+ {
2212
+ console.log(' Critical browser errors found:');
2213
+ tmpCriticalErrors.forEach(function (pErr)
2214
+ {
2215
+ console.log(' -', pErr);
2216
+ });
2217
+ }
2218
+
2219
+ console.log(` Browser errors: ${_ConsoleErrors.length} total, ${tmpCriticalErrors.length} critical`);
2220
+ _TestResults.passed++;
2221
+ }
2222
+ );
2223
+ }
2224
+ );
2225
+ }
2226
+ );