argusqa-os 9.2.0

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 (57) hide show
  1. package/.mcp.json +8 -0
  2. package/LICENSE +21 -0
  3. package/README.md +879 -0
  4. package/package.json +69 -0
  5. package/src/adapters/browser.js +82 -0
  6. package/src/argus.js +8 -0
  7. package/src/batch-runner.js +8 -0
  8. package/src/cli/init.js +314 -0
  9. package/src/config/schema.js +108 -0
  10. package/src/config/targets.js +309 -0
  11. package/src/domain/finding.js +25 -0
  12. package/src/mcp-server.js +156 -0
  13. package/src/orchestration/crawl-and-report.js +16 -0
  14. package/src/orchestration/dispatcher.js +263 -0
  15. package/src/orchestration/env-comparison.js +498 -0
  16. package/src/orchestration/orchestrator.js +1128 -0
  17. package/src/orchestration/report-processor.js +134 -0
  18. package/src/orchestration/slack-notifier.js +337 -0
  19. package/src/orchestration/watch-mode.js +316 -0
  20. package/src/registry.js +18 -0
  21. package/src/server/index.js +94 -0
  22. package/src/server/interaction-handler.js +126 -0
  23. package/src/server/slash-command-handler.js +185 -0
  24. package/src/utils/api-frequency.js +128 -0
  25. package/src/utils/baseline-manager.js +255 -0
  26. package/src/utils/codebase-analyzer.js +299 -0
  27. package/src/utils/content-analyzer.js +155 -0
  28. package/src/utils/contract-validator.js +178 -0
  29. package/src/utils/css-analyzer.js +407 -0
  30. package/src/utils/diff.js +189 -0
  31. package/src/utils/flakiness-detector.js +82 -0
  32. package/src/utils/flow-runner.js +572 -0
  33. package/src/utils/github-reporter.js +310 -0
  34. package/src/utils/hover-analyzer.js +214 -0
  35. package/src/utils/html-reporter.js +301 -0
  36. package/src/utils/issues-analyzer.js +171 -0
  37. package/src/utils/keyboard-analyzer.js +141 -0
  38. package/src/utils/lighthouse-checker.js +120 -0
  39. package/src/utils/logger.js +39 -0
  40. package/src/utils/login-orchestrator.js +99 -0
  41. package/src/utils/mcp-client.js +264 -0
  42. package/src/utils/mcp-parsers.js +57 -0
  43. package/src/utils/memory-analyzer.js +270 -0
  44. package/src/utils/network-timing-analyzer.js +76 -0
  45. package/src/utils/parallel-crawler.js +28 -0
  46. package/src/utils/responsive-analyzer.js +253 -0
  47. package/src/utils/retry.js +36 -0
  48. package/src/utils/route-discoverer.js +306 -0
  49. package/src/utils/security-analyzer.js +302 -0
  50. package/src/utils/seo-analyzer.js +164 -0
  51. package/src/utils/session-manager.js +12 -0
  52. package/src/utils/session-persistence.js +214 -0
  53. package/src/utils/severity-overrides.js +91 -0
  54. package/src/utils/slack-guard.js +18 -0
  55. package/src/utils/slug.js +8 -0
  56. package/src/utils/snapshot-analyzer.js +330 -0
  57. package/src/utils/telemetry.js +190 -0
