@matt82198/aesop 0.1.0-beta.4 → 0.1.0-beta.5

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 (174) hide show
  1. package/CHANGELOG.md +46 -5
  2. package/README.md +57 -1
  3. package/aesop.config.example.json +8 -1
  4. package/bin/CLAUDE.md +30 -2
  5. package/bin/cli.js +286 -73
  6. package/daemons/CLAUDE.md +4 -1
  7. package/daemons/run-watchdog.sh +5 -2
  8. package/docs/RELEASING.md +159 -0
  9. package/docs/archive/README.md +3 -0
  10. package/docs/{spikes → archive/spikes}/tiered-cognition/README.md +1 -3
  11. package/docs/case-study-portfolio.md +61 -0
  12. package/docs/self-stats-data.json +11 -0
  13. package/docs/templates/FLEET-OPS-ANALYSIS.example.md +27 -0
  14. package/docs/templates/FLEET-OPS-RECOMMENDATIONS.example.md +24 -0
  15. package/docs/templates/PROPOSALS-LOG.example.md +64 -0
  16. package/hooks/CLAUDE.md +23 -0
  17. package/mcp/CLAUDE.md +213 -0
  18. package/mcp/package.json +26 -0
  19. package/mcp/server.mjs +543 -0
  20. package/monitor/CHARTER.md +76 -110
  21. package/monitor/collect-signals.mjs +85 -0
  22. package/package.json +4 -1
  23. package/scan/fleet-scan.example.mjs +292 -0
  24. package/skills/healthcheck/SKILL.md +44 -0
  25. package/state_store/CLAUDE.md +39 -0
  26. package/state_store/__init__.py +24 -0
  27. package/state_store/__pycache__/__init__.cpython-314.pyc +0 -0
  28. package/state_store/__pycache__/api.cpython-314.pyc +0 -0
  29. package/state_store/__pycache__/export.cpython-314.pyc +0 -0
  30. package/state_store/__pycache__/ingest.cpython-314.pyc +0 -0
  31. package/state_store/__pycache__/projections.cpython-314.pyc +0 -0
  32. package/state_store/__pycache__/store.cpython-314.pyc +0 -0
  33. package/state_store/api.py +35 -0
  34. package/state_store/export.py +19 -0
  35. package/state_store/ingest.py +24 -0
  36. package/state_store/projections.py +52 -0
  37. package/state_store/store.py +102 -0
  38. package/tools/CLAUDE.md +30 -149
  39. package/tools/__pycache__/alert_bridge.cpython-314.pyc +0 -0
  40. package/tools/__pycache__/ci_merge_wait.cpython-314.pyc +0 -0
  41. package/tools/__pycache__/fleet_prompt_extractor.cpython-314.pyc +0 -0
  42. package/tools/__pycache__/healthcheck.cpython-314.pyc +0 -0
  43. package/tools/__pycache__/metrics_gate.cpython-314.pyc +0 -0
  44. package/tools/__pycache__/secret_scan.cpython-314.pyc +0 -0
  45. package/tools/__pycache__/self_stats.cpython-314.pyc +0 -0
  46. package/tools/__pycache__/session_usage_summary.cpython-314.pyc +0 -0
  47. package/tools/__pycache__/stall_check.cpython-314.pyc +0 -0
  48. package/tools/__pycache__/transcript_replay.cpython-314.pyc +0 -0
  49. package/tools/__pycache__/transcript_timeline.cpython-314.pyc +0 -0
  50. package/tools/__pycache__/verify_dash.cpython-314.pyc +0 -0
  51. package/tools/__pycache__/verify_submit_encoding.cpython-314.pyc +0 -0
  52. package/tools/alert_bridge.py +449 -0
  53. package/tools/ci_merge_wait.py +121 -8
  54. package/tools/fleet_prompt_extractor.py +134 -0
  55. package/tools/healthcheck.py +296 -0
  56. package/tools/secret_scan.py +8 -1
  57. package/tools/self_stats.py +509 -0
  58. package/tools/session_usage_summary.py +198 -0
  59. package/tools/svg_to_png.mjs +50 -0
  60. package/tools/transcript_replay.py +236 -0
  61. package/tools/transcript_timeline.py +184 -0
  62. package/tools/verify_dash.py +361 -542
  63. package/tools/verify_submit_encoding.py +12 -4
  64. package/ui/CLAUDE.md +57 -41
  65. package/ui/__pycache__/agents.cpython-314.pyc +0 -0
  66. package/ui/__pycache__/collectors.cpython-314.pyc +0 -0
  67. package/ui/__pycache__/config.cpython-314.pyc +0 -0
  68. package/ui/__pycache__/cost.cpython-314.pyc +0 -0
  69. package/ui/__pycache__/csrf.cpython-314.pyc +0 -0
  70. package/ui/__pycache__/handler.cpython-314.pyc +0 -0
  71. package/ui/__pycache__/render.cpython-314.pyc +0 -0
  72. package/ui/__pycache__/serve.cpython-314.pyc +0 -0
  73. package/ui/__pycache__/sse.cpython-314.pyc +0 -0
  74. package/ui/agents.py +9 -5
  75. package/ui/api/__pycache__/submit.cpython-314.pyc +0 -0
  76. package/ui/api/submit.py +44 -5
  77. package/ui/collectors.py +184 -35
  78. package/ui/config.py +9 -0
  79. package/ui/cost.py +260 -0
  80. package/ui/csrf.py +7 -3
  81. package/ui/handler.py +254 -4
  82. package/ui/render.py +26 -6
  83. package/ui/sse.py +16 -1
  84. package/ui/web/.gitattributes +13 -0
  85. package/ui/web/dist/assets/index-2LZDQirC.js +9 -0
  86. package/ui/web/dist/assets/index-D4M1qyOv.css +1 -0
  87. package/ui/web/dist/index.html +14 -0
  88. package/ui/web/index.html +13 -0
  89. package/ui/web/package-lock.json +2225 -0
  90. package/ui/web/package.json +26 -0
  91. package/ui/web/src/App.test.tsx +74 -0
  92. package/ui/web/src/App.tsx +142 -0
  93. package/ui/web/src/CONTRIBUTING-UI.md +49 -0
  94. package/ui/web/src/components/AgentRow.css +187 -0
  95. package/ui/web/src/components/AgentRow.test.tsx +209 -0
  96. package/ui/web/src/components/AgentRow.tsx +207 -0
  97. package/ui/web/src/components/AgentsPanel.css +108 -0
  98. package/ui/web/src/components/AgentsPanel.test.tsx +41 -0
  99. package/ui/web/src/components/AgentsPanel.tsx +58 -0
  100. package/ui/web/src/components/AlertsPanel.css +88 -0
  101. package/ui/web/src/components/AlertsPanel.test.tsx +51 -0
  102. package/ui/web/src/components/AlertsPanel.tsx +67 -0
  103. package/ui/web/src/components/BacklogPanel.test.tsx +126 -0
  104. package/ui/web/src/components/BacklogPanel.tsx +122 -0
  105. package/ui/web/src/components/CostChart.css +110 -0
  106. package/ui/web/src/components/CostChart.test.tsx +144 -0
  107. package/ui/web/src/components/CostChart.tsx +152 -0
  108. package/ui/web/src/components/CostTable.css +93 -0
  109. package/ui/web/src/components/CostTable.test.tsx +165 -0
  110. package/ui/web/src/components/CostTable.tsx +94 -0
  111. package/ui/web/src/components/EventsFeed.css +68 -0
  112. package/ui/web/src/components/EventsFeed.test.tsx +36 -0
  113. package/ui/web/src/components/EventsFeed.tsx +31 -0
  114. package/ui/web/src/components/HealthHeader.css +137 -0
  115. package/ui/web/src/components/HealthHeader.test.tsx +278 -0
  116. package/ui/web/src/components/HealthHeader.tsx +281 -0
  117. package/ui/web/src/components/InboxForm.css +135 -0
  118. package/ui/web/src/components/InboxForm.test.tsx +208 -0
  119. package/ui/web/src/components/InboxForm.tsx +116 -0
  120. package/ui/web/src/components/MessagesTail.module.css +144 -0
  121. package/ui/web/src/components/MessagesTail.test.tsx +176 -0
  122. package/ui/web/src/components/MessagesTail.tsx +94 -0
  123. package/ui/web/src/components/ReposPanel.css +90 -0
  124. package/ui/web/src/components/ReposPanel.test.tsx +45 -0
  125. package/ui/web/src/components/ReposPanel.tsx +67 -0
  126. package/ui/web/src/components/Scorecard.css +106 -0
  127. package/ui/web/src/components/Scorecard.test.tsx +117 -0
  128. package/ui/web/src/components/Scorecard.tsx +85 -0
  129. package/ui/web/src/components/Timeline.module.css +151 -0
  130. package/ui/web/src/components/Timeline.test.tsx +215 -0
  131. package/ui/web/src/components/Timeline.tsx +99 -0
  132. package/ui/web/src/components/TrackerBoard.test.tsx +121 -0
  133. package/ui/web/src/components/TrackerBoard.tsx +107 -0
  134. package/ui/web/src/components/TrackerCard.test.tsx +180 -0
  135. package/ui/web/src/components/TrackerCard.tsx +160 -0
  136. package/ui/web/src/components/TrackerForm.test.tsx +189 -0
  137. package/ui/web/src/components/TrackerForm.tsx +144 -0
  138. package/ui/web/src/lib/api.ts +218 -0
  139. package/ui/web/src/lib/format.test.ts +89 -0
  140. package/ui/web/src/lib/format.ts +103 -0
  141. package/ui/web/src/lib/sanitizeUrl.test.ts +84 -0
  142. package/ui/web/src/lib/sanitizeUrl.ts +38 -0
  143. package/ui/web/src/lib/types.ts +230 -0
  144. package/ui/web/src/lib/useHashRoute.test.ts +60 -0
  145. package/ui/web/src/lib/useHashRoute.ts +23 -0
  146. package/ui/web/src/lib/useSSE.ts +175 -0
  147. package/ui/web/src/main.tsx +10 -0
  148. package/ui/web/src/styles/global.css +179 -0
  149. package/ui/web/src/styles/theme.css +184 -0
  150. package/ui/web/src/styles/work.css +572 -0
  151. package/ui/web/src/test/fixtures.ts +385 -0
  152. package/ui/web/src/test/setup.ts +49 -0
  153. package/ui/web/src/views/Activity.module.css +43 -0
  154. package/ui/web/src/views/Activity.test.tsx +89 -0
  155. package/ui/web/src/views/Activity.tsx +31 -0
  156. package/ui/web/src/views/Cost.css +87 -0
  157. package/ui/web/src/views/Cost.test.tsx +142 -0
  158. package/ui/web/src/views/Cost.tsx +54 -0
  159. package/ui/web/src/views/Overview.css +51 -0
  160. package/ui/web/src/views/Overview.test.tsx +76 -0
  161. package/ui/web/src/views/Overview.tsx +46 -0
  162. package/ui/web/src/views/Work.test.tsx +82 -0
  163. package/ui/web/src/views/Work.tsx +79 -0
  164. package/ui/web/src/vite-env.d.ts +10 -0
  165. package/ui/web/tsconfig.json +22 -0
  166. package/ui/web/vite.config.ts +25 -0
  167. package/ui/web/vitest.config.ts +12 -0
  168. package/ui/templates/dashboard.html +0 -1202
  169. /package/docs/{spikes → archive/spikes}/tiered-cognition/ACTIVATION.md +0 -0
  170. /package/docs/{spikes → archive/spikes}/tiered-cognition/DESIGN.md +0 -0
  171. /package/docs/{spikes → archive/spikes}/tiered-cognition/FINDINGS.md +0 -0
  172. /package/docs/{spikes → archive/spikes}/tiered-cognition/aesop-cognition.example.md +0 -0
  173. /package/docs/{spikes → archive/spikes}/tiered-cognition/force-model-policy.merged.mjs +0 -0
  174. /package/docs/{spikes → archive/spikes}/tiered-cognition/strip-tools-hook.mjs +0 -0
