@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.
- package/CHANGELOG.md +46 -5
- package/README.md +57 -1
- package/aesop.config.example.json +8 -1
- package/bin/CLAUDE.md +30 -2
- package/bin/cli.js +286 -73
- package/daemons/CLAUDE.md +4 -1
- package/daemons/run-watchdog.sh +5 -2
- package/docs/RELEASING.md +159 -0
- package/docs/archive/README.md +3 -0
- package/docs/{spikes → archive/spikes}/tiered-cognition/README.md +1 -3
- package/docs/case-study-portfolio.md +61 -0
- package/docs/self-stats-data.json +11 -0
- package/docs/templates/FLEET-OPS-ANALYSIS.example.md +27 -0
- package/docs/templates/FLEET-OPS-RECOMMENDATIONS.example.md +24 -0
- package/docs/templates/PROPOSALS-LOG.example.md +64 -0
- package/hooks/CLAUDE.md +23 -0
- package/mcp/CLAUDE.md +213 -0
- package/mcp/package.json +26 -0
- package/mcp/server.mjs +543 -0
- package/monitor/CHARTER.md +76 -110
- package/monitor/collect-signals.mjs +85 -0
- package/package.json +4 -1
- package/scan/fleet-scan.example.mjs +292 -0
- package/skills/healthcheck/SKILL.md +44 -0
- package/state_store/CLAUDE.md +39 -0
- package/state_store/__init__.py +24 -0
- package/state_store/__pycache__/__init__.cpython-314.pyc +0 -0
- package/state_store/__pycache__/api.cpython-314.pyc +0 -0
- package/state_store/__pycache__/export.cpython-314.pyc +0 -0
- package/state_store/__pycache__/ingest.cpython-314.pyc +0 -0
- package/state_store/__pycache__/projections.cpython-314.pyc +0 -0
- package/state_store/__pycache__/store.cpython-314.pyc +0 -0
- package/state_store/api.py +35 -0
- package/state_store/export.py +19 -0
- package/state_store/ingest.py +24 -0
- package/state_store/projections.py +52 -0
- package/state_store/store.py +102 -0
- package/tools/CLAUDE.md +30 -149
- package/tools/__pycache__/alert_bridge.cpython-314.pyc +0 -0
- package/tools/__pycache__/ci_merge_wait.cpython-314.pyc +0 -0
- package/tools/__pycache__/fleet_prompt_extractor.cpython-314.pyc +0 -0
- package/tools/__pycache__/healthcheck.cpython-314.pyc +0 -0
- package/tools/__pycache__/metrics_gate.cpython-314.pyc +0 -0
- package/tools/__pycache__/secret_scan.cpython-314.pyc +0 -0
- package/tools/__pycache__/self_stats.cpython-314.pyc +0 -0
- package/tools/__pycache__/session_usage_summary.cpython-314.pyc +0 -0
- package/tools/__pycache__/stall_check.cpython-314.pyc +0 -0
- package/tools/__pycache__/transcript_replay.cpython-314.pyc +0 -0
- package/tools/__pycache__/transcript_timeline.cpython-314.pyc +0 -0
- package/tools/__pycache__/verify_dash.cpython-314.pyc +0 -0
- package/tools/__pycache__/verify_submit_encoding.cpython-314.pyc +0 -0
- package/tools/alert_bridge.py +449 -0
- package/tools/ci_merge_wait.py +121 -8
- package/tools/fleet_prompt_extractor.py +134 -0
- package/tools/healthcheck.py +296 -0
- package/tools/secret_scan.py +8 -1
- package/tools/self_stats.py +509 -0
- package/tools/session_usage_summary.py +198 -0
- package/tools/svg_to_png.mjs +50 -0
- package/tools/transcript_replay.py +236 -0
- package/tools/transcript_timeline.py +184 -0
- package/tools/verify_dash.py +361 -542
- package/tools/verify_submit_encoding.py +12 -4
- package/ui/CLAUDE.md +57 -41
- package/ui/__pycache__/agents.cpython-314.pyc +0 -0
- package/ui/__pycache__/collectors.cpython-314.pyc +0 -0
- package/ui/__pycache__/config.cpython-314.pyc +0 -0
- package/ui/__pycache__/cost.cpython-314.pyc +0 -0
- package/ui/__pycache__/csrf.cpython-314.pyc +0 -0
- package/ui/__pycache__/handler.cpython-314.pyc +0 -0
- package/ui/__pycache__/render.cpython-314.pyc +0 -0
- package/ui/__pycache__/serve.cpython-314.pyc +0 -0
- package/ui/__pycache__/sse.cpython-314.pyc +0 -0
- package/ui/agents.py +9 -5
- package/ui/api/__pycache__/submit.cpython-314.pyc +0 -0
- package/ui/api/submit.py +44 -5
- package/ui/collectors.py +184 -35
- package/ui/config.py +9 -0
- package/ui/cost.py +260 -0
- package/ui/csrf.py +7 -3
- package/ui/handler.py +254 -4
- package/ui/render.py +26 -6
- package/ui/sse.py +16 -1
- package/ui/web/.gitattributes +13 -0
- package/ui/web/dist/assets/index-2LZDQirC.js +9 -0
- package/ui/web/dist/assets/index-D4M1qyOv.css +1 -0
- package/ui/web/dist/index.html +14 -0
- package/ui/web/index.html +13 -0
- package/ui/web/package-lock.json +2225 -0
- package/ui/web/package.json +26 -0
- package/ui/web/src/App.test.tsx +74 -0
- package/ui/web/src/App.tsx +142 -0
- package/ui/web/src/CONTRIBUTING-UI.md +49 -0
- package/ui/web/src/components/AgentRow.css +187 -0
- package/ui/web/src/components/AgentRow.test.tsx +209 -0
- package/ui/web/src/components/AgentRow.tsx +207 -0
- package/ui/web/src/components/AgentsPanel.css +108 -0
- package/ui/web/src/components/AgentsPanel.test.tsx +41 -0
- package/ui/web/src/components/AgentsPanel.tsx +58 -0
- package/ui/web/src/components/AlertsPanel.css +88 -0
- package/ui/web/src/components/AlertsPanel.test.tsx +51 -0
- package/ui/web/src/components/AlertsPanel.tsx +67 -0
- package/ui/web/src/components/BacklogPanel.test.tsx +126 -0
- package/ui/web/src/components/BacklogPanel.tsx +122 -0
- package/ui/web/src/components/CostChart.css +110 -0
- package/ui/web/src/components/CostChart.test.tsx +144 -0
- package/ui/web/src/components/CostChart.tsx +152 -0
- package/ui/web/src/components/CostTable.css +93 -0
- package/ui/web/src/components/CostTable.test.tsx +165 -0
- package/ui/web/src/components/CostTable.tsx +94 -0
- package/ui/web/src/components/EventsFeed.css +68 -0
- package/ui/web/src/components/EventsFeed.test.tsx +36 -0
- package/ui/web/src/components/EventsFeed.tsx +31 -0
- package/ui/web/src/components/HealthHeader.css +137 -0
- package/ui/web/src/components/HealthHeader.test.tsx +278 -0
- package/ui/web/src/components/HealthHeader.tsx +281 -0
- package/ui/web/src/components/InboxForm.css +135 -0
- package/ui/web/src/components/InboxForm.test.tsx +208 -0
- package/ui/web/src/components/InboxForm.tsx +116 -0
- package/ui/web/src/components/MessagesTail.module.css +144 -0
- package/ui/web/src/components/MessagesTail.test.tsx +176 -0
- package/ui/web/src/components/MessagesTail.tsx +94 -0
- package/ui/web/src/components/ReposPanel.css +90 -0
- package/ui/web/src/components/ReposPanel.test.tsx +45 -0
- package/ui/web/src/components/ReposPanel.tsx +67 -0
- package/ui/web/src/components/Scorecard.css +106 -0
- package/ui/web/src/components/Scorecard.test.tsx +117 -0
- package/ui/web/src/components/Scorecard.tsx +85 -0
- package/ui/web/src/components/Timeline.module.css +151 -0
- package/ui/web/src/components/Timeline.test.tsx +215 -0
- package/ui/web/src/components/Timeline.tsx +99 -0
- package/ui/web/src/components/TrackerBoard.test.tsx +121 -0
- package/ui/web/src/components/TrackerBoard.tsx +107 -0
- package/ui/web/src/components/TrackerCard.test.tsx +180 -0
- package/ui/web/src/components/TrackerCard.tsx +160 -0
- package/ui/web/src/components/TrackerForm.test.tsx +189 -0
- package/ui/web/src/components/TrackerForm.tsx +144 -0
- package/ui/web/src/lib/api.ts +218 -0
- package/ui/web/src/lib/format.test.ts +89 -0
- package/ui/web/src/lib/format.ts +103 -0
- package/ui/web/src/lib/sanitizeUrl.test.ts +84 -0
- package/ui/web/src/lib/sanitizeUrl.ts +38 -0
- package/ui/web/src/lib/types.ts +230 -0
- package/ui/web/src/lib/useHashRoute.test.ts +60 -0
- package/ui/web/src/lib/useHashRoute.ts +23 -0
- package/ui/web/src/lib/useSSE.ts +175 -0
- package/ui/web/src/main.tsx +10 -0
- package/ui/web/src/styles/global.css +179 -0
- package/ui/web/src/styles/theme.css +184 -0
- package/ui/web/src/styles/work.css +572 -0
- package/ui/web/src/test/fixtures.ts +385 -0
- package/ui/web/src/test/setup.ts +49 -0
- package/ui/web/src/views/Activity.module.css +43 -0
- package/ui/web/src/views/Activity.test.tsx +89 -0
- package/ui/web/src/views/Activity.tsx +31 -0
- package/ui/web/src/views/Cost.css +87 -0
- package/ui/web/src/views/Cost.test.tsx +142 -0
- package/ui/web/src/views/Cost.tsx +54 -0
- package/ui/web/src/views/Overview.css +51 -0
- package/ui/web/src/views/Overview.test.tsx +76 -0
- package/ui/web/src/views/Overview.tsx +46 -0
- package/ui/web/src/views/Work.test.tsx +82 -0
- package/ui/web/src/views/Work.tsx +79 -0
- package/ui/web/src/vite-env.d.ts +10 -0
- package/ui/web/tsconfig.json +22 -0
- package/ui/web/vite.config.ts +25 -0
- package/ui/web/vitest.config.ts +12 -0
- package/ui/templates/dashboard.html +0 -1202
- /package/docs/{spikes → archive/spikes}/tiered-cognition/ACTIVATION.md +0 -0
- /package/docs/{spikes → archive/spikes}/tiered-cognition/DESIGN.md +0 -0
- /package/docs/{spikes → archive/spikes}/tiered-cognition/FINDINGS.md +0 -0
- /package/docs/{spikes → archive/spikes}/tiered-cognition/aesop-cognition.example.md +0 -0
- /package/docs/{spikes → archive/spikes}/tiered-cognition/force-model-policy.merged.mjs +0 -0
- /package/docs/{spikes → archive/spikes}/tiered-cognition/strip-tools-hook.mjs +0 -0
package/tools/verify_dash.py
CHANGED
|
@@ -1,18 +1,31 @@
|
|
|
1
|
-
"""Browser-level proof for the
|
|
2
|
-
|
|
3
|
-
Drives
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
(
|
|
8
|
-
(
|
|
9
|
-
(
|
|
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
|
-
|
|
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]
|
|
39
|
-
- 🔵 **[
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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}.
|
|
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
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
)
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|
|
123
|
-
|
|
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
|
-
|
|
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
|
-
|
|
235
|
+
failed_urls = []
|
|
236
|
+
build_empty_fixture(root)
|
|
142
237
|
try:
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
|
|
161
|
-
|
|
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)
|
|
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
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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"(
|
|
322
|
+
failures.append(f"(b) app does not serve React dist: {e}")
|
|
274
323
|
|
|
275
|
-
# (
|
|
324
|
+
# (c) health header rendered
|
|
276
325
|
try:
|
|
277
|
-
page.wait_for_selector("
|
|
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)
|
|
328
|
+
failures.append(f"(c) health-header testid not found: {e}")
|
|
302
329
|
|
|
303
|
-
# (d)
|
|
330
|
+
# (d) overview view on first paint
|
|
304
331
|
try:
|
|
305
|
-
|
|
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)
|
|
334
|
+
failures.append(f"(d) view testids not found: {e}")
|
|
320
335
|
|
|
321
|
-
# (e)
|
|
336
|
+
# (e) inbox form present
|
|
322
337
|
try:
|
|
323
|
-
|
|
324
|
-
|
|
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
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
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.
|
|
361
|
-
|
|
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"(
|
|
360
|
+
failures.append(f"(m) keyboard expand / expansion-survival failed: {e}")
|
|
369
361
|
|
|
370
|
-
# (
|
|
362
|
+
# (j) SSE live update WITHOUT reload: mutate tracker.json directly
|
|
371
363
|
try:
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
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.
|
|
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"(
|
|
375
|
+
failures.append(f"(j) SSE live tracker update failed: {e}")
|
|
395
376
|
|
|
396
|
-
# (
|
|
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
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
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"(
|
|
384
|
+
failures.append(f"(l) XSS pr_link not inert: {e}")
|
|
416
385
|
|
|
417
|
-
# (
|
|
386
|
+
# (k) tracker round-trip through the real form (CSRF path)
|
|
418
387
|
try:
|
|
419
|
-
|
|
420
|
-
|
|
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.
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
for
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
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"(
|
|
411
|
+
failures.append(f"(k) tracker round-trip failed: {e}")
|
|
507
412
|
|
|
508
|
-
# (
|
|
413
|
+
# (f) cost view renders table/chart/scorecard from the fixture ledger
|
|
509
414
|
try:
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
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"(
|
|
422
|
+
failures.append(f"(f) cost view did not render fixture ledger: {e}")
|
|
515
423
|
|
|
516
|
-
# (
|
|
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(
|
|
527
|
-
|
|
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.
|
|
530
|
-
timeout=
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
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"(
|
|
439
|
+
failures.append(f"(o) audit-phase badge failed: {e}")
|
|
537
440
|
|
|
538
|
-
# (n)
|
|
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
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
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"(
|
|
465
|
+
failures.append(f"(n) theme contrast check failed: {e}")
|
|
548
466
|
|
|
549
|
-
# (
|
|
467
|
+
# (g) CSS stylesheet loaded
|
|
550
468
|
try:
|
|
551
|
-
|
|
552
|
-
"
|
|
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"(
|
|
472
|
+
failures.append(f"(g) CSS stylesheet check failed: {e}")
|
|
557
473
|
|
|
558
|
-
# (
|
|
474
|
+
# (h) live regions present
|
|
559
475
|
try:
|
|
560
|
-
|
|
561
|
-
|
|
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"(
|
|
480
|
+
failures.append(f"(h) live regions check failed: {e}")
|
|
587
481
|
|
|
588
|
-
# (
|
|
589
|
-
|
|
590
|
-
|
|
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[:
|
|
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
|
|
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
|
-
|
|
684
|
-
|
|
685
|
-
"(
|
|
686
|
-
"(
|
|
687
|
-
"(
|
|
688
|
-
"(
|
|
689
|
-
"(
|
|
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
|
|