@@ -0,0 +1,572 @@
1
+ /**
2
+ * Argus v9.3.0 — User Flow Runner
3
+ *
4
+ * Executes reusable multi-step interaction sequences defined in targets.js flows[].
5
+ * Each flow is a named sequence of steps that exercises a user journey end-to-end.
6
+ *
7
+ * Supported step actions:
8
+ * navigate — navigate_page to step.url or baseUrl + step.path
9
+ * fill — browser.fill (fires one consolidated input event with full value,
10
+ * no per-keystroke keydown/keyup events)
11
+ * Add typing: true to use browser.typeText instead, which
12
+ * dispatches real per-keystroke keydown/keyup/input events (D8.3)
13
+ * click — browser.click on step.selector
14
+ * press_key — browser.pressKey with step.key
15
+ * drag — browser.drag from step.selector to step.target (D8.4)
16
+ * upload_file — browser.uploadFile via uid from page snapshot; finds the
17
+ * file input by its [Upload] accessibility role (D8.5)
18
+ * DSL: { action: 'upload_file', selector: 'input[type=file]',
19
+ * filePath: '/path/to/file' }
20
+ * Pass uid directly to skip snapshot lookup:
21
+ * { action: 'upload_file', uid: 'e4', filePath: '...' }
22
+ * waitFor — browser.waitFor until step.selector appears
23
+ * sleep — pause step.ms milliseconds
24
+ * handle_dialog — browser.handleDialog (accept/dismiss + optional promptText)
25
+ * assert — run an inline assertion (see assert types below)
26
+ *
27
+ * Assert types:
28
+ * no_console_errors — list_console_messages must return zero errors
29
+ * no_network_errors — list_network_requests must return zero 4xx/5xx
30
+ * element_visible — selector must appear in DOM within timeout
31
+ * element_not_visible — selector must not exist in DOM
32
+ * url_contains — window.location.href must include value
33
+ * no_js_errors — window.__argusErrors must be empty
34
+ */
35
+
36
+ import fs from 'fs';
37
+ import os from 'os';
38
+ import path from 'path';
39
+ import { unwrapEval } from './mcp-client.js';
40
+ import { childLogger } from './logger.js';
41
+ import { startSpan } from './telemetry.js';
42
+
43
+ const logger = childLogger('flow-runner');
44
+
45
+ const INJECT_ERROR_LISTENER = `() => {
46
+ if (window.__argusErrorsPatched) return;
47
+ window.__argusErrorsPatched = true;
48
+ window.__argusErrors = [];
49
+ window.addEventListener('error', function(e) {
50
+ window.__argusErrors.push({ message: e.message, source: e.filename, line: e.lineno });
51
+ });
52
+ window.addEventListener('unhandledrejection', function(e) {
53
+ window.__argusErrors.push({ message: String(e.reason), source: 'unhandledrejection' });
54
+ });
55
+ }`;
56
+
57
+ const DEFAULT_TIMEOUT = 10_000;
58
+
59
+ /**
60
+ * Resolve a CSS selector to an MCP accessibility-tree uid.
61
+ *
62
+ * type_text and drag require uid (not CSS selectors) per the MCP API contract.
63
+ * Strategy: evaluate the selector in the page to get a distinguishing attribute
64
+ * (id, aria-label, name, placeholder), then scan the snapshot text for that
65
+ * attribute adjacent to a uid token.
66
+ *
67
+ * Returns null if the element is not found or has no distinguishing attribute.
68
+ *
69
+ * Added to support type_text and drag which require uids.
70
+ */
71
+ export async function resolveUidForSelector(browser, selector) {
72
+ // Collect multiple candidate identifiers — CDP snapshots use accessible names
73
+ // (button text, label text), not HTML id attributes, so we try several sources.
74
+ const rawAttr = await browser.evaluate(`() => {
75
+ const el = document.querySelector(${JSON.stringify(selector)});
76
+ if (!el) return null;
77
+ const idents = [];
78
+ const ariaLabel = el.getAttribute('aria-label');
79
+ if (ariaLabel) idents.push(ariaLabel);
80
+ if (el.id) {
81
+ // Check for an associated <label> — its text is the accessible name in the snapshot
82
+ const lbl = document.querySelector('label[for="' + el.id + '"]');
83
+ if (lbl) idents.push(lbl.textContent.trim().slice(0, 50));
84
+ idents.push(el.id);
85
+ }
86
+ // Button/link text content IS the accessible name in the CDP snapshot
87
+ const txt = (el.textContent ?? '').trim().replace(/\\s+/g, ' ').slice(0, 50);
88
+ if (txt) idents.push(txt);
89
+ const name = el.getAttribute('name');
90
+ if (name) idents.push(name);
91
+ const placeholder = el.getAttribute('placeholder');
92
+ if (placeholder) idents.push(placeholder);
93
+ return [...new Set(idents)].filter(Boolean).join('\\n') || null;
94
+ }`);
95
+ const combined = unwrapEval(rawAttr);
96
+ if (!combined) return null;
97
+ const identifiers = combined.split('\n').filter(Boolean);
98
+ if (!identifiers.length) return null;
99
+
100
+ const snap = await browser.snapshot();
101
+ let text = typeof snap === 'string' ? snap : JSON.stringify(snap ?? '');
102
+ const fence = text.match(/```(?:json|text)?\s*([\s\S]*?)\s*```/);
103
+ if (fence) text = fence[1];
104
+
105
+ for (const identifier of identifiers) {
106
+ const esc = identifier.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
107
+ // Current snapshot format: "uid=N_M role "accessible name" [attrs]"
108
+ // uid precedes the role and accessible name; MCP tools expect just the N_M part (no "uid=" prefix).
109
+ // Prefer interactive element lines (combobox, button, etc.) over StaticText label
110
+ // nodes — both may share the same accessible name (e.g. a <label> and its <select>).
111
+ const m1 = text.match(new RegExp(`uid=([^\\s]+)\\s+(?!StaticText)[^\\n]*"[^"]*${esc}`, 'm'));
112
+ if (m1) return m1[1];
113
+ // Fallback: accept StaticText nodes (e.g. draggable divs whose only a11y node is text)
114
+ const m1b = text.match(new RegExp(`uid=([^\\s]+)[^\\n]*"[^"]*${esc}`, 'm'));
115
+ if (m1b) return m1b[1];
116
+ // Legacy JSON tree: "uid":"e15" near identifier string
117
+ const m2 = text.match(new RegExp(`"${esc}"[^}]{0,300}"uid"\\s*:\\s*"([^"]+)"`));
118
+ if (m2) return m2[1];
119
+ const m3 = text.match(new RegExp(`"uid"\\s*:\\s*"([^"]+)"[^}]{0,300}"${esc}"`));
120
+ if (m3) return m3[1];
121
+ }
122
+ return null;
123
+ }
124
+
125
+ /**
126
+ * Extract the uid of the first file input from a take_snapshot response.
127
+ *
128
+ * Current snapshot format: "uid=N_M button "Choose file:" value="No file chosen""
129
+ * File inputs render as buttons with value="No file chosen" (Chrome's default label).
130
+ */
131
+ function extractFileInputUid(snapResponse) {
132
+ let text = typeof snapResponse === 'string'
133
+ ? snapResponse
134
+ : JSON.stringify(snapResponse ?? '');
135
+
136
+ // Strip markdown code fence if present (mirrors evaluate_script wrapping)
137
+ const fence = text.match(/```(?:json|text)?\s*([\s\S]*?)\s*```/);
138
+ if (fence) text = fence[1];
139
+
140
+ // Pattern 1: current format — file inputs appear as button with "No file chosen" value
141
+ // "uid=N_M button "Choose file:" value="No file chosen""
142
+ // MCP tools expect the N_M part only (no "uid=" prefix).
143
+ const p1 = text.match(/uid=([^\s]+)[^\n]*value="No file chosen"/);
144
+ if (p1) return p1[1];
145
+
146
+ // Pattern 2: any line containing "Choose file" (the default Chrome file-input label)
147
+ const p2 = text.match(/uid=([^\s]+)[^\n]*[Cc]hoose file/);
148
+ if (p2) return p2[1];
149
+
150
+ // Pattern 3: legacy text-tree format — "- input [Upload] e4"
151
+ const uploadRole = text.match(/\[Upload\]\s+([A-Za-z0-9_-]+)/);
152
+ if (uploadRole) return uploadRole[1];
153
+
154
+ // Pattern 4: legacy JSON tree — uid near inputType:"file" marker
155
+ const jsonA = text.match(/"inputType"\s*:\s*"file"[^}]{0,200}"uid"\s*:\s*"([^"]+)"/);
156
+ if (jsonA) return jsonA[1];
157
+ const jsonB = text.match(/"uid"\s*:\s*"([^"]+)"[^}]{0,200}"inputType"\s*:\s*"file"/);
158
+ if (jsonB) return jsonB[1];
159
+
160
+ // Pattern 5: line-scan — any line with uid= near upload/file keywords
161
+ const lines = text.split('\n');
162
+ for (let i = 0; i < lines.length; i++) {
163
+ if (/upload|file.input|Choose file/i.test(lines[i])) {
164
+ const m = lines[i].match(/uid=([^\s]+)/);
165
+ if (m) return m[1];
166
+ }
167
+ }
168
+
169
+ return null;
170
+ }
171
+
172
+ export function normalizeArray(val) {
173
+ if (!val) return [];
174
+ if (Array.isArray(val)) return val;
175
+ if (Array.isArray(val.messages)) return val.messages;
176
+ if (Array.isArray(val.requests)) return val.requests;
177
+ if (Array.isArray(val.result)) return val.result;
178
+ return [];
179
+ }
180
+
181
+ async function runAssert(step, browser, flowName, baseUrl, baselines) {
182
+ const findings = [];
183
+
184
+ switch (step.type) {
185
+ case 'no_console_errors': {
186
+ const msgs = await browser.listConsole();
187
+ // Only consider messages produced during this flow — filter out pre-existing session noise.
188
+ const recent = msgs.slice(baselines?.consoleMsgCount ?? 0);
189
+ const errors = recent.filter(m => (m.level ?? '').toLowerCase() === 'error');
190
+ if (errors.length > 0) {
191
+ findings.push({
192
+ type: 'flow_assert_failed',
193
+ flowName,
194
+ assertType: step.type,
195
+ message: `[${flowName}] assert no_console_errors: ${errors.length} error(s) — ${errors.slice(0, 2).map(e => e.text ?? String(e)).join('; ')}`,
196
+ severity: step.severity ?? 'warning',
197
+ url: baseUrl,
198
+ });
199
+ }
200
+ break;
201
+ }
202
+
203
+ case 'no_network_errors': {
204
+ const reqs = await browser.listNetwork();
205
+ const recent = reqs.slice(baselines?.networkReqCount ?? 0);
206
+ const failures = recent.filter(r => (r.status ?? 0) >= 400);
207
+ if (failures.length > 0) {
208
+ findings.push({
209
+ type: 'flow_assert_failed',
210
+ flowName,
211
+ assertType: step.type,
212
+ message: `[${flowName}] assert no_network_errors: ${failures.length} failed request(s) — ${failures.slice(0, 2).map(r => `HTTP ${r.status} ${r.url}`).join('; ')}`,
213
+ severity: step.severity ?? 'warning',
214
+ url: baseUrl,
215
+ });
216
+ }
217
+ break;
218
+ }
219
+
220
+ case 'element_visible': {
221
+ // Poll via evaluate_script — wait_for doesn't reliably throw on timeout in headless MCP mode.
222
+ const timeout = step.timeout ?? 5000;
223
+ const start = Date.now();
224
+ let present = false;
225
+ do {
226
+ const raw = await browser.evaluate(`() => !!document.querySelector(${JSON.stringify(step.selector)})`);
227
+ present = !!unwrapEval(raw);
228
+ if (present) break;
229
+ await new Promise(r => setTimeout(r, 200));
230
+ } while (Date.now() - start < timeout);
231
+
232
+ if (!present) {
233
+ findings.push({
234
+ type: 'flow_assert_failed',
235
+ flowName,
236
+ assertType: step.type,
237
+ selector: step.selector,
238
+ message: `[${flowName}] assert element_visible: "${step.selector}" not found in DOM within ${timeout}ms`,
239
+ severity: step.severity ?? 'critical',
240
+ url: baseUrl,
241
+ });
242
+ }
243
+ break;
244
+ }
245
+
246
+ case 'element_not_visible': {
247
+ const raw = await browser.evaluate(`() => !document.querySelector(${JSON.stringify(step.selector)})`);
248
+ const absent = unwrapEval(raw);
249
+ if (!absent) {
250
+ findings.push({
251
+ type: 'flow_assert_failed',
252
+ flowName,
253
+ assertType: step.type,
254
+ selector: step.selector,
255
+ message: `[${flowName}] assert element_not_visible: "${step.selector}" unexpectedly present in DOM`,
256
+ severity: step.severity ?? 'warning',
257
+ url: baseUrl,
258
+ });
259
+ }
260
+ break;
261
+ }
262
+
263
+ case 'url_contains': {
264
+ const raw = await browser.evaluate(`() => window.location.href.includes(${JSON.stringify(step.value)})`);
265
+ const matches = unwrapEval(raw);
266
+ if (!matches) {
267
+ findings.push({
268
+ type: 'flow_assert_failed',
269
+ flowName,
270
+ assertType: step.type,
271
+ expected: step.value,
272
+ message: `[${flowName}] assert url_contains: URL does not contain "${step.value}"`,
273
+ severity: step.severity ?? 'warning',
274
+ url: baseUrl,
275
+ });
276
+ }
277
+ break;
278
+ }
279
+
280
+ case 'no_js_errors': {
281
+ const raw = await browser.evaluate(`() => JSON.stringify(window.__argusErrors ?? [])`);
282
+ let errors = [];
283
+ try {
284
+ const val = unwrapEval(raw);
285
+ errors = Array.isArray(val) ? val
286
+ : JSON.parse(typeof val === 'string' ? val : '[]');
287
+ } catch {}
288
+ if (errors.length > 0) {
289
+ findings.push({
290
+ type: 'flow_assert_failed',
291
+ flowName,
292
+ assertType: step.type,
293
+ message: `[${flowName}] assert no_js_errors: ${errors.length} uncaught JS error(s) — ${errors.slice(0, 2).map(e => e.message ?? String(e.reason ?? e)).join('; ')}`,
294
+ severity: step.severity ?? 'critical',
295
+ url: baseUrl,
296
+ });
297
+ }
298
+ break;
299
+ }
300
+
301
+ default:
302
+ logger.warn(`[ARGUS] Flow "${flowName}": unknown assert type "${step.type}" — skipped`);
303
+ }
304
+
305
+ return findings;
306
+ }
307
+
308
+ /**
309
+ * Execute a single user flow and return the result.
310
+ * Stops on the first step that throws (page state is unknown after a hard failure).
311
+ * Critical assert failures also stop execution immediately unless step.failFast is false.
312
+ */
313
+ export async function runFlow(flow, baseUrl, browser) {
314
+ return startSpan('argus.flow', { flow_name: flow.name, url: baseUrl }, async () => {
315
+ const result = {
316
+ flowName: flow.name,
317
+ ranAt: new Date().toISOString(),
318
+ status: 'pass',
319
+ findings: [],
320
+ stepsCompleted: 0,
321
+ totalSteps: flow.steps?.length ?? 0,
322
+ };
323
+
324
+ if (!flow.steps?.length) return result;
325
+
326
+ // Snapshot console/network buffer lengths before the flow runs so assertions
327
+ // in this flow don't flag noise carried over from earlier work.
328
+ const baselines = {
329
+ consoleMsgCount: (await browser.listConsole().catch(() => [])).length,
330
+ networkReqCount: (await browser.listNetwork().catch(() => [])).length,
331
+ };
332
+
333
+ let _earlyExit = false;
334
+
335
+ for (const step of flow.steps) {
336
+ try {
337
+ await startSpan('argus.flow_step', { flow_name: flow.name, action: step.action ?? '', selector: step.selector ?? '' }, async () => {
338
+ switch (step.action) {
339
+ case 'navigate':
340
+ // step.url = absolute URL override; step.path = relative to baseUrl
341
+ await browser.navigate(step.url ?? (`${baseUrl.replace(/\/$/, '')}/${(step.path ?? '').replace(/^\//, '')}`));
342
+ // Re-inject error listener — navigation destroys the previous page context
343
+ await browser.evaluate(INJECT_ERROR_LISTENER).catch(err => logger.warn('[ARGUS] flow-runner: INJECT_ERROR_LISTENER failed:', err.message));
344
+ break;
345
+
346
+ case 'fill': {
347
+ // MCP fill/click require uid (not CSS selector) — resolve via snapshot.
348
+ // typing: true uses browser.type (dispatches real per-keystroke keyboard events)
349
+ // instead of browser.fill (which fires one consolidated input event with the full value,
350
+ // but not keydown/keypress/keyup per character).
351
+ // Use typing: true when the target input needs per-keystroke event handling (D8.3).
352
+ const fillUid = await resolveUidForSelector(browser, step.selector);
353
+ if (!fillUid) throw new Error(`fill: no uid found for selector "${step.selector}"`);
354
+ if (step.typing) {
355
+ await browser.click(fillUid);
356
+ await browser.type(step.value ?? '');
357
+ } else {
358
+ await browser.fill(fillUid, step.value ?? '');
359
+ }
360
+ break;
361
+ }
362
+
363
+ case 'click': {
364
+ // MCP click requires uid — resolve CSS selector to uid via snapshot.
365
+ const clickUid = await resolveUidForSelector(browser, step.selector);
366
+ if (!clickUid) throw new Error(`click: no uid found for selector "${step.selector}"`);
367
+ await browser.click(clickUid);
368
+ break;
369
+ }
370
+
371
+ case 'press_key':
372
+ if (!step.key) throw new Error('press_key: step.key is required');
373
+ await browser.pressKey(step.key);
374
+ break;
375
+
376
+ case 'waitFor': {
377
+ // wait_for({ selector }) is unreliable in headless MCP mode — it can
378
+ // early-exit without actually polling. Use evaluate_script polling instead.
379
+ const wfFound = await waitForSelector(browser, step.selector, step.timeout ?? DEFAULT_TIMEOUT);
380
+ if (!wfFound) throw new Error(`waitFor: selector "${step.selector}" not found within ${step.timeout ?? DEFAULT_TIMEOUT}ms`);
381
+ break;
382
+ }
383
+
384
+ case 'sleep':
385
+ await new Promise(r => setTimeout(r, step.ms ?? 1000));
386
+ break;
387
+
388
+ case 'drag': {
389
+ // drag MCP API requires { from_uid, to_uid } — CSS selectors are not
390
+ // accepted. The DSL exposes sourceSelector/targetSelector (with selector/target
391
+ // as backwards-compatible aliases) and resolves them to uids via snapshot.
392
+ // Fires dragstart → dragover → drop on the target; drop only lands if the
393
+ // target's dragover handler calls event.preventDefault() (D8.4).
394
+ const srcSelector = step.sourceSelector ?? step.selector;
395
+ const tgtSelector = step.targetSelector ?? step.target;
396
+ const startUid = await resolveUidForSelector(browser, srcSelector);
397
+ const endUid = await resolveUidForSelector(browser, tgtSelector);
398
+ if (!startUid) throw new Error(`drag: no uid found for source "${srcSelector}"`);
399
+ if (!endUid) throw new Error(`drag: no uid found for target "${tgtSelector}"`);
400
+ await browser.drag(startUid, endUid);
401
+ break;
402
+ }
403
+
404
+ case 'upload_file': {
405
+ // upload_file requires a uid from the page accessibility snapshot.
406
+ // Priority: explicit step.uid > step.selector resolution > first-upload fallback.
407
+ let uploadUid = step.uid;
408
+ if (!uploadUid) {
409
+ if (step.selector) {
410
+ // When a selector is provided, resolve it to the matching uid so
411
+ // pages with multiple file inputs upload to the intended field, not just the first.
412
+ uploadUid = await resolveUidForSelector(browser, step.selector);
413
+ if (!uploadUid) {
414
+ // File inputs appear as [Upload] in the CDP snapshot, not by id — fall back.
415
+ const snap = await browser.snapshot();
416
+ uploadUid = extractFileInputUid(snap);
417
+ }
418
+ if (!uploadUid) {
419
+ throw new Error(
420
+ `upload_file: no uid found for selector "${step.selector}" — ` +
421
+ `ensure the element is visible and has id/aria-label/name/placeholder, ` +
422
+ `or pass uid directly: { action: 'upload_file', uid: 'e4', filePath: '...' }`
423
+ );
424
+ }
425
+ } else {
426
+ const snap = await browser.snapshot();
427
+ uploadUid = extractFileInputUid(snap);
428
+ if (!uploadUid) {
429
+ throw new Error(
430
+ `upload_file: no file-input uid found in page snapshot. ` +
431
+ `Ensure the page has a visible <input type="file"> element, ` +
432
+ `or pass uid directly: { action: 'upload_file', uid: 'e4', filePath: '...' }`
433
+ );
434
+ }
435
+ }
436
+ }
437
+ if (!step.filePath) throw new Error('upload_file: step.filePath is required');
438
+ if (!fs.existsSync(step.filePath))
439
+ throw new Error(`upload_file: file not found: "${step.filePath}"`);
440
+ await browser.uploadFile(uploadUid, step.filePath);
441
+ break;
442
+ }
443
+
444
+ case 'handle_dialog':
445
+ await browser.handleDialog(step.accept ?? true, step.text ?? '');
446
+ break;
447
+
448
+ case 'select_option': {
449
+ // select_option requires a uid from the page snapshot.
450
+ // Accepts explicit step.uid or resolves from step.selector.
451
+ let selectUid = step.uid;
452
+ if (!selectUid) {
453
+ if (!step.selector) {
454
+ throw new Error('select_option: requires either uid or selector');
455
+ }
456
+ selectUid = await resolveUidForSelector(browser, step.selector);
457
+ if (!selectUid) {
458
+ throw new Error(
459
+ `select_option: no uid found for selector "${step.selector}" — ` +
460
+ `ensure the <select> is visible and has id/aria-label/name, ` +
461
+ `or pass uid directly: { action: 'select_option', uid: 'e5', value: '...' }`
462
+ );
463
+ }
464
+ }
465
+ // browser.fill on a combobox requires the option LABEL text, not the HTML value
466
+ // attribute. Resolve value → label via in-page evaluation when selector is known.
467
+ let fillValue = step.value ?? '';
468
+ if (step.selector && fillValue) {
469
+ const rawLabel = await browser.evaluate(`() => {
470
+ const sel = document.querySelector(${JSON.stringify(step.selector)});
471
+ if (!sel) return null;
472
+ const opt = Array.from(sel.options || []).find(o => o.value === ${JSON.stringify(fillValue)});
473
+ return opt ? opt.textContent.trim() : null;
474
+ }`);
475
+ const label = unwrapEval(rawLabel);
476
+ if (label) fillValue = label;
477
+ }
478
+ await browser.fill(selectUid, fillValue);
479
+ break;
480
+ }
481
+
482
+ case 'assert': {
483
+ const assertFindings = await runAssert(step, browser, flow.name, baseUrl, baselines);
484
+ result.findings.push(...assertFindings);
485
+ // Stop on critical assert failure — page state may be invalid for further steps
486
+ if (assertFindings.some(f => f.severity === 'critical') && step.failFast !== false) {
487
+ result.status = 'fail';
488
+ result.stepsCompleted++;
489
+ _earlyExit = true;
490
+ return; // exit span fn; outer loop checks _earlyExit before incrementing again
491
+ }
492
+ break;
493
+ }
494
+
495
+ default:
496
+ logger.warn(`[ARGUS] Flow "${flow.name}": unknown step action "${step.action}" — skipped`);
497
+ }
498
+ }); // end argus.flow_step span
499
+ if (_earlyExit) return result; // propagate early exit from critical assert
500
+ result.stepsCompleted++;
501
+ } catch (err) {
502
+ // Capture a screenshot of the failure state for debugging before the page changes.
503
+ let screenshotPath = null;
504
+ try {
505
+ const ts = Date.now();
506
+ screenshotPath = path.join(os.tmpdir(), `argus-flow-fail-${flow.name.replace(/[^a-z0-9]/gi, '_')}-${ts}.png`);
507
+ await browser.screenshot({ filePath: screenshotPath });
508
+ } catch { screenshotPath = null; }
509
+
510
+ result.findings.push({
511
+ type: 'flow_step_failed',
512
+ flowName: flow.name,
513
+ action: step.action,
514
+ selector: step.selector ?? null,
515
+ message: `[${flow.name}] step "${step.action}"${step.selector ? ` on "${step.selector}"` : ''} failed: ${err.message}`,
516
+ screenshotPath,
517
+ severity: 'critical',
518
+ url: baseUrl,
519
+ });
520
+ result.status = 'fail';
521
+ result.stepsCompleted++;
522
+ break;
523
+ }
524
+ }
525
+
526
+ if (result.findings.some(f => f.severity === 'critical' || f.severity === 'warning')) {
527
+ result.status = 'fail';
528
+ }
529
+
530
+ return result;
531
+ }); // end argus.flow span
532
+ }
533
+
534
+ /**
535
+ * Poll for a CSS selector to appear in the DOM using evaluate_script.
536
+ * More reliable than browser.waitFor({ selector }) which can early-exit in headless mode.
537
+ *
538
+ * @param {object} browser - CdpBrowserAdapter
539
+ * @param {string} selector - CSS selector to wait for
540
+ * @param {number} timeoutMs - Total wait budget in ms (default 10 000)
541
+ * @returns {Promise<boolean>} true if found within budget, false on timeout
542
+ */
543
+ export async function waitForSelector(browser, selector, timeoutMs = 10_000) {
544
+ const end = Date.now() + timeoutMs;
545
+ while (Date.now() < end) {
546
+ const raw = await browser.evaluate(`() => !!document.querySelector(${JSON.stringify(selector)})`).catch(() => null);
547
+ const found = unwrapEval(raw);
548
+ if (found === true || String(found) === 'true') return true;
549
+ if (Date.now() < end) await new Promise(r => setTimeout(r, 300));
550
+ }
551
+ return false;
552
+ }
553
+
554
+ /**
555
+ * Run all flows defined in targets.js and return aggregated results.
556
+ */
557
+ export async function runAllFlows(flows, baseUrl, browser) {
558
+ if (!flows?.length) return { results: [], findings: [] };
559
+
560
+ const results = [];
561
+ const allFindings = [];
562
+
563
+ for (const flow of flows) {
564
+ logger.info(`[ARGUS] Running flow: ${flow.name}`);
565
+ const result = await runFlow(flow, baseUrl, browser);
566
+ results.push(result);
567
+ allFindings.push(...result.findings);
568
+ logger.info(`[ARGUS] Flow "${flow.name}": ${result.status} (${result.stepsCompleted}/${result.totalSteps} steps, ${result.findings.length} finding(s))`);
569
+ }
570
+
571
+ return { results, findings: allFindings };
572
+ }