@@ -1,18 +1,31 @@
1
- """Browser-level proof for the realtime SSE dashboard (ui/serve.py).
2
-
3
- Drives a real headless Chromium (python-playwright) against a fixture fleet and
4
- asserts the contract the unit tests can't see:
5
- (a) zero console errors on load and across the run
6
- (b) backlog panel renders the seeded tiers/items
7
- (c) clicking an agent row expands detail containing the actual dispatch prompt
8
- (d) file changes push to the page over SSE within ~5s WITHOUT reload
9
- (e) the expanded row is STILL expanded after those live updates
1
+ """Browser-level proof for the wave-14 React dashboard (ui/web/dist/).
2
+
3
+ Drives the BUILT app served by `python ui/serve.py` against fixture fleet state,
4
+ asserting the contract via data-testid hooks only (never CSS internals):
5
+
6
+ Populated-state phase:
7
+ (a) console clean of errors across the whole run
8
+ (b) app serves React dist (not legacy template)
9
+ (c) health-header testid present and rendered
10
+ (d) view testids present (overview on first paint)
11
+ (e) inbox form testids (submit flow)
12
+ (f) cost view renders table/chart/scorecard from the fixture ledger
13
+ (g) CSS stylesheet loaded (reduced-motion media query proven by vitest)
14
+ (h) a11y live regions present (role=status or aria-live)
15
+ (j) SSE live update WITHOUT reload (tracker.json mutation appears in DOM)
16
+ (k) tracker round-trip through the real form: create -> proposed lane, claim/done move lanes
17
+ (l) hostile javascript: pr_link is inert in the real DOM (no javascript: href)
18
+ (m) keyboard-only agent-row expand; expansion SURVIVES an SSE agents update
19
+ (n) computed contrast of health-header label >= 4.5:1 in BOTH themes (toggle exercised)
20
+ (o) orchestrator-status phase=audit -> audit badge appears in a live region
21
+
22
+ Empty-state phase (separate boot, empty tracker/agents/alerts/backlog):
23
+ (i) all four views render their empty states with a clean console
10
24
 
11
25
  Run: python tools/verify_dash.py (exit 0 = proven, 1 = failed)
12
26
  python tools/verify_dash.py --allow-skip (exit 0 = proven or skipped, 1 = failed)
13
27
 
14
28
  Fails with exit 1 if playwright/chromium is unavailable (unless --allow-skip is passed).
15
- Use --allow-skip for environments that legitimately can't run a browser.
16
29
  """
17
30
  import argparse
18
31
  import json
@@ -23,31 +36,79 @@ import subprocess
23
36
  import sys
24
37
  import tempfile
25
38
  import time
26
- from datetime import datetime, timezone
27
39
  from pathlib import Path
28
40
 
29
41
  REPO = Path(__file__).resolve().parent.parent
30
42
  SERVE = REPO / "ui" / "serve.py"
31
43
 
32
- FIXTURE_BACKLOG = """# Audit backlog — verify_dash fixture
44
+
45
+ def _real_console_errors(console_errors, failed_urls):
46
+ """Drop noise from the captured console errors.
47
+
48
+ Playwright reports a failed sub-resource as a generic
49
+ "Failed to load resource: ... 404" console line that carries NO url, so
50
+ string-matching it is fragile. We instead correlate with the actual
51
+ failed-response urls: a generic resource-load line is ignorable when the
52
+ only failed responses were favicon (which the server now answers 204, so
53
+ this is belt-and-suspenders). Non-favicon failed responses are surfaced
54
+ explicitly so a real broken asset still fails the proof.
55
+ """
56
+ non_favicon = [u for u in failed_urls if "favicon" not in u.lower()]
57
+ real = []
58
+ for e in console_errors:
59
+ low = e.lower()
60
+ if "favicon" in low:
61
+ continue
62
+ if "failed to load resource" in low and not non_favicon:
63
+ continue # only favicon failed → ignore the urlless generic line
64
+ real.append(e)
65
+ real.extend(f"failed resource: {u}" for u in non_favicon)
66
+ return real
67
+
68
+ AGENT_FULL_ID = "verifyagent0123456789ab"
69
+ PROMPT_MARKER = "FIXTURE-PROMPT-MARKER: rebuild the flux capacitor\n" + "\n".join(
70
+ f"line {i}: recalibrate subsystem {i} and verify each tolerance band carefully"
71
+ for i in range(60))
72
+
73
+ FIXTURE_BACKLOG = """# Audit backlog — wave-14 verify_dash fixture
33
74
 
34
75
  **Status legend:** ⬜ unclaimed · 🔵 dispatched · ✅ merged · ⏸ user call
35
76
 
36
77
  ## P0 — correctness / security
37
78
 
38
- - ✅ **[sec] BACKLOG-SEED-ALPHA item.** done.
39
- - 🔵 **[js] BACKLOG-SEED-BETA item.** in flight.
79
+ - ✅ **[sec] Dashboard rewrite (U1 foundation).** completed.
80
+ - 🔵 **[ui] React component library (U4-U7).** in progress.
81
+
82
+ ## P1 — observability
83
+
84
+ - ⬜ **[cost] Cost analytics per model.** todo.
40
85
 
41
86
  ## Landing log
42
87
  - fixture
43
88
  """
