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
@@ -37,6 +37,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
37
37
  };
38
38
  Object.defineProperty(exports, "__esModule", { value: true });
39
39
  exports.startManualWorkspaceServer = startManualWorkspaceServer;
40
+ const localWorkspace_1 = require("../../security/localWorkspace");
40
41
  const projectPaths_1 = require("../../utils/projectPaths");
41
42
  const security_1 = require("../../manual/security");
42
43
  const http = __importStar(require("http"));
@@ -44,6 +45,72 @@ const path = __importStar(require("path"));
44
45
  const fs = __importStar(require("fs"));
45
46
  const ws_1 = require("ws");
46
47
  const chalk_1 = __importDefault(require("chalk"));
48
+ /** Complete local persistence before publishing a usable media URL. */
49
+ function receiveManualUpload(req, res, filePath, maxSize, limitMessage, onSaved, onChunk) {
50
+ let size = 0;
51
+ let saved = false;
52
+ let failure;
53
+ const output = fs.createWriteStream(filePath);
54
+ const sendFailure = () => {
55
+ if (!res.destroyed && !res.writableEnded && !res.headersSent) {
56
+ res.writeHead(failure?.status || 500, { 'Content-Type': 'application/json' });
57
+ res.end(JSON.stringify({ error: failure?.message || 'Upload stream failed' }));
58
+ }
59
+ };
60
+ const fail = (status, message) => {
61
+ if (saved || failure)
62
+ return;
63
+ failure = { status, message };
64
+ output.destroy();
65
+ // Drain the rejected request without buffering it so the error can be sent.
66
+ req.resume();
67
+ };
68
+ output.on('error', () => fail(500, 'Upload stream failed'));
69
+ output.once('close', () => {
70
+ if (saved)
71
+ return;
72
+ // An early destroy may race the asynchronous file open. Cleanup belongs
73
+ // after close, when a partial file can no longer be recreated or written.
74
+ try {
75
+ fs.unlinkSync(filePath);
76
+ }
77
+ catch (error) {
78
+ if (error.code !== 'ENOENT')
79
+ console.warn('[Upload]: Could not remove a partial media file.');
80
+ }
81
+ sendFailure();
82
+ });
83
+ output.once('finish', () => {
84
+ if (failure)
85
+ return;
86
+ saved = true;
87
+ Promise.resolve().then(() => onSaved(size)).catch(() => {
88
+ failure = { status: 500, message: 'Upload completion failed' };
89
+ sendFailure();
90
+ });
91
+ });
92
+ req.on('data', (chunk) => {
93
+ if (failure || saved)
94
+ return;
95
+ size += chunk.length;
96
+ if (size > maxSize) {
97
+ fail(413, limitMessage);
98
+ return;
99
+ }
100
+ onChunk?.(chunk);
101
+ if (!output.write(chunk)) {
102
+ req.pause();
103
+ output.once('drain', () => { if (!failure)
104
+ req.resume(); });
105
+ }
106
+ });
107
+ req.once('end', () => { if (!failure)
108
+ output.end(); });
109
+ req.once('aborted', () => fail(400, 'Upload aborted'));
110
+ req.once('error', () => fail(500, 'Upload stream failed'));
111
+ res.once('close', () => { if (!res.writableFinished)
112
+ fail(400, 'Upload aborted'); });
113
+ }
47
114
  async function startManualWorkspaceServer() {
48
115
  const cliInstallPath = path.resolve(__dirname, "../../..");
49
116
  const uiDistPath = path.join(cliInstallPath, "src", "ui", "dist");
@@ -74,9 +141,18 @@ async function startManualWorkspaceServer() {
74
141
  ".webm": "video/webm",
75
142
  };
76
143
  const httpServer = http.createServer(async (req, res) => {
77
- res.setHeader("Access-Control-Allow-Origin", "*");
78
- res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
79
- res.setHeader("Access-Control-Allow-Headers", "Content-Type, X-File-Extension");
144
+ const origin = typeof req.headers.origin === 'string' ? req.headers.origin : undefined;
145
+ const fetchSite = typeof req.headers['sec-fetch-site'] === 'string' ? req.headers['sec-fetch-site'] : undefined;
146
+ if (!(0, localWorkspace_1.isLocalWorkspaceRequest)(req.headers.host, origin, req.socket.localPort, fetchSite)) {
147
+ res.writeHead(403, { 'Content-Type': 'text/plain' });
148
+ res.end('Forbidden');
149
+ return;
150
+ }
151
+ if (origin)
152
+ res.setHeader('Access-Control-Allow-Origin', origin);
153
+ res.setHeader('Vary', 'Origin');
154
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
155
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type, X-File-Extension');
80
156
  if (req.method === "OPTIONS") {
81
157
  res.writeHead(204);
82
158
  return res.end();
@@ -95,43 +171,11 @@ async function startManualWorkspaceServer() {
95
171
  }
96
172
  const safeFilename = `media_${Date.now()}_${Math.random().toString(36).substring(2, 9)}${ext.toLowerCase()}`;
97
173
  const filePath = (0, projectPaths_1.projectPath)(process.cwd(), path.join(mediaDir, safeFilename));
98
- const MAX_SIZE = 104857600;
99
- let uploadedSize = 0;
100
- let isAborted = false;
101
- const writeStream = fs.createWriteStream(filePath);
102
- req.on("data", (chunk) => {
103
- if (isAborted)
104
- return;
105
- uploadedSize += chunk.length;
106
- if (uploadedSize > MAX_SIZE) {
107
- isAborted = true;
108
- writeStream.destroy();
109
- if (fs.existsSync(filePath))
110
- fs.unlinkSync(filePath);
111
- res.writeHead(413, { "Content-Type": "application/json" });
112
- res.end(JSON.stringify({ error: "Payload Too Large. Max size is 100MB." }));
113
- }
114
- else {
115
- writeStream.write(chunk);
116
- }
117
- });
118
- req.on("end", () => {
119
- if (isAborted)
120
- return;
121
- writeStream.end();
174
+ receiveManualUpload(req, res, filePath, 104857600, 'Payload Too Large. Max size is 100MB.', (uploadedSize) => {
122
175
  console.log(chalk_1.default.blue(`\n[Reference Uploaded]: Securely saved ${safeFilename} (${(uploadedSize / 1024 / 1024).toFixed(2)} MB)`));
123
- res.writeHead(200, { "Content-Type": "application/json" });
176
+ res.writeHead(200, { 'Content-Type': 'application/json' });
124
177
  res.end(JSON.stringify({ url: `/media/${safeFilename}` }));
125
178
  });
126
- req.on("error", (err) => {
127
- if (!isAborted) {
128
- writeStream.destroy();
129
- if (fs.existsSync(filePath))
130
- fs.unlinkSync(filePath);
131
- res.writeHead(500, { "Content-Type": "application/json" });
132
- res.end(JSON.stringify({ error: "Upload stream failed" }));
133
- }
134
- });
135
179
  return;
136
180
  }
137
181
  if (req.method === "DELETE" && pathname === "/api/media") {
@@ -216,44 +260,12 @@ async function startManualWorkspaceServer() {
216
260
  }
217
261
  const safeFilename = `defect_${Date.now()}_${Math.random().toString(36).substring(2, 9)}${ext.toLowerCase()}`;
218
262
  const filePath = (0, projectPaths_1.projectPath)(process.cwd(), path.join(defectMediaDir, safeFilename));
219
- const MAX_SIZE = 41943040; // Capped at 40MB
220
- let uploadedSize = 0;
221
- let isAborted = false;
222
- const writeStream = fs.createWriteStream(filePath);
223
- req.on("data", (chunk) => {
224
- if (isAborted)
225
- return;
226
- uploadedSize += chunk.length;
227
- if (uploadedSize > MAX_SIZE) {
228
- isAborted = true;
229
- writeStream.destroy();
230
- if (fs.existsSync(filePath))
231
- fs.unlinkSync(filePath);
232
- res.writeHead(413, { "Content-Type": "application/json" });
233
- res.end(JSON.stringify({ error: "Payload Too Large. Max size is 40MB." }));
234
- }
235
- else {
236
- writeStream.write(chunk);
237
- }
238
- });
239
- req.on("end", async () => {
240
- if (isAborted)
241
- return;
242
- writeStream.end();
263
+ receiveManualUpload(req, res, filePath, 41943040, 'Payload Too Large. Max size is 40MB.', () => {
243
264
  const finalUrl = `/defect_media/${safeFilename}`;
244
265
  console.log(chalk_1.default.green(`\n[Vault Success]: Defect Media secured locally at -> ${finalUrl}`));
245
- res.writeHead(200, { "Content-Type": "application/json" });
266
+ res.writeHead(200, { 'Content-Type': 'application/json' });
246
267
  res.end(JSON.stringify({ url: finalUrl }));
247
268
  });
248
- req.on("error", (err) => {
249
- if (!isAborted) {
250
- writeStream.destroy();
251
- if (fs.existsSync(filePath))
252
- fs.unlinkSync(filePath);
253
- res.writeHead(500, { "Content-Type": "application/json" });
254
- res.end(JSON.stringify({ error: "Upload stream failed" }));
255
- }
256
- });
257
269
  return;
258
270
  }
259
271
  if (req.method === "DELETE" && pathname === "/api/defect-media") {
@@ -727,7 +739,11 @@ async function startManualWorkspaceServer() {
727
739
  }
728
740
  }
729
741
  });
730
- const wss = new ws_1.WebSocketServer({ server: httpServer });
742
+ const wss = new ws_1.WebSocketServer({
743
+ server: httpServer,
744
+ verifyClient: (info) => Boolean(info.origin) &&
745
+ (0, localWorkspace_1.isLocalWorkspaceRequest)(info.req.headers.host, info.origin, info.req.socket.localPort, typeof info.req.headers['sec-fetch-site'] === 'string' ? info.req.headers['sec-fetch-site'] : undefined),
746
+ });
731
747
  wss.on("connection", (ws) => {
732
748
  ws.send(JSON.stringify({ type: "CLI_CONTEXT", payload: { command: "open" } }));
733
749
  ws.on("error", (err) => {
@@ -738,7 +754,7 @@ async function startManualWorkspaceServer() {
738
754
  console.error(chalk_1.default.red(`\n[Error]: Failed to start manual workspace server: ${e.message}`));
739
755
  process.exit(1);
740
756
  });
741
- httpServer.listen(0, async () => {
757
+ httpServer.listen(0, '127.0.0.1', async () => {
742
758
  try {
743
759
  const address = httpServer.address();
744
760
  const port = typeof address === "object" && address !== null ? address.port : null;
@@ -746,7 +762,7 @@ async function startManualWorkspaceServer() {
746
762
  console.error(chalk_1.default.red("\n[Error]: Could not bind to an available port."));
747
763
  process.exit(1);
748
764
  }
749
- const localUrl = `http://localhost:${port}`;
765
+ const localUrl = `http://127.0.0.1:${port}`;
750
766
  console.log(chalk_1.default.yellow(`\n[Workspace]: Manual Design server running at ${localUrl}`));
751
767
  const { default: open } = await import("open");
752
768
  await open(localUrl);
@@ -62,6 +62,8 @@ export declare const manualRunSchema: z.ZodObject<{
62
62
  skipped: "skipped";
63
63
  success: "success";
64
64
  pending: "pending";
65
+ pass: "pass";
66
+ blocked: "blocked";
65
67
  Pending: "Pending";
66
68
  Current: "Current";
67
69
  Passed: "Passed";
@@ -71,8 +73,6 @@ export declare const manualRunSchema: z.ZodObject<{
71
73
  Pass: "Pass";
72
74
  Fail: "Fail";
73
75
  Success: "Success";
74
- pass: "pass";
75
- blocked: "blocked";
76
76
  }>;
77
77
  group_title: z.ZodOptional<z.ZodString>;
78
78
  id: z.ZodNumber;
@@ -144,6 +144,8 @@ export declare const manualCanvasSchema: z.ZodObject<{
144
144
  skipped: "skipped";
145
145
  success: "success";
146
146
  pending: "pending";
147
+ pass: "pass";
148
+ blocked: "blocked";
147
149
  Pending: "Pending";
148
150
  Current: "Current";
149
151
  Passed: "Passed";
@@ -153,8 +155,6 @@ export declare const manualCanvasSchema: z.ZodObject<{
153
155
  Pass: "Pass";
154
156
  Fail: "Fail";
155
157
  Success: "Success";
156
- pass: "pass";
157
- blocked: "blocked";
158
158
  }>;
159
159
  group_title: z.ZodOptional<z.ZodString>;
160
160
  id: z.ZodNumber;
@@ -1,3 +1,4 @@
1
+ import { type AssertionReporter } from '../reporting/assertion_result';
1
2
  import type { Page } from 'playwright';
2
3
  import type { FlowstrideConfig } from '../types';
3
- export declare function accessibility(page: Page, config: FlowstrideConfig, action: string, input: unknown): Promise<string | undefined>;
4
+ export declare function accessibility(page: Page, config: FlowstrideConfig, action: string, input: unknown, reporter?: AssertionReporter): Promise<string | undefined>;
@@ -4,13 +4,14 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.accessibility = accessibility;
7
+ const assertion_result_1 = require("../reporting/assertion_result");
7
8
  const zod_1 = require("zod");
8
9
  const node_fs_1 = __importDefault(require("node:fs"));
9
10
  const node_path_1 = __importDefault(require("node:path"));
10
11
  const playwright_1 = __importDefault(require("@axe-core/playwright"));
11
12
  const validation_1 = require("./validation");
12
13
  const projectPaths_1 = require("../utils/projectPaths");
13
- async function accessibility(page, config, action, input) {
14
+ async function accessibility(page, config, action, input, reporter) {
14
15
  if (action === 'scan') {
15
16
  const data = zod_1.z.object({ name: validation_1.name.default('accessibility'), include: zod_1.z.array(zod_1.z.string()).optional(), exclude: zod_1.z.array(zod_1.z.string()).optional(), tags: zod_1.z.array(zod_1.z.string()).optional(), rules: zod_1.z.array(zod_1.z.string()).optional(), disableRules: zod_1.z.array(zod_1.z.string()).optional(), impacts: zod_1.z.array(zod_1.z.enum(['minor', 'moderate', 'serious', 'critical'])).optional(), maxViolations: zod_1.z.number().int().nonnegative().default(0) }).strict().parse(input);
16
17
  let builder = new playwright_1.default({ page });
@@ -29,8 +30,7 @@ async function accessibility(page, config, action, input) {
29
30
  const file = (0, validation_1.evidencePath)(config, `${data.name}-${Date.now()}.json`);
30
31
  // Reports may contain application content. They remain in local evidence.
31
32
  node_fs_1.default.writeFileSync(file, JSON.stringify({ ...result, violations }, null, 2), { mode: 0o600 });
32
- if (violations.length > data.maxViolations)
33
- throw new Error(`Accessibility assertion failed: ${violations.length} violations (${violations.map(item => item.id).join(', ')}). Report: ${file}`);
33
+ (0, assertion_result_1.requireAssertion)('a11y.scan', 'accessibility scan ' + data.name, [(0, assertion_result_1.assertionCheck)('violations', 'Violation count within the allowed maximum', violations.length <= data.maxViolations, `at most ${data.maxViolations}`, violations.length)], reporter, { reason: violations.length > data.maxViolations ? `Accessibility assertion failed: ${violations.length} violations (${violations.map(item => item.id).slice(0, 30).join(', ')}). Report: ${file}` : undefined });
34
34
  return file;
35
35
  }
36
36
  if (action === 'snapshot') {
@@ -54,14 +54,30 @@ async function accessibility(page, config, action, input) {
54
54
  }
55
55
  else {
56
56
  const expected = (0, validation_1.readProjectFile)(config, baseline).toString('utf8').replace(/\r\n/g, '\n').trim();
57
- if (expected !== snapshot.trim())
58
- throw new Error(`ARIA baseline differs. Review the captured snapshot before updating: ${file}`);
57
+ (0, assertion_result_1.requireAssertion)('a11y.snapshot', 'ARIA baseline ' + data.baseline, [(0, assertion_result_1.assertionCheck)('baseline', 'ARIA baseline matches', expected === snapshot.trim(), expected, snapshot.trim())], reporter, { reason: expected !== snapshot.trim() ? `ARIA baseline differs. Review the captured snapshot before updating: ${file}` : undefined });
59
58
  }
60
59
  return file;
61
60
  }
62
61
  if (action === 'match') {
63
62
  const data = zod_1.z.object({ selector: zod_1.z.string().default('body'), yaml: zod_1.z.string().min(1), timeout: validation_1.positive.optional() }).strict().parse(input);
64
- await (0, validation_1.poll)(async () => (await page.locator(data.selector).ariaSnapshot()).trim() === data.yaml.replace(/\r\n/g, '\n').trim(), data.timeout ?? config.timeout, 'ARIA snapshot differs');
63
+ const started = Date.now(), timeout = data.timeout ?? config.timeout;
64
+ let latest = (0, assertion_result_1.reportAssertion)('a11y.match', 'ARIA snapshot ' + data.selector, [{ key: 'snapshot', label: 'ARIA snapshot matches', status: 'running' }], reporter, { status: 'running', timeout, elapsed: 0 });
65
+ let closed = false;
66
+ try {
67
+ await (0, validation_1.poll)(async () => { const actual = (await page.locator(data.selector).ariaSnapshot()).trim(), expected = data.yaml.replace(/\r\n/g, '\n').trim(); const check = (0, assertion_result_1.assertionCheck)('snapshot', 'ARIA snapshot matches', actual === expected, expected, actual); if (!closed)
68
+ latest = (0, assertion_result_1.reportAssertion)('a11y.match', 'ARIA snapshot ' + data.selector, [check], reporter, { status: 'running', timeout, elapsed: Date.now() - started }); return check.status === 'passed'; }, timeout, 'ARIA snapshot differs');
69
+ closed = true;
70
+ reporter?.({ ...latest, status: 'passed', elapsed: Date.now() - started });
71
+ }
72
+ catch (error) {
73
+ closed = true;
74
+ const result = { ...latest, status: 'failed', elapsed: Date.now() - started, reason: (0, assertion_result_1.diagnosticValue)(error instanceof Error ? error.message : String(error)) };
75
+ for (const check of result.checks)
76
+ if (check.status === 'running')
77
+ check.status = 'failed';
78
+ reporter?.(result);
79
+ throw new assertion_result_1.AssertionFailure(result);
80
+ }
65
81
  return;
66
82
  }
67
83
  throw new Error(`Unknown accessibility action: ${action}`);
@@ -1,2 +1,3 @@
1
1
  import type { Page } from 'playwright';
2
- export declare function assertBrowser(page: Page, input: unknown, defaultTimeout: number): Promise<void>;
2
+ import { type AssertionReporter } from '../reporting/assertion_result';
3
+ export declare function assertBrowser(page: Page, input: unknown, defaultTimeout: number, reporter?: AssertionReporter): Promise<void>;
@@ -8,6 +8,7 @@ const node_path_1 = __importDefault(require("node:path"));
8
8
  const zod_1 = require("zod");
9
9
  const validation_1 = require("./validation");
10
10
  const node_util_1 = require("node:util");
11
+ const assertion_result_1 = require("../reporting/assertion_result");
11
12
  let accessibilityBundle;
12
13
  async function installAccessibilityNames(page) {
13
14
  if (await page.evaluate(() => !!window.__FLOWSTRIDE_ACCESSIBILITY_NAMES__))
@@ -28,8 +29,8 @@ const schema = zod_1.z.object({ selector: zod_1.z.string().optional(), role: zod
28
29
  value: zod_1.z.unknown().optional(), attribute: zod_1.z.string().optional(), property: zod_1.z.string().optional(), key: zod_1.z.string().optional(),
29
30
  matches: zod_1.z.string().optional(), flags: zod_1.z.string().regex(/^[imsu]*$/).optional(), not: zod_1.z.boolean().default(false), timeout: validation_1.positive.optional(),
30
31
  }).strict();
31
- async function assertBrowser(page, input, defaultTimeout) {
32
- const data = schema.parse(input), timeout = data.timeout ?? defaultTimeout;
32
+ async function assertBrowser(page, input, defaultTimeout, reporter) {
33
+ const data = schema.parse(input), timeout = data.timeout ?? defaultTimeout, started = Date.now();
33
34
  const expected = data.matches !== undefined ? new RegExp(data.matches, data.flags) : data.value;
34
35
  const equals = (actual) => expected instanceof RegExp ? typeof actual === 'string' && expected.test(actual) : (0, node_util_1.isDeepStrictEqual)(actual, expected);
35
36
  const scope = data.frame ? page.frameLocator(data.frame) : page;
@@ -44,97 +45,179 @@ async function assertBrowser(page, input, defaultTimeout) {
44
45
  throw new Error('Assertion requires an attribute, property, or storage key.');
45
46
  if (data.condition === 'count' && (!Number.isSafeInteger(expected) || expected < 0))
46
47
  throw new Error('Count requires a nonnegative integer.');
47
- await (0, validation_1.poll)(async () => {
48
- if (['accessibleName', 'accessibleDescription'].includes(data.condition))
49
- await installAccessibilityNames(page);
50
- let matched;
51
- switch (data.condition) {
52
- case 'url':
53
- matched = equals(page.url());
54
- break;
55
- case 'title':
56
- matched = equals(await page.title());
57
- break;
58
- case 'localStorage':
59
- case 'sessionStorage':
60
- matched = equals(await page.evaluate(({ kind, key }) => window[kind].getItem(key), { kind: data.condition, key: data.key }));
61
- break;
62
- case 'count':
63
- matched = equals(await locator.count());
64
- break;
65
- case 'attached':
66
- matched = await locator.count() > 0;
67
- break;
68
- case 'hidden':
69
- matched = await locator.count() === 0 || await locator.isHidden();
70
- break;
71
- case 'visible':
72
- matched = await locator.isVisible();
73
- break;
74
- default: {
75
- if (await locator.count() !== 1)
76
- return false;
77
- switch (data.condition) {
78
- case 'enabled':
79
- matched = await locator.isEnabled();
80
- break;
81
- case 'disabled':
82
- matched = await locator.isDisabled();
83
- break;
84
- case 'editable':
85
- matched = await locator.isEditable();
86
- break;
87
- case 'checked':
88
- matched = await locator.isChecked();
89
- break;
90
- case 'focused':
91
- matched = await locator.evaluate(element => element === element.ownerDocument.activeElement);
92
- break;
93
- case 'empty':
94
- matched = await locator.evaluate(element => (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement ? element.value : element.textContent || '').length === 0);
95
- break;
96
- case 'text':
97
- matched = equals((await locator.textContent())?.replace(/\s+/g, ' ').trim());
98
- break;
99
- case 'containsText': {
100
- const text = (await locator.textContent() || '').replace(/\s+/g, ' ').trim();
101
- matched = expected instanceof RegExp ? expected.test(text) : text.includes(String(expected));
102
- break;
48
+ const target = elementCondition ? (data.role ? `${data.role}${data.name === undefined ? '' : ' named ' + JSON.stringify(data.name)}` : data.selector) + (data.frame ? ` inside frame ${data.frame}` : '') : data.key ? `${data.condition} key ${JSON.stringify(data.key)}` : `page ${data.condition}`;
49
+ const needsSingle = elementCondition && !['count', 'attached'].includes(data.condition);
50
+ let latest = (0, assertion_result_1.reportAssertion)('assert.that', target, [{ key: 'target', label: 'Target lookup: ' + target, status: 'running' }, { key: 'condition', label: 'condition: ' + data.condition, status: 'not_evaluated' }], reporter, { status: 'running', timeout, elapsed: 0 });
51
+ let closed = false, lastReadError;
52
+ const progress = (checks) => {
53
+ if (closed)
54
+ return;
55
+ latest = (0, assertion_result_1.reportAssertion)('assert.that', target, checks, reporter, { status: 'running', timeout, elapsed: Date.now() - started });
56
+ };
57
+ try {
58
+ await (0, validation_1.poll)(async () => {
59
+ let count, roleCount, actual, matched = false, evaluated = false;
60
+ let sensitive = !!data.key && /password|passcode|token|secret|authorization|cookie|api.?key|otp/i.test(data.key);
61
+ lastReadError = undefined;
62
+ try {
63
+ if (elementCondition) {
64
+ count = await locator.count();
65
+ roleCount = data.role && data.name !== undefined && count === 0 ? await scope.getByRole(data.role).count() : count;
66
+ if (count === 1 && (['value', 'values'].includes(data.condition) || data.attribute === 'value' || data.property === 'value')) {
67
+ sensitive ||= await locator.evaluate(element => {
68
+ const hints = ['type', 'name', 'id', 'autocomplete'].map(key => element.getAttribute(key) || '').join(' ');
69
+ return /password|passcode|token|secret|authorization|cookie|api.?key|one-time-code|otp/i.test(hints);
70
+ });
103
71
  }
104
- case 'value':
105
- matched = equals(await locator.inputValue());
106
- break;
107
- case 'values':
108
- matched = equals(await locator.evaluate(element => Array.from(element.selectedOptions).map(option => option.value)));
109
- break;
110
- case 'attribute': {
111
- const value = await locator.getAttribute(data.attribute);
112
- matched = expected === undefined ? value !== null : equals(value);
113
- break;
72
+ }
73
+ if (!needsSingle || count === 1 || count === 0 && ['visible', 'hidden'].includes(data.condition)) {
74
+ evaluated = true;
75
+ if (['accessibleName', 'accessibleDescription'].includes(data.condition))
76
+ await installAccessibilityNames(page);
77
+ switch (data.condition) {
78
+ case 'url':
79
+ actual = page.url();
80
+ matched = equals(actual);
81
+ break;
82
+ case 'title':
83
+ actual = await page.title();
84
+ matched = equals(actual);
85
+ break;
86
+ case 'localStorage':
87
+ case 'sessionStorage':
88
+ actual = await page.evaluate(({ kind, key }) => window[kind].getItem(key), { kind: data.condition, key: data.key });
89
+ matched = equals(actual);
90
+ break;
91
+ case 'count':
92
+ actual = count;
93
+ matched = equals(actual);
94
+ break;
95
+ case 'attached':
96
+ actual = count > 0;
97
+ matched = actual === true;
98
+ break;
99
+ case 'hidden':
100
+ actual = count === 0 || await locator.isHidden();
101
+ matched = actual === true;
102
+ break;
103
+ case 'visible':
104
+ actual = await locator.isVisible();
105
+ matched = actual === true;
106
+ break;
107
+ case 'enabled':
108
+ actual = await locator.isEnabled();
109
+ matched = actual === true;
110
+ break;
111
+ case 'disabled':
112
+ actual = await locator.isDisabled();
113
+ matched = actual === true;
114
+ break;
115
+ case 'editable':
116
+ actual = await locator.isEditable();
117
+ matched = actual === true;
118
+ break;
119
+ case 'checked':
120
+ actual = await locator.isChecked();
121
+ matched = actual === true;
122
+ break;
123
+ case 'focused':
124
+ actual = await locator.evaluate(element => element === element.ownerDocument.activeElement);
125
+ matched = actual === true;
126
+ break;
127
+ case 'empty':
128
+ actual = await locator.evaluate(element => (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement ? element.value : element.textContent || '').length === 0);
129
+ matched = actual === true;
130
+ break;
131
+ case 'text':
132
+ actual = (await locator.textContent())?.replace(/\s+/g, ' ').trim();
133
+ matched = equals(actual);
134
+ break;
135
+ case 'containsText':
136
+ actual = (await locator.textContent() || '').replace(/\s+/g, ' ').trim();
137
+ matched = expected instanceof RegExp ? expected.test(String(actual)) : String(actual).includes(String(expected));
138
+ break;
139
+ case 'value':
140
+ actual = await locator.inputValue();
141
+ matched = equals(actual);
142
+ break;
143
+ case 'values':
144
+ actual = await locator.evaluate(element => Array.from(element.selectedOptions).map(option => option.value));
145
+ matched = equals(actual);
146
+ break;
147
+ case 'attribute':
148
+ actual = await locator.getAttribute(data.attribute);
149
+ matched = expected === undefined ? actual !== null : equals(actual);
150
+ break;
151
+ case 'id':
152
+ actual = await locator.getAttribute('id');
153
+ matched = equals(actual);
154
+ break;
155
+ case 'css':
156
+ actual = await locator.evaluate((element, key) => getComputedStyle(element).getPropertyValue(key), data.property);
157
+ matched = equals(actual);
158
+ break;
159
+ case 'property':
160
+ actual = await locator.evaluate((element, key) => element[key], data.property);
161
+ matched = equals(actual);
162
+ break;
163
+ case 'accessibleName':
164
+ case 'accessibleDescription':
165
+ actual = await locator.evaluate((element, condition) => { const api = element.ownerDocument.defaultView.__FLOWSTRIDE_ACCESSIBILITY_NAMES__; return condition === 'accessibleName' ? api.computeAccessibleName(element) : api.computeAccessibleDescription(element); }, data.condition);
166
+ matched = equals(actual);
167
+ break;
168
+ case 'inViewport':
169
+ actual = await locator.evaluate(element => { const box = element.getBoundingClientRect(); return box.width > 0 && box.height > 0 && box.bottom > 0 && box.right > 0 && box.top < innerHeight && box.left < innerWidth; });
170
+ matched = actual === true;
171
+ break;
114
172
  }
115
- case 'id':
116
- matched = equals(await locator.getAttribute('id'));
117
- break;
118
- case 'css':
119
- matched = equals(await locator.evaluate((element, key) => getComputedStyle(element).getPropertyValue(key), data.property));
120
- break;
121
- case 'property':
122
- matched = equals(await locator.evaluate((element, key) => element[key], data.property));
123
- break;
124
- case 'accessibleName':
125
- case 'accessibleDescription':
126
- matched = equals(await locator.evaluate((element, condition) => {
127
- const api = element.ownerDocument.defaultView.__FLOWSTRIDE_ACCESSIBILITY_NAMES__;
128
- return condition === 'accessibleName' ? api.computeAccessibleName(element) : api.computeAccessibleDescription(element);
129
- }, data.condition));
130
- break;
131
- case 'inViewport':
132
- matched = await locator.evaluate(element => { const box = element.getBoundingClientRect(); return box.width > 0 && box.height > 0 && box.bottom > 0 && box.right > 0 && box.top < innerHeight && box.left < innerWidth; });
133
- break;
134
- default: throw new Error('Unknown assertion condition.');
173
+ if (data.not)
174
+ matched = !matched;
135
175
  }
136
176
  }
137
- }
138
- return data.not ? !matched : matched;
139
- }, timeout, `${data.condition} ${data.selector || data.role || data.key || ''}`);
177
+ catch (error) {
178
+ lastReadError = (0, assertion_result_1.diagnosticValue)(error instanceof Error ? error.message : String(error));
179
+ evaluated = false;
180
+ matched = false;
181
+ }
182
+ const checks = [];
183
+ if (elementCondition) {
184
+ const absenceAllowed = count === 0 && matched;
185
+ const lookup = { key: 'target', label: count === 0 && absenceAllowed ? 'Target absent as expected' : 'Target found: ' + target, status: count === undefined ? 'failed' : count > 0 || absenceAllowed ? 'passed' : data.condition === 'count' ? 'not_evaluated' : 'failed', actual: count === undefined ? 'Could not resolve the target' : `${count} matching target(s)` };
186
+ if (lastReadError && count === undefined)
187
+ lookup.reason = lastReadError;
188
+ const part = (key, label, found) => ({ key, label, status: found === true ? 'passed' : absenceAllowed || count === undefined || data.condition === 'count' ? 'not_evaluated' : found === false ? 'failed' : 'not_evaluated' });
189
+ lookup.children = [];
190
+ if (data.role)
191
+ lookup.children.push(part('role', 'role: ' + data.role, roleCount === undefined ? undefined : roleCount > 0));
192
+ if (data.name !== undefined)
193
+ lookup.children.push(part('name', 'name: ' + data.name, count === undefined || !roleCount ? undefined : count > 0));
194
+ if (data.selector)
195
+ lookup.children.push(part('selector', 'selector: ' + data.selector, count === undefined ? undefined : count > 0));
196
+ checks.push(lookup);
197
+ if (needsSingle && count !== 0)
198
+ checks.push(count === undefined ? { key: 'unique', label: 'Exactly one matching target', status: 'not_evaluated' } : (0, assertion_result_1.assertionCheck)('unique', 'Exactly one matching target', count === 1, 1, count));
199
+ else if (needsSingle)
200
+ checks.push({ key: 'unique', label: 'Exactly one matching target', status: 'not_evaluated', reason: absenceAllowed ? 'A matching element is not required for this condition.' : 'Target lookup did not find an element.' });
201
+ }
202
+ const opposites = { visible: 'not visible', hidden: 'visible', attached: 'absent', enabled: 'disabled', disabled: 'enabled', editable: 'not editable', checked: 'unchecked', empty: 'not empty', focused: 'not focused', inViewport: 'outside the viewport' };
203
+ const positiveLabel = { inViewport: 'inside the viewport' };
204
+ const wanted = sensitive ? '[REDACTED]' : booleanConditions.includes(data.condition) ? (data.not ? opposites[data.condition] : positiveLabel[data.condition] || data.condition) : data.condition === 'attribute' && expected === undefined ? 'present' : `${data.not ? 'not ' : ''}${expected instanceof RegExp ? expected.toString() : (0, assertion_result_1.diagnosticValue)(expected)}`;
205
+ const observed = sensitive ? '[REDACTED]' : booleanConditions.includes(data.condition) && typeof actual === 'boolean' ? (actual ? positiveLabel[data.condition] || data.condition : opposites[data.condition]) : (0, assertion_result_1.diagnosticValue)(actual);
206
+ checks.push(evaluated ? (0, assertion_result_1.assertionCheck)('condition', 'condition: ' + data.condition + (data.attribute ? ' ' + data.attribute : data.property ? ' ' + data.property : ''), matched, wanted, observed) : { key: 'condition', label: 'condition: ' + data.condition, status: 'not_evaluated', expected: wanted, reason: lastReadError || 'The target could not be resolved uniquely.' });
207
+ if (lastReadError && checks.at(-1))
208
+ checks.at(-1).reason = lastReadError;
209
+ progress(checks);
210
+ return evaluated && matched;
211
+ }, timeout, `${data.condition} ${target}`);
212
+ closed = true;
213
+ reporter?.({ ...latest, status: 'passed', elapsed: Date.now() - started });
214
+ }
215
+ catch (error) {
216
+ closed = true;
217
+ const result = { ...latest, status: 'failed', elapsed: Date.now() - started, reason: (0, assertion_result_1.diagnosticValue)(error instanceof Error ? error.message : String(error)) };
218
+ if (!result.checks.some(check => check.status === 'failed'))
219
+ result.checks.push({ key: 'read', label: 'Assertion could be evaluated', status: 'failed', reason: lastReadError || result.reason });
220
+ reporter?.(result);
221
+ throw new assertion_result_1.AssertionFailure(result);
222
+ }
140
223
  }
@@ -0,0 +1,2 @@
1
+ /** Only commands whose validated contract accepts a timeout may override it. */
2
+ export declare function nativeStepTimeout(command: string, input: unknown, fallback: number): number;