@vibecheckai/cli 3.1.0 → 3.1.1

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 (158) hide show
  1. package/bin/.generated +25 -25
  2. package/bin/dev/run-v2-torture.js +30 -30
  3. package/bin/registry.js +105 -105
  4. package/bin/runners/lib/__tests__/entitlements-v2.test.js +295 -295
  5. package/bin/runners/lib/analysis-core.js +271 -271
  6. package/bin/runners/lib/analyzers.js +579 -579
  7. package/bin/runners/lib/auth-truth.js +193 -193
  8. package/bin/runners/lib/backup.js +62 -62
  9. package/bin/runners/lib/billing.js +107 -107
  10. package/bin/runners/lib/claims.js +118 -118
  11. package/bin/runners/lib/cli-output.js +368 -368
  12. package/bin/runners/lib/cli-ui.js +540 -540
  13. package/bin/runners/lib/contracts/auth-contract.js +202 -202
  14. package/bin/runners/lib/contracts/env-contract.js +181 -181
  15. package/bin/runners/lib/contracts/external-contract.js +206 -206
  16. package/bin/runners/lib/contracts/guard.js +168 -168
  17. package/bin/runners/lib/contracts/index.js +89 -89
  18. package/bin/runners/lib/contracts/plan-validator.js +311 -311
  19. package/bin/runners/lib/contracts/route-contract.js +199 -199
  20. package/bin/runners/lib/contracts.js +804 -804
  21. package/bin/runners/lib/detect.js +89 -89
  22. package/bin/runners/lib/detectors-v2.js +703 -703
  23. package/bin/runners/lib/doctor/autofix.js +254 -254
  24. package/bin/runners/lib/doctor/index.js +37 -37
  25. package/bin/runners/lib/doctor/modules/dependencies.js +325 -325
  26. package/bin/runners/lib/doctor/modules/index.js +46 -46
  27. package/bin/runners/lib/doctor/modules/network.js +250 -250
  28. package/bin/runners/lib/doctor/modules/project.js +312 -312
  29. package/bin/runners/lib/doctor/modules/runtime.js +224 -224
  30. package/bin/runners/lib/doctor/modules/security.js +348 -348
  31. package/bin/runners/lib/doctor/modules/system.js +213 -213
  32. package/bin/runners/lib/doctor/modules/vibecheck.js +394 -394
  33. package/bin/runners/lib/doctor/reporter.js +262 -262
  34. package/bin/runners/lib/doctor/service.js +262 -262
  35. package/bin/runners/lib/doctor/types.js +113 -113
  36. package/bin/runners/lib/doctor/ui.js +263 -263
  37. package/bin/runners/lib/doctor-v2.js +608 -608
  38. package/bin/runners/lib/drift.js +425 -425
  39. package/bin/runners/lib/enforcement.js +72 -72
  40. package/bin/runners/lib/enterprise-detect.js +603 -603
  41. package/bin/runners/lib/enterprise-init.js +942 -942
  42. package/bin/runners/lib/entitlements-v2.js +490 -489
  43. package/bin/runners/lib/env-resolver.js +417 -417
  44. package/bin/runners/lib/env-template.js +66 -66
  45. package/bin/runners/lib/env.js +189 -189
  46. package/bin/runners/lib/extractors/client-calls.js +990 -990
  47. package/bin/runners/lib/extractors/fastify-route-dump.js +573 -573
  48. package/bin/runners/lib/extractors/fastify-routes.js +426 -426
  49. package/bin/runners/lib/extractors/index.js +363 -363
  50. package/bin/runners/lib/extractors/next-routes.js +524 -524
  51. package/bin/runners/lib/extractors/proof-graph.js +431 -431
  52. package/bin/runners/lib/extractors/route-matcher.js +451 -451
  53. package/bin/runners/lib/extractors/truthpack-v2.js +377 -377
  54. package/bin/runners/lib/extractors/ui-bindings.js +547 -547
  55. package/bin/runners/lib/findings-schema.js +281 -281
  56. package/bin/runners/lib/firewall-prompt.js +50 -50
  57. package/bin/runners/lib/graph/graph-builder.js +265 -265
  58. package/bin/runners/lib/graph/html-renderer.js +413 -413
  59. package/bin/runners/lib/graph/index.js +32 -32
  60. package/bin/runners/lib/graph/runtime-collector.js +215 -215
  61. package/bin/runners/lib/graph/static-extractor.js +518 -518
  62. package/bin/runners/lib/html-report.js +650 -650
  63. package/bin/runners/lib/init-wizard.js +308 -308
  64. package/bin/runners/lib/llm.js +75 -75
  65. package/bin/runners/lib/meter.js +61 -61
  66. package/bin/runners/lib/missions/evidence.js +126 -126
  67. package/bin/runners/lib/missions/plan.js +69 -69
  68. package/bin/runners/lib/missions/templates.js +192 -192
  69. package/bin/runners/lib/patch.js +40 -40
  70. package/bin/runners/lib/permissions/auth-model.js +213 -213
  71. package/bin/runners/lib/permissions/idor-prover.js +205 -205
  72. package/bin/runners/lib/permissions/index.js +45 -45
  73. package/bin/runners/lib/permissions/matrix-builder.js +198 -198
  74. package/bin/runners/lib/pkgjson.js +28 -28
  75. package/bin/runners/lib/policy.js +295 -295
  76. package/bin/runners/lib/preflight.js +142 -142
  77. package/bin/runners/lib/reality/correlation-detectors.js +359 -359
  78. package/bin/runners/lib/reality/index.js +318 -318
  79. package/bin/runners/lib/reality/request-hashing.js +416 -416
  80. package/bin/runners/lib/reality/request-mapper.js +453 -453
  81. package/bin/runners/lib/reality/safety-rails.js +463 -463
  82. package/bin/runners/lib/reality/semantic-snapshot.js +408 -408
  83. package/bin/runners/lib/reality/toast-detector.js +393 -393
  84. package/bin/runners/lib/reality-findings.js +84 -84
  85. package/bin/runners/lib/receipts.js +179 -179
  86. package/bin/runners/lib/redact.js +29 -29
  87. package/bin/runners/lib/replay/capsule-manager.js +154 -154
  88. package/bin/runners/lib/replay/index.js +263 -263
  89. package/bin/runners/lib/replay/player.js +348 -348
  90. package/bin/runners/lib/replay/recorder.js +331 -331
  91. package/bin/runners/lib/report-engine.js +447 -447
  92. package/bin/runners/lib/report-html.js +1499 -1499
  93. package/bin/runners/lib/report-templates.js +969 -969
  94. package/bin/runners/lib/report.js +135 -135
  95. package/bin/runners/lib/route-detection.js +1140 -1140
  96. package/bin/runners/lib/route-truth.js +477 -477
  97. package/bin/runners/lib/sandbox/index.js +59 -59
  98. package/bin/runners/lib/sandbox/proof-chain.js +399 -399
  99. package/bin/runners/lib/sandbox/sandbox-runner.js +205 -205
  100. package/bin/runners/lib/sandbox/worktree.js +174 -174
  101. package/bin/runners/lib/schema-validator.js +350 -350
  102. package/bin/runners/lib/schemas/contracts.schema.json +160 -160
  103. package/bin/runners/lib/schemas/finding.schema.json +100 -100
  104. package/bin/runners/lib/schemas/mission-pack.schema.json +206 -206
  105. package/bin/runners/lib/schemas/proof-graph.schema.json +176 -176
  106. package/bin/runners/lib/schemas/reality-report.schema.json +162 -162
  107. package/bin/runners/lib/schemas/share-pack.schema.json +180 -180
  108. package/bin/runners/lib/schemas/ship-report.schema.json +117 -117
  109. package/bin/runners/lib/schemas/truthpack-v2.schema.json +303 -303
  110. package/bin/runners/lib/schemas/validator.js +438 -438
  111. package/bin/runners/lib/score-history.js +282 -282
  112. package/bin/runners/lib/share-pack.js +239 -239
  113. package/bin/runners/lib/snippets.js +67 -67
  114. package/bin/runners/lib/truth.js +667 -667
  115. package/bin/runners/lib/upsell.js +510 -510
  116. package/bin/runners/lib/usage.js +153 -153
  117. package/bin/runners/lib/validate-patch.js +156 -156
  118. package/bin/runners/lib/verdict-engine.js +628 -628
  119. package/bin/runners/reality/engine.js +917 -917
  120. package/bin/runners/reality/flows.js +122 -122
  121. package/bin/runners/reality/report.js +378 -378
  122. package/bin/runners/reality/session.js +193 -193
  123. package/bin/runners/runAuth.js +51 -0
  124. package/bin/runners/runClaimVerifier.js +483 -483
  125. package/bin/runners/runContext.js +56 -56
  126. package/bin/runners/runContextCompiler.js +385 -385
  127. package/bin/runners/runCtx.js +674 -674
  128. package/bin/runners/runCtxDiff.js +301 -301
  129. package/bin/runners/runCtxGuard.js +176 -176
  130. package/bin/runners/runCtxSync.js +116 -116
  131. package/bin/runners/runGate.js +17 -17
  132. package/bin/runners/runGraph.js +454 -454
  133. package/bin/runners/runGuard.js +168 -168
  134. package/bin/runners/runInitGha.js +164 -164
  135. package/bin/runners/runInstall.js +277 -277
  136. package/bin/runners/runInteractive.js +388 -388
  137. package/bin/runners/runLabs.js +340 -340
  138. package/bin/runners/runMissionGenerator.js +282 -282
  139. package/bin/runners/runPR.js +255 -255
  140. package/bin/runners/runPermissions.js +304 -304
  141. package/bin/runners/runPreflight.js +580 -553
  142. package/bin/runners/runProve.js +1252 -1252
  143. package/bin/runners/runReality.js +1328 -1328
  144. package/bin/runners/runReplay.js +499 -499
  145. package/bin/runners/runReport.js +584 -584
  146. package/bin/runners/runShare.js +212 -212
  147. package/bin/runners/runStatus.js +138 -138
  148. package/bin/runners/runTruthpack.js +636 -636
  149. package/bin/runners/runVerify.js +272 -272
  150. package/bin/runners/runWatch.js +407 -407
  151. package/bin/vibecheck.js +2 -1
  152. package/mcp-server/consolidated-tools.js +804 -804
  153. package/mcp-server/package.json +1 -1
  154. package/mcp-server/tools/index.js +72 -72
  155. package/mcp-server/truth-context.js +581 -581
  156. package/mcp-server/truth-firewall-tools.js +1500 -1500
  157. package/package.json +1 -1
  158. package/bin/runners/runProof.zip +0 -0