44
89
 
45
- AGENT_FULL_ID = "verifyagent0123456789ab"
46
- # Long, multi-line prompt so the .dispatch-prompt box (max-height 300px) actually
47
- # overflows and is scrollable required to test scroll-position preservation.
48
- PROMPT_MARKER = "FIXTURE-PROMPT-MARKER: rebuild the flux capacitor\n" + "\n".join(
49
- f"line {i}: recalibrate subsystem {i} and verify each tolerance band carefully"
50
- for i in range(60))
90
+ FIXTURE_LEDGER = """| timestamp | agent_type | model | duration_seconds | tokens_in | tokens_out | verdict |
91
+ | --- | --- | --- | --- | --- | --- | --- |
92
+ | 2026-07-13T14:00:00Z | orchestrator | claude-opus-4-20250805 | 120 | 50000 | 12000 | OK |
93
+ | 2026-07-13T14:02:30Z | haiku | claude-haiku-4-5-20251001 | 45 | 12000 | 3500 | OK |
94
+ | 2026-07-13T14:05:15Z | haiku | claude-haiku-4-5-20251001 | 50 | 14000 | 4200 | OK |
95
+ | 2026-07-13T14:08:00Z | sonnet | claude-sonnet-4-5-20250929 | 85 | 28000 | 8100 | OK |
96
+ | 2026-07-13T14:12:20Z | haiku | claude-haiku-4-5-20251001 | 40 | 11000 | 3200 | FAILED |
97
+ """
98
+
99
+ XSS_ITEM = {
100
+ "id": "fixturexss01",
101
+ "title": "fixture xss probe",
102
+ "priority": "P2",
103
+ "status": "todo",
104
+ "lane": "proposed",
105
+ "source": "verify-dash",
106
+ "tags": ["fixture"],
107
+ "notes": "hostile pr_link must render inert",
108
+ "pr_link": "javascript:alert(1)",
109
+ "created_at": "2026-07-14T00:00:00Z",
110
+ "completed_at": None,
111
+ }
51
112
 
52
113
 
53
114
  def free_port():
@@ -58,20 +119,52 @@ def free_port():
58
119
  return port
59
120
 
60
121
 
61
- def build_fixture(root: Path, hint: str, with_high_alerts: bool = False):
122
+ def _rel_luminance(rgb):
123
+ def chan(c):
124
+ c = c / 255.0
125
+ return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4
126
+ r, g, b = (chan(v) for v in rgb)
127
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b
128
+
129
+
130
+ def contrast_ratio(rgb1, rgb2):
131
+ l1, l2 = _rel_luminance(rgb1), _rel_luminance(rgb2)
132
+ hi, lo = max(l1, l2), min(l1, l2)
133
+ return (hi + 0.05) / (lo + 0.05)
134
+
135
+
136
+ def parse_rgb(css_color):
137
+ """Parse 'rgb(r, g, b)' / 'rgba(r, g, b, a)' into an (r, g, b) tuple."""
138
+ inner = css_color[css_color.index("(") + 1: css_color.rindex(")")]
139
+ parts = [p.strip() for p in inner.split(",")]
140
+ return tuple(int(float(p)) for p in parts[:3])
141
+
142
+
143
+ def copy_dist(root: Path):
144
+ real_dist = REPO / "ui" / "web" / "dist"
145
+ if real_dist.is_dir():
146
+ shutil.copytree(real_dist, root / "ui" / "web" / "dist")
147
+
148
+
149
+ def build_fixture(root: Path, hint: str):
150
+ """Populated fixture: agent + tracker (with XSS probe) + alerts + ledger + backlog."""
62
151
  (root / "state").mkdir(exist_ok=True)
63
152
  (root / "transcripts").mkdir(exist_ok=True)
64
153
  (root / "dash").mkdir(exist_ok=True)
154
+ copy_dist(root)
155
+
65
156
  (root / "AUDIT-BACKLOG.md").write_text(FIXTURE_BACKLOG, encoding="utf-8")
66
157
 
67
- # Optional: seed with HIGH and MED severity alerts for testing
68
- if with_high_alerts:
69
- alerts_log = root / "state" / "SECURITY-ALERTS.log"
70
- alerts_log.write_text(
71
- "2025-07-12T14:32:01Z | HIGH | API secret exposed in logs\n"
72
- "2025-07-12T14:30:05Z | MED | Unvalidated user input detected\n",
73
- encoding="utf-8"
74
- )
158
+ (root / "state" / "ledger").mkdir(parents=True, exist_ok=True)
159
+ (root / "state" / "ledger" / "OUTCOMES-LEDGER.md").write_text(
160
+ FIXTURE_LEDGER, encoding="utf-8")
161
+
162
+ (root / "state" / "SECURITY-ALERTS.log").write_text(
163
+ "2026-07-13T14:32:01Z | HIGH | fixture alert high\n"
164
+ "2026-07-13T14:30:05Z | MED | fixture alert med\n", encoding="utf-8")
165
+
166
+ (root / "state" / "tracker.json").write_text(
167
+ json.dumps({"version": 1, "items": [XSS_ITEM]}), encoding="utf-8")
75
168
 
76
169
  # Fake detector: reads hint.txt so live agent updates are deterministic.
77
170
  (root / "hint.txt").write_text(hint, encoding="utf-8")
@@ -82,7 +175,7 @@ def build_fixture(root: Path, hint: str, with_high_alerts: bool = False):
82
175
  "status:'running',age_s:4,hint:hint,taskLabel:hint}]));\n"
83
176
  )
84
177
  (root / "dash" / "dash-extra.mjs").write_text(fake, encoding="utf-8")
85
- transcript = root / "transcripts" / f"{AGENT_FULL_ID}.output"
178
+ transcript = root / "transcripts" / f"agent-{AGENT_FULL_ID}.jsonl"
86
179
  lines = [
87
180
  json.dumps({"type": "user", "parentUuid": None,
88
181
  "message": {"content": PROMPT_MARKER}}),
@@ -90,589 +183,315 @@ def build_fixture(root: Path, hint: str, with_high_alerts: bool = False):
90
183
  "message": {"content": "working"}}),
91
184
  ]
92
185
  transcript.write_text("\n".join(lines) + "\n", encoding="utf-8")
93
- # fingerprint seed so the collector re-invokes the fake detector on touch
94
186
  (root / "transcripts" / "agent-seed.jsonl").write_text("{}\n", encoding="utf-8")
95
187
 
96
188
 
97
- def main():
98
- parser = argparse.ArgumentParser(
99
- description="Browser-level proof for the realtime SSE dashboard"
100
- )
101
- parser.add_argument(
102
- "--allow-skip",
103
- action="store_true",
104
- help="Allow skipping if playwright/chromium is unavailable (exit 0 instead of 1)"
105
- )
106
- args = parser.parse_args()
189
+ def build_empty_fixture(root: Path):
190
+ """Empty fixture: no tracker items, no agents, no alerts, no backlog, no ledger."""
191
+ (root / "state").mkdir(exist_ok=True)
192
+ (root / "transcripts").mkdir(exist_ok=True)
193
+ (root / "dash").mkdir(exist_ok=True)
194
+ copy_dist(root)
195
+ (root / "dash" / "dash-extra.mjs").write_text(
196
+ "console.log(JSON.stringify([]));\n", encoding="utf-8")
107
197
 
108
- try:
109
- from playwright.sync_api import sync_playwright
110
- except ImportError:
111
- msg = "playwright missing — run `python -m playwright install chromium`, or pass --allow-skip"
112
- if args.allow_skip:
113
- print(f"SKIP: {msg}")
114
- return 0
115
- else:
116
- print(f"FAIL: {msg}")
117
- return 1
118
-
119
- root = Path(tempfile.mkdtemp(prefix="aesop-verify-dash-"))
120
- state_root = root / "state"
121
198
 
