rohitsar567 commited on
Commit
cf9ef2d
Β·
1 Parent(s): 0aca675

feat(audit): Tier 4 functionality (API smoke + Playwright E2E) + selftest fixtures

Browse files
audit/e2e/insurancebot_e2e.js ADDED
@@ -0,0 +1,151 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Tier-4 E2E β€” deterministic UI-presence journeys for the Insurance Advisor
3
+ * frontend. Run via the playwright-skill runner:
4
+ * node /Users/rohitsar/.claude/skills/playwright-skill/run.js \
5
+ * audit/e2e/insurancebot_e2e.js
6
+ *
7
+ * The runner injects Playwright (resolved from the skill's node_modules) and
8
+ * passes this file through verbatim because it already has `require(` + an
9
+ * async IIFE. Every journey resolves to a boolean; everything is wrapped so a
10
+ * single `RJSON {...}` line is ALWAYS printed to stdout β€” even on a hard
11
+ * failure β€” so the Python check can parse it (no RJSON => WARN/infra, never a
12
+ * silent FAIL). These are presence/no-error checks only: NO multi-turn LLM
13
+ * chat (that is flaky/slow and not a deterministic UI signal).
14
+ */
15
+ const { chromium } = require('playwright');
16
+
17
+ const TARGET = process.env.TARGET_URL || 'http://localhost:3000';
18
+
19
+ (async () => {
20
+ const R = {
21
+ loads: false,
22
+ marketplaceRenders: false,
23
+ logosOk: false,
24
+ composerWorks: false,
25
+ noConsoleError: false,
26
+ };
27
+
28
+ let browser;
29
+ // Console / page errors collected across the whole session.
30
+ const errors = [];
31
+
32
+ try {
33
+ browser = await chromium.launch({ headless: true });
34
+ const context = await browser.newContext({
35
+ viewport: { width: 1440, height: 900 },
36
+ });
37
+ const page = await context.newPage();
38
+
39
+ page.on('pageerror', (e) => errors.push('pageerror: ' + (e && e.message)));
40
+ page.on('console', (msg) => {
41
+ if (msg.type() === 'error') errors.push('console.error: ' + msg.text());
42
+ });
43
+
44
+ // ---- journey: loads -------------------------------------------------
45
+ try {
46
+ await page.goto(TARGET, { waitUntil: 'domcontentloaded', timeout: 60000 });
47
+ await page.waitForSelector('h1', { state: 'visible', timeout: 30000 });
48
+ R.loads = (await page.locator('h1').count()) > 0;
49
+ } catch (e) {
50
+ errors.push('loads: ' + (e && e.message));
51
+ }
52
+
53
+ // ---- journey: marketplaceRenders -----------------------------------
54
+ // Open the Policy-Library / marketplace panel, assert >=1 policy card is
55
+ // visible, and assert no horizontal overflow at 1440px.
56
+ try {
57
+ const trigger = page
58
+ .locator(
59
+ 'button:has-text("Policy Library"), button:has-text("ΰ€ͺΰ₯‰ΰ€²ΰ€Ώΰ€Έΰ₯€ ΰ€²ΰ€Ύΰ€‡ΰ€¬ΰ₯ΰ€°ΰ₯‡ΰ€°ΰ₯€")'
60
+ )
61
+ .first();
62
+ await trigger.click({ timeout: 20000 });
63
+ await page.waitForSelector('.policy-card', {
64
+ state: 'visible',
65
+ timeout: 30000,
66
+ });
67
+ const cards = await page.locator('.policy-card:visible').count();
68
+ const overflow = await page.evaluate(
69
+ () =>
70
+ document.documentElement.scrollWidth >
71
+ window.innerWidth + 1
72
+ );
73
+ R.marketplaceRenders = cards >= 1 && !overflow;
74
+ } catch (e) {
75
+ errors.push('marketplaceRenders: ' + (e && e.message));
76
+ }
77
+
78
+ // ---- journey: logosOk ----------------------------------------------
79
+ // At least one insurer logo <img src*="insurer-logos"> with naturalWidth>0.
80
+ try {
81
+ await page
82
+ .waitForSelector('img[src*="insurer-logos"]', { timeout: 20000 })
83
+ .catch(() => {});
84
+ const logoLoaded = await page.evaluate(() => {
85
+ const imgs = Array.from(
86
+ document.querySelectorAll('img[src*="insurer-logos"]')
87
+ );
88
+ if (imgs.length === 0) return false;
89
+ return imgs.some((im) => im.naturalWidth && im.naturalWidth > 0);
90
+ });
91
+ R.logosOk = logoLoaded;
92
+ } catch (e) {
93
+ errors.push('logosOk: ' + (e && e.message));
94
+ }
95
+
96
+ // ---- journey: composerWorks ----------------------------------------
97
+ // The chat composer textarea is fillable and the Send button is clickable
98
+ // once non-empty. (We do NOT actually submit a turn β€” no LLM dependency.)
99
+ try {
100
+ // The marketplace panel is a single-active <aside> that REPLACES the
101
+ // chat view, so the composer textarea stays in the DOM but is hidden
102
+ // while the panel is open. Toggle the Policy-Library trigger again to
103
+ // close the panel (its onClick is togglePanel(...)), then wait for the
104
+ // composer to become visible again.
105
+ const ml = page
106
+ .locator(
107
+ 'button:has-text("Policy Library"), button:has-text("ΰ€ͺΰ₯‰ΰ€²ΰ€Ώΰ€Έΰ₯€ ΰ€²ΰ€Ύΰ€‡ΰ€¬ΰ₯ΰ€°ΰ₯‡ΰ€°ΰ₯€")'
108
+ )
109
+ .first();
110
+ if (await ml.isVisible().catch(() => false)) {
111
+ await ml.click({ timeout: 10000 }).catch(() => {});
112
+ }
113
+ const ta = page.locator('textarea[aria-label="Message"]').first();
114
+ await ta.waitFor({ state: 'visible', timeout: 20000 });
115
+ await ta.fill('audit e2e presence check');
116
+ const val = await ta.inputValue();
117
+ const send = page
118
+ .locator('button:has-text("Send")')
119
+ .first();
120
+ // After typing, Send must no longer be disabled (it gates on input).
121
+ const sendEnabled = await send.isEnabled().catch(() => false);
122
+ R.composerWorks = val === 'audit e2e presence check' && sendEnabled;
123
+ } catch (e) {
124
+ errors.push('composerWorks: ' + (e && e.message));
125
+ }
126
+
127
+ // ---- journey: noConsoleError ---------------------------------------
128
+ // No pageerror / console.error captured during the whole session.
129
+ R.noConsoleError = errors.every(
130
+ (m) =>
131
+ !m.startsWith('pageerror:') && !m.startsWith('console.error:')
132
+ );
133
+ } catch (fatal) {
134
+ // Hard failure (browser launch, etc.) β€” journeys stay false; still emit
135
+ // RJSON so the Python side classifies (FAIL/WARN), never a silent hang.
136
+ errors.push('fatal: ' + (fatal && fatal.message));
137
+ } finally {
138
+ try {
139
+ if (browser) await browser.close();
140
+ } catch (_) {
141
+ /* ignore */
142
+ }
143
+ }
144
+
145
+ if (errors.length) {
146
+ console.log('E2E_DIAG ' + JSON.stringify(errors.slice(0, 20)));
147
+ }
148
+ console.log('RJSON ' + JSON.stringify(R));
149
+ // Exit 0 regardless: the boolean payload (not the exit code) is the signal.
150
+ process.exit(0);
151
+ })();
audit/selftest_fixtures.py CHANGED
@@ -311,6 +311,156 @@ def _f_t3_3():
311
  helper.unlink()
312
 
313
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
314
  FIXTURES.update({
315
  "T1.1": _f_t1_1,
316
  "T1.2": _f_t1_2,
@@ -326,4 +476,10 @@ FIXTURES.update({
326
  "T3.1": _f_t3_1,
327
  "T3.2": _f_t3_2,
328
  "T3.3": _f_t3_3,
 
 
 
 
 
 
329
  })
 
311
  helper.unlink()
312
 
313
 
314
+ # ---------------------------------------------------------------------------
315
+ # Tier 4 fixtures.
316
+ #
317
+ # T4 checks hit a LIVE local backend / frontend / playwright runner, so a
318
+ # file-on-disk fixture (the T1–T3 pattern) cannot force a deterministic FAIL.
319
+ # Instead each fixture monkeypatches the *module seams* of
320
+ # `audit.tier4_functional` (`_backend_up`, `_get`, `_post`, the module's
321
+ # `urllib.request.urlopen`, `sh`) so the check exercises its real logic against
322
+ # a controlled response and returns FAIL. Every patch is captured BEFORE the
323
+ # yield and restored UNCONDITIONALLY in `finally` β€” even if the check raises β€”
324
+ # so no module attr (and no file) is left mutated. No repo files are touched.
325
+ # ---------------------------------------------------------------------------
326
+
327
+
328
+ @contextlib.contextmanager
329
+ def _f_t4_1():
330
+ """Backend 'up' but /api/health reports a non-ok status -> T4.1 FAIL."""
331
+ import audit.tier4_functional as m
332
+ orig_up, orig_get = m._backend_up, m._get
333
+ m._backend_up = lambda: True
334
+ m._get = lambda p, timeout=20: (200, '{"status":"bad"}')
335
+ try:
336
+ yield
337
+ finally:
338
+ m._backend_up, m._get = orig_up, orig_get
339
+
340
+
341
+ @contextlib.contextmanager
342
+ def _f_t4_2():
343
+ """Coverage counts far outside the sane window -> T4.2 FAIL."""
344
+ import audit.tier4_functional as m
345
+ orig_up, orig_get = m._backend_up, m._get
346
+ m._backend_up = lambda: True
347
+ m._get = lambda p, timeout=20: (
348
+ 200,
349
+ '{"total_policies":1,"total_insurers":1,"total_chunks":1}',
350
+ )
351
+ try:
352
+ yield
353
+ finally:
354
+ m._backend_up, m._get = orig_up, orig_get
355
+
356
+
357
+ @contextlib.contextmanager
358
+ def _f_t4_3():
359
+ """Chat returns an empty reply_text -> T4.3 FAIL (empty reply)."""
360
+ import audit.tier4_functional as m
361
+ orig_up, orig_post = m._backend_up, m._post
362
+ m._backend_up = lambda: True
363
+ m._post = lambda p, payload, timeout=90: (
364
+ 200,
365
+ '{"reply_text":"","brain_used":"x"}',
366
+ )
367
+ try:
368
+ yield
369
+ finally:
370
+ m._backend_up, m._post = orig_up, orig_post
371
+
372
+
373
+ @contextlib.contextmanager
374
+ def _f_t4_4():
375
+ """Make the junk-PDF upload POST 'succeed' (fake 200) -> T4.4 FAIL.
376
+
377
+ T4.4 calls `urllib.request.urlopen` directly (not via `_post`). A real junk
378
+ upload raises HTTPError(4xx) -> PASS. To force the FAIL branch we patch the
379
+ `urllib.request.urlopen` *as referenced inside tier4_functional* so the
380
+ POST returns a fake 200 response instead of raising β€” i.e. the app
381
+ 'accepted junk', which T4.4 must report as FAIL.
382
+ """
383
+ import audit.tier4_functional as m
384
+ orig_up = m._backend_up
385
+ orig_urlopen = m.urllib.request.urlopen
386
+
387
+ class _FakeResp:
388
+ status = 200
389
+
390
+ def read(self):
391
+ return b"{}"
392
+
393
+ def __enter__(self):
394
+ return self
395
+
396
+ def __exit__(self, *a):
397
+ return False
398
+
399
+ def _fake_urlopen(req, *a, **k):
400
+ return _FakeResp()
401
+
402
+ m._backend_up = lambda: True
403
+ m.urllib.request.urlopen = _fake_urlopen
404
+ try:
405
+ yield
406
+ finally:
407
+ m._backend_up = orig_up
408
+ m.urllib.request.urlopen = orig_urlopen
409
+
410
+
411
+ @contextlib.contextmanager
412
+ def _f_t4_5():
413
+ """Profile/session endpoints return HTTP 500 -> T4.5 FAIL."""
414
+ import audit.tier4_functional as m
415
+ orig_up, orig_get, orig_post = m._backend_up, m._get, m._post
416
+ m._backend_up = lambda: True
417
+ m._get = lambda p, timeout=20: (500, "err")
418
+ m._post = lambda p, payload, timeout=90: (500, "err")
419
+ try:
420
+ yield
421
+ finally:
422
+ m._backend_up, m._get, m._post = orig_up, orig_get, orig_post
423
+
424
+
425
+ @contextlib.contextmanager
426
+ def _f_t4_e2e():
427
+ """Frontend 'reachable' + e2e run reports a failed journey -> T4.E2E FAIL.
428
+
429
+ PW_RUN exists on this machine, so `os.path.exists(PW_RUN)` is already True.
430
+ We patch the module's `urllib.request.urlopen` so the FE :3000 reachability
431
+ probe 'passes' (fake 200) regardless of whether a dev server is up, and
432
+ patch the module's `sh` to return a fake CompletedProcess whose stdout is
433
+ `RJSON {"loads":false}` β€” a failing journey, which T4.E2E must report as
434
+ FAIL. No real browser/runner is spawned.
435
+ """
436
+ import subprocess
437
+ import audit.tier4_functional as m
438
+ orig_urlopen = m.urllib.request.urlopen
439
+ orig_sh = m.sh
440
+
441
+ class _FakeResp:
442
+ status = 200
443
+
444
+ def read(self):
445
+ return b"<html><h1>x</h1></html>"
446
+
447
+ def __enter__(self):
448
+ return self
449
+
450
+ def __exit__(self, *a):
451
+ return False
452
+
453
+ m.urllib.request.urlopen = lambda req, *a, **k: _FakeResp()
454
+ m.sh = lambda cmd, timeout=120: subprocess.CompletedProcess(
455
+ cmd, 0, stdout='RJSON {"loads":false}\n', stderr=""
456
+ )
457
+ try:
458
+ yield
459
+ finally:
460
+ m.urllib.request.urlopen = orig_urlopen
461
+ m.sh = orig_sh
462
+
463
+
464
  FIXTURES.update({
465
  "T1.1": _f_t1_1,
466
  "T1.2": _f_t1_2,
 
476
  "T3.1": _f_t3_1,
477
  "T3.2": _f_t3_2,
478
  "T3.3": _f_t3_3,
479
+ "T4.1": _f_t4_1,
480
+ "T4.2": _f_t4_2,
481
+ "T4.3": _f_t4_3,
482
+ "T4.4": _f_t4_4,
483
+ "T4.5": _f_t4_5,
484
+ "T4.E2E": _f_t4_e2e,
485
  })
audit/tier4_functional.py CHANGED
@@ -1 +1,147 @@
1
- # filled in a later task
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tier 4 β€” functionality: API smoke (deterministic) + Playwright E2E.
2
+ Infra-dependent: SKIPs (never false-PASS, never crash) when the local
3
+ backend / frontend / playwright runner is unavailable."""
4
+ from __future__ import annotations
5
+ import json, os, time, urllib.request, urllib.error
6
+ from audit.core import register, Result, Status, REPO, sh
7
+
8
+ BK = "http://127.0.0.1:8000"
9
+ FE = "http://localhost:3000"
10
+ PW_RUN = "/Users/rohitsar/.claude/skills/playwright-skill/run.js"
11
+
12
+
13
+ def _get(path, timeout=20):
14
+ with urllib.request.urlopen(BK + path, timeout=timeout) as f:
15
+ return f.status, f.read().decode("utf-8", "replace")
16
+
17
+
18
+ def _post(path, payload, timeout=90):
19
+ req = urllib.request.Request(
20
+ BK + path, data=json.dumps(payload).encode(),
21
+ headers={"Content-Type": "application/json"}, method="POST")
22
+ with urllib.request.urlopen(req, timeout=timeout) as f:
23
+ return f.status, f.read().decode("utf-8", "replace")
24
+
25
+
26
+ def _backend_up():
27
+ try:
28
+ s, b = _get("/api/health", timeout=6)
29
+ return s == 200 and '"status":"ok"' in b
30
+ except Exception:
31
+ return False
32
+
33
+
34
+ @register("T4.1", "functional", "API: health + version")
35
+ def t4_1():
36
+ if not _backend_up():
37
+ return Result("T4.1", Status.SKIP, "no local backend on :8000", "start uvicorn backend.main:app --port 8000")
38
+ try:
39
+ _, hb = _get("/api/health")
40
+ _, vb = _get("/api/version")
41
+ ok = '"status":"ok"' in hb and json.loads(vb) is not None
42
+ return Result("T4.1", Status.PASS if ok else Status.FAIL,
43
+ "health ok + version json" if ok else f"health={hb[:80]} version={vb[:80]}",
44
+ "" if ok else "investigate /api/health or /api/version")
45
+ except Exception as e:
46
+ return Result("T4.1", Status.FAIL, f"{type(e).__name__}: {e}", "endpoint error")
47
+
48
+
49
+ @register("T4.2", "functional", "API: coverage counts sane")
50
+ def t4_2():
51
+ if not _backend_up():
52
+ return Result("T4.2", Status.SKIP, "no local backend", "start backend")
53
+ try:
54
+ _, b = _get("/api/coverage", timeout=40)
55
+ d = json.loads(b)
56
+ p, i, c = d.get("total_policies"), d.get("total_insurers"), d.get("total_chunks")
57
+ ok = isinstance(p, int) and 130 <= p <= 170 and i == 20 and isinstance(c, int) and c > 5000
58
+ return Result("T4.2", Status.PASS if ok else Status.FAIL,
59
+ f"policies={p} insurers={i} chunks={c}",
60
+ "" if ok else "coverage outside expected (~148 policies / 20 insurers / >5000 chunks)")
61
+ except Exception as e:
62
+ return Result("T4.2", Status.FAIL, f"{type(e).__name__}: {e}", "coverage endpoint error")
63
+
64
+
65
+ @register("T4.3", "functional", "API: chat returns a grounded reply")
66
+ def t4_3():
67
+ if not _backend_up():
68
+ return Result("T4.3", Status.SKIP, "no local backend", "start backend")
69
+ try:
70
+ _, b = _post("/api/chat",
71
+ {"user_text": "What is a waiting period in health insurance?",
72
+ "session_id": f"audit-smoke-{int(time.time())}"}, timeout=90)
73
+ d = json.loads(b)
74
+ reply = (d.get("reply_text") or "").strip()
75
+ brain = d.get("brain_used", "")
76
+ if not reply:
77
+ return Result("T4.3", Status.FAIL, f"empty reply (brain={brain})", "chat returned no text")
78
+ if "error_fallback" in brain:
79
+ return Result("T4.3", Status.WARN, f"reply via {brain}", "LLM chain degraded (env/keys) β€” verify live")
80
+ return Result("T4.3", Status.PASS, f"reply ok ({len(reply)} chars, brain={brain})")
81
+ except Exception as e:
82
+ return Result("T4.3", Status.WARN, f"{type(e).__name__}: {e}",
83
+ "chat slow/unavailable (LLM dependency); not a code FAIL on its own")
84
+
85
+
86
+ @register("T4.4", "functional", "API: upload-policy rejects junk")
87
+ def t4_4():
88
+ if not _backend_up():
89
+ return Result("T4.4", Status.SKIP, "no local backend", "start backend")
90
+ boundary = "----auditST"
91
+ body = (f"--{boundary}\r\nContent-Disposition: form-data; name=\"file\"; "
92
+ f"filename=\"junk.pdf\"\r\nContent-Type: application/pdf\r\n\r\n"
93
+ + "not a real pdf " * 10 + f"\r\n--{boundary}--\r\n").encode()
94
+ req = urllib.request.Request(BK + "/api/upload-policy", data=body, method="POST",
95
+ headers={"Content-Type": f"multipart/form-data; boundary={boundary}"})
96
+ try:
97
+ urllib.request.urlopen(req, timeout=30)
98
+ return Result("T4.4", Status.FAIL, "junk PDF was accepted", "upload security gates not rejecting")
99
+ except urllib.error.HTTPError as e:
100
+ return (Result("T4.4", Status.PASS, f"junk rejected (HTTP {e.code})")
101
+ if e.code in (400, 413, 415, 422)
102
+ else Result("T4.4", Status.FAIL, f"unexpected HTTP {e.code}", "check upload gate"))
103
+ except Exception as e:
104
+ return Result("T4.4", Status.WARN, f"{type(e).__name__}: {e}", "upload endpoint unreachable")
105
+
106
+
107
+ @register("T4.5", "functional", "API: profile + session endpoints")
108
+ def t4_5():
109
+ if not _backend_up():
110
+ return Result("T4.5", Status.SKIP, "no local backend", "start backend")
111
+ sid = f"audit-smoke-{int(time.time())}"
112
+ try:
113
+ s1, _ = _get(f"/api/profile/completeness?session_id={sid}", timeout=20)
114
+ s2, _ = _post("/api/session/clear", {"session_id": sid}, timeout=20)
115
+ ok = s1 == 200 and s2 == 200
116
+ return Result("T4.5", Status.PASS if ok else Status.FAIL,
117
+ f"profile/completeness={s1} session/clear={s2}",
118
+ "" if ok else "profile/session endpoint non-200")
119
+ except Exception as e:
120
+ return Result("T4.5", Status.FAIL, f"{type(e).__name__}: {e}", "profile/session error")
121
+
122
+
123
+ @register("T4.E2E", "functional", "Frontend E2E journeys (Playwright)")
124
+ def t4_e2e():
125
+ if not os.path.exists(PW_RUN):
126
+ return Result("T4.E2E", Status.SKIP, "playwright-skill runner not found",
127
+ "install/locate the playwright-skill to enable E2E")
128
+ try:
129
+ with urllib.request.urlopen(FE, timeout=6) as f:
130
+ if f.status != 200:
131
+ raise RuntimeError("frontend not 200")
132
+ except Exception:
133
+ return Result("T4.E2E", Status.SKIP, "no frontend on :3000",
134
+ "run `cd frontend && npm run dev` to enable E2E")
135
+ r = sh(["node", PW_RUN, str(REPO / "audit/e2e/insurancebot_e2e.js")], timeout=300)
136
+ out = r.stdout + r.stderr
137
+ import re
138
+ m = re.search(r"RJSON (\{.*\})", out)
139
+ if not m:
140
+ return Result("T4.E2E", Status.WARN, "no RJSON from e2e run (infra/flake)",
141
+ "inspect audit/e2e/insurancebot_e2e.js output; not a code FAIL alone")
142
+ d = json.loads(m.group(1))
143
+ fails = [k for k, v in d.items() if v is False]
144
+ if fails:
145
+ return Result("T4.E2E", Status.FAIL, f"E2E journey failures: {fails}",
146
+ "investigate the failing UI journey")
147
+ return Result("T4.E2E", Status.PASS, f"E2E ok: {sorted(d)}")