flowstride 1.0.21 → 1.0.22

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 (81) hide show
  1. package/NATIVE_BROWSER.md +40 -0
  2. package/NATIVE_CI.md +14 -2
  3. package/NATIVE_WORKFLOWS.md +44 -0
  4. package/README.md +8 -1
  5. package/dist/adapters/api/undici.d.ts +3 -1
  6. package/dist/adapters/api/undici.js +17 -3
  7. package/dist/adapters/web/playwright.js +23 -73
  8. package/dist/ci/runtime.js +9 -1
  9. package/dist/cli/commands/run.js +14 -4
  10. package/dist/cli/manual_server/workspace_server.js +90 -74
  11. package/dist/manual/security.d.ts +4 -4
  12. package/dist/native/accessibility.d.ts +2 -1
  13. package/dist/native/accessibility.js +22 -6
  14. package/dist/native/assertions.d.ts +2 -1
  15. package/dist/native/assertions.js +174 -91
  16. package/dist/native/deadline.d.ts +2 -0
  17. package/dist/native/deadline.js +13 -0
  18. package/dist/native/events.d.ts +3 -2
  19. package/dist/native/events.js +35 -8
  20. package/dist/native/functions.d.ts +2 -1
  21. package/dist/native/functions.js +34 -6
  22. package/dist/native/runtime.d.ts +3 -0
  23. package/dist/native/runtime.js +12 -6
  24. package/dist/parser/assertion_contract.d.ts +12 -0
  25. package/dist/parser/assertion_contract.js +56 -0
  26. package/dist/parser/parser.js +14 -6
  27. package/dist/reporting/assertion_result.d.ts +36 -0
  28. package/dist/reporting/assertion_result.js +104 -0
  29. package/dist/reporting/pdf_generator.js +25 -20
  30. package/dist/reporting/runtime_failure.d.ts +10 -0
  31. package/dist/reporting/runtime_failure.js +64 -0
  32. package/dist/reporting/step_status.d.ts +10 -0
  33. package/dist/reporting/step_status.js +44 -0
  34. package/dist/runner/flow_orchestrator.d.ts +1 -0
  35. package/dist/runner/flow_orchestrator.js +8 -3
  36. package/dist/runner/flow_worker.d.ts +8 -1
  37. package/dist/runner/flow_worker.js +119 -41
  38. package/dist/runner/plugin_manager.js +3 -0
  39. package/dist/runner/temporary_store.d.ts +1 -0
  40. package/dist/runner/temporary_store.js +3 -0
  41. package/dist/sdk/api_proxy.d.ts +3 -0
  42. package/dist/sdk/api_proxy.js +16 -0
  43. package/dist/security/localWorkspace.d.ts +2 -0
  44. package/dist/security/localWorkspace.js +17 -0
  45. package/dist/security/redaction.js +1 -1
  46. package/dist/types/index.d.ts +3 -0
  47. package/examples/native-workflows/README.md +144 -0
  48. package/examples/native-workflows/api-and-ui/README.md +80 -0
  49. package/examples/native-workflows/api-and-ui/flows/api-and-ui.flow +27 -0
  50. package/examples/native-workflows/api-and-ui/negative/wrong-backend-state.flow +27 -0
  51. package/examples/native-workflows/api-ui-otp/README.md +140 -0
  52. package/examples/native-workflows/api-ui-otp/templates/api-ui-otp.flow +31 -0
  53. package/examples/native-workflows/api-ui-otp/templates/existing-account-otp.flow +20 -0
  54. package/examples/native-workflows/audio-to-text/README.md +131 -0
  55. package/examples/native-workflows/audio-to-text/fixtures/microphone-tone.wav +0 -0
  56. package/examples/native-workflows/audio-to-text/flows/microphone-demo.flow +23 -0
  57. package/examples/native-workflows/audio-to-text/negative/wrong-transcript.flow +23 -0
  58. package/examples/native-workflows/audio-to-text/templates/real-transcription.flow +25 -0
  59. package/examples/native-workflows/iframes/README.md +116 -0
  60. package/examples/native-workflows/iframes/flows/explicit-frame-plugin.flow +20 -0
  61. package/examples/native-workflows/iframes/flows/readable-frame.flow +13 -0
  62. package/examples/native-workflows/iframes/negative/wrong-frame-result.flow +13 -0
  63. package/examples/native-workflows/iframes/plugins/frame-actions.ts +7 -0
  64. package/examples/native-workflows/manual-testing/README.md +85 -0
  65. package/examples/native-workflows/manual-testing/fixtures/profile-review-mismatch.json +93 -0
  66. package/examples/native-workflows/manual-testing/fixtures/profile-review.json +93 -0
  67. package/examples/native-workflows/shared/server.mjs +80 -0
  68. package/examples/native-workflows/tables-and-cells/README.md +118 -0
  69. package/examples/native-workflows/tables-and-cells/flows/coordinates.flow +17 -0
  70. package/examples/native-workflows/tables-and-cells/flows/search-extract-reuse.flow +27 -0
  71. package/examples/native-workflows/tables-and-cells/negative/wrong-cell-value.flow +27 -0
  72. package/examples/native-workflows/variables-and-reuse/README.md +140 -0
  73. package/examples/native-workflows/variables-and-reuse/flows/reuse.flow +9 -0
  74. package/examples/native-workflows/variables-and-reuse/flows/setup.first.flow +27 -0
  75. package/examples/native-workflows/variables-and-reuse/negative/wrong-reused-value.flow +27 -0
  76. package/examples/native-workflows/variables-and-reuse/sessions/restore.flow +10 -0
  77. package/examples/native-workflows/variables-and-reuse/sessions/save.flow +29 -0
  78. package/package.json +11 -8
  79. package/src/ui/dist/assets/index-s0WYkGPt.js +636 -0
  80. package/src/ui/dist/index.html +1 -1
  81. package/src/ui/dist/assets/index-DglsL_J2.js +0 -636