@@ -1,917 +1,917 @@
1
- /**
2
- * Reality Mode Execution Engine
3
- * Step execution, danger classification, assertions, surface discovery
4
- */
5
-
6
- const fs = require("fs");
7
- const path = require("path");
8
- const crypto = require("crypto");
9
-
10
- // ============================================================================
11
- // Utilities
12
- // ============================================================================
13
-
14
- function uuid() {
15
- try {
16
- return crypto.randomUUID();
17
- } catch {
18
- return crypto.randomBytes(16).toString("hex");
19
- }
20
- }
21
-
22
- function randomEmail() {
23
- return `reality+${Date.now()}_${Math.floor(Math.random() * 1e6)}@example.com`;
24
- }
25
-
26
- function template(str, vars) {
27
- if (typeof str !== "string") return str;
28
- return str.replace(/\{\{([^}]+)\}\}/g, (_, key) => {
29
- const k = String(key || "").trim();
30
- if (k === "$timestamp") return String(Date.now());
31
- if (k === "$uuid") return uuid();
32
- if (k === "$randomEmail") return randomEmail();
33
- if (k === "$isoDate") return new Date().toISOString();
34
- if (k.startsWith("env.")) return process.env[k.slice(4)] || "";
35
- if (k.startsWith("stored.")) return vars?.[k] ?? "";
36
- return vars?.[k] != null ? String(vars[k]) : "";
37
- });
38
- }
39
-
40
- // ============================================================================
41
- // Danger Classification System
42
- // ============================================================================
43
-
44
- const DANGER_TEXT_PATTERNS = [
45
- /\bdelete\b/i,
46
- /\bremove\b/i,
47
- /\bdestroy\b/i,
48
- /\bdeactivate\b/i,
49
- /\bterminate\b/i,
50
- /\bclose\s*account\b/i,
51
- /\bcancel\s*subscription\b/i,
52
- /\breset\s*all\b/i,
53
- /\bwipe\b/i,
54
- /\berase\b/i,
55
- /\birreversible\b/i,
56
- /\bpermanently\b/i,
57
- /\bcannot\s*be\s*undone\b/i,
58
- /\brevoke\b/i,
59
- /\bdisconnect\b/i,
60
- /\bunlink\b/i,
61
- ];
62
-
63
- const DANGER_CLASS_PATTERNS = [
64
- /\bbtn-danger\b/,
65
- /\bdestructive\b/,
66
- /\bdelete-/,
67
- /\bdanger-/,
68
- /\bremove-/,
69
- ];
70
-
71
- function classifyDanger({ text, className, ariaLabel, role, url, method }) {
72
- const signals = [];
73
- let score = 0;
74
-
75
- const t = `${text || ""}`.toLowerCase();
76
- const cls = `${className || ""}`.toLowerCase();
77
- const aria = `${ariaLabel || ""}`.toLowerCase();
78
- const r = `${role || ""}`.toLowerCase();
79
- const u = `${url || ""}`.toLowerCase();
80
- const m = `${method || ""}`.toUpperCase();
81
-
82
- // Text patterns
83
- for (const pattern of DANGER_TEXT_PATTERNS) {
84
- if (pattern.test(t) || pattern.test(aria)) {
85
- signals.push({ type: "text", value: pattern.source, weight: 0.3 });
86
- score += 0.3;
87
- }
88
- }
89
-
90
- // Class patterns
91
- for (const pattern of DANGER_CLASS_PATTERNS) {
92
- if (pattern.test(cls)) {
93
- signals.push({ type: "class", value: pattern.source, weight: 0.25 });
94
- score += 0.25;
95
- }
96
- }
97
-
98
- // ARIA patterns
99
- if (r === "alertdialog") {
100
- signals.push({ type: "aria", value: "role=alertdialog", weight: 0.2 });
101
- score += 0.2;
102
- }
103
-
104
- // Network patterns (DELETE method or destructive endpoints)
105
- if (m === "DELETE") {
106
- signals.push({ type: "network", value: "DELETE method", weight: 0.4 });
107
- score += 0.4;
108
- }
109
- if (/\/delete\b|\/destroy\b|\/deactivate\b|\/remove\b/.test(u)) {
110
- signals.push({ type: "network", value: "destructive endpoint", weight: 0.35 });
111
- score += 0.35;
112
- }
113
-
114
- const dangerous = score >= 0.25;
115
- const reason = signals.length > 0 ? signals[0].type : "none";
116
-
117
- return {
118
- dangerous,
119
- score: Math.min(1, score),
120
- signals,
121
- reason,
122
- };
123
- }
124
-
125
- // ============================================================================
126
- // Screenshots & DOM Snapshots
127
- // ============================================================================
128
-
129
- async function screenshot(page, outPath) {
130
- try {
131
- await page.screenshot({ path: outPath, fullPage: true });
132
- } catch {
133
- // Non-fatal
134
- }
135
- }
136
-
137
- async function domSnapshot(page) {
138
- try {
139
- return await page.evaluate(() => {
140
- const el = document.activeElement;
141
- return {
142
- url: location.href,
143
- title: document.title,
144
- active: el ? { tag: el.tagName, id: el.id, name: el.getAttribute("name") } : null,
145
- bodyTextSample: document.body?.innerText?.slice(0, 500) || "",
146
- };
147
- });
148
- } catch {
149
- return null;
150
- }
151
- }
152
-
153
- // ============================================================================
154
- // Navigation Helpers
155
- // ============================================================================
156
-
157
- async function safeGoto(page, url, timeout) {
158
- const started = Date.now();
159
- try {
160
- const res = await page.goto(url, { waitUntil: "domcontentloaded", timeout });
161
- await page.waitForLoadState("networkidle", { timeout: Math.min(timeout, 5000) }).catch(() => {});
162
- const status = res ? res.status() : 0;
163
- return { ok: status >= 200 && status < 400, status, ms: Date.now() - started };
164
- } catch (e) {
165
- return { ok: false, status: 0, ms: Date.now() - started, error: String(e?.message || e).slice(0, 180) };
166
- }
167
- }
168
-
169
- async function findLocator(page, target) {
170
- return page.locator(target).first();
171
- }
172
-
173
- // ============================================================================
174
- // Surface Discovery
175
- // ============================================================================
176
-
177
- async function discoverSurface({ page, baseUrl, timeout, maxPages }) {
178
- const discovered = {
179
- routes: [],
180
- elements: [],
181
- forms: [],
182
- };
183
-
184
- // Visit base
185
- const home = await safeGoto(page, baseUrl, timeout);
186
- if (home.status) {
187
- discovered.routes.push({ path: "/", status: home.ok ? "success" : "error", httpStatus: home.status });
188
- }
189
-
190
- // Extract routes
191
- const routes = await page.evaluate(() => {
192
- const out = new Set();
193
- document.querySelectorAll("a[href]").forEach(a => {
194
- const href = a.getAttribute("href") || "";
195
- if (!href || href.startsWith("http") || href.startsWith("//") || href.includes("#")) return;
196
- if (href.startsWith("/")) out.add(href);
197
- });
198
- // Common SPA routes
199
- ["/dashboard", "/app", "/account", "/settings", "/profile", "/projects", "/analytics", "/billing"].forEach(r => out.add(r));
200
- return Array.from(out);
201
- }).catch(() => []);
202
-
203
- const queue = routes.slice(0, maxPages * 2);
204
- const seen = new Set(["/", baseUrl]);
205
-
206
- for (const route of queue.slice(0, maxPages)) {
207
- if (seen.has(route)) continue;
208
- seen.add(route);
209
-
210
- const res = await safeGoto(page, baseUrl + route, timeout);
211
- discovered.routes.push({
212
- path: route,
213
- status: res.ok ? "success" : "error",
214
- httpStatus: res.status || 0,
215
- error: res.error,
216
- });
217
-
218
- // Discover elements on this page
219
- const pageEls = await page.evaluate(() => {
220
- const pickText = (el) => (el.textContent || el.getAttribute("aria-label") || "").trim().slice(0, 80);
221
- const els = Array.from(document.querySelectorAll('button, [role="button"], input[type="submit"], a[href], [data-testid]'));
222
- return els.slice(0, 100).map((el, idx) => ({
223
- tag: el.tagName.toLowerCase(),
224
- text: pickText(el),
225
- id: el.id || "",
226
- testid: el.getAttribute("data-testid") || "",
227
- className: el.className || "",
228
- ariaLabel: el.getAttribute("aria-label") || "",
229
- href: el.getAttribute("href") || "",
230
- index: idx,
231
- }));
232
- }).catch(() => []);
233
-
234
- for (const e of pageEls) {
235
- if (!e.text && !e.testid && !e.id) continue;
236
- discovered.elements.push({ ...e, page: route });
237
- }
238
-
239
- // Discover forms
240
- const forms = await page.evaluate(() => {
241
- const forms = Array.from(document.querySelectorAll("form"));
242
- return forms.slice(0, 20).map((form, idx) => {
243
- const fields = Array.from(form.querySelectorAll("input, textarea, select")).slice(0, 30).map((f) => ({
244
- name: f.getAttribute("name") || f.id || "",
245
- type: f.getAttribute("type") || f.tagName.toLowerCase(),
246
- required: !!f.required,
247
- }));
248
- return {
249
- selector: form.id ? `#${form.id}` : form.getAttribute("data-testid") ? `[data-testid="${form.getAttribute("data-testid")}"]` : `form:nth-of-type(${idx + 1})`,
250
- fields,
251
- };
252
- });
253
- }).catch(() => []);
254
-
255
- for (const f of forms) discovered.forms.push({ ...f, page: route });
256
- }
257
-
258
- // Dedupe
259
- discovered.routes = dedupe(discovered.routes, r => r.path);
260
- discovered.elements = dedupe(discovered.elements, e => `${e.page}|${e.tag}|${e.testid}|${e.id}|${e.text}`);
261
- discovered.forms = dedupe(discovered.forms, f => `${f.page}|${f.selector}`);
262
-
263
- return discovered;
264
- }
265
-
266
- function dedupe(arr, keyFn) {
267
- const seen = new Set();
268
- const out = [];
269
- for (const x of arr) {
270
- const k = keyFn(x);
271
- if (seen.has(k)) continue;
272
- seen.add(k);
273
- out.push(x);
274
- }
275
- return out;
276
- }
277
-
278
- // ============================================================================
279
- // Flow Plan Execution
280
- // ============================================================================
281
-
282
- async function runFlowPlan({ plan, page, context, telemetry }) {
283
- const results = {
284
- meta: {
285
- baseUrl: plan.baseUrl,
286
- startedAt: new Date().toISOString(),
287
- danger: plan.danger,
288
- maxPages: plan.maxPages,
289
- timeout: plan.timeout,
290
- },
291
- discovery: null,
292
- coverage: {
293
- routesDiscovered: 0,
294
- routesWorking: 0,
295
- elementsDiscovered: 0,
296
- elementsWorking: 0,
297
- formsDiscovered: 0,
298
- formsWorking: 0,
299
- },
300
- flows: [],
301
- routes: [],
302
- elements: [],
303
- forms: [],
304
- errors: [],
305
- network: [],
306
- console: [],
307
- timeline: [],
308
- score: 0,
309
- duration: 0,
310
- };
311
-
312
- // Discovery pass
313
- const discovery = await discoverSurface({
314
- page,
315
- baseUrl: plan.baseUrl,
316
- timeout: plan.timeout,
317
- maxPages: plan.maxPages,
318
- });
319
-
320
- results.discovery = {
321
- routes: discovery.routes.length,
322
- elements: discovery.elements.length,
323
- forms: discovery.forms.length,
324
- };
325
-
326
- results.routes = discovery.routes;
327
- results.elements = discovery.elements;
328
- results.forms = discovery.forms;
329
-
330
- results.coverage.routesDiscovered = discovery.routes.length;
331
- results.coverage.routesWorking = discovery.routes.filter(r => r.status === "success").length;
332
- results.coverage.elementsDiscovered = discovery.elements.length;
333
- results.coverage.formsDiscovered = discovery.forms.length;
334
-
335
- // Collect telemetry
336
- results.console = telemetry.console.slice(-500);
337
- results.errors = [
338
- ...telemetry.pageErrors.map(e => ({ type: "uncaught", message: e.message, url: e.url, ts: e.ts })),
339
- ...telemetry.console.filter(m => m.type === "error").map(m => ({ type: "console", message: m.text, url: m.url, ts: m.ts })),
340
- ];
341
- results.network = telemetry.responses.slice(-500);
342
-
343
- // Run Flow Packs
344
- const flowVarsBase = makeBaseVars(plan);
345
- const flowsToRun = Array.isArray(plan.flows) ? plan.flows : [];
346
-
347
- for (const flow of flowsToRun) {
348
- const flowResult = await runSingleFlow({
349
- flow,
350
- plan,
351
- page,
352
- context,
353
- telemetry,
354
- varsBase: flowVarsBase,
355
- results,
356
- });
357
- results.flows.push(flowResult);
358
- }
359
-
360
- // Count working elements from successful flow steps
361
- let elementWorking = 0;
362
- for (const f of results.flows) {
363
- for (const s of (f.steps || [])) {
364
- if ((s.action === "click" || s.action === "fill") && s.status === "success") elementWorking++;
365
- }
366
- }
367
- results.coverage.elementsWorking = Math.min(elementWorking, results.coverage.elementsDiscovered);
368
-
369
- // Calculate score
370
- results.score = calculateScore(results);
371
-
372
- return results;
373
- }
374
-
375
- function makeBaseVars(plan) {
376
- const vars = {
377
- baseUrl: plan.baseUrl,
378
- timestamp: String(Date.now()),
379
- $timestamp: String(Date.now()),
380
- $uuid: uuid(),
381
- $randomEmail: randomEmail(),
382
- $isoDate: new Date().toISOString(),
383
- };
384
-
385
- if (plan.auth && typeof plan.auth === "string" && plan.auth.includes(":")) {
386
- const [email, ...rest] = plan.auth.split(":");
387
- vars.email = email;
388
- vars.password = rest.join(":");
389
- }
390
-
391
- return vars;
392
- }
393
-
394
- // ============================================================================
395
- // Single Flow Execution
396
- // ============================================================================
397
-
398
- async function runSingleFlow({ flow, plan, page, telemetry, varsBase, results }) {
399
- const flowStart = Date.now();
400
- const flowVars = { ...varsBase, ...(flow.vars || {}) };
401
-
402
- // Template all vars
403
- for (const k of Object.keys(flowVars)) {
404
- flowVars[k] = template(flowVars[k], flowVars);
405
- }
406
-
407
- const flowOut = {
408
- id: flow.id,
409
- name: flow.name,
410
- source: flow.__source,
411
- required: !!flow.required,
412
- status: "success",
413
- startedAt: new Date(flowStart).toISOString(),
414
- durationMs: 0,
415
- steps: [],
416
- assertions: [],
417
- errors: [],
418
- };
419
-
420
- // Run steps
421
- let stepIndex = 0;
422
- for (const step of flow.steps || []) {
423
- stepIndex++;
424
- const stepId = `${flow.id}#${stepIndex}`;
425
- const stepName = step.name || `${step.action || "step"}-${stepIndex}`;
426
- const stepStart = Date.now();
427
-
428
- const stepRec = {
429
- id: stepId,
430
- name: stepName,
431
- action: step.action,
432
- target: step.target || null,
433
- status: "success",
434
- error: null,
435
- danger: null,
436
- artifacts: {},
437
- startedAt: new Date(stepStart).toISOString(),
438
- durationMs: 0,
439
- };
440
-
441
- // Trace marker
442
- try {
443
- await page.evaluate((label) => console.log(`[reality:trace] ${label}`), `STEP ${stepId}`);
444
- } catch {}
445
-
446
- // Before screenshot
447
- const beforeShot = path.join(plan.artifacts.screenshotsDir, `${flow.id}__${stepIndex}__before.png`);
448
- await screenshot(page, beforeShot);
449
- stepRec.artifacts.beforeScreenshot = path.relative(plan.outputDir, beforeShot);
450
- stepRec.artifacts.beforeDom = await domSnapshot(page);
451
-
452
- // Execute action
453
- try {
454
- await executeStep({ step, stepRec, plan, page, flowVars, telemetry });
455
- } catch (e) {
456
- stepRec.status = "error";
457
- stepRec.error = String(e?.message || e).slice(0, 220);
458
- flowOut.errors.push({ step: stepIndex, message: stepRec.error });
459
- }
460
-
461
- // After screenshot
462
- const afterShot = path.join(plan.artifacts.screenshotsDir, `${flow.id}__${stepIndex}__after.png`);
463
- await screenshot(page, afterShot);
464
- stepRec.artifacts.afterScreenshot = path.relative(plan.outputDir, afterShot);
465
- stepRec.artifacts.afterDom = await domSnapshot(page);
466
-
467
- stepRec.durationMs = Date.now() - stepStart;
468
-
469
- // Add to timeline
470
- results.timeline.push({
471
- flowId: flow.id,
472
- step: stepIndex,
473
- name: stepRec.name,
474
- action: stepRec.action,
475
- status: stepRec.status,
476
- at: Date.now(),
477
- url: page.url(),
478
- before: stepRec.artifacts.beforeScreenshot,
479
- after: stepRec.artifacts.afterScreenshot,
480
- });
481
-
482
- flowOut.steps.push(stepRec);
483
- }
484
-
485
- // Run assertions
486
- for (const a of flow.assertions || []) {
487
- const ar = await runAssertion({ assertion: a, plan, page, telemetry, vars: flowVars });
488
- flowOut.assertions.push(ar);
489
- if (ar.status === "fail" && a.critical) {
490
- flowOut.status = "error";
491
- }
492
- }
493
-
494
- // Cleanup (best-effort)
495
- for (const step of flow.cleanup || []) {
496
- try {
497
- await executeStep({ step, stepRec: {}, plan, page, flowVars, telemetry });
498
- } catch {}
499
- }
500
-
501
- flowOut.durationMs = Date.now() - flowStart;
502
- if (flowOut.errors.length > 0) flowOut.status = "error";
503
-
504
- return flowOut;
505
- }
506
-
507
- // ============================================================================
508
- // Step Execution
509
- // ============================================================================
510
-
511
- async function executeStep({ step, stepRec, plan, page, flowVars, telemetry }) {
512
- const action = String(step.action || "").toLowerCase();
513
-
514
- if (action === "navigate") {
515
- const target = template(step.target || "/", flowVars);
516
- const url = target.startsWith("http") ? target : (plan.baseUrl + (target.startsWith("/") ? target : `/${target}`));
517
- const res = await safeGoto(page, url, plan.timeout);
518
- if (!res.ok) throw new Error(res.error || `Navigation failed: ${url}`);
519
- }
520
-
521
- else if (action === "wait") {
522
- if (step.for === "selector" && step.target) {
523
- const target = template(step.target, flowVars);
524
- const state = step.state || "visible";
525
- const loc = await findLocator(page, target);
526
- await loc.waitFor({ state, timeout: Number(step.timeout || plan.timeout) });
527
- } else if (step.for === "navigation" && step.match) {
528
- const match = new RegExp(template(step.match, flowVars));
529
- const timeout = Number(step.timeout || plan.timeout);
530
- const end = Date.now() + timeout;
531
- while (Date.now() < end) {
532
- if (match.test(page.url())) break;
533
- await page.waitForTimeout(200);
534
- }
535
- if (!match.test(page.url())) throw new Error(`URL did not match ${step.match}`);
536
- } else if (step.for === "network" && step.match) {
537
- const match = template(step.match, flowVars);
538
- const timeout = Number(step.timeout || plan.timeout);
539
- const since = Date.now();
540
- const end = since + timeout;
541
- let found = false;
542
- while (Date.now() < end) {
543
- const recent = telemetry.responses.filter(r => r.ts >= since);
544
- if (recent.some(r => r.url.includes(match))) { found = true; break; }
545
- await page.waitForTimeout(200);
546
- }
547
- if (!found) throw new Error(`No network call matching ${match}`);
548
- } else {
549
- const ms = Number(step.timeout || step.ms || 1000);
550
- await page.waitForTimeout(ms);
551
- }
552
- }
553
-
554
- else if (action === "fill") {
555
- const target = template(step.target, flowVars);
556
- const value = template(step.value, flowVars);
557
- const loc = await findLocator(page, target);
558
- await loc.waitFor({ state: "visible", timeout: plan.timeout }).catch(() => {});
559
- if (step.clear !== false) await loc.clear().catch(() => {});
560
- await loc.fill(String(value ?? ""), { timeout: plan.timeout });
561
- }
562
-
563
- else if (action === "click") {
564
- const target = template(step.target, flowVars);
565
- const loc = await findLocator(page, target);
566
-
567
- // Danger detection
568
- let meta = {};
569
- try {
570
- meta = await loc.evaluate((el) => ({
571
- text: (el.textContent || "").trim().slice(0, 120),
572
- className: el.className || "",
573
- ariaLabel: el.getAttribute("aria-label") || "",
574
- role: el.getAttribute("role") || "",
575
- }));
576
- } catch {}
577
-
578
- const danger = classifyDanger({ ...meta, url: page.url() });
579
- stepRec.danger = danger;
580
-
581
- const policy = String(step.dangerPolicy || "skip").toLowerCase();
582
- if (danger.dangerous && !plan.danger) {
583
- if (policy === "block") {
584
- stepRec.status = "blocked";
585
- stepRec.error = `Blocked destructive action (${danger.reason}). Use --danger to allow.`;
586
- return;
587
- }
588
- stepRec.status = "skipped";
589
- stepRec.error = `Skipped destructive action (${danger.reason}). Use --danger to allow.`;
590
- return;
591
- }
592
-
593
- await loc.waitFor({ state: "visible", timeout: plan.timeout }).catch(() => {});
594
- try {
595
- await loc.click({ timeout: plan.timeout });
596
- } catch {
597
- await loc.click({ timeout: plan.timeout, force: true });
598
- }
599
- await page.waitForTimeout(300);
600
- }
601
-
602
- else if (action === "check") {
603
- const target = template(step.target, flowVars);
604
- const loc = await findLocator(page, target);
605
- await loc.check({ timeout: plan.timeout });
606
- }
607
-
608
- else if (action === "uncheck") {
609
- const target = template(step.target, flowVars);
610
- const loc = await findLocator(page, target);
611
- await loc.uncheck({ timeout: plan.timeout });
612
- }
613
-
614
- else if (action === "select") {
615
- const target = template(step.target, flowVars);
616
- const loc = await findLocator(page, target);
617
- if (step.value) {
618
- await loc.selectOption({ value: template(step.value, flowVars) });
619
- } else if (step.label) {
620
- await loc.selectOption({ label: template(step.label, flowVars) });
621
- } else if (step.index != null) {
622
- await loc.selectOption({ index: Number(step.index) });
623
- }
624
- }
625
-
626
- else if (action === "hover") {
627
- const target = template(step.target, flowVars);
628
- const loc = await findLocator(page, target);
629
- await loc.hover({ timeout: plan.timeout });
630
- }
631
-
632
- else if (action === "press") {
633
- const key = template(step.key, flowVars);
634
- await page.keyboard.press(key);
635
- }
636
-
637
- else if (action === "screenshot") {
638
- const name = template(step.name || `manual-${Date.now()}`, flowVars);
639
- const outPath = path.join(plan.artifacts.screenshotsDir, `${name}.png`);
640
- await screenshot(page, outPath);
641
- stepRec.artifacts.manualScreenshot = path.relative(plan.outputDir, outPath);
642
- }
643
-
644
- else if (action === "scroll") {
645
- if (step.to === "bottom") {
646
- await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
647
- } else if (step.to === "top") {
648
- await page.evaluate(() => window.scrollTo(0, 0));
649
- } else if (step.target) {
650
- const target = template(step.target, flowVars);
651
- const loc = await findLocator(page, target);
652
- await loc.scrollIntoViewIfNeeded();
653
- }
654
- }
655
-
656
- else if (action === "execute") {
657
- if (step.script) {
658
- await page.evaluate(step.script);
659
- } else if (step.scriptFile) {
660
- const scriptPath = path.resolve(step.scriptFile);
661
- const script = fs.readFileSync(scriptPath, "utf8");
662
- await page.evaluate(script);
663
- }
664
- }
665
-
666
- else {
667
- stepRec.status = "unknown";
668
- stepRec.error = `Unknown action: ${step.action}`;
669
- }
670
- }
671
-
672
- // ============================================================================
673
- // Assertions
674
- // ============================================================================
675
-
676
- async function runAssertion({ assertion, plan, page, telemetry, vars }) {
677
- const type = String(assertion.type || "").toLowerCase().replace(/-/g, "");
678
- const critical = !!assertion.critical;
679
-
680
- const out = {
681
- type: assertion.type,
682
- critical,
683
- status: "pass",
684
- message: "",
685
- };
686
-
687
- try {
688
- if (type === "urlcontains") {
689
- const v = template(assertion.value, vars);
690
- if (!page.url().toLowerCase().includes(v.toLowerCase())) {
691
- throw new Error(`URL does not contain: ${v} (got ${page.url()})`);
692
- }
693
- }
694
-
695
- else if (type === "urlmatches") {
696
- const v = template(assertion.pattern || assertion.value, vars);
697
- const re = new RegExp(v);
698
- if (!re.test(page.url())) throw new Error(`URL does not match: ${v}`);
699
- }
700
-
701
- else if (type === "urlnotcontains") {
702
- const v = template(assertion.value, vars);
703
- if (page.url().toLowerCase().includes(v.toLowerCase())) {
704
- throw new Error(`URL should not contain: ${v}`);
705
- }
706
- }
707
-
708
- else if (type === "routechanged") {
709
- const within = Number(assertion.within || 3000);
710
- const match = assertion.match ? new RegExp(template(assertion.match, vars)) : null;
711
- const before = page.url();
712
- await page.waitForTimeout(Math.min(within, 500));
713
- const after = page.url();
714
- if (after === before) throw new Error(`Route did not change`);
715
- if (match && !match.test(after)) throw new Error(`Route changed but does not match: ${assertion.match}`);
716
- }
717
-
718
- else if (type === "elementvisible") {
719
- const target = template(assertion.target, vars);
720
- const loc = await findLocator(page, target);
721
- await loc.waitFor({ state: "visible", timeout: Number(assertion.within || plan.timeout) });
722
- }
723
-
724
- else if (type === "elementhidden") {
725
- const target = template(assertion.target, vars);
726
- const loc = await findLocator(page, target);
727
- await loc.waitFor({ state: "hidden", timeout: Number(assertion.within || plan.timeout) });
728
- }
729
-
730
- else if (type === "elementtext") {
731
- const target = template(assertion.target, vars);
732
- const loc = await findLocator(page, target);
733
- const text = await loc.textContent({ timeout: plan.timeout });
734
- if (assertion.contains) {
735
- const expected = template(assertion.contains, vars);
736
- if (!String(text || "").includes(expected)) {
737
- throw new Error(`Element text does not contain "${expected}"`);
738
- }
739
- }
740
- if (assertion.matches) {
741
- const re = new RegExp(template(assertion.matches, vars));
742
- if (!re.test(text || "")) {
743
- throw new Error(`Element text does not match ${assertion.matches}`);
744
- }
745
- }
746
- }
747
-
748
- else if (type === "elementcount") {
749
- const target = template(assertion.target, vars);
750
- const count = await page.locator(target).count();
751
- if (assertion.min != null && count < assertion.min) {
752
- throw new Error(`Element count ${count} < min ${assertion.min}`);
753
- }
754
- if (assertion.max != null && count > assertion.max) {
755
- throw new Error(`Element count ${count} > max ${assertion.max}`);
756
- }
757
- if (assertion.equals != null && count !== assertion.equals) {
758
- throw new Error(`Element count ${count} !== ${assertion.equals}`);
759
- }
760
- }
761
-
762
- else if (type === "toastcontains" || type === "notificationvisible") {
763
- const v = template(assertion.text || assertion.contains || assertion.value, vars);
764
- const within = Number(assertion.within || 5000);
765
- const toastSel = assertion.target || '[role="status"], [role="alert"], .toast, .sonner-toast, [data-sonner-toast], .notification';
766
- const loc = page.locator(toastSel).filter({ hasText: new RegExp(v, "i") }).first();
767
- await loc.waitFor({ state: "visible", timeout: within });
768
- }
769
-
770
- else if (type === "toastnotcontains") {
771
- const v = template(assertion.text || assertion.value, vars);
772
- const within = Number(assertion.within || 3000);
773
- const toastSel = assertion.target || '[role="status"], [role="alert"], .toast, .sonner-toast, [data-sonner-toast], .notification';
774
- await page.waitForTimeout(Math.min(within, 1500));
775
- const count = await page.locator(toastSel).filter({ hasText: new RegExp(v, "i") }).count();
776
- if (count > 0) throw new Error(`Toast contains forbidden text: ${v}`);
777
- }
778
-
779
- else if (type === "networkcalled") {
780
- const v = template(assertion.match || assertion.value, vars);
781
- const within = Number(assertion.within || 10000);
782
- const minTimes = assertion.times ?? assertion.minTimes ?? 1;
783
- const since = Date.now() - within;
784
- const matches = telemetry.responses.filter(r => r.ts >= since && r.url.includes(v));
785
- if (matches.length < minTimes) {
786
- throw new Error(`Expected ${minTimes} calls matching ${v}, got ${matches.length}`);
787
- }
788
- }
789
-
790
- else if (type === "networkstatus") {
791
- const v = template(assertion.match || assertion.value, vars);
792
- const expectStatus = Number(assertion.status || 200);
793
- const within = Number(assertion.within || 10000);
794
- const since = Date.now() - within;
795
- const match = telemetry.responses.find(r => r.ts >= since && r.url.includes(v));
796
- if (!match) throw new Error(`No response matching: ${v}`);
797
- if (match.status !== expectStatus) {
798
- throw new Error(`Expected ${expectStatus}, got ${match.status} for ${match.url}`);
799
- }
800
- }
801
-
802
- else if (type === "networktiming") {
803
- const v = template(assertion.match || assertion.value, vars);
804
- const maxDuration = Number(assertion.maxDuration || 5000);
805
- // Check request/response pairs - simplified version
806
- const match = telemetry.responses.find(r => r.url.includes(v));
807
- if (!match) throw new Error(`No response matching: ${v}`);
808
- // We don't have timing data in this simplified version
809
- out.message = `Network timing check (simplified): ${v}`;
810
- }
811
-
812
- else if (type === "noconsoleerrors") {
813
- const severity = String(assertion.severity || "error").toLowerCase();
814
- const windowMs = Number(assertion.within || 60000);
815
- const since = Date.now() - windowMs;
816
-
817
- const ignorePatterns = (assertion.ignore || []).map(p => new RegExp(p));
818
-
819
- const bad = telemetry.console
820
- .filter(m => m.ts >= since)
821
- .filter(m => {
822
- if (severity === "error") return m.type === "error";
823
- if (severity === "warning" || severity === "warn") return m.type === "error" || m.type === "warning";
824
- return m.type === "error";
825
- })
826
- .filter(m => !ignorePatterns.some(re => re.test(m.text)));
827
-
828
- if (bad.length > 0) {
829
- throw new Error(`Console ${bad[0].type}: ${String(bad[0].text || "").slice(0, 140)}`);
830
- }
831
- }
832
-
833
- else if (type === "consolecontains") {
834
- const match = template(assertion.match || assertion.value, vars);
835
- const severity = String(assertion.severity || "log").toLowerCase();
836
- const found = telemetry.console.some(m => {
837
- if (severity !== "all" && m.type !== severity) return false;
838
- return m.text.includes(match);
839
- });
840
- if (!found) throw new Error(`Console does not contain: ${match}`);
841
- }
842
-
843
- else if (type === "localstorage") {
844
- const key = template(assertion.key, vars);
845
- const value = await page.evaluate((k) => localStorage.getItem(k), key);
846
- if (assertion.exists === true && value === null) {
847
- throw new Error(`localStorage key "${key}" does not exist`);
848
- }
849
- if (assertion.exists === false && value !== null) {
850
- throw new Error(`localStorage key "${key}" should not exist`);
851
- }
852
- if (assertion.equals != null && value !== assertion.equals) {
853
- throw new Error(`localStorage "${key}" !== "${assertion.equals}"`);
854
- }
855
- if (assertion.matches) {
856
- const re = new RegExp(assertion.matches);
857
- if (!re.test(value || "")) throw new Error(`localStorage "${key}" does not match ${assertion.matches}`);
858
- }
859
- }
860
-
861
- else if (type === "sessionstorage") {
862
- const key = template(assertion.key, vars);
863
- const value = await page.evaluate((k) => sessionStorage.getItem(k), key);
864
- if (assertion.exists === true && value === null) {
865
- throw new Error(`sessionStorage key "${key}" does not exist`);
866
- }
867
- if (assertion.matches) {
868
- const re = new RegExp(assertion.matches);
869
- if (!re.test(value || "")) throw new Error(`sessionStorage "${key}" does not match ${assertion.matches}`);
870
- }
871
- }
872
-
873
- else {
874
- out.status = "skip";
875
- out.message = `Assertion type not implemented: ${assertion.type}`;
876
- }
877
- } catch (e) {
878
- out.status = "fail";
879
- out.message = String(e?.message || e).slice(0, 220);
880
- }
881
-
882
- return out;
883
- }
884
-
885
- // ============================================================================
886
- // Score Calculation
887
- // ============================================================================
888
-
889
- function calculateScore(results) {
890
- const routesDiscovered = results.coverage?.routesDiscovered || 0;
891
- const routesWorking = results.coverage?.routesWorking || 0;
892
- const routePct = routesDiscovered ? (routesWorking / routesDiscovered) : 1;
893
-
894
- const flows = results.flows || [];
895
- const flowTotal = flows.length || 1;
896
- const flowOk = flows.filter(f => f.status === "success").length;
897
-
898
- let assertsTotal = 0;
899
- let assertsPass = 0;
900
- for (const f of flows) {
901
- for (const a of (f.assertions || [])) {
902
- if (a.status === "skip") continue;
903
- assertsTotal++;
904
- if (a.status === "pass") assertsPass++;
905
- }
906
- }
907
- const assertPct = assertsTotal ? (assertsPass / assertsTotal) : 1;
908
-
909
- const errors = results.errors?.length || 0;
910
- const penalty = Math.min(errors * 3, 20);
911
-
912
- const score = (routePct * 30) + ((flowOk / flowTotal) * 40) + (assertPct * 30) - penalty;
913
-
914
- return Math.max(0, Math.min(100, Math.round(score)));
915
- }
916
-
917
- module.exports = { runFlowPlan, classifyDanger, discoverSurface };
1
+ /**
2
+ * Reality Mode Execution Engine
3
+ * Step execution, danger classification, assertions, surface discovery
4
+ */
5
+
6
+ const fs = require("fs");
7
+ const path = require("path");
8
+ const crypto = require("crypto");
9
+
10
+ // ============================================================================
11
+ // Utilities
12
+ // ============================================================================
13
+
14
+ function uuid() {
15
+ try {
16
+ return crypto.randomUUID();
17
+ } catch {
18
+ return crypto.randomBytes(16).toString("hex");
19
+ }
20
+ }
21
+
22
+ function randomEmail() {
23
+ return `reality+${Date.now()}_${Math.floor(Math.random() * 1e6)}@example.com`;
24
+ }
25
+
26
+ function template(str, vars) {
27
+ if (typeof str !== "string") return str;
28
+ return str.replace(/\{\{([^}]+)\}\}/g, (_, key) => {
29
+ const k = String(key || "").trim();
30
+ if (k === "$timestamp") return String(Date.now());
31
+ if (k === "$uuid") return uuid();
32
+ if (k === "$randomEmail") return randomEmail();
33
+ if (k === "$isoDate") return new Date().toISOString();
34
+ if (k.startsWith("env.")) return process.env[k.slice(4)] || "";
35
+ if (k.startsWith("stored.")) return vars?.[k] ?? "";
36
+ return vars?.[k] != null ? String(vars[k]) : "";
37
+ });
38
+ }
39
+
40
+ // ============================================================================
41
+ // Danger Classification System
42
+ // ============================================================================
43
+
44
+ const DANGER_TEXT_PATTERNS = [
45
+ /\bdelete\b/i,
46
+ /\bremove\b/i,
47
+ /\bdestroy\b/i,
48
+ /\bdeactivate\b/i,
49
+ /\bterminate\b/i,
50
+ /\bclose\s*account\b/i,
51
+ /\bcancel\s*subscription\b/i,
52
+ /\breset\s*all\b/i,
53
+ /\bwipe\b/i,
54
+ /\berase\b/i,
55
+ /\birreversible\b/i,
56
+ /\bpermanently\b/i,
57
+ /\bcannot\s*be\s*undone\b/i,
58
+ /\brevoke\b/i,
59
+ /\bdisconnect\b/i,
60
+ /\bunlink\b/i,
61
+ ];
62
+
63
+ const DANGER_CLASS_PATTERNS = [
64
+ /\bbtn-danger\b/,
65
+ /\bdestructive\b/,
66
+ /\bdelete-/,
67
+ /\bdanger-/,
68
+ /\bremove-/,
69
+ ];
70
+
71
+ function classifyDanger({ text, className, ariaLabel, role, url, method }) {
72
+ const signals = [];
73
+ let score = 0;
74
+
75
+ const t = `${text || ""}`.toLowerCase();
76
+ const cls = `${className || ""}`.toLowerCase();
77
+ const aria = `${ariaLabel || ""}`.toLowerCase();
78
+ const r = `${role || ""}`.toLowerCase();
79
+ const u = `${url || ""}`.toLowerCase();
80
+ const m = `${method || ""}`.toUpperCase();
81
+
82
+ // Text patterns
83
+ for (const pattern of DANGER_TEXT_PATTERNS) {
84
+ if (pattern.test(t) || pattern.test(aria)) {
85
+ signals.push({ type: "text", value: pattern.source, weight: 0.3 });
86
+ score += 0.3;
87
+ }
88
+ }
89
+
90
+ // Class patterns
91
+ for (const pattern of DANGER_CLASS_PATTERNS) {
92
+ if (pattern.test(cls)) {
93
+ signals.push({ type: "class", value: pattern.source, weight: 0.25 });
94
+ score += 0.25;
95
+ }
96
+ }
97
+
98
+ // ARIA patterns
99
+ if (r === "alertdialog") {
100
+ signals.push({ type: "aria", value: "role=alertdialog", weight: 0.2 });
101
+ score += 0.2;
102
+ }
103
+
104
+ // Network patterns (DELETE method or destructive endpoints)
105
+ if (m === "DELETE") {
106
+ signals.push({ type: "network", value: "DELETE method", weight: 0.4 });
107
+ score += 0.4;
108
+ }
109
+ if (/\/delete\b|\/destroy\b|\/deactivate\b|\/remove\b/.test(u)) {
110
+ signals.push({ type: "network", value: "destructive endpoint", weight: 0.35 });
111
+ score += 0.35;
112
+ }
113
+
114
+ const dangerous = score >= 0.25;
115
+ const reason = signals.length > 0 ? signals[0].type : "none";
116
+
117
+ return {
118
+ dangerous,
119
+ score: Math.min(1, score),
120
+ signals,
121
+ reason,
122
+ };
123
+ }
124
+
125
+ // ============================================================================
126
+ // Screenshots & DOM Snapshots
127
+ // ============================================================================
128
+
129
+ async function screenshot(page, outPath) {
130
+ try {
131
+ await page.screenshot({ path: outPath, fullPage: true });
132
+ } catch {
133
+ // Non-fatal
134
+ }
135
+ }
136
+
137
+ async function domSnapshot(page) {
138
+ try {
139
+ return await page.evaluate(() => {
140
+ const el = document.activeElement;
141
+ return {
142
+ url: location.href,
143
+ title: document.title,
144
+ active: el ? { tag: el.tagName, id: el.id, name: el.getAttribute("name") } : null,
145
+ bodyTextSample: document.body?.innerText?.slice(0, 500) || "",
146
+ };
147
+ });
148
+ } catch {
149
+ return null;
150
+ }
151
+ }
152
+
153
+ // ============================================================================
154
+ // Navigation Helpers
155
+ // ============================================================================
156
+
157
+ async function safeGoto(page, url, timeout) {
158
+ const started = Date.now();
159
+ try {
160
+ const res = await page.goto(url, { waitUntil: "domcontentloaded", timeout });
161
+ await page.waitForLoadState("networkidle", { timeout: Math.min(timeout, 5000) }).catch(() => {});
162
+ const status = res ? res.status() : 0;
163
+ return { ok: status >= 200 && status < 400, status, ms: Date.now() - started };
164
+ } catch (e) {
165
+ return { ok: false, status: 0, ms: Date.now() - started, error: String(e?.message || e).slice(0, 180) };
166
+ }
167
+ }
168
+
169
+ async function findLocator(page, target) {
170
+ return page.locator(target).first();
171
+ }
172
+
173
+ // ============================================================================
174
+ // Surface Discovery
175
+ // ============================================================================
176
+
177
+ async function discoverSurface({ page, baseUrl, timeout, maxPages }) {
178
+ const discovered = {
179
+ routes: [],
180
+ elements: [],
181
+ forms: [],
182
+ };
183
+
184
+ // Visit base
185
+ const home = await safeGoto(page, baseUrl, timeout);
186
+ if (home.status) {
187
+ discovered.routes.push({ path: "/", status: home.ok ? "success" : "error", httpStatus: home.status });
188
+ }
189
+
190
+ // Extract routes
191
+ const routes = await page.evaluate(() => {
192
+ const out = new Set();
193
+ document.querySelectorAll("a[href]").forEach(a => {
194
+ const href = a.getAttribute("href") || "";
195
+ if (!href || href.startsWith("http") || href.startsWith("//") || href.includes("#")) return;
196
+ if (href.startsWith("/")) out.add(href);
197
+ });
198
+ // Common SPA routes
199
+ ["/dashboard", "/app", "/account", "/settings", "/profile", "/projects", "/analytics", "/billing"].forEach(r => out.add(r));
200
+ return Array.from(out);
201
+ }).catch(() => []);
202
+
203
+ const queue = routes.slice(0, maxPages * 2);
204
+ const seen = new Set(["/", baseUrl]);
205
+
206
+ for (const route of queue.slice(0, maxPages)) {
207
+ if (seen.has(route)) continue;
208
+ seen.add(route);
209
+
210
+ const res = await safeGoto(page, baseUrl + route, timeout);
211
+ discovered.routes.push({
212
+ path: route,
213
+ status: res.ok ? "success" : "error",
214
+ httpStatus: res.status || 0,
215
+ error: res.error,
216
+ });
217
+
218
+ // Discover elements on this page
219
+ const pageEls = await page.evaluate(() => {
220
+ const pickText = (el) => (el.textContent || el.getAttribute("aria-label") || "").trim().slice(0, 80);
221
+ const els = Array.from(document.querySelectorAll('button, [role="button"], input[type="submit"], a[href], [data-testid]'));
222
+ return els.slice(0, 100).map((el, idx) => ({
223
+ tag: el.tagName.toLowerCase(),
224
+ text: pickText(el),
225
+ id: el.id || "",
226
+ testid: el.getAttribute("data-testid") || "",
227
+ className: el.className || "",
228
+ ariaLabel: el.getAttribute("aria-label") || "",
229
+ href: el.getAttribute("href") || "",
230
+ index: idx,
231
+ }));
232
+ }).catch(() => []);
233
+
234
+ for (const e of pageEls) {
235
+ if (!e.text && !e.testid && !e.id) continue;
236
+ discovered.elements.push({ ...e, page: route });
237
+ }
238
+
239
+ // Discover forms
240
+ const forms = await page.evaluate(() => {
241
+ const forms = Array.from(document.querySelectorAll("form"));
242
+ return forms.slice(0, 20).map((form, idx) => {
243
+ const fields = Array.from(form.querySelectorAll("input, textarea, select")).slice(0, 30).map((f) => ({
244
+ name: f.getAttribute("name") || f.id || "",
245
+ type: f.getAttribute("type") || f.tagName.toLowerCase(),
246
+ required: !!f.required,
247
+ }));
248
+ return {
249
+ selector: form.id ? `#${form.id}` : form.getAttribute("data-testid") ? `[data-testid="${form.getAttribute("data-testid")}"]` : `form:nth-of-type(${idx + 1})`,
250
+ fields,
251
+ };
252
+ });
253
+ }).catch(() => []);
254
+
255
+ for (const f of forms) discovered.forms.push({ ...f, page: route });
256
+ }
257
+
258
+ // Dedupe
259
+ discovered.routes = dedupe(discovered.routes, r => r.path);
260
+ discovered.elements = dedupe(discovered.elements, e => `${e.page}|${e.tag}|${e.testid}|${e.id}|${e.text}`);
261
+ discovered.forms = dedupe(discovered.forms, f => `${f.page}|${f.selector}`);
262
+
263
+ return discovered;
264
+ }
265
+
266
+ function dedupe(arr, keyFn) {
267
+ const seen = new Set();
268
+ const out = [];
269
+ for (const x of arr) {
270
+ const k = keyFn(x);
271
+ if (seen.has(k)) continue;
272
+ seen.add(k);
273
+ out.push(x);
274
+ }
275
+ return out;
276
+ }
277
+
278
+ // ============================================================================
279
+ // Flow Plan Execution
280
+ // ============================================================================
281
+
282
+ async function runFlowPlan({ plan, page, context, telemetry }) {
283
+ const results = {
284
+ meta: {
285
+ baseUrl: plan.baseUrl,
286
+ startedAt: new Date().toISOString(),
287
+ danger: plan.danger,
288
+ maxPages: plan.maxPages,
289
+ timeout: plan.timeout,
290
+ },
291
+ discovery: null,
292
+ coverage: {
293
+ routesDiscovered: 0,
294
+ routesWorking: 0,
295
+ elementsDiscovered: 0,
296
+ elementsWorking: 0,
297
+ formsDiscovered: 0,
298
+ formsWorking: 0,
299
+ },
300
+ flows: [],
301
+ routes: [],
302
+ elements: [],
303
+ forms: [],
304
+ errors: [],
305
+ network: [],
306
+ console: [],
307
+ timeline: [],
308
+ score: 0,
309
+ duration: 0,
310
+ };
311
+
312
+ // Discovery pass
313
+ const discovery = await discoverSurface({
314
+ page,
315
+ baseUrl: plan.baseUrl,
316
+ timeout: plan.timeout,
317
+ maxPages: plan.maxPages,
318
+ });
319
+
320
+ results.discovery = {
321
+ routes: discovery.routes.length,
322
+ elements: discovery.elements.length,
323
+ forms: discovery.forms.length,
324
+ };
325
+
326
+ results.routes = discovery.routes;
327
+ results.elements = discovery.elements;
328
+ results.forms = discovery.forms;
329
+
330
+ results.coverage.routesDiscovered = discovery.routes.length;
331
+ results.coverage.routesWorking = discovery.routes.filter(r => r.status === "success").length;
332
+ results.coverage.elementsDiscovered = discovery.elements.length;
333
+ results.coverage.formsDiscovered = discovery.forms.length;
334
+
335
+ // Collect telemetry
336
+ results.console = telemetry.console.slice(-500);
337
+ results.errors = [
338
+ ...telemetry.pageErrors.map(e => ({ type: "uncaught", message: e.message, url: e.url, ts: e.ts })),
339
+ ...telemetry.console.filter(m => m.type === "error").map(m => ({ type: "console", message: m.text, url: m.url, ts: m.ts })),
340
+ ];
341
+ results.network = telemetry.responses.slice(-500);
342
+
343
+ // Run Flow Packs
344
+ const flowVarsBase = makeBaseVars(plan);
345
+ const flowsToRun = Array.isArray(plan.flows) ? plan.flows : [];
346
+
347
+ for (const flow of flowsToRun) {
348
+ const flowResult = await runSingleFlow({
349
+ flow,
350
+ plan,
351
+ page,
352
+ context,
353
+ telemetry,
354
+ varsBase: flowVarsBase,
355
+ results,
356
+ });
357
+ results.flows.push(flowResult);
358
+ }
359
+
360
+ // Count working elements from successful flow steps
361
+ let elementWorking = 0;
362
+ for (const f of results.flows) {
363
+ for (const s of (f.steps || [])) {
364
+ if ((s.action === "click" || s.action === "fill") && s.status === "success") elementWorking++;
365
+ }
366
+ }
367
+ results.coverage.elementsWorking = Math.min(elementWorking, results.coverage.elementsDiscovered);
368
+
369
+ // Calculate score
370
+ results.score = calculateScore(results);
371
+
372
+ return results;
373
+ }
374
+
375
+ function makeBaseVars(plan) {
376
+ const vars = {
377
+ baseUrl: plan.baseUrl,
378
+ timestamp: String(Date.now()),
379
+ $timestamp: String(Date.now()),
380
+ $uuid: uuid(),
381
+ $randomEmail: randomEmail(),
382
+ $isoDate: new Date().toISOString(),
383
+ };
384
+
385
+ if (plan.auth && typeof plan.auth === "string" && plan.auth.includes(":")) {
386
+ const [email, ...rest] = plan.auth.split(":");
387
+ vars.email = email;
388
+ vars.password = rest.join(":");
389
+ }
390
+
391
+ return vars;
392
+ }
393
+
394
+ // ============================================================================
395
+ // Single Flow Execution
396
+ // ============================================================================
397
+
398
+ async function runSingleFlow({ flow, plan, page, telemetry, varsBase, results }) {
399
+ const flowStart = Date.now();
400
+ const flowVars = { ...varsBase, ...(flow.vars || {}) };
401
+
402
+ // Template all vars
403
+ for (const k of Object.keys(flowVars)) {
404
+ flowVars[k] = template(flowVars[k], flowVars);
405
+ }
406
+
407
+ const flowOut = {
408
+ id: flow.id,
409
+ name: flow.name,
410
+ source: flow.__source,
411
+ required: !!flow.required,
412
+ status: "success",
413
+ startedAt: new Date(flowStart).toISOString(),
414
+ durationMs: 0,
415
+ steps: [],
416
+ assertions: [],
417
+ errors: [],
418
+ };
419
+
420
+ // Run steps
421
+ let stepIndex = 0;
422
+ for (const step of flow.steps || []) {
423
+ stepIndex++;
424
+ const stepId = `${flow.id}#${stepIndex}`;
425
+ const stepName = step.name || `${step.action || "step"}-${stepIndex}`;
426
+ const stepStart = Date.now();
427
+
428
+ const stepRec = {
429
+ id: stepId,
430
+ name: stepName,
431
+ action: step.action,
432
+ target: step.target || null,
433
+ status: "success",
434
+ error: null,
435
+ danger: null,
436
+ artifacts: {},
437
+ startedAt: new Date(stepStart).toISOString(),
438
+ durationMs: 0,
439
+ };
440
+
441
+ // Trace marker
442
+ try {
443
+ await page.evaluate((label) => console.log(`[reality:trace] ${label}`), `STEP ${stepId}`);
444
+ } catch {}
445
+
446
+ // Before screenshot
447
+ const beforeShot = path.join(plan.artifacts.screenshotsDir, `${flow.id}__${stepIndex}__before.png`);
448
+ await screenshot(page, beforeShot);
449
+ stepRec.artifacts.beforeScreenshot = path.relative(plan.outputDir, beforeShot);
450
+ stepRec.artifacts.beforeDom = await domSnapshot(page);
451
+
452
+ // Execute action
453
+ try {
454
+ await executeStep({ step, stepRec, plan, page, flowVars, telemetry });
455
+ } catch (e) {
456
+ stepRec.status = "error";
457
+ stepRec.error = String(e?.message || e).slice(0, 220);
458
+ flowOut.errors.push({ step: stepIndex, message: stepRec.error });
459
+ }
460
+
461
+ // After screenshot
462
+ const afterShot = path.join(plan.artifacts.screenshotsDir, `${flow.id}__${stepIndex}__after.png`);
463
+ await screenshot(page, afterShot);
464
+ stepRec.artifacts.afterScreenshot = path.relative(plan.outputDir, afterShot);
465
+ stepRec.artifacts.afterDom = await domSnapshot(page);
466
+
467
+ stepRec.durationMs = Date.now() - stepStart;
468
+
469
+ // Add to timeline
470
+ results.timeline.push({
471
+ flowId: flow.id,
472
+ step: stepIndex,
473
+ name: stepRec.name,
474
+ action: stepRec.action,
475
+ status: stepRec.status,
476
+ at: Date.now(),
477
+ url: page.url(),
478
+ before: stepRec.artifacts.beforeScreenshot,
479
+ after: stepRec.artifacts.afterScreenshot,
480
+ });
481
+
482
+ flowOut.steps.push(stepRec);
483
+ }
484
+
485
+ // Run assertions
486
+ for (const a of flow.assertions || []) {
487
+ const ar = await runAssertion({ assertion: a, plan, page, telemetry, vars: flowVars });
488
+ flowOut.assertions.push(ar);
489
+ if (ar.status === "fail" && a.critical) {
490
+ flowOut.status = "error";
491
+ }
492
+ }
493
+
494
+ // Cleanup (best-effort)
495
+ for (const step of flow.cleanup || []) {
496
+ try {
497
+ await executeStep({ step, stepRec: {}, plan, page, flowVars, telemetry });
498
+ } catch {}
499
+ }
500
+
501
+ flowOut.durationMs = Date.now() - flowStart;
502
+ if (flowOut.errors.length > 0) flowOut.status = "error";
503
+
504
+ return flowOut;
505
+ }
506
+
507
+ // ============================================================================
508
+ // Step Execution
509
+ // ============================================================================
510
+
511
+ async function executeStep({ step, stepRec, plan, page, flowVars, telemetry }) {
512
+ const action = String(step.action || "").toLowerCase();
513
+
514
+ if (action === "navigate") {
515
+ const target = template(step.target || "/", flowVars);
516
+ const url = target.startsWith("http") ? target : (plan.baseUrl + (target.startsWith("/") ? target : `/${target}`));
517
+ const res = await safeGoto(page, url, plan.timeout);
518
+ if (!res.ok) throw new Error(res.error || `Navigation failed: ${url}`);
519
+ }
520
+
521
+ else if (action === "wait") {
522
+ if (step.for === "selector" && step.target) {
523
+ const target = template(step.target, flowVars);
524
+ const state = step.state || "visible";
525
+ const loc = await findLocator(page, target);
526
+ await loc.waitFor({ state, timeout: Number(step.timeout || plan.timeout) });
527
+ } else if (step.for === "navigation" && step.match) {
528
+ const match = new RegExp(template(step.match, flowVars));
529
+ const timeout = Number(step.timeout || plan.timeout);
530
+ const end = Date.now() + timeout;
531
+ while (Date.now() < end) {
532
+ if (match.test(page.url())) break;
533
+ await page.waitForTimeout(200);
534
+ }
535
+ if (!match.test(page.url())) throw new Error(`URL did not match ${step.match}`);
536
+ } else if (step.for === "network" && step.match) {
537
+ const match = template(step.match, flowVars);
538
+ const timeout = Number(step.timeout || plan.timeout);
539
+ const since = Date.now();
540
+ const end = since + timeout;
541
+ let found = false;
542
+ while (Date.now() < end) {
543
+ const recent = telemetry.responses.filter(r => r.ts >= since);
544
+ if (recent.some(r => r.url.includes(match))) { found = true; break; }
545
+ await page.waitForTimeout(200);
546
+ }
547
+ if (!found) throw new Error(`No network call matching ${match}`);
548
+ } else {
549
+ const ms = Number(step.timeout || step.ms || 1000);
550
+ await page.waitForTimeout(ms);
551
+ }
552
+ }
553
+
554
+ else if (action === "fill") {
555
+ const target = template(step.target, flowVars);
556
+ const value = template(step.value, flowVars);
557
+ const loc = await findLocator(page, target);
558
+ await loc.waitFor({ state: "visible", timeout: plan.timeout }).catch(() => {});
559
+ if (step.clear !== false) await loc.clear().catch(() => {});
560
+ await loc.fill(String(value ?? ""), { timeout: plan.timeout });
561
+ }
562
+
563
+ else if (action === "click") {
564
+ const target = template(step.target, flowVars);
565
+ const loc = await findLocator(page, target);
566
+
567
+ // Danger detection
568
+ let meta = {};
569
+ try {
570
+ meta = await loc.evaluate((el) => ({
571
+ text: (el.textContent || "").trim().slice(0, 120),
572
+ className: el.className || "",
573
+ ariaLabel: el.getAttribute("aria-label") || "",
574
+ role: el.getAttribute("role") || "",
575
+ }));
576
+ } catch {}
577
+
578
+ const danger = classifyDanger({ ...meta, url: page.url() });
579
+ stepRec.danger = danger;
580
+
581
+ const policy = String(step.dangerPolicy || "skip").toLowerCase();
582
+ if (danger.dangerous && !plan.danger) {
583
+ if (policy === "block") {
584
+ stepRec.status = "blocked";
585
+ stepRec.error = `Blocked destructive action (${danger.reason}). Use --danger to allow.`;
586
+ return;
587
+ }
588
+ stepRec.status = "skipped";
589
+ stepRec.error = `Skipped destructive action (${danger.reason}). Use --danger to allow.`;
590
+ return;
591
+ }
592
+
593
+ await loc.waitFor({ state: "visible", timeout: plan.timeout }).catch(() => {});
594
+ try {
595
+ await loc.click({ timeout: plan.timeout });
596
+ } catch {
597
+ await loc.click({ timeout: plan.timeout, force: true });
598
+ }
599
+ await page.waitForTimeout(300);
600
+ }
601
+
602
+ else if (action === "check") {
603
+ const target = template(step.target, flowVars);
604
+ const loc = await findLocator(page, target);
605
+ await loc.check({ timeout: plan.timeout });
606
+ }
607
+
608
+ else if (action === "uncheck") {
609
+ const target = template(step.target, flowVars);
610
+ const loc = await findLocator(page, target);
611
+ await loc.uncheck({ timeout: plan.timeout });
612
+ }
613
+
614
+ else if (action === "select") {
615
+ const target = template(step.target, flowVars);
616
+ const loc = await findLocator(page, target);
617
+ if (step.value) {
618
+ await loc.selectOption({ value: template(step.value, flowVars) });
619
+ } else if (step.label) {
620
+ await loc.selectOption({ label: template(step.label, flowVars) });
621
+ } else if (step.index != null) {
622
+ await loc.selectOption({ index: Number(step.index) });
623
+ }
624
+ }
625
+
626
+ else if (action === "hover") {
627
+ const target = template(step.target, flowVars);
628
+ const loc = await findLocator(page, target);
629
+ await loc.hover({ timeout: plan.timeout });
630
+ }
631
+
632
+ else if (action === "press") {
633
+ const key = template(step.key, flowVars);
634
+ await page.keyboard.press(key);
635
+ }
636
+
637
+ else if (action === "screenshot") {
638
+ const name = template(step.name || `manual-${Date.now()}`, flowVars);
639
+ const outPath = path.join(plan.artifacts.screenshotsDir, `${name}.png`);
640
+ await screenshot(page, outPath);
641
+ stepRec.artifacts.manualScreenshot = path.relative(plan.outputDir, outPath);
642
+ }
643
+
644
+ else if (action === "scroll") {
645
+ if (step.to === "bottom") {
646
+ await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
647
+ } else if (step.to === "top") {
648
+ await page.evaluate(() => window.scrollTo(0, 0));
649
+ } else if (step.target) {
650
+ const target = template(step.target, flowVars);
651
+ const loc = await findLocator(page, target);
652
+ await loc.scrollIntoViewIfNeeded();
653
+ }
654
+ }
655
+
656
+ else if (action === "execute") {
657
+ if (step.script) {
658
+ await page.evaluate(step.script);
659
+ } else if (step.scriptFile) {
660
+ const scriptPath = path.resolve(step.scriptFile);
661
+ const script = fs.readFileSync(scriptPath, "utf8");
662
+ await page.evaluate(script);
663
+ }
664
+ }
665
+
666
+ else {
667
+ stepRec.status = "unknown";
668
+ stepRec.error = `Unknown action: ${step.action}`;
669
+ }
670
+ }
671
+
672
+ // ============================================================================
673
+ // Assertions
674
+ // ============================================================================
675
+
676
+ async function runAssertion({ assertion, plan, page, telemetry, vars }) {
677
+ const type = String(assertion.type || "").toLowerCase().replace(/-/g, "");
678
+ const critical = !!assertion.critical;
679
+
680
+ const out = {
681
+ type: assertion.type,
682
+ critical,
683
+ status: "pass",
684
+ message: "",
685
+ };
686
+
687
+ try {
688
+ if (type === "urlcontains") {
689
+ const v = template(assertion.value, vars);
690
+ if (!page.url().toLowerCase().includes(v.toLowerCase())) {
691
+ throw new Error(`URL does not contain: ${v} (got ${page.url()})`);
692
+ }
693
+ }
694
+
695
+ else if (type === "urlmatches") {
696
+ const v = template(assertion.pattern || assertion.value, vars);
697
+ const re = new RegExp(v);
698
+ if (!re.test(page.url())) throw new Error(`URL does not match: ${v}`);
699
+ }
700
+
701
+ else if (type === "urlnotcontains") {
702
+ const v = template(assertion.value, vars);
703
+ if (page.url().toLowerCase().includes(v.toLowerCase())) {
704
+ throw new Error(`URL should not contain: ${v}`);
705
+ }
706
+ }
707
+
708
+ else if (type === "routechanged") {
709
+ const within = Number(assertion.within || 3000);
710
+ const match = assertion.match ? new RegExp(template(assertion.match, vars)) : null;
711
+ const before = page.url();
712
+ await page.waitForTimeout(Math.min(within, 500));
713
+ const after = page.url();
714
+ if (after === before) throw new Error(`Route did not change`);
715
+ if (match && !match.test(after)) throw new Error(`Route changed but does not match: ${assertion.match}`);
716
+ }
717
+
718
+ else if (type === "elementvisible") {
719
+ const target = template(assertion.target, vars);
720
+ const loc = await findLocator(page, target);
721
+ await loc.waitFor({ state: "visible", timeout: Number(assertion.within || plan.timeout) });
722
+ }
723
+
724
+ else if (type === "elementhidden") {
725
+ const target = template(assertion.target, vars);
726
+ const loc = await findLocator(page, target);
727
+ await loc.waitFor({ state: "hidden", timeout: Number(assertion.within || plan.timeout) });
728
+ }
729
+
730
+ else if (type === "elementtext") {
731
+ const target = template(assertion.target, vars);
732
+ const loc = await findLocator(page, target);
733
+ const text = await loc.textContent({ timeout: plan.timeout });
734
+ if (assertion.contains) {
735
+ const expected = template(assertion.contains, vars);
736
+ if (!String(text || "").includes(expected)) {
737
+ throw new Error(`Element text does not contain "${expected}"`);
738
+ }
739
+ }
740
+ if (assertion.matches) {
741
+ const re = new RegExp(template(assertion.matches, vars));
742
+ if (!re.test(text || "")) {
743
+ throw new Error(`Element text does not match ${assertion.matches}`);
744
+ }
745
+ }
746
+ }
747
+
748
+ else if (type === "elementcount") {
749
+ const target = template(assertion.target, vars);
750
+ const count = await page.locator(target).count();
751
+ if (assertion.min != null && count < assertion.min) {
752
+ throw new Error(`Element count ${count} < min ${assertion.min}`);
753
+ }
754
+ if (assertion.max != null && count > assertion.max) {
755
+ throw new Error(`Element count ${count} > max ${assertion.max}`);
756
+ }
757
+ if (assertion.equals != null && count !== assertion.equals) {
758
+ throw new Error(`Element count ${count} !== ${assertion.equals}`);
759
+ }
760
+ }
761
+
762
+ else if (type === "toastcontains" || type === "notificationvisible") {
763
+ const v = template(assertion.text || assertion.contains || assertion.value, vars);
764
+ const within = Number(assertion.within || 5000);
765
+ const toastSel = assertion.target || '[role="status"], [role="alert"], .toast, .sonner-toast, [data-sonner-toast], .notification';
766
+ const loc = page.locator(toastSel).filter({ hasText: new RegExp(v, "i") }).first();
767
+ await loc.waitFor({ state: "visible", timeout: within });
768
+ }
769
+
770
+ else if (type === "toastnotcontains") {
771
+ const v = template(assertion.text || assertion.value, vars);
772
+ const within = Number(assertion.within || 3000);
773
+ const toastSel = assertion.target || '[role="status"], [role="alert"], .toast, .sonner-toast, [data-sonner-toast], .notification';
774
+ await page.waitForTimeout(Math.min(within, 1500));
775
+ const count = await page.locator(toastSel).filter({ hasText: new RegExp(v, "i") }).count();
776
+ if (count > 0) throw new Error(`Toast contains forbidden text: ${v}`);
777
+ }
778
+
779
+ else if (type === "networkcalled") {
780
+ const v = template(assertion.match || assertion.value, vars);
781
+ const within = Number(assertion.within || 10000);
782
+ const minTimes = assertion.times ?? assertion.minTimes ?? 1;
783
+ const since = Date.now() - within;
784
+ const matches = telemetry.responses.filter(r => r.ts >= since && r.url.includes(v));
785
+ if (matches.length < minTimes) {
786
+ throw new Error(`Expected ${minTimes} calls matching ${v}, got ${matches.length}`);
787
+ }
788
+ }
789
+
790
+ else if (type === "networkstatus") {
791
+ const v = template(assertion.match || assertion.value, vars);
792
+ const expectStatus = Number(assertion.status || 200);
793
+ const within = Number(assertion.within || 10000);
794
+ const since = Date.now() - within;
795
+ const match = telemetry.responses.find(r => r.ts >= since && r.url.includes(v));
796
+ if (!match) throw new Error(`No response matching: ${v}`);
797
+ if (match.status !== expectStatus) {
798
+ throw new Error(`Expected ${expectStatus}, got ${match.status} for ${match.url}`);
799
+ }
800
+ }
801
+
802
+ else if (type === "networktiming") {
803
+ const v = template(assertion.match || assertion.value, vars);
804
+ const maxDuration = Number(assertion.maxDuration || 5000);
805
+ // Check request/response pairs - simplified version
806
+ const match = telemetry.responses.find(r => r.url.includes(v));
807
+ if (!match) throw new Error(`No response matching: ${v}`);
808
+ // We don't have timing data in this simplified version
809
+ out.message = `Network timing check (simplified): ${v}`;
810
+ }
811
+
812
+ else if (type === "noconsoleerrors") {
813
+ const severity = String(assertion.severity || "error").toLowerCase();
814
+ const windowMs = Number(assertion.within || 60000);
815
+ const since = Date.now() - windowMs;
816
+
817
+ const ignorePatterns = (assertion.ignore || []).map(p => new RegExp(p));
818
+
819
+ const bad = telemetry.console
820
+ .filter(m => m.ts >= since)
821
+ .filter(m => {
822
+ if (severity === "error") return m.type === "error";
823
+ if (severity === "warning" || severity === "warn") return m.type === "error" || m.type === "warning";
824
+ return m.type === "error";
825
+ })
826
+ .filter(m => !ignorePatterns.some(re => re.test(m.text)));
827
+
828
+ if (bad.length > 0) {
829
+ throw new Error(`Console ${bad[0].type}: ${String(bad[0].text || "").slice(0, 140)}`);
830
+ }
831
+ }
832
+
833
+ else if (type === "consolecontains") {
834
+ const match = template(assertion.match || assertion.value, vars);
835
+ const severity = String(assertion.severity || "log").toLowerCase();
836
+ const found = telemetry.console.some(m => {
837
+ if (severity !== "all" && m.type !== severity) return false;
838
+ return m.text.includes(match);
839
+ });
840
+ if (!found) throw new Error(`Console does not contain: ${match}`);
841
+ }
842
+
843
+ else if (type === "localstorage") {
844
+ const key = template(assertion.key, vars);
845
+ const value = await page.evaluate((k) => localStorage.getItem(k), key);
846
+ if (assertion.exists === true && value === null) {
847
+ throw new Error(`localStorage key "${key}" does not exist`);
848
+ }
849
+ if (assertion.exists === false && value !== null) {
850
+ throw new Error(`localStorage key "${key}" should not exist`);
851
+ }
852
+ if (assertion.equals != null && value !== assertion.equals) {
853
+ throw new Error(`localStorage "${key}" !== "${assertion.equals}"`);
854
+ }
855
+ if (assertion.matches) {
856
+ const re = new RegExp(assertion.matches);
857
+ if (!re.test(value || "")) throw new Error(`localStorage "${key}" does not match ${assertion.matches}`);
858
+ }
859
+ }
860
+
861
+ else if (type === "sessionstorage") {
862
+ const key = template(assertion.key, vars);
863
+ const value = await page.evaluate((k) => sessionStorage.getItem(k), key);
864
+ if (assertion.exists === true && value === null) {
865
+ throw new Error(`sessionStorage key "${key}" does not exist`);
866
+ }
867
+ if (assertion.matches) {
868
+ const re = new RegExp(assertion.matches);
869
+ if (!re.test(value || "")) throw new Error(`sessionStorage "${key}" does not match ${assertion.matches}`);
870
+ }
871
+ }
872
+
873
+ else {
874
+ out.status = "skip";
875
+ out.message = `Assertion type not implemented: ${assertion.type}`;
876
+ }
877
+ } catch (e) {
878
+ out.status = "fail";
879
+ out.message = String(e?.message || e).slice(0, 220);
880
+ }
881
+
882
+ return out;
883
+ }
884
+
885
+ // ============================================================================
886
+ // Score Calculation
887
+ // ============================================================================
888
+
889
+ function calculateScore(results) {
890
+ const routesDiscovered = results.coverage?.routesDiscovered || 0;
891
+ const routesWorking = results.coverage?.routesWorking || 0;
892
+ const routePct = routesDiscovered ? (routesWorking / routesDiscovered) : 1;
893
+
894
+ const flows = results.flows || [];
895
+ const flowTotal = flows.length || 1;
896
+ const flowOk = flows.filter(f => f.status === "success").length;
897
+
898
+ let assertsTotal = 0;
899
+ let assertsPass = 0;
900
+ for (const f of flows) {
901
+ for (const a of (f.assertions || [])) {
902
+ if (a.status === "skip") continue;
903
+ assertsTotal++;
904
+ if (a.status === "pass") assertsPass++;
905
+ }
906
+ }
907
+ const assertPct = assertsTotal ? (assertsPass / assertsTotal) : 1;
908
+
909
+ const errors = results.errors?.length || 0;
910
+ const penalty = Math.min(errors * 3, 20);
911
+
912
+ const score = (routePct * 30) + ((flowOk / flowTotal) * 40) + (assertPct * 30) - penalty;
913
+
914
+ return Math.max(0, Math.min(100, Math.round(score)));
915
+ }
916
+
917
+ module.exports = { runFlowPlan, classifyDanger, discoverSurface };