122
- # HARD GUARD: refuse to run if state_root looks like the real repo state dir
123
- # (e.g., ~/aesop/state or an absolute path ending with /aesop/state)
199
+ def start_server(root: Path, port: int):
200
+ state_root = root / "state"
124
201
  real_state = Path.home() / "aesop" / "state"
125
202
  if state_root.resolve() == real_state.resolve():
126
- print("FAIL: state dir resolved to real repo state (~aesop/state), refusing to run")
127
- return 1
128
-
129
- port = free_port()
203
+ raise RuntimeError("state dir resolved to real repo state (~aesop/state)")
130
204
  env = dict(os.environ,
131
205
  AESOP_ROOT=str(root),
132
206
  AESOP_STATE_ROOT=str(state_root),
133
207
  AESOP_TRANSCRIPTS_ROOT=str(root / "transcripts"),
134
208
  AESOP_UI_COLLECT_INTERVAL="0.3",
135
209
  PORT=str(port))
136
- # Build with HIGH/MED severity alerts for testing alarm color semantics
137
- build_fixture(root, hint="initial fixture task", with_high_alerts=True)
138
210
  server = subprocess.Popen([sys.executable, str(SERVE)], env=env,
139
211
  stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
212
+ for _ in range(50):
213
+ try:
214
+ socket.create_connection(("127.0.0.1", port), timeout=0.2).close()
215
+ return server
216
+ except OSError:
217
+ time.sleep(0.2)
218
+ server.kill()
219
+ raise RuntimeError("server never came up")
220
+
221
+
222
+ def stop_server(server):
223
+ server.terminate()
224
+ try:
225
+ server.wait(timeout=5)
226
+ except subprocess.TimeoutExpired:
227
+ server.kill()
228
+
229
+
230
+ def run_empty_phase(pw, failures):
231
+ """(i) all four views render empty states with a clean console."""
232
+ root = Path(tempfile.mkdtemp(prefix="aesop-verify-w14-empty-"))
233
+ port = free_port()
140
234
  console_errors = []
141
- failures = []
235
+ failed_urls = []
236
+ build_empty_fixture(root)
142
237
  try:
143
- # wait for server
144
- for _ in range(50):
238
+ server = start_server(root, port)
239
+ except RuntimeError as e:
240
+ failures.append(f"(i) empty-state server failed: {e}")
241
+ shutil.rmtree(root, ignore_errors=True)
242
+ return
243
+ try:
244
+ browser = pw.chromium.launch(headless=True)
245
+ page = browser.new_page()
246
+ page.on("console", lambda m: console_errors.append(m.text)
247
+ if m.type == "error" else None)
248
+ page.on("pageerror", lambda e: console_errors.append(str(e)))
249
+ page.on("response", lambda r: failed_urls.append(r.url) if r.status >= 400 else None)
250
+ page.goto(f"http://127.0.0.1:{port}/", wait_until="domcontentloaded")
251
+ try:
252
+ page.wait_for_selector("[data-testid='health-header']", timeout=15000)
253
+ except Exception as e:
254
+ failures.append(f"(i) empty-state app never mounted: {e}")
255
+ views = [("#/", "view-overview"), ("#/work", "view-work"),
256
+ ("#/activity", "view-activity"), ("#/cost", "view-cost")]
257
+ for hash_, testid in views:
258
+ page.evaluate(f"location.hash = '{hash_}'")
145
259
  try:
146
- socket.create_connection(("127.0.0.1", port), timeout=0.2).close()
147
- break
148
- except OSError:
149
- time.sleep(0.2)
150
- else:
151
- print("FAIL: server never came up")
152
- return 1
260
+ page.wait_for_selector(f"[data-testid='{testid}']", timeout=12000)
261
+ except Exception as e:
262
+ failures.append(f"(i) empty-state view {testid} did not render: {e}")
263
+ time.sleep(0.5)
264
+ real_errors = _real_console_errors(console_errors, failed_urls)
265
+ if real_errors:
266
+ failures.append(f"(i) empty-state console errors: {real_errors[:3]}")
267
+ browser.close()
268
+ finally:
269
+ stop_server(server)
270
+ shutil.rmtree(root, ignore_errors=True)
271
+
272
+
273
+ def main():
274
+ parser = argparse.ArgumentParser(
275
+ description="Browser-level proof for the wave-14 React dashboard (ui/web/dist/)")
276
+ parser.add_argument("--allow-skip", action="store_true",
277
+ help="Allow skipping if playwright/chromium is unavailable")
278
+ args = parser.parse_args()
279
+
280
+ try:
281
+ from playwright.sync_api import sync_playwright
282
+ except ImportError:
283
+ msg = "playwright missing — run `python -m playwright install chromium`, or pass --allow-skip"
284
+ print(f"SKIP: {msg}" if args.allow_skip else f"FAIL: {msg}")
285
+ return 0 if args.allow_skip else 1
286
+
287
+ root = Path(tempfile.mkdtemp(prefix="aesop-verify-wave14-dash-"))
288
+ port = free_port()
289
+ console_errors = []
290
+ failed_urls = []
291
+ failures = []
292
+ build_fixture(root, hint="fixture hint alpha")
293
+ try:
294
+ server = start_server(root, port)
295
+ except RuntimeError as e:
296
+ print(f"FAIL: {e}")
297
+ shutil.rmtree(root, ignore_errors=True)
298
+ return 1
153
299
 
300
+ try:
154
301
  with sync_playwright() as pw:
155
302
  try:
156
303
  browser = pw.chromium.launch(headless=True)
157
304
  except Exception as e:
158
305
  msg = f"chromium unavailable ({e}); run: python -m playwright install chromium"
159
- if args.allow_skip:
160
- print(f"SKIP: {msg}")
161
- return 0
162
- else:
163
- print(f"FAIL: {msg}")
164
- return 1
306
+ print(f"SKIP: {msg}" if args.allow_skip else f"FAIL: {msg}")
307
+ return 0 if args.allow_skip else 1
308
+
165
309
  page = browser.new_page()
166
310
  page.on("console", lambda m: console_errors.append(m.text)
167
311
  if m.type == "error" else None)
168
312
  page.on("pageerror", lambda e: console_errors.append(str(e)))
313
+ page.on("response", lambda r: failed_urls.append(r.url) if r.status >= 400 else None)
169
314
  page.goto(f"http://127.0.0.1:{port}/", wait_until="domcontentloaded")
170
315
 
171
- # (b) backlog panel renders seeded items
172
-
173
- # (P2-UX) Layout order verification: Fleet Agents + Security Alerts in top third
174
- try:
175
- page.wait_for_selector("#agents-list", timeout=8000)
176
- page.wait_for_selector("#alerts-list", timeout=8000)
177
- page.wait_for_selector("#backlog-tiers", timeout=8000)
178
-
179
- # Verify DOM order: agents should appear before backlog
180
- agents_index = page.evaluate(
181
- "Array.from(document.querySelectorAll('[id]')).findIndex(el => el.id === 'agents-list')"
182
- )
183
- alerts_index = page.evaluate(
184
- "Array.from(document.querySelectorAll('[id]')).findIndex(el => el.id === 'alerts-list')"
185
- )
186
- backlog_index = page.evaluate(
187
- "Array.from(document.querySelectorAll('[id]')).findIndex(el => el.id === 'backlog-tiers')"
188
- )
189
-
190
- assert agents_index < backlog_index, f"Fleet Agents should appear BEFORE Audit Backlog in DOM (agents={agents_index}, backlog={backlog_index})"
191
- assert alerts_index < backlog_index, f"Security Alerts should appear BEFORE Audit Backlog in DOM (alerts={alerts_index}, backlog={backlog_index})"
192
-
193
- except Exception as e:
194
- failures.append(f"(P2-UX) layout order verification failed: {e}")
195
-
196
- # (P2-UX) Collapsed done backlog items: verify reduced padding and opacity
197
- try:
198
- # Ensure backlog items render with the fixture
199
- page.wait_for_function(
200
- "document.querySelector('.backlog-item.done') !== null",
201
- timeout=8000
202
- )
203
-
204
- # Get height and opacity of done item vs active item
205
- done_item_styles = page.evaluate("""
206
- (() => {
207
- const done = document.querySelector('.backlog-item.done');
208
- const cs = window.getComputedStyle(done);
209
- return {
210
- height: done.offsetHeight,
211
- opacity: cs.opacity,
212
- padding: cs.padding
213
- };
214
- })()
215
- """)
216
-
217
- active_item_styles = page.evaluate("""
218
- (() => {
219
- const active = Array.from(document.querySelectorAll('.backlog-item'))
220
- .find(el => !el.classList.contains('done'));
221
- if (!active) return {height: 0, opacity: '1', padding: '0px'};
222
- const cs = window.getComputedStyle(active);
223
- return {
224
- height: active.offsetHeight,
225
- opacity: cs.opacity,
226
- padding: cs.padding
227
- };
228
- })()
229
- """)
230
-
231
- # Done items should be more compact (lower height, lower opacity)
232
- assert done_item_styles['height'] <= active_item_styles['height'], f"Done items should be more compact (height {done_item_styles['height']} > {active_item_styles['height']})"
233
- assert float(done_item_styles['opacity']) < 1.0, f"Done items should have reduced opacity (got {done_item_styles['opacity']})"
234
-
235
- except Exception as e:
236
- failures.append(f"(P2-UX) done item visual collapse verification failed: {e}")
237
-
238
-
239
- try:
240
- page.wait_for_selector("#backlog-tiers:not(.loading)", timeout=8000)
241
- assert "BACKLOG-SEED-ALPHA" in page.inner_text("#backlog-tiers")
242
- except Exception as e:
243
- failures.append(f"(b) backlog panel did not render seed items: {e}")
244
-
245
- # (b2) alarm color semantics: alert count renders in alarm color when HIGH alerts exist
316
+ # (b) app serves React dist, not the legacy template
246
317
  try:
247
- # Wait for the alert count to get a class (indicating data has loaded)
248
- page.wait_for_function(
249
- "document.getElementById('alert-count').className !== ''",
250
- timeout=8000
251
- )
252
- # Check that the element has the alarm-high class (red color)
253
- class_list = page.evaluate("document.getElementById('alert-count').className")
254
- assert "alarm-high" in class_list or "alarm-med" in class_list, \
255
- f"Alert count should have 'alarm-high' or 'alarm-med' class for severity, got: {class_list}"
256
- # Verify computed color is NOT neutral gray (should be red/amber)
257
- color = page.evaluate("window.getComputedStyle(document.getElementById('alert-count')).color")
258
- # Color should be high-alert red (not gray/neutral) — just verify it's a color
259
- assert "rgb(" in color, f"Alert count should have computed color, got: {color}"
260
- except Exception as e:
261
- failures.append(f"(b2) alert count alarm color not set: {e}")
262
-
263
- # (b3) Security Alerts panel has distinct alarm styling when HIGH alerts exist
264
- try:
265
- alerts_box = page.query_selector(".alerts-box")
266
- alerts_box_class = page.evaluate("document.querySelector('.alerts-box').className")
267
- assert "has-high-alerts" in alerts_box_class or "has-alerts" in alerts_box_class, \
268
- f"Alerts box should have alarm styling class, got: {alerts_box_class}"
269
- # Verify border changed from neutral #333 to alarm #f44
270
- border_color = page.evaluate("window.getComputedStyle(document.querySelector('.alerts-box')).borderColor")
271
- assert "rgb(" in border_color, f"Alerts box should have computed border color: {border_color}"
318
+ page_html = page.content()
319
+ assert "data-testid" in page_html, "React app should use data-testid hooks"
320
+ assert "audit-banner" not in page_html, "should not be serving old template"
272
321
  except Exception as e:
273
- failures.append(f"(b3) alerts panel alarm styling not applied: {e}")
322
+ failures.append(f"(b) app does not serve React dist: {e}")
274
323
 
275
- # (b4) affordances: stronger expand-toggle + responsive header wrap (no horizontal overflow when narrow)
324
+ # (c) health header rendered
276
325
  try:
277
- page.wait_for_selector(".agent-row", timeout=8000)
278
- toggle_size = page.evaluate(
279
- "parseFloat(getComputedStyle(document.querySelector('.agent-expand-toggle')).fontSize)")
280
- assert toggle_size >= 13, f"expand toggle should be a stronger affordance (>=13px), got {toggle_size}px"
281
- # narrow the viewport: header must wrap, body must not scroll horizontally
282
- page.set_viewport_size({"width": 600, "height": 800})
283
- page.wait_for_timeout(200)
284
- overflow = page.evaluate(
285
- "document.documentElement.scrollWidth - document.documentElement.clientWidth")
286
- assert overflow <= 2, f"body overflows horizontally at 600px (header not wrapping): {overflow}px"
287
- page.set_viewport_size({"width": 1280, "height": 900})
288
- except Exception as e:
289
- failures.append(f"(b4) affordance/responsive-header check failed: {e}")
290
-
291
- # (c) click agent row -> expands with the real dispatch prompt
292
- try:
293
- page.wait_for_selector(".agent-row", timeout=8000)
294
- page.click(".agent-row")
295
- page.wait_for_selector(".agent-row.expanded", timeout=4000)
296
- page.wait_for_function(
297
- "document.querySelector('.agent-row.expanded .agent-details')"
298
- f" && document.querySelector('.agent-row.expanded .agent-details').innerText.includes('FIXTURE-PROMPT-MARKER')",
299
- timeout=8000)
326
+ page.wait_for_selector("[data-testid='health-header']", timeout=5000)
300
327
  except Exception as e:
301
- failures.append(f"(c) click-to-expand with prompt failed: {e}")
328
+ failures.append(f"(c) health-header testid not found: {e}")
302
329
 
303
- # (d) live updates over SSE, no reload: backlog file + agent hint change
330
+ # (d) overview view on first paint
304
331
  try:
305
- bl = root / "AUDIT-BACKLOG.md"
306
- content = bl.read_text(encoding="utf-8").replace(
307
- "## Landing log",
308
- "- ⬜ **[test] LIVE-BACKLOG-MARKER item.** pushed live.\n\n## Landing log")
309
- bl.write_text(content, encoding="utf-8")
310
- (root / "hint.txt").write_text("LIVE-AGENT-MARKER task", encoding="utf-8")
311
- (root / "transcripts" / "agent-live.jsonl").write_text("{}\n", encoding="utf-8")
312
- page.wait_for_function(
313
- "document.querySelector('#backlog-tiers').innerText.includes('LIVE-BACKLOG-MARKER')",
314
- timeout=8000)
315
- page.wait_for_function(
316
- "document.querySelector('#agents-list').innerText.includes('LIVE-AGENT-MARKER')",
317
- timeout=8000)
332
+ page.wait_for_selector("[data-testid='view-overview']", timeout=5000)
318
333
  except Exception as e:
319
- failures.append(f"(d) live SSE update did not reach the page: {e}")
334
+ failures.append(f"(d) view testids not found: {e}")
320
335
 
321
- # (e) expansion survived the live updates
336
+ # (e) inbox form present
322
337
  try:
323
- assert page.query_selector(".agent-row.expanded") is not None, \
324
- "expanded row lost after live updates"
338
+ page.wait_for_selector("[data-testid='inbox-input']", timeout=5000)
339
+ page.wait_for_selector("[data-testid='inbox-submit']", timeout=5000)
325
340
  except Exception as e:
326
- failures.append(f"(e) {e}")
341
+ failures.append(f"(e) inbox form testids not found: {e}")
327
342
 
328
-
329
- # (f) scroll position and text selection survive live updates (bugfix P2 #1)
343
+ # (m) keyboard-only agent expand + survival across an SSE agents update
330
344
  try:
331
- # Expand an agent again to get its prompt box visible
332
- expanded_row = page.query_selector(".agent-row.expanded")
333
- if not expanded_row:
334
- # Re-expand if needed
335
- page.click(".agent-row")
336
- page.wait_for_selector(".agent-row.expanded", timeout=4000)
337
-
338
- # Get the prompt box and scroll it down
339
- prompt_box = page.query_selector(".agent-row.expanded .dispatch-prompt")
340
- assert prompt_box is not None, "Prompt box not found"
341
-
342
- # Scroll the prompt box to bottom
343
- initial_scroll = page.evaluate(
344
- "document.querySelector('.agent-row.expanded .dispatch-prompt').scrollTop || 0")
345
- page.evaluate(
346
- "document.querySelector('.agent-row.expanded .dispatch-prompt').scrollTop = 999")
347
- scroll_before = page.evaluate(
348
- "document.querySelector('.agent-row.expanded .dispatch-prompt').scrollTop")
349
- assert scroll_before > initial_scroll, f"Failed to scroll; before={initial_scroll}, after={scroll_before}"
350
-
351
- # Trigger a live update by touching the backlog
352
- bl = root / "AUDIT-BACKLOG.md"
353
- content = bl.read_text(encoding="utf-8").replace(
354
- "## Landing log",
355
- "- ⬜ **[test] SCROLL-PERSIST-MARKER item.** live update.\n\n## Landing log")
356
- bl.write_text(content, encoding="utf-8")
357
-
358
- # Wait for the update to arrive
345
+ page.wait_for_selector("[data-testid='agent-row']", timeout=8000)
346
+ # keyboard-only: focus the row's real <button> and press Enter
347
+ expand_btn = page.locator("[data-testid='agent-row'] button").first
348
+ expand_btn.focus()
349
+ page.keyboard.press("Enter")
350
+ page.wait_for_selector("[data-testid='agent-row-detail']", timeout=8000)
351
+ # trigger a live agents update: change hint + touch the fingerprint seed
352
+ (root / "hint.txt").write_text("fixture hint beta", encoding="utf-8")
353
+ seed = root / "transcripts" / "agent-seed.jsonl"
354
+ seed.write_text('{"touch": %d}\n' % time.time_ns(), encoding="utf-8")
359
355
  page.wait_for_function(
360
- "document.querySelector('#backlog-tiers').innerText.includes('SCROLL-PERSIST-MARKER')",
361
- timeout=8000)
362
-
363
- # Check that scroll position survived the update
364
- scroll_after = page.evaluate(
365
- "document.querySelector('.agent-row.expanded .dispatch-prompt').scrollTop")
366
- assert scroll_after >= scroll_before - 2, f"Scroll position lost during live update: before={scroll_before}, after={scroll_after}"
356
+ "document.body.innerText.includes('fixture hint beta')", timeout=10000)
357
+ assert page.query_selector("[data-testid='agent-row-detail']") is not None, \
358
+ "expansion did not survive the SSE agents update"
367
359
  except Exception as e:
368
- failures.append(f"(f) scroll position/selection not preserved during live update: {e}")
360
+ failures.append(f"(m) keyboard expand / expansion-survival failed: {e}")
369
361
 
370
- # (g) promptCache eviction works: removed agents don't stay cached (bugfix P2 #2)
362
+ # (j) SSE live update WITHOUT reload: mutate tracker.json directly
371
363
  try:
372
- # Get initial cache size
373
- cache_size_before = page.evaluate("window.__getPromptCacheSize()")
374
- assert cache_size_before > 0, "Cache should have entries for expanded agents"
375
-
376
- # Change agent hint to force a new agent to appear
377
- (root / "hint.txt").write_text("NEW-AGENT-AFTER-EVICT", encoding="utf-8")
378
- (root / "transcripts" / "agent-evict-marker.jsonl").write_text("{}", encoding="utf-8")
379
- page.wait_for_function(
380
- "document.querySelector('#agents-list').innerText.includes('NEW-AGENT-AFTER-EVICT')",
381
- timeout=8000)
382
-
383
- # Now change it again to a different agent (old one gets removed from DOM)
384
- (root / "hint.txt").write_text("FINAL-AGENT-STATE", encoding="utf-8")
385
- (root / "transcripts" / "agent-final-marker.jsonl").write_text("{}", encoding="utf-8")
364
+ page.evaluate("location.hash = '#/work'")
365
+ page.wait_for_selector("[data-testid='view-work']", timeout=5000)
366
+ tracker_file = root / "state" / "tracker.json"
367
+ data = json.loads(tracker_file.read_text(encoding="utf-8"))
368
+ data["items"].append(dict(XSS_ITEM, id="fixturesse001",
369
+ title="SSE-LIVE-MARKER item",
370
+ pr_link=None))
371
+ tracker_file.write_text(json.dumps(data), encoding="utf-8")
386
372
  page.wait_for_function(
387
- "document.querySelector('#agents-list').innerText.includes('FINAL-AGENT-STATE')",
388
- timeout=8000)
389
-
390
- # Cache should have evicted old entries
391
- cache_size_after = page.evaluate("window.__getPromptCacheSize()")
392
- assert cache_size_after <= cache_size_before + 1, f"Cache grew unbounded: before={cache_size_before}, after={cache_size_after}"
373
+ "document.body.innerText.includes('SSE-LIVE-MARKER')", timeout=10000)
393
374
  except Exception as e:
394
- failures.append(f"(g) promptCache not evicting removed agents: {e}")
375
+ failures.append(f"(j) SSE live tracker update failed: {e}")
395
376
 
396
- # (h) /submit writes the inbox file as valid UTF-8, even on first write
397
- # (regression for PR #36: an encoding-less header write corrupted the file)
377
+ # (l) hostile javascript: pr_link inert in the real DOM
398
378
  try:
399
- marker = "SUBMIT-ENC-MARKER café ✓ orchestrator"
400
- page.fill("#inbox-input", marker)
401
- page.click("#inbox-button")
402
- inbox_file = root / "state" / "ui-inbox.md"
403
- deadline = time.time() + 6
404
- ok = False
405
- while time.time() < deadline:
406
- if inbox_file.exists():
407
- # Must decode as UTF-8 without raising UnicodeDecodeError.
408
- text = inbox_file.read_text(encoding="utf-8")
409
- if marker in text:
410
- ok = True
411
- break
412
- time.sleep(0.2)
413
- assert ok, f"submitted UTF-8 marker not found in {inbox_file}"
379
+ bad = page.evaluate(
380
+ "Array.from(document.querySelectorAll('a[href]'))"
381
+ ".filter(a => a.href.toLowerCase().startsWith('javascript:')).length")
382
+ assert bad == 0, f"{bad} anchor(s) carry a javascript: href"
414
383
  except Exception as e:
415
- failures.append(f"(h) /submit did not write a valid UTF-8 inbox file: {e}")
384
+ failures.append(f"(l) XSS pr_link not inert: {e}")
416
385
 
417
- # (i) tracker panel renders with lanes (proposed, ranked, in-progress, done)
386
+ # (k) tracker round-trip through the real form (CSRF path)
418
387
  try:
419
- page.wait_for_selector("#tracker-lanes", timeout=8000)
420
- # Verify lane headers exist
388
+ # the add form sits behind a "+ Add Item" toggle
389
+ if page.locator("[data-testid='tracker-form-title']").count() == 0:
390
+ page.get_by_role("button", name="+ Add Item").click()
391
+ page.fill("[data-testid='tracker-form-title']", "ROUNDTRIP-MARKER item")
392
+ page.click("[data-testid='tracker-form-submit']")
421
393
  page.wait_for_function(
422
- "document.querySelector('[data-lane=\"proposed\"]') !== null",
423
- timeout=5000)
424
- lanes = page.evaluate(
425
- "Array.from(document.querySelectorAll('[data-lane]')).map(el => el.dataset.lane)")
426
- expected_lanes = ['proposed', 'ranked', 'in-progress', 'done']
427
- for lane in expected_lanes:
428
- assert lane in lanes, f"Lane '{lane}' not found in tracker"
429
- except Exception as e:
430
- failures.append(f"(i) tracker panel did not render lanes: {e}")
431
-
432
- # (j) POST tracker item via form → appears in proposed lane via SSE (no reload)
433
- try:
434
- page.fill("#tracker-title", "Tracker Test Item")
435
- page.select_option("#tracker-priority", "P1")
436
- page.fill("#tracker-notes", "Test notes for tracker item")
437
- page.click("#tracker-add-btn")
438
- # Wait for item to appear in proposed lane via SSE (no page reload)
439
- page.wait_for_function(
440
- "document.querySelector('[data-lane=\"proposed\"]')?.innerText.includes('Tracker Test Item')",
441
- timeout=8000)
442
- # Verify priority chip rendered
443
- page.wait_for_function(
444
- "document.querySelector('.priority-p1') !== null",
445
- timeout=5000)
446
- except Exception as e:
447
- failures.append(f"(j) tracker add-item form did not create/SSE item: {e}")
448
-
449
- # (m) tracker item pr_link XSS: javascript: URLs must never reach a DOM href
450
- # (wave-10 P0 — render-side defense-in-depth for buildTrackerItem)
451
- try:
452
- render = page.evaluate(
453
- """
454
- (() => {
455
- const el = buildTrackerItem({
456
- id: 'xss-test-item',
457
- title: 'XSS Probe',
458
- priority: 'P1',
459
- lane: 'proposed',
460
- pr_link: 'javascript:alert(1)'
461
- });
462
- const a = el.querySelector('a');
463
- return {
464
- html: el.innerHTML,
465
- anchorHref: a ? a.getAttribute('href') : null,
466
- anchorPresent: a !== null
467
- };
468
- })()
469
- """
470
- )
471
- # The raw pr_link text may still be SHOWN (escaped, inert) to the user
472
- # for visibility — that's fine. What must never happen is the scheme
473
- # landing inside an href="..." attribute, which is what makes it
474
- # click-to-execute.
475
- assert 'href="javascript' not in render["html"].lower(), \
476
- f"(m) javascript: scheme leaked into a rendered href attribute: {render['html']}"
477
- # No <a> should be emitted for a rejected scheme (label falls back to
478
- # a plain, non-clickable span), and no href value may resolve to
479
- # anything other than the empty/neutralized string.
480
- assert not (render["anchorPresent"] and render["anchorHref"]
481
- and "javascript:" in render["anchorHref"].lower()), \
482
- f"(m) tracker item rendered an executable javascript: href: {render['anchorHref']}"
483
- assert not render["anchorPresent"], \
484
- f"(m) expected pr_link with unsafe scheme to render inert (no <a>), got href={render['anchorHref']}"
485
-
486
- # Sanity check: a legitimate https:// pr_link must still render as a
487
- # real, clickable link so the fix doesn't break valid PR links.
488
- safe_render = page.evaluate(
489
- """
490
- (() => {
491
- const el = buildTrackerItem({
492
- id: 'safe-link-item',
493
- title: 'Safe Link',
494
- priority: 'P1',
495
- lane: 'proposed',
496
- pr_link: 'https://github.com/example/repo/pull/1'
497
- });
498
- const a = el.querySelector('a');
499
- return { anchorHref: a ? a.getAttribute('href') : null };
500
- })()
501
- """
502
- )
503
- assert safe_render["anchorHref"] == "https://github.com/example/repo/pull/1", \
504
- f"(m) valid https pr_link should still render a real href, got: {safe_render['anchorHref']}"
394
+ "document.body.innerText.includes('ROUNDTRIP-MARKER')", timeout=10000)
395
+ # locate the card, then drive its lane actions by accessible name
396
+ card = page.locator("[data-testid='tracker-card']",
397
+ has_text="ROUNDTRIP-MARKER").first
398
+ moved = 0
399
+ for action in ("Claim", "Done"):
400
+ btn = card.get_by_role("button", name=action)
401
+ if btn.count() == 0:
402
+ # expand the card first if actions are inside the detail area
403
+ card.click()
404
+ btn = card.get_by_role("button", name=action)
405
+ if btn.count() > 0:
406
+ btn.first.click()
407
+ time.sleep(1.0)
408
+ moved += 1
409
+ assert moved == 2, f"only {moved}/2 lane actions (Claim/Done) were operable"
505
410
  except Exception as e:
506
- failures.append(f"(m) tracker pr_link XSS probe failed: {e}")
411
+ failures.append(f"(k) tracker round-trip failed: {e}")
507
412
 
508
- # (k) orchestrator status shows "no active session" when file absent
413
+ # (f) cost view renders table/chart/scorecard from the fixture ledger
509
414
  try:
510
- orch_status = page.inner_text("#orchestrator-status")
511
- assert "no active session" in orch_status or orch_status == "—", \
512
- f"Expected 'no active session' when status file absent, got: '{orch_status}'"
415
+ page.evaluate("location.hash = '#/cost'")
416
+ page.wait_for_selector("[data-testid='cost-table']", timeout=8000)
417
+ page.wait_for_selector("[data-testid='cost-chart']", timeout=5000)
418
+ page.wait_for_selector("[data-testid='scorecard']", timeout=5000)
419
+ assert "haiku" in page.inner_text("[data-testid='cost-table']").lower(), \
420
+ "fixture ledger models not rendered in cost table"
513
421
  except Exception as e:
514
- failures.append(f"(k) orchestrator status did not show 'no active session': {e}")
422
+ failures.append(f"(f) cost view did not render fixture ledger: {e}")
515
423
 
516
- # (l) write orchestrator-status.json with phase=audit ASCII banner appears
424
+ # (o) orchestrator-status phase=audit -> audit badge in a live region
517
425
  try:
518
- status_data = {
519
- "id": "main",
520
- "role": "orchestrator",
521
- "activity": "running audit",
522
- "phase": "audit",
523
- "updated_at": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
524
- }
525
426
  status_file = root / "state" / "orchestrator-status.json"
526
- status_file.write_text(json.dumps(status_data, indent=2), encoding="utf-8")
527
- # Wait for banner to appear via SSE
427
+ status_file.write_text(json.dumps({
428
+ "id": "main", "role": "orchestrator",
429
+ "activity": "running audit", "phase": "audit"}), encoding="utf-8")
528
430
  page.wait_for_function(
529
- "document.getElementById('audit-banner').style.display === 'block'",
530
- timeout=8000)
531
- # Verify ASCII art is present
532
- banner_text = page.inner_text("#audit-banner")
533
- assert "AUDIT CYCLE RUNNING" in banner_text and "scanning" in banner_text, \
534
- f"Audit banner missing expected content: {banner_text}"
431
+ "(() => { const el = document.querySelector(\"[data-testid='health-orchestrator']\");"
432
+ " return el && /audit/i.test(el.textContent); })()", timeout=10000)
433
+ announced = page.evaluate(
434
+ "(() => { const el = document.querySelector(\"[data-testid='health-orchestrator']\");"
435
+ " const near = el.closest('[role=status],[aria-live]') || el.querySelector('[role=status],[aria-live]');"
436
+ " return !!near || el.getAttribute('role') === 'status' || el.hasAttribute('aria-live'); })()")
437
+ assert announced, "audit badge is not in/near a live region"
535
438
  except Exception as e:
536
- failures.append(f"(l) orchestrator status audit banner did not appear: {e}")
439
+ failures.append(f"(o) audit-phase badge failed: {e}")
537
440
 
538
- # (n) Wave-13 UX/a11y fixes validation
539
- # (n1) header-label contrast: verify color changed from #666 to #999
441
+ # (n) computed contrast of a health-header label in BOTH themes
540
442
  try:
541
- header_label_color = page.evaluate(
542
- "window.getComputedStyle(document.querySelector('.header-label')).color")
543
- # #999 = rgb(153, 153, 153)
544
- assert "153" in header_label_color, \
545
- f"(n1) header-label should have #999 contrast fix (rgb ~153,153,153), got: {header_label_color}"
443
+ page.evaluate("location.hash = '#/'")
444
+ page.wait_for_selector("[data-testid='health-watchdog']", timeout=5000)
445
+ ratios = {}
446
+ for theme_pass in ("first", "second"):
447
+ styles = page.evaluate(
448
+ "(() => { const el = document.querySelector(\"[data-testid='health-watchdog']\");"
449
+ " const cs = getComputedStyle(el);"
450
+ " let bg = 'rgba(0, 0, 0, 0)'; let node = el;"
451
+ " while (node) { const c = getComputedStyle(node).backgroundColor;"
452
+ " if (c && !c.includes('0, 0, 0, 0')) { bg = c; break; } node = node.parentElement; }"
453
+ " if (bg.includes('0, 0, 0, 0')) bg = getComputedStyle(document.body).backgroundColor;"
454
+ " return { fg: cs.color, bg: bg,"
455
+ " theme: document.documentElement.getAttribute('data-theme') || 'default' }; })()")
456
+ ratio = contrast_ratio(parse_rgb(styles["fg"]), parse_rgb(styles["bg"]))
457
+ ratios[styles["theme"] + "/" + theme_pass] = round(ratio, 2)
458
+ assert ratio >= 4.5, \
459
+ f"health label contrast {ratio:.2f}:1 in theme '{styles['theme']}' (< 4.5)"
460
+ if theme_pass == "first":
461
+ page.click("[data-testid='theme-toggle']")
462
+ time.sleep(0.5)
463
+ print(f" contrast ratios: {ratios}")
546
464
  except Exception as e:
