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.
- package/NATIVE_BROWSER.md +40 -0
- package/NATIVE_CI.md +14 -2
- package/NATIVE_WORKFLOWS.md +44 -0
- package/README.md +8 -1
- package/dist/adapters/api/undici.d.ts +3 -1
- package/dist/adapters/api/undici.js +17 -3
- package/dist/adapters/web/playwright.js +23 -73
- package/dist/ci/runtime.js +9 -1
- package/dist/cli/commands/run.js +14 -4
- package/dist/cli/manual_server/workspace_server.js +90 -74
- package/dist/manual/security.d.ts +4 -4
- package/dist/native/accessibility.d.ts +2 -1
- package/dist/native/accessibility.js +22 -6
- package/dist/native/assertions.d.ts +2 -1
- package/dist/native/assertions.js +174 -91
- package/dist/native/deadline.d.ts +2 -0
- package/dist/native/deadline.js +13 -0
- package/dist/native/events.d.ts +3 -2
- package/dist/native/events.js +35 -8
- package/dist/native/functions.d.ts +2 -1
- package/dist/native/functions.js +34 -6
- package/dist/native/runtime.d.ts +3 -0
- package/dist/native/runtime.js +12 -6
- package/dist/parser/assertion_contract.d.ts +12 -0
- package/dist/parser/assertion_contract.js +56 -0
- package/dist/parser/parser.js +14 -6
- package/dist/reporting/assertion_result.d.ts +36 -0
- package/dist/reporting/assertion_result.js +104 -0
- package/dist/reporting/pdf_generator.js +25 -20
- package/dist/reporting/runtime_failure.d.ts +10 -0
- package/dist/reporting/runtime_failure.js +64 -0
- package/dist/reporting/step_status.d.ts +10 -0
- package/dist/reporting/step_status.js +44 -0
- package/dist/runner/flow_orchestrator.d.ts +1 -0
- package/dist/runner/flow_orchestrator.js +8 -3
- package/dist/runner/flow_worker.d.ts +8 -1
- package/dist/runner/flow_worker.js +119 -41
- package/dist/runner/plugin_manager.js +3 -0
- package/dist/runner/temporary_store.d.ts +1 -0
- package/dist/runner/temporary_store.js +3 -0
- package/dist/sdk/api_proxy.d.ts +3 -0
- package/dist/sdk/api_proxy.js +16 -0
- package/dist/security/localWorkspace.d.ts +2 -0
- package/dist/security/localWorkspace.js +17 -0
- package/dist/security/redaction.js +1 -1
- package/dist/types/index.d.ts +3 -0
- package/examples/native-workflows/README.md +144 -0
- package/examples/native-workflows/api-and-ui/README.md +80 -0
- package/examples/native-workflows/api-and-ui/flows/api-and-ui.flow +27 -0
- package/examples/native-workflows/api-and-ui/negative/wrong-backend-state.flow +27 -0
- package/examples/native-workflows/api-ui-otp/README.md +140 -0
- package/examples/native-workflows/api-ui-otp/templates/api-ui-otp.flow +31 -0
- package/examples/native-workflows/api-ui-otp/templates/existing-account-otp.flow +20 -0
- package/examples/native-workflows/audio-to-text/README.md +131 -0
- package/examples/native-workflows/audio-to-text/fixtures/microphone-tone.wav +0 -0
- package/examples/native-workflows/audio-to-text/flows/microphone-demo.flow +23 -0
- package/examples/native-workflows/audio-to-text/negative/wrong-transcript.flow +23 -0
- package/examples/native-workflows/audio-to-text/templates/real-transcription.flow +25 -0
- package/examples/native-workflows/iframes/README.md +116 -0
- package/examples/native-workflows/iframes/flows/explicit-frame-plugin.flow +20 -0
- package/examples/native-workflows/iframes/flows/readable-frame.flow +13 -0
- package/examples/native-workflows/iframes/negative/wrong-frame-result.flow +13 -0
- package/examples/native-workflows/iframes/plugins/frame-actions.ts +7 -0
- package/examples/native-workflows/manual-testing/README.md +85 -0
- package/examples/native-workflows/manual-testing/fixtures/profile-review-mismatch.json +93 -0
- package/examples/native-workflows/manual-testing/fixtures/profile-review.json +93 -0
- package/examples/native-workflows/shared/server.mjs +80 -0
- package/examples/native-workflows/tables-and-cells/README.md +118 -0
- package/examples/native-workflows/tables-and-cells/flows/coordinates.flow +17 -0
- package/examples/native-workflows/tables-and-cells/flows/search-extract-reuse.flow +27 -0
- package/examples/native-workflows/tables-and-cells/negative/wrong-cell-value.flow +27 -0
- package/examples/native-workflows/variables-and-reuse/README.md +140 -0
- package/examples/native-workflows/variables-and-reuse/flows/reuse.flow +9 -0
- package/examples/native-workflows/variables-and-reuse/flows/setup.first.flow +27 -0
- package/examples/native-workflows/variables-and-reuse/negative/wrong-reused-value.flow +27 -0
- package/examples/native-workflows/variables-and-reuse/sessions/restore.flow +10 -0
- package/examples/native-workflows/variables-and-reuse/sessions/save.flow +29 -0
- package/package.json +11 -8
- package/src/ui/dist/assets/index-s0WYkGPt.js +636 -0
- package/src/ui/dist/index.html +1 -1
- 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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
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, {
|
|
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
|
-
|
|
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, {
|
|
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({
|
|
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://
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
116
|
-
matched =
|
|
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
|
-
|
|
139
|
-
|
|
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
|
}
|