@@ -0,0 +1,13 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.nativeStepTimeout = nativeStepTimeout;
4
+ const validation_1 = require("./validation");
5
+ /** Only commands whose validated contract accepts a timeout may override it. */
6
+ function nativeStepTimeout(command, input, fallback) {
7
+ if (['assert.that', 'a11y.match', 'a11y.snapshot', 'stub.expect'].includes(command) &&
8
+ input !== null && typeof input === 'object' && !Array.isArray(input) &&
9
+ Object.hasOwn(input, 'timeout')) {
10
+ return validation_1.positive.parse(input.timeout);
11
+ }
12
+ return fallback;
13
+ }
@@ -1,3 +1,4 @@
1
+ import { type AssertionReporter } from '../reporting/assertion_result';
1
2
  import type { FlowstrideConfig } from '../types';
2
3
  import { WebAdapter } from '../adapters/web/playwright';
3
4
  export declare class ArmedEvent<T> {
@@ -27,8 +28,8 @@ export declare class NativeEvents {
27
28
  private savedDownloads;
28
29
  constructor(web: WebAdapter, config: FlowstrideConfig);
29
30
  private key;
30
- network(action: string, input: unknown): Promise<any>;
31
- download(action: string, input: unknown): Promise<any>;
31
+ network(action: string, input: unknown, reporter?: AssertionReporter): Promise<any>;
32
+ download(action: string, input: unknown, reporter?: AssertionReporter): Promise<any>;
32
33
  disposeProfile(profile: string): Promise<void>;
33
34
  dispose(): Promise<void>;
34
35
  }
@@ -5,6 +5,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.NativeEvents = exports.ArmedEvent = void 0;
7
7
  exports.matchesURL = matchesURL;
8
+ const assertion_result_1 = require("../reporting/assertion_result");
8
9
  const zod_1 = require("zod");
9
10
  const validation_1 = require("./validation");
10
11
  const projectPaths_1 = require("../utils/projectPaths");
@@ -60,7 +61,7 @@ class NativeEvents {
60
61
  this.config = config;
61
62
  }
62
63
  key(value) { return `${this.web.activeProfile}:${value}`; }
63
- async network(action, input) {
64
+ async network(action, input, reporter) {
64
65
  if (this.routeErrors.length)
65
66
  throw new Error(`Network route failed: ${this.routeErrors.shift()}`);
66
67
  if (action === 'route') {
@@ -180,19 +181,26 @@ class NativeEvents {
180
181
  throw new Error('No captured event; call network.wait first.');
181
182
  if (!['status', 'method', 'equals', 'contains'].some(key => key in data))
182
183
  throw new Error('Provide a network expectation.');
183
- if (data.status !== undefined && result.status !== data.status || data.method && result.method !== data.method.toUpperCase())
184
- throw new Error('Network status/method assertion failed.');
184
+ const checks = [];
185
+ if (data.status !== undefined)
186
+ checks.push((0, assertion_result_1.assertionCheck)('status', 'status', result.status === data.status, data.status, result.status));
187
+ if (data.method)
188
+ checks.push((0, assertion_result_1.assertionCheck)('method', 'method', result.method === data.method.toUpperCase(), data.method.toUpperCase(), result.method));
185
189
  let actual = data.header ? result.headers[data.header.toLowerCase()] : result.body;
186
190
  if (data.path)
187
191
  for (const key of data.path.split('.'))
188
192
  actual = actual?.[key];
189
- if ('equals' in data && !(0, node_util_1.isDeepStrictEqual)(actual, data.equals) || data.contains !== undefined && !String(actual).includes(data.contains))
190
- throw new Error('Network content assertion failed.');
193
+ const protect = (value) => /password|passcode|secret|token|authorization|cookie|api.?key|otp/i.test((data.header || '') + ' ' + (data.path || '')) ? '[REDACTED]' : value;
194
+ if ('equals' in data)
195
+ checks.push((0, assertion_result_1.assertionCheck)('equals', (data.header ? 'header ' + data.header : data.path ? 'body path ' + data.path : 'body') + ' equals', (0, node_util_1.isDeepStrictEqual)(actual, data.equals), protect(data.equals), protect(actual)));
196
+ if (data.contains !== undefined)
197
+ checks.push((0, assertion_result_1.assertionCheck)('contains', 'content contains', String(actual).includes(data.contains), protect(data.contains), protect(actual)));
198
+ (0, assertion_result_1.requireAssertion)('network.expect', 'captured network event ' + JSON.stringify(data.name), checks, reporter, { reason: checks.some(check => check.status === 'failed' && ['status', 'method'].includes(check.key)) ? 'Network status/method assertion failed.' : checks.some(check => check.status === 'failed') ? 'Network content assertion failed.' : undefined });
191
199
  return;
192
200
  }
193
201
  throw new Error(`Unknown network action: ${action}`);
194
202
  }
195
- async download(action, input) {
203
+ async download(action, input, reporter) {
196
204
  if (action === 'arm') {
197
205
  const data = zod_1.z.object({ name: validation_1.name, timeout: validation_1.positive.optional() }).strict().parse(input), key = this.key(data.name), page = this.web.getPage();
198
206
  if (this.downloads.has(key))
@@ -240,8 +248,27 @@ class NativeEvents {
240
248
  hash.update(chunk);
241
249
  sha256 = hash.digest('hex');
242
250
  }
243
- if (data.filename !== undefined && saved.suggested !== data.filename || data.size !== undefined && length !== data.size || data.minSize !== undefined && length < data.minSize || data.contains !== undefined && !body.toString('utf8').includes(data.contains) || data.sha256 !== undefined && sha256 !== data.sha256 || 'json' in data && !(0, node_util_1.isDeepStrictEqual)(JSON.parse(body.toString('utf8')), data.json))
244
- throw new Error('Download assertion failed.');
251
+ const checks = [];
252
+ if (data.filename !== undefined)
253
+ checks.push((0, assertion_result_1.assertionCheck)('filename', 'filename', saved.suggested === data.filename, data.filename, saved.suggested));
254
+ if (data.size !== undefined)
255
+ checks.push((0, assertion_result_1.assertionCheck)('size', 'size', length === data.size, data.size, length));
256
+ if (data.minSize !== undefined)
257
+ checks.push((0, assertion_result_1.assertionCheck)('minSize', 'minimum size', length >= data.minSize, data.minSize, length));
258
+ if (data.contains !== undefined)
259
+ checks.push((0, assertion_result_1.assertionCheck)('contains', 'content contains', body.toString('utf8').includes(data.contains), data.contains, body.toString('utf8')));
260
+ if (data.sha256 !== undefined)
261
+ checks.push((0, assertion_result_1.assertionCheck)('sha256', 'SHA-256', sha256 === data.sha256, data.sha256, sha256));
262
+ if ('json' in data) {
263
+ try {
264
+ const actual = JSON.parse(body.toString('utf8'));
265
+ checks.push((0, assertion_result_1.assertionCheck)('json', 'JSON content', (0, node_util_1.isDeepStrictEqual)(actual, data.json), data.json, actual));
266
+ }
267
+ catch (error) {
268
+ checks.push({ ...(0, assertion_result_1.assertionCheck)('json', 'JSON content', false, data.json, body.toString('utf8')), reason: 'Downloaded content is not valid JSON: ' + (0, assertion_result_1.diagnosticValue)(error instanceof Error ? error.message : String(error)) });
269
+ }
270
+ }
271
+ (0, assertion_result_1.requireAssertion)('download.expect', 'saved download ' + JSON.stringify(data.name), checks, reporter, { reason: checks.some(check => check.status === 'failed') ? 'Download assertion failed.' : undefined });
245
272
  return;
246
273
  }
247
274
  throw new Error(`Unknown download action: ${action}`);
@@ -1,9 +1,10 @@
1
+ import { type AssertionReporter } from '../reporting/assertion_result';
1
2
  import type { Page } from 'playwright';
2
3
  export declare class NativeFunctions {
3
4
  private installed;
4
5
  private pages;
5
6
  private counter;
6
7
  private key;
7
- execute(page: Page, action: string, input: unknown, timeout: number): Promise<void>;
8
+ execute(page: Page, action: string, input: unknown, timeout: number, reporter?: AssertionReporter): Promise<void>;
8
9
  dispose(): Promise<void>;
9
10
  }
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.NativeFunctions = void 0;
4
+ const assertion_result_1 = require("../reporting/assertion_result");
4
5
  const zod_1 = require("zod");
5
6
  const validation_1 = require("./validation");
6
7
  const node_util_1 = require("node:util");
@@ -50,7 +51,7 @@ class NativeFunctions {
50
51
  counter = 0;
51
52
  key(page, name) { if (!this.pages.has(page))
52
53
  this.pages.set(page, ++this.counter); return `${this.pages.get(page)}:${name}`; }
53
- async execute(page, action, input, timeout) {
54
+ async execute(page, action, input, timeout, reporter) {
54
55
  if (action === 'install') {
55
56
  const data = schema.parse(input);
56
57
  if (this.installed.has(this.key(page, data.name)))
@@ -64,7 +65,7 @@ class NativeFunctions {
64
65
  await script?.dispose();
65
66
  throw error;
66
67
  }
67
- this.installed.set(this.key(page, data.name), { name: data.name, page, script });
68
+ this.installed.set(this.key(page, data.name), { name: data.name, target: data.target, page, script });
68
69
  return;
69
70
  }
70
71
  if (action === 'restore') {
@@ -81,10 +82,37 @@ class NativeFunctions {
81
82
  const data = zod_1.z.object({ name: validation_1.name, count: zod_1.z.number().int().nonnegative().optional(), minCount: zod_1.z.number().int().nonnegative().optional(), args: zod_1.z.array(zod_1.z.unknown()).optional(), timeout: validation_1.positive.optional() }).strict().parse(input);
82
83
  if (!['count', 'minCount', 'args'].some(key => key in data))
83
84
  throw new Error('Provide a call count or expected arguments.');
84
- await (0, validation_1.poll)(async () => {
85
- const calls = await page.evaluate(name => window[Symbol.for('flowstride.function-doubles')]?.[name]?.calls, data.name);
86
- return Array.isArray(calls) && (data.count === undefined || calls.length === data.count) && (data.minCount === undefined || calls.length >= data.minCount) && (data.args === undefined || calls.some(call => (0, node_util_1.isDeepStrictEqual)(call, data.args)));
87
- }, data.timeout ?? timeout, `function double ${data.name}`);
85
+ const started = Date.now(), limit = data.timeout ?? timeout;
86
+ const sensitive = /auth|login|password|passcode|secret|token|cookie|api.?key|otp/i.test(this.installed.get(this.key(page, data.name))?.target || data.name);
87
+ let latest = (0, assertion_result_1.reportAssertion)('stub.expect', 'function double ' + JSON.stringify(data.name), [{ key: 'calls', label: 'Recorded calls available', status: 'running' }], reporter, { status: 'running', timeout: limit, elapsed: 0 });
88
+ let closed = false;
89
+ try {
90
+ await (0, validation_1.poll)(async () => {
91
+ const calls = await page.evaluate(name => window[Symbol.for('flowstride.function-doubles')]?.[name]?.calls, data.name);
92
+ const checks = [(0, assertion_result_1.assertionCheck)('calls', 'Recorded calls available', Array.isArray(calls), 'recorded calls', Array.isArray(calls) ? calls.length : 'No function double found on this page')];
93
+ if (Array.isArray(calls)) {
94
+ if (data.count !== undefined)
95
+ checks.push((0, assertion_result_1.assertionCheck)('count', 'call count', calls.length === data.count, data.count, calls.length));
96
+ if (data.minCount !== undefined)
97
+ checks.push((0, assertion_result_1.assertionCheck)('minCount', 'minimum call count', calls.length >= data.minCount, data.minCount, calls.length));
98
+ if (data.args !== undefined)
99
+ checks.push((0, assertion_result_1.assertionCheck)('args', 'matching call arguments', calls.some(call => (0, node_util_1.isDeepStrictEqual)(call, data.args)), sensitive ? "[REDACTED]" : data.args, sensitive ? "[REDACTED]" : calls));
100
+ }
101
+ if (!closed)
102
+ latest = (0, assertion_result_1.reportAssertion)('stub.expect', 'function double ' + JSON.stringify(data.name), checks, reporter, { status: 'running', timeout: limit, elapsed: Date.now() - started });
103
+ return checks.every(check => check.status === 'passed');
104
+ }, limit, `function double ${data.name}`);
105
+ closed = true;
106
+ reporter?.({ ...latest, status: 'passed', elapsed: Date.now() - started });
107
+ }
108
+ catch (error) {
109
+ closed = true;
110
+ const result = { ...latest, status: 'failed', elapsed: Date.now() - started, reason: (0, assertion_result_1.diagnosticValue)(error instanceof Error ? error.message : String(error)) };
111
+ if (!result.checks.some(check => check.status === 'failed'))
112
+ result.checks.push({ key: 'read', label: 'Recorded calls could be read', status: 'failed', reason: result.reason });
113
+ reporter?.(result);
114
+ throw new assertion_result_1.AssertionFailure(result);
115
+ }
88
116
  return;
89
117
  }
90
118
  throw new Error(`Unknown stub action: ${action}`);
@@ -1,3 +1,4 @@
1
+ import type { AssertionResult } from '../reporting/assertion_result';
1
2
  import type { FlowstrideConfig } from '../types';
2
3
  import { WebAdapter } from '../adapters/web/playwright';
3
4
  import { NativeEvents } from './events';
@@ -5,6 +6,8 @@ export declare class NativeRuntime {
5
6
  private web;
6
7
  private config;
7
8
  readonly events: NativeEvents;
9
+ lastAssertion?: AssertionResult;
10
+ private assertionGeneration;
8
11
  private functions;
9
12
  private components;
10
13
  constructor(web: WebAdapter, config: FlowstrideConfig);
@@ -18,6 +18,8 @@ class NativeRuntime {
18
18
  web;
19
19
  config;
20
20
  events;
21
+ lastAssertion;
22
+ assertionGeneration = 0;
21
23
  functions = new functions_1.NativeFunctions();
22
24
  components;
23
25
  constructor(web, config) {
@@ -27,6 +29,10 @@ class NativeRuntime {
27
29
  this.components = new components_1.NativeComponents(config);
28
30
  }
29
31
  async execute(command, input = {}) {
32
+ this.lastAssertion = undefined;
33
+ const generation = ++this.assertionGeneration;
34
+ const report = result => { if (generation === this.assertionGeneration)
35
+ this.lastAssertion = result; };
30
36
  const [area, action, extra] = command.split('.');
31
37
  if (!action || extra)
32
38
  throw new Error(`Unknown native command: flow.${command}`);
@@ -78,18 +84,18 @@ class NativeRuntime {
78
84
  if (area === 'assert') {
79
85
  if (action !== 'that')
80
86
  throw new Error(`Unknown assertion action: ${action}`);
81
- return (0, assertions_1.assertBrowser)(this.web.getPage(), input, this.config.timeout);
87
+ return (0, assertions_1.assertBrowser)(this.web.getPage(), input, this.config.timeout, report);
82
88
  }
83
89
  if (area === 'network')
84
- return this.events.network(action, input);
90
+ return this.events.network(action, input, report);
85
91
  if (area === 'download')
86
- return this.events.download(action, input);
92
+ return this.events.download(action, input, report);
87
93
  if (area === 'stub')
88
- return this.functions.execute(this.web.getPage(), action, input, this.config.timeout);
94
+ return this.functions.execute(this.web.getPage(), action, input, this.config.timeout, report);
89
95
  if (area === 'component')
90
96
  return this.components.execute(this.web.getPage(), action, input);
91
97
  if (area === 'a11y')
92
- return (0, accessibility_1.accessibility)(this.web.getPage(), this.config, action, input);
98
+ return (0, accessibility_1.accessibility)(this.web.getPage(), this.config, action, input, report);
93
99
  if (area === 'clock') {
94
100
  const clock = this.web.getPage().clock;
95
101
  if (action === 'install') {
@@ -149,6 +155,6 @@ class NativeRuntime {
149
155
  node_fs_1.default.writeFileSync(file, html, { mode: 0o600 });
150
156
  return file;
151
157
  }
152
- async dispose() { await this.events.dispose(); await this.functions.dispose(); await this.components.dispose(); }
158
+ async dispose() { this.assertionGeneration++; this.lastAssertion = undefined; await this.events.dispose(); await this.functions.dispose(); await this.components.dispose(); }
153
159
  }
154
160
  exports.NativeRuntime = NativeRuntime;
@@ -0,0 +1,12 @@
1
+ export declare const expectationTypes: Set<string>;
2
+ export declare const apiAssertionConditions: Set<string>;
3
+ export declare function validateExpectationType(type: unknown): void;
4
+ /** Templates are checked after interpolation; literal thresholds fail at parse time. */
5
+ export declare function validateResponseTimeThreshold(value: unknown, allowTemplate?: boolean): number | undefined;
6
+ export declare function validateApiAssertions(assertions: unknown): asserts assertions is {
7
+ condition: string;
8
+ value: string;
9
+ }[];
10
+ /** And remains limited to assertions and existing custom assertion plugins. */
11
+ export declare const nativeAssertionCommands: Set<string>;
12
+ export declare function isAssertionStep(action: string, payload: any): boolean;
@@ -0,0 +1,56 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.nativeAssertionCommands = exports.apiAssertionConditions = exports.expectationTypes = void 0;
4
+ exports.validateExpectationType = validateExpectationType;
5
+ exports.validateResponseTimeThreshold = validateResponseTimeThreshold;
6
+ exports.validateApiAssertions = validateApiAssertions;
7
+ exports.isAssertionStep = isAssertionStep;
8
+ exports.expectationTypes = new Set([
9
+ 'status', 'responseTime', 'resBody', 'resHeader', 'cookie',
10
+ 'visible', 'text', 'value', 'attribute', 'transcript',
11
+ ]);
12
+ exports.apiAssertionConditions = new Set([
13
+ 'equals', 'contains', 'to.be', 'to.not.be', 'is', 'is.not',
14
+ 'type.of', 'format', 'matches', 'lessthan', 'greaterthan',
15
+ 'length', 'length.greaterthan', 'length.lessthan',
16
+ 'includes', 'does.not.include', 'has.key', 'does.not.have.key',
17
+ 'is.empty', 'not.empty',
18
+ ]);
19
+ function validateExpectationType(type) {
20
+ if (typeof type !== 'string' || !exports.expectationTypes.has(type)) {
21
+ throw new Error(`Unsupported flow.expect modifier: ${String(type)}.`);
22
+ }
23
+ }
24
+ /** Templates are checked after interpolation; literal thresholds fail at parse time. */
25
+ function validateResponseTimeThreshold(value, allowTemplate = false) {
26
+ if (allowTemplate && typeof value === 'string' &&
27
+ /\{\{env\.[^}]+\}\}|(?:@|\$)(?:global\[[^\]]+\]|[a-zA-Z_]\w*)/.test(value))
28
+ return;
29
+ if ((typeof value !== 'string' && typeof value !== 'number') ||
30
+ (typeof value === 'string' && value.trim() === '')) {
31
+ throw new Error('API responseTime threshold must be a finite nonnegative number.');
32
+ }
33
+ const threshold = Number(value);
34
+ if (!Number.isFinite(threshold) || threshold < 0) {
35
+ throw new Error('API responseTime threshold must be a finite nonnegative number.');
36
+ }
37
+ return threshold;
38
+ }
39
+ function validateApiAssertions(assertions) {
40
+ if (!Array.isArray(assertions) || assertions.length === 0) {
41
+ throw new Error('API assertion requires at least one supported condition.');
42
+ }
43
+ for (const assertion of assertions) {
44
+ if (!assertion || !exports.apiAssertionConditions.has(assertion.condition)) {
45
+ throw new Error(`Unsupported API assertion condition: ${String(assertion?.condition)}.`);
46
+ }
47
+ if (!['is.empty', 'not.empty'].includes(assertion.condition) && typeof assertion.value !== 'string') {
48
+ throw new Error(`API assertion ${assertion.condition} requires an expected string.`);
49
+ }
50
+ }
51
+ }
52
+ /** And remains limited to assertions and existing custom assertion plugins. */
53
+ exports.nativeAssertionCommands = new Set(['assert.that', 'network.expect', 'download.expect', 'stub.expect', 'a11y.scan', 'a11y.match']);
54
+ function isAssertionStep(action, payload) {
55
+ return action === 'expect' || action === 'plugin' || action === 'native' && exports.nativeAssertionCommands.has(payload?.pluginMethod);
56
+ }
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.Parser = void 0;
4
+ const assertion_contract_1 = require("./assertion_contract");
4
5
  const lexer_1 = require("./lexer");
5
6
  class Parser {
6
7
  tokens;
@@ -99,8 +100,8 @@ class Parser {
99
100
  declarations.push(this.parseDeclaration());
100
101
  }
101
102
  else if (token.type === lexer_1.TokenType.BLOCK) {
102
- if (isLocked)
103
- throw new Error(`Line ${token.line}: Terminal Lock Violation. No steps allowed after Then.`);
103
+ if (isLocked && token.value.toLowerCase().replace(/^stop\./, '') !== 'and')
104
+ throw new Error(`Line ${token.line}: Terminal Lock Violation. Only assertion And blocks are allowed after Then.`);
104
105
  const blockToken = this.advance();
105
106
  let rawBlockType = blockToken.value.toLowerCase();
106
107
  if (rawBlockType.startsWith("stop.")) {
@@ -149,7 +150,7 @@ class Parser {
149
150
  }
150
151
  else if (token.type === lexer_1.TokenType.COMMAND ||
151
152
  token.type === lexer_1.TokenType.ALIAS_COMMAND) {
152
- if (isLocked && currentBlockType !== "then") {
153
+ if (isLocked && currentBlockType !== "then" && currentBlockType !== "and") {
153
154
  throw new Error(`Line ${token.line}: Terminal Lock Violation.`);
154
155
  }
155
156
  const step = this.parseCommand();
@@ -157,8 +158,7 @@ class Parser {
157
158
  step.isStop = true;
158
159
  }
159
160
  if (currentBlockType === "and" &&
160
- step.action !== "expect" &&
161
- step.action !== "plugin") {
161
+ !(0, assertion_contract_1.isAssertionStep)(step.action, step.payload)) {
162
162
  throw new Error(`Line ${token.line}: And blocks are strictly for assertions or custom assertion plugins.`);
163
163
  }
164
164
  steps.push(step);
@@ -371,7 +371,13 @@ class Parser {
371
371
  const viaToken = this.consume(lexer_1.TokenType.PREPOSITION, 'Expected "via"');
372
372
  if (viaToken.value.toLowerCase() !== "via")
373
373
  throw new Error(`Line ${viaToken.line}: Expected 'via'`);
374
- payload.verb = this.consume(lexer_1.TokenType.COMMAND, "Expected API verb (e.g. POST)").value.toLowerCase();
374
+ payload.verb = this.consume(lexer_1.TokenType.COMMAND, "Expected API verb (e.g. POST)").value.toLowerCase().replace(/^flow\./, '');
375
+ if (!['get', 'post', 'put', 'patch', 'delete'].includes(payload.verb)) {
376
+ throw new Error('Authentication refresh requires an HTTP verb: get, post, put, patch or delete.');
377
+ }
378
+ if (!['401', '403'].includes(payload.targetStatus)) {
379
+ throw new Error('Authentication refresh status must be 401 or 403.');
380
+ }
375
381
  payload.url = this.consume(lexer_1.TokenType.STRING, "Endpoint URL expected").value;
376
382
  payload.headers = [];
377
383
  this.parseApiModifiers(payload);
@@ -507,6 +513,7 @@ class Parser {
507
513
  if (mod.toLowerCase() === "responsetime" && mod !== "responseTime") {
508
514
  throw new Error(`Syntax Error on line ${modToken.line}: Invalid keyword '${mod}'. Flowstride uses strict camelCase. Did you mean 'responseTime'?`);
509
515
  }
516
+ (0, assertion_contract_1.validateExpectationType)(mod);
510
517
  payload.type = mod;
511
518
  if (mod === "status") {
512
519
  payload.statusCode = this.consume(lexer_1.TokenType.STRING, 'Status code expected (e.g., "200")').value;
@@ -517,6 +524,7 @@ class Parser {
517
524
  throw new Error(`Line ${modToken.line}: Expected 'lessThan' after responseTime`);
518
525
  }
519
526
  payload.threshold = this.consume(lexer_1.TokenType.STRING, "Time threshold expected").value;
527
+ (0, assertion_contract_1.validateResponseTimeThreshold)(payload.threshold, true);
520
528
  }
521
529
  else if (mod === "resBody" ||
522
530
  mod === "resHeader" ||
@@ -0,0 +1,36 @@
1
+ export type AssertionCheckStatus = 'passed' | 'failed' | 'not_evaluated' | 'running';
2
+ export interface AssertionCheck {
3
+ key: string;
4
+ label: string;
5
+ status: AssertionCheckStatus;
6
+ expected?: string;
7
+ actual?: string;
8
+ reason?: string;
9
+ children?: AssertionCheck[];
10
+ }
11
+ export interface AssertionResult {
12
+ command: string;
13
+ target: string;
14
+ status: 'passed' | 'failed' | 'running' | 'not_evaluated';
15
+ checks: AssertionCheck[];
16
+ timeout?: number;
17
+ elapsed?: number;
18
+ reason?: string;
19
+ sourceFile?: string;
20
+ sourceLine?: number;
21
+ profile?: string;
22
+ }
23
+ export type AssertionReporter = (result: AssertionResult) => void;
24
+ /** Diagnostic text is bounded data, including in terminals and exported reports. */
25
+ export declare function diagnosticValue(value: unknown): string;
26
+ export declare function assertionCheck(key: string, label: string, matched: boolean, expected?: unknown, actual?: unknown): AssertionCheck;
27
+ export declare function assertionText(result: AssertionResult): string;
28
+ export declare class AssertionFailure extends Error {
29
+ readonly assertion: AssertionResult;
30
+ constructor(assertion: AssertionResult);
31
+ }
32
+ export declare function reportAssertion(command: string, target: string, checks: AssertionCheck[], reporter?: AssertionReporter, metadata?: Partial<AssertionResult>): AssertionResult;
33
+ export declare function requireAssertion(command: string, target: string, checks: AssertionCheck[], reporter?: AssertionReporter, metadata?: Partial<AssertionResult>): void;
34
+ export declare function failedAssertion(result: AssertionResult | undefined, reason: string): AssertionResult | undefined;
35
+ export declare function commandLabel(action: string, payload: any): string;
36
+ export declare function unexecutedAssertion(action: string, payload: any, reason?: string): AssertionResult | undefined;
@@ -0,0 +1,104 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.AssertionFailure = void 0;
4
+ exports.diagnosticValue = diagnosticValue;
5
+ exports.assertionCheck = assertionCheck;
6
+ exports.assertionText = assertionText;
7
+ exports.reportAssertion = reportAssertion;
8
+ exports.requireAssertion = requireAssertion;
9
+ exports.failedAssertion = failedAssertion;
10
+ exports.commandLabel = commandLabel;
11
+ exports.unexecutedAssertion = unexecutedAssertion;
12
+ const redaction_1 = require("../security/redaction");
13
+ /** Diagnostic text is bounded data, including in terminals and exported reports. */
14
+ function diagnosticValue(value) {
15
+ let text;
16
+ try {
17
+ const safe = (0, redaction_1.redactPayload)(value);
18
+ text = typeof safe === 'string' ? safe : JSON.stringify(safe);
19
+ }
20
+ catch {
21
+ text = '[unavailable]';
22
+ }
23
+ if (text === undefined)
24
+ text = '(not observed)';
25
+ return text.replace(/[\x00-\x1f\x7f-\x9f]/g, c => '\\u' + c.charCodeAt(0).toString(16).padStart(4, '0')).slice(0, 1500);
26
+ }
27
+ function assertionCheck(key, label, matched, expected, actual) {
28
+ return { key, label, status: matched ? 'passed' : 'failed',
29
+ ...(expected === undefined ? {} : { expected: diagnosticValue(expected) }),
30
+ ...(actual === undefined ? {} : { actual: diagnosticValue(actual) }) };
31
+ }
32
+ function assertionText(result) {
33
+ const lines = [`${result.status === 'passed' ? '✔' : result.status === 'not_evaluated' ? '○' : result.status === 'running' ? '◌' : '✖'} flow.${result.command}`];
34
+ const visit = (check, depth) => {
35
+ const prefix = ' '.repeat(depth);
36
+ lines.push(`${prefix}${check.status === 'passed' ? '✔' : check.status === 'failed' ? '✖' : '○'} ${diagnosticValue(check.label)}${check.status === 'not_evaluated' ? ' — Not evaluated' : ''}`);
37
+ if (check.status === 'failed') {
38
+ if (check.expected !== undefined)
39
+ lines.push(`${prefix} Expected: ${diagnosticValue(check.expected)}`);
40
+ if (check.actual !== undefined)
41
+ lines.push(`${prefix} Actual: ${diagnosticValue(check.actual)}`);
42
+ if (check.reason)
43
+ lines.push(`${prefix} Reason: ${diagnosticValue(check.reason)}`);
44
+ }
45
+ for (const child of check.children || [])
46
+ visit(child, depth + 1);
47
+ };
48
+ for (const check of result.checks)
49
+ visit(check, 1);
50
+ if (result.status === 'failed' && result.reason)
51
+ lines.push(` Reason: ${diagnosticValue(result.reason)}`);
52
+ if (result.timeout !== undefined)
53
+ lines.push(` Timeout: ${result.timeout} ms; elapsed: ${result.elapsed || 0} ms`);
54
+ if (result.sourceFile)
55
+ lines.push(` Location: ${diagnosticValue(result.sourceFile)}${result.sourceLine ? ':' + result.sourceLine : ''}`);
56
+ if (result.profile)
57
+ lines.push(` Profile: ${diagnosticValue(result.profile)}`);
58
+ return lines.join('\n');
59
+ }
60
+ class AssertionFailure extends Error {
61
+ assertion;
62
+ constructor(assertion) {
63
+ super(`${assertion.reason?.includes('timed out') ? 'Assertion timed out' : 'Assertion failed'}: ${assertion.target}\n${assertionText(assertion)}`);
64
+ this.assertion = assertion;
65
+ this.name = 'AssertionFailure';
66
+ }
67
+ }
68
+ exports.AssertionFailure = AssertionFailure;
69
+ function reportAssertion(command, target, checks, reporter, metadata = {}) {
70
+ const result = { command, target, status: checks.some(check => check.status === 'failed') ? 'failed' : 'passed', checks, ...metadata };
71
+ reporter?.(result);
72
+ return result;
73
+ }
74
+ function requireAssertion(command, target, checks, reporter, metadata = {}) {
75
+ const result = reportAssertion(command, target, checks, reporter, metadata);
76
+ if (result.status === 'failed')
77
+ throw new AssertionFailure(result);
78
+ }
79
+ function failedAssertion(result, reason) {
80
+ if (!result)
81
+ return;
82
+ const copy = structuredClone(result);
83
+ copy.status = 'failed';
84
+ copy.reason ||= diagnosticValue(reason.split("\n")[0]);
85
+ if (/exceeded|timed out|timeout/i.test(reason))
86
+ copy.elapsed = Math.max(copy.elapsed || 0, copy.timeout || 0);
87
+ for (const check of copy.checks)
88
+ if (check.status === 'running')
89
+ check.status = 'failed';
90
+ if (!copy.checks.some(check => check.status === 'failed'))
91
+ copy.checks.push({ key: 'completion', label: 'Command completed within its deadline', status: 'failed', reason: diagnosticValue(reason) });
92
+ return copy;
93
+ }
94
+ function commandLabel(action, payload) {
95
+ return action === 'native' && typeof payload?.pluginMethod === 'string' ? 'flow.' + payload.pluginMethod
96
+ : action === 'plugin' && typeof payload?.pluginAlias === 'string' ? payload.pluginAlias + '.' + payload.pluginMethod : action;
97
+ }
98
+ function unexecutedAssertion(action, payload, reason = 'A previous required step failed.') {
99
+ if (action !== 'native' || !['assert.that', 'network.expect', 'download.expect', 'stub.expect', 'a11y.scan', 'a11y.match', 'a11y.snapshot'].includes(payload?.pluginMethod))
100
+ return;
101
+ const data = payload.data && typeof payload.data === 'object' ? payload.data : {};
102
+ const fields = payload.pluginMethod === 'assert.that' ? ['role', 'name', 'selector', 'condition'] : Object.keys(data).filter(key => !['timeout', 'name'].includes(key));
103
+ return { command: payload.pluginMethod, target: payload.pluginMethod, status: 'not_evaluated', reason, checks: fields.slice(0, 100).map(key => ({ key, label: key + ': ' + diagnosticValue(data[key]), status: 'not_evaluated', reason })) };
104
+ }
@@ -34,9 +34,16 @@ var __importStar = (this && this.__importStar) || (function () {
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.PdfGenerator = void 0;
37
+ const assertion_result_1 = require("./assertion_result");
37
38
  const fs = __importStar(require("fs"));
38
39
  const path = __importStar(require("path"));
39
40
  const playwright_1 = require("playwright");
41
+ const step_status_1 = require("./step_status");
42
+ function escapeHtml(value) {
43
+ return String(value ?? "").replace(/[&<>"']/g, (character) => ({
44
+ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;",
45
+ })[character]);
46
+ }
40
47
  class PdfGenerator {
41
48
  async generateTestcasePdf(data, customOutputPath) {
42
49
  let outputDir = "";
@@ -79,7 +86,7 @@ class PdfGenerator {
79
86
  else if (Array.isArray(data)) {
80
87
  scenariosToProcess = data;
81
88
  }
82
- const groupedScenarios = {};
89
+ const groupedScenarios = Object.create(null);
83
90
  scenariosToProcess.forEach((scenario) => {
84
91
  const key = scenario.fileName || "General";
85
92
  if (!groupedScenarios[key])
@@ -95,31 +102,29 @@ class PdfGenerator {
95
102
  let fileRows = "";
96
103
  flatSteps.forEach((step, idx) => {
97
104
  totalSteps++;
98
- const status = (step.status || "PENDING").toUpperCase();
99
- const isSuccess = status === "PASS" || status === "PASSED" || status === "SUCCESS";
100
- const isFail = status === "FAILED" || status === "FAIL";
101
- if (isSuccess)
105
+ const result = (0, step_status_1.stepStatusPresentation)(step.status);
106
+ if (result.isPassed)
102
107
  passedSteps++;
103
- else if (isFail)
108
+ else if (result.isFailed)
104
109
  failedSteps++;
105
- const color = isSuccess ? "#10b981" : isFail ? "#ef4444" : "#64748b";
106
- const actualResultText = isFail
107
- ? "Validation Failed"
108
- : "Action verified successfully.";
109
- const actualResultColor = isFail ? "#ef4444" : "#64748b";
110
+ const actionText = step.commandLabel || step.command || step.action || "-";
111
+ const expectedText = step.description
112
+ ? `${step.type ? `${step.type} ` : ""}${step.description}`
113
+ : step.expected || "-";
114
+ const actualResultColor = result.isFailed ? "#ef4444" : "#64748b";
110
115
  fileRows += `
111
- <tr>
116
+ <tr data-step-status="${result.status}">
112
117
  <td style="padding: 16px 14px; border-bottom: 1px solid #e2e8f0; color: #64748b; font-weight: 500; width: 50px;">${idx + 1}</td>
113
- <td style="padding: 16px 14px; border-bottom: 1px solid #e2e8f0; font-weight: 700; color: #1e293b;">${step.command || "action"}</td>
114
- <td style="padding: 16px 14px; border-bottom: 1px solid #e2e8f0; color: #475569; font-style: italic;">${step.type || ""} ${step.description || "-"}</td>
115
- <td style="padding: 16px 14px; border-bottom: 1px solid #e2e8f0; color: ${actualResultColor};">${actualResultText}</td>
116
- <td style="padding: 16px 14px; border-bottom: 1px solid #e2e8f0; font-weight: 900; font-size: 10px; color: ${color};">${status}</td>
118
+ <td style="padding: 16px 14px; border-bottom: 1px solid #e2e8f0; font-weight: 700; color: #1e293b;">${escapeHtml(actionText)}</td>
119
+ <td style="padding: 16px 14px; border-bottom: 1px solid #e2e8f0; color: #475569; font-style: italic;">${escapeHtml(expectedText)}</td>
120
+ <td style="padding: 16px 14px; border-bottom: 1px solid #e2e8f0; color: ${actualResultColor};">${step.assertion ? `<pre style="white-space:pre-wrap;overflow-wrap:anywhere">${escapeHtml((0, assertion_result_1.assertionText)(step.assertion))}</pre>` : step.error ? `<pre style="white-space:pre-wrap;overflow-wrap:anywhere">${escapeHtml(step.error)}</pre>` : result.actualResult}</td>
121
+ <td style="padding: 16px 14px; border-bottom: 1px solid #e2e8f0; font-weight: 900; font-size: 10px; color: ${result.color};">${result.label}</td>
117
122
  </tr>
118
123
  `;
119
124
  });
120
125
  tablesHtml += `
121
126
  <div class="file-separator">
122
- <span>📄 ${fileName.toUpperCase()}</span>
127
+ <span>📄 ${escapeHtml(fileName.toUpperCase())}</span>
123
128
  </div>
124
129
  <table>
125
130
  <thead>
@@ -164,15 +169,15 @@ class PdfGenerator {
164
169
  </div>
165
170
  <div class="summary">
166
171
  <div class="stat-box">
167
- <div class="stat-value" style="color: #1e293b;">${totalSteps}</div>
172
+ <div class="stat-value" data-report-count="total" style="color: #1e293b;">${totalSteps}</div>
168
173
  <div style="font-size:10px;color:#64748b;text-transform:uppercase;margin-top:4px;font-weight:700;">Total Steps</div>
169
174
  </div>
170
175
  <div class="stat-box">
171
- <div class="stat-value" style="color:#10b981;">${passedSteps}</div>
176
+ <div class="stat-value" data-report-count="passed" style="color:#10b981;">${passedSteps}</div>
172
177
  <div style="font-size:10px;color:#64748b;text-transform:uppercase;margin-top:4px;font-weight:700;">Passed</div>
173
178
  </div>
174
179
  <div class="stat-box">
175
- <div class="stat-value" style="color:#ef4444;">${failedSteps}</div>
180
+ <div class="stat-value" data-report-count="failed" style="color:#ef4444;">${failedSteps}</div>
176
181
  <div style="font-size:10px;color:#64748b;text-transform:uppercase;margin-top:4px;font-weight:700;">Failed</div>
177
182
  </div>
178
183
  </div>