547
- failures.append(f"(n1) header-label contrast check failed: {e}")
465
+ failures.append(f"(n) theme contrast check failed: {e}")
548
466
 
549
- # (n2) priority select has visual affordance (chevron)
467
+ # (g) CSS stylesheet loaded
550
468
  try:
551
- select_bg = page.evaluate(
552
- "window.getComputedStyle(document.getElementById('tracker-priority')).backgroundImage")
553
- assert "url(" in select_bg and ("svg" in select_bg.lower() or "data:" in select_bg), \
554
- f"(n2) tracker-priority should have chevron background, got: {select_bg}"
469
+ assert page.query_selector("link[rel='stylesheet']") is not None, \
470
+ "page should have CSS stylesheet links"
555
471
  except Exception as e:
556
- failures.append(f"(n2) priority select chevron not found: {e}")
472
+ failures.append(f"(g) CSS stylesheet check failed: {e}")
557
473
 
558
- # (n3) empty tracker lane shows placeholder
474
+ # (h) live regions present
559
475
  try:
560
- # Create a tracker with no items to get empty state
561
- page.evaluate("""
562
- (() => {
563
- const container = document.getElementById('tracker-lanes');
564
- container.innerHTML = '';
565
- const laneEl = document.createElement('div');
566
- laneEl.className = 'tracker-lane';
567
- laneEl.innerHTML = `
568
- <div class="lane-header">
569
- <span>Empty Test Lane</span>
570
- <span class="lane-count" aria-label="Empty Test Lane: 0 items">0</span>
571
- </div>
572
- <div class="lane-items empty" data-lane="test"></div>
573
- `;
574
- const itemsContainer = laneEl.querySelector('.lane-items');
575
- const placeholder = document.createElement('div');
576
- placeholder.className = 'lane-empty-placeholder';
577
- placeholder.textContent = 'empty';
578
- itemsContainer.appendChild(placeholder);
579
- container.appendChild(laneEl);
580
- })()
581
- """)
582
- empty_lane_text = page.inner_text('[data-lane="test"]')
583
- assert "empty" in empty_lane_text.lower(), \
584
- f"(n3) empty lane should show 'empty' placeholder, got: '{empty_lane_text}'"
476
+ live_regions = page.evaluate(
477
+ "document.querySelectorAll('[role=\"status\"], [aria-live]').length")
478
+ assert live_regions > 0, "no live regions on page"
585
479
  except Exception as e:
586
- failures.append(f"(n3) empty lane placeholder check failed: {e}")
480
+ failures.append(f"(h) live regions check failed: {e}")
587
481
 
588
- # (n4) prefers-reduced-motion media query is in CSS
589
- try:
590
- css_contains_prefers = page.evaluate("""
591
- (() => {
592
- const styles = Array.from(document.styleSheets)
593
- .filter(s => !s.href || s.href.includes('http://localhost'))
594
- .map(s => {
595
- try { return s.cssText; } catch { return ''; }
596
- })
597
- .join(' ');
598
- const pageContent = document.documentElement.outerHTML;
599
- return pageContent.includes('prefers-reduced-motion');
600
- })()
601
- """)
602
- assert css_contains_prefers, \
603
- "(n4) CSS should include @media (prefers-reduced-motion: reduce) query"
604
- except Exception as e:
605
- failures.append(f"(n4) prefers-reduced-motion query check failed: {e}")
606
-
607
- # (n5) tracker-live-region exists for a11y announcements (sr-only)
608
- try:
609
- live_region = page.query_selector("#tracker-live-region")
610
- assert live_region is not None, \
611
- "(n5) tracker-live-region should exist for screen reader announcements"
612
- # Verify it's sr-only (absolutely positioned, off-screen)
613
- sr_only_styles = page.evaluate("""
614
- (() => {
615
- const el = document.getElementById('tracker-live-region');
616
- const cs = window.getComputedStyle(el);
617
- return {
618
- position: cs.position,
619
- width: cs.width,
620
- height: cs.height
621
- };
622
- })()
623
- """)
624
- assert sr_only_styles["position"] == "absolute", \
625
- f"(n5) tracker-live-region should be sr-only (position:absolute), got: {sr_only_styles}"
626
- except Exception as e:
627
- failures.append(f"(n5) tracker-live-region sr-only check failed: {e}")
628
-
629
- # (n6) lane-count elements have aria-labels
630
- try:
631
- page.wait_for_selector(".lane-count", timeout=5000)
632
- lane_count_labels = page.evaluate("""
633
- (() => {
634
- return Array.from(document.querySelectorAll('.lane-count'))
635
- .map(el => el.getAttribute('aria-label') || 'missing')
636
- .filter(l => l !== 'missing');
637
- })()
638
- """)
639
- assert len(lane_count_labels) > 0 and all(": " in l for l in lane_count_labels), \
640
- f"(n6) lane-count elements should have 'Lane Name: N items' aria-labels, got: {lane_count_labels}"
641
- except Exception as e:
642
- failures.append(f"(n6) lane-count aria-label check failed: {e}")
643
-
644
- # (n7) running-count has toned down styling (not 18px/900)
645
- try:
646
- running_count_style = page.evaluate("""
647
- (() => {
648
- const el = document.getElementById('running-count');
649
- const cs = window.getComputedStyle(el);
650
- return { fontSize: cs.fontSize, fontWeight: cs.fontWeight };
651
- })()
652
- """)
653
- # After fix: 16px/600, not 18px/900
654
- font_size = int(running_count_style["fontSize"].replace("px", ""))
655
- assert font_size <= 16, \
656
- f"(n7) running-count should have toned down font-size (<=16px), got: {font_size}px"
657
- # Font-weight 600 should be "600" or similar, not "900"
658
- assert "900" not in str(running_count_style["fontWeight"]), \
659
- f"(n7) running-count should have reduced font-weight (not 900), got: {running_count_style['fontWeight']}"
660
- except Exception as e:
661
- failures.append(f"(n7) running-count styling check failed: {e}")
662
-
663
- # (a) console clean across the whole run
664
- time.sleep(1.0)
665
- real_errors = [e for e in console_errors if "favicon" not in e.lower()]
482
+ # (a) console clean across the whole populated run
483
+ time.sleep(0.5)
484
+ real_errors = _real_console_errors(console_errors, failed_urls)
666
485
  if real_errors:
667
- failures.append(f"(a) console errors: {real_errors[:5]}")
486
+ failures.append(f"(a) console errors: {real_errors[:3]}")
668
487
 
669
488
  browser.close()
489
+
490
+ # (i) empty-state phase — separate boot, separate console
491
+ run_empty_phase(pw, failures)
492
+
670
493
  finally:
671
- server.terminate()
672
- try:
673
- server.wait(timeout=5)
674
- except subprocess.TimeoutExpired:
675
- server.kill()
494
+ stop_server(server)
676
495
  shutil.rmtree(root, ignore_errors=True)
677
496
 
678
497
  if failures:
@@ -680,13 +499,13 @@ def main():
680
499
  for f in failures:
681
500
  print(" -", f)
682
501
  return 1
683
- print("PROVEN: (a) console clean (b) backlog rendered (b2) alert-count alarm color "
684
- "(b3) alerts-box alarm styling (c) click-expand with prompt (d) SSE live updates (e) expansion survived "
685
- "(i) tracker lanes rendered (j) tracker add-item SSE (k) orchestrator status (l) audit banner ASCII "
686
- "(m) tracker pr_link javascript: XSS neutralized, https pr_link still clickable "
687
- "(n1) header-label contrast #999 (7.5:1 WCAG AA) (n2) priority select chevron affordance "
688
- "(n3) empty lane placeholder (n4) prefers-reduced-motion animations disabled "
689
- "(n5) tracker sr-only live region (n6) lane-count aria-labels (n7) running-count toned down")
502
+
503
+ print("PROVEN: (a) console clean (b) React dist served (c) health-header "
504
+ "(d) view testids (e) inbox form (f) cost view renders fixture ledger "
505
+ "(g) stylesheet (h) live regions (i) empty-state pass all views "
506
+ "(j) SSE tracker update w/o reload (k) tracker form round-trip incl. lane actions "
507
+ "(l) javascript: pr_link inert (m) keyboard expand survives SSE update "
508
+ "(n) AA contrast both themes (o) audit badge announced")
690
509
  return 0
691
510
 
692
511