@matt82198/aesop 0.1.0-beta.5 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +158 -5
- package/LICENSE +66 -21
- package/README.md +97 -33
- package/aesop.config.example.json +6 -0
- package/bin/CLAUDE.md +12 -3
- package/bin/cli.js +211 -40
- package/daemons/CLAUDE.md +1 -1
- package/daemons/backup-fleet.sh +209 -51
- package/daemons/run-watchdog.sh +208 -62
- package/dash/dash-extra.mjs +73 -4
- package/dash/watchdog-gui.sh +41 -35
- package/docs/ARCHITECTURE.md +296 -0
- package/docs/CONCEPTS.md +254 -0
- package/docs/CONFIGURE.md +256 -0
- package/docs/FIRST-WAVE.md +276 -0
- package/docs/INSTALL.md +224 -0
- package/docs/README.md +176 -27
- package/docs/autonomous-swe.md +149 -0
- package/docs/reproduce.md +121 -0
- package/hooks/CLAUDE.md +28 -0
- package/hooks/install-waveguard.sh +68 -0
- package/hooks/pre-commit-waveguard.sh +26 -0
- package/hooks/pre-push-policy.sh +253 -15
- package/monitor/CLAUDE.md +3 -3
- package/monitor/collect-signals.mjs +133 -20
- package/package.json +15 -7
- package/scan/CLAUDE.md +30 -0
- package/skills/CLAUDE.md +1 -0
- package/state_store/ingest.py +18 -1
- package/state_store/projections.py +78 -4
- package/state_store/store.py +102 -7
- package/tools/CLAUDE.md +25 -18
- package/tools/alert_bridge.py +14 -10
- package/tools/bench_runner.py +438 -0
- package/tools/buildlog.py +4 -7
- package/tools/ci_merge_wait.py +215 -34
- package/tools/common.py +62 -0
- package/tools/cost_ceiling.py +191 -0
- package/tools/dash.js +102 -0
- package/tools/doctor.js +220 -0
- package/tools/fleet_ledger.py +189 -17
- package/tools/halt.py +172 -0
- package/tools/healthcheck.py +24 -21
- package/tools/heartbeat.py +4 -7
- package/tools/metrics_gate.py +8 -2
- package/tools/orchestrator_status.py +4 -9
- package/tools/reconcile.py +277 -0
- package/tools/rotate_logs.py +152 -62
- package/tools/scanner_selftest.py +28 -3
- package/tools/secret_scan.py +359 -90
- package/tools/self_stats.py +292 -4
- package/tools/status.js +187 -0
- package/tools/verify_agent_inspector.py +289 -0
- package/tools/verify_prboard.py +344 -0
- package/tools/watch.js +49 -0
- package/ui/CLAUDE.md +2 -0
- package/ui/agents.py +332 -33
- package/ui/api/tracker.py +15 -7
- package/ui/collectors.py +125 -32
- package/ui/config.py +22 -2
- package/ui/cost.py +98 -3
- package/ui/csrf.py +2 -1
- package/ui/handler.py +209 -21
- package/ui/sse.py +102 -16
- package/ui/wave_prs.py +230 -0
- package/ui/web/dist/assets/index-0qQYnvMC.js +9 -0
- package/ui/web/dist/assets/index-BdIlFieV.css +1 -0
- package/ui/web/dist/index.html +2 -2
- package/monitor/.signal-state.json +0 -3
- package/monitor/ACTIONS.log +0 -1
- package/monitor/BRIEF.md +0 -24
- package/monitor/SIGNALS.json +0 -54
- 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/tools/__pycache__/alert_bridge.cpython-314.pyc +0 -0
- package/tools/__pycache__/buildlog.cpython-314.pyc +0 -0
- package/tools/__pycache__/ci_merge_wait.cpython-314.pyc +0 -0
- package/tools/__pycache__/ensure_state.cpython-314.pyc +0 -0
- package/tools/__pycache__/eod_sweep.cpython-314.pyc +0 -0
- package/tools/__pycache__/fleet_ledger.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__/heartbeat.cpython-314.pyc +0 -0
- package/tools/__pycache__/inbox_drain.cpython-314.pyc +0 -0
- package/tools/__pycache__/launch_tui.cpython-314.pyc +0 -0
- package/tools/__pycache__/metrics_gate.cpython-314.pyc +0 -0
- package/tools/__pycache__/orchestrator_status.cpython-314.pyc +0 -0
- package/tools/__pycache__/power_selftest.cpython-314.pyc +0 -0
- package/tools/__pycache__/prepublish_scan.cpython-314.pyc +0 -0
- package/tools/__pycache__/rotate_logs.cpython-314.pyc +0 -0
- package/tools/__pycache__/scanner_selftest.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/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/api/__pycache__/__init__.cpython-314.pyc +0 -0
- package/ui/api/__pycache__/submit.cpython-314.pyc +0 -0
- package/ui/api/__pycache__/tracker.cpython-314.pyc +0 -0
- package/ui/web/.gitattributes +0 -13
- package/ui/web/dist/assets/index-2LZDQirC.js +0 -9
- package/ui/web/dist/assets/index-D4M1qyOv.css +0 -1
- package/ui/web/index.html +0 -13
- package/ui/web/package-lock.json +0 -2225
- package/ui/web/package.json +0 -26
- package/ui/web/src/App.test.tsx +0 -74
- package/ui/web/src/App.tsx +0 -142
- package/ui/web/src/CONTRIBUTING-UI.md +0 -49
- package/ui/web/src/components/AgentRow.css +0 -187
- package/ui/web/src/components/AgentRow.test.tsx +0 -209
- package/ui/web/src/components/AgentRow.tsx +0 -207
- package/ui/web/src/components/AgentsPanel.css +0 -108
- package/ui/web/src/components/AgentsPanel.test.tsx +0 -41
- package/ui/web/src/components/AgentsPanel.tsx +0 -58
- package/ui/web/src/components/AlertsPanel.css +0 -88
- package/ui/web/src/components/AlertsPanel.test.tsx +0 -51
- package/ui/web/src/components/AlertsPanel.tsx +0 -67
- package/ui/web/src/components/BacklogPanel.test.tsx +0 -126
- package/ui/web/src/components/BacklogPanel.tsx +0 -122
- package/ui/web/src/components/CostChart.css +0 -110
- package/ui/web/src/components/CostChart.test.tsx +0 -144
- package/ui/web/src/components/CostChart.tsx +0 -152
- package/ui/web/src/components/CostTable.css +0 -93
- package/ui/web/src/components/CostTable.test.tsx +0 -165
- package/ui/web/src/components/CostTable.tsx +0 -94
- package/ui/web/src/components/EventsFeed.css +0 -68
- package/ui/web/src/components/EventsFeed.test.tsx +0 -36
- package/ui/web/src/components/EventsFeed.tsx +0 -31
- package/ui/web/src/components/HealthHeader.css +0 -137
- package/ui/web/src/components/HealthHeader.test.tsx +0 -278
- package/ui/web/src/components/HealthHeader.tsx +0 -281
- package/ui/web/src/components/InboxForm.css +0 -135
- package/ui/web/src/components/InboxForm.test.tsx +0 -208
- package/ui/web/src/components/InboxForm.tsx +0 -116
- package/ui/web/src/components/MessagesTail.module.css +0 -144
- package/ui/web/src/components/MessagesTail.test.tsx +0 -176
- package/ui/web/src/components/MessagesTail.tsx +0 -94
- package/ui/web/src/components/ReposPanel.css +0 -90
- package/ui/web/src/components/ReposPanel.test.tsx +0 -45
- package/ui/web/src/components/ReposPanel.tsx +0 -67
- package/ui/web/src/components/Scorecard.css +0 -106
- package/ui/web/src/components/Scorecard.test.tsx +0 -117
- package/ui/web/src/components/Scorecard.tsx +0 -85
- package/ui/web/src/components/Timeline.module.css +0 -151
- package/ui/web/src/components/Timeline.test.tsx +0 -215
- package/ui/web/src/components/Timeline.tsx +0 -99
- package/ui/web/src/components/TrackerBoard.test.tsx +0 -121
- package/ui/web/src/components/TrackerBoard.tsx +0 -107
- package/ui/web/src/components/TrackerCard.test.tsx +0 -180
- package/ui/web/src/components/TrackerCard.tsx +0 -160
- package/ui/web/src/components/TrackerForm.test.tsx +0 -189
- package/ui/web/src/components/TrackerForm.tsx +0 -144
- package/ui/web/src/lib/api.ts +0 -218
- package/ui/web/src/lib/format.test.ts +0 -89
- package/ui/web/src/lib/format.ts +0 -103
- package/ui/web/src/lib/sanitizeUrl.test.ts +0 -84
- package/ui/web/src/lib/sanitizeUrl.ts +0 -38
- package/ui/web/src/lib/types.ts +0 -230
- package/ui/web/src/lib/useHashRoute.test.ts +0 -60
- package/ui/web/src/lib/useHashRoute.ts +0 -23
- package/ui/web/src/lib/useSSE.ts +0 -175
- package/ui/web/src/main.tsx +0 -10
- package/ui/web/src/styles/global.css +0 -179
- package/ui/web/src/styles/theme.css +0 -184
- package/ui/web/src/styles/work.css +0 -572
- package/ui/web/src/test/fixtures.ts +0 -385
- package/ui/web/src/test/setup.ts +0 -49
- package/ui/web/src/views/Activity.module.css +0 -43
- package/ui/web/src/views/Activity.test.tsx +0 -89
- package/ui/web/src/views/Activity.tsx +0 -31
- package/ui/web/src/views/Cost.css +0 -87
- package/ui/web/src/views/Cost.test.tsx +0 -142
- package/ui/web/src/views/Cost.tsx +0 -54
- package/ui/web/src/views/Overview.css +0 -51
- package/ui/web/src/views/Overview.test.tsx +0 -76
- package/ui/web/src/views/Overview.tsx +0 -46
- package/ui/web/src/views/Work.test.tsx +0 -82
- package/ui/web/src/views/Work.tsx +0 -79
- package/ui/web/src/vite-env.d.ts +0 -10
- package/ui/web/tsconfig.json +0 -22
- package/ui/web/vite.config.ts +0 -25
- package/ui/web/vitest.config.ts +0 -12
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
"""Browser-level proof for the Agent Inspector drawer (ui/web/dist/ + /api/agent).
|
|
2
|
+
|
|
3
|
+
Drives the BUILT React app served by `python ui/serve.py` and exercises the
|
|
4
|
+
whole vertical slice of the inspector in a real Chromium via Playwright:
|
|
5
|
+
route + AgentsPanel mount, the real get_fleet_agents() → agents list (fed by a
|
|
6
|
+
stubbed dash-extra.mjs), and the real GET /api/agent?id= endpoint reading a
|
|
7
|
+
stubbed transcript. Everything is stubbed via a temp AESOP_ROOT so the proof is
|
|
8
|
+
deterministic and never touches the operator's real fleet.
|
|
9
|
+
|
|
10
|
+
One populated phase asserts:
|
|
11
|
+
(a) console clean (no errors / no XSS side effects)
|
|
12
|
+
(b) an agent row's "Inspect" button opens the drawer (role=dialog)
|
|
13
|
+
(c) the transcript TAIL renders (distinctive stub line is present)
|
|
14
|
+
(d) status is shown as TEXT ("running"), not color alone
|
|
15
|
+
(e) an XSS payload in the transcript is rendered as escaped TEXT — no <script>
|
|
16
|
+
/<img> element is injected and no onerror side effect fires
|
|
17
|
+
(f) focus moves INTO the dialog on open (activeElement inside it)
|
|
18
|
+
(g) Escape closes the drawer AND focus returns to the Inspect trigger
|
|
19
|
+
|
|
20
|
+
Run: python tools/verify_agent_inspector.py (exit 0 = proven, 1 = failed)
|
|
21
|
+
python tools/verify_agent_inspector.py --allow-skip (exit 0 = proven or skipped)
|
|
22
|
+
|
|
23
|
+
Fails with exit 1 if playwright/chromium is unavailable (unless --allow-skip).
|
|
24
|
+
"""
|
|
25
|
+
import argparse
|
|
26
|
+
import json
|
|
27
|
+
import os
|
|
28
|
+
import shutil
|
|
29
|
+
import socket
|
|
30
|
+
import subprocess
|
|
31
|
+
import sys
|
|
32
|
+
import tempfile
|
|
33
|
+
import time
|
|
34
|
+
from pathlib import Path
|
|
35
|
+
|
|
36
|
+
REPO = Path(__file__).resolve().parent.parent
|
|
37
|
+
SERVE = REPO / "ui" / "serve.py"
|
|
38
|
+
|
|
39
|
+
# Generous, CI-safe waits. A cold ubuntu runner (cold chromium, cold `node`
|
|
40
|
+
# spawn for dash-extra.mjs, shared CPU) is much slower than a warm dev box, so
|
|
41
|
+
# every server/selector wait below is sized for the slow path, env-overridable.
|
|
42
|
+
SERVER_BOOT_TRIES = int(os.environ.get("AESOP_VERIFY_BOOT_TRIES", "150")) # *0.2s = 30s
|
|
43
|
+
SERVER_BOOT_SLEEP = 0.2
|
|
44
|
+
SEL_TIMEOUT_MS = int(os.environ.get("AESOP_VERIFY_SEL_TIMEOUT_MS", "30000"))
|
|
45
|
+
|
|
46
|
+
# The stub agent id (also the transcript filename stem). Full id — the frontend
|
|
47
|
+
# fetches /api/agent?id=<this> and the backend prefix-globs agent-<id>*.jsonl.
|
|
48
|
+
AGENT_ID = "inspectme12345abc"
|
|
49
|
+
|
|
50
|
+
# A distinctive tail line the proof asserts on, plus an XSS payload that must be
|
|
51
|
+
# rendered as inert text (never injected as real DOM).
|
|
52
|
+
TAIL_MARKER = "DISTINCTIVE_TAIL_LINE_42"
|
|
53
|
+
XSS_PAYLOAD = "<img src=x onerror=\"window.__xss_fired=true\"><script>window.__xss_fired=true</script>"
|
|
54
|
+
|
|
55
|
+
# dash-extra.mjs stub: emits one agent so get_fleet_agents() lists a row.
|
|
56
|
+
DASH_EXTRA_STUB = (
|
|
57
|
+
"console.log(JSON.stringify(["
|
|
58
|
+
+ json.dumps({
|
|
59
|
+
"id": AGENT_ID,
|
|
60
|
+
"project": "aesop",
|
|
61
|
+
"status": "running",
|
|
62
|
+
"age_s": 12,
|
|
63
|
+
"hint": "wave-31 agent inspector drawer",
|
|
64
|
+
"startedAt": "2026-07-17T14:02:11.000Z",
|
|
65
|
+
"lastActivity": "2026-07-17T14:31:47.000Z",
|
|
66
|
+
"runtimeSeconds": 1776,
|
|
67
|
+
"tokensUsed": 48213,
|
|
68
|
+
"taskLabel": "Build the Agent Inspector drawer for aesop.",
|
|
69
|
+
})
|
|
70
|
+
+ "]));\n"
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _real_console_errors(console_errors, failed_urls):
|
|
75
|
+
"""Drop favicon/urlless-resource noise; surface real broken assets."""
|
|
76
|
+
non_favicon = [u for u in failed_urls if "favicon" not in u.lower()]
|
|
77
|
+
real = []
|
|
78
|
+
for e in console_errors:
|
|
79
|
+
low = e.lower()
|
|
80
|
+
if "favicon" in low:
|
|
81
|
+
continue
|
|
82
|
+
if "failed to load resource" in low and not non_favicon:
|
|
83
|
+
continue
|
|
84
|
+
real.append(e)
|
|
85
|
+
real.extend(f"failed resource: {u}" for u in non_favicon)
|
|
86
|
+
return real
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def free_port():
|
|
90
|
+
s = socket.socket()
|
|
91
|
+
s.bind(("127.0.0.1", 0))
|
|
92
|
+
port = s.getsockname()[1]
|
|
93
|
+
s.close()
|
|
94
|
+
return port
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def copy_dist(root: Path):
|
|
98
|
+
real_dist = REPO / "ui" / "web" / "dist"
|
|
99
|
+
if real_dist.is_dir():
|
|
100
|
+
shutil.copytree(real_dist, root / "ui" / "web" / "dist")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def build_root():
|
|
104
|
+
"""Fresh temp root with dist + a dash-extra stub + a stub transcript."""
|
|
105
|
+
root = Path(tempfile.mkdtemp(prefix="aesop-verify-inspector-"))
|
|
106
|
+
(root / "state").mkdir(exist_ok=True)
|
|
107
|
+
transcripts = root / "transcripts"
|
|
108
|
+
transcripts.mkdir(exist_ok=True)
|
|
109
|
+
(root / "dash").mkdir(exist_ok=True)
|
|
110
|
+
copy_dist(root)
|
|
111
|
+
(root / "dash" / "dash-extra.mjs").write_text(DASH_EXTRA_STUB, encoding="utf-8")
|
|
112
|
+
|
|
113
|
+
lines = [
|
|
114
|
+
json.dumps({"type": "user", "parentUuid": None,
|
|
115
|
+
"message": {"content": "Build the Agent Inspector drawer for aesop."}}),
|
|
116
|
+
json.dumps({"type": "assistant", "model": "claude-haiku-4-5",
|
|
117
|
+
"message": {"content": [{"type": "text", "text": "Reading the plan first."}]}}),
|
|
118
|
+
json.dumps({"type": "assistant",
|
|
119
|
+
"message": {"content": f"{TAIL_MARKER} — progress update"}}),
|
|
120
|
+
json.dumps({"type": "assistant",
|
|
121
|
+
"message": {"content": XSS_PAYLOAD}}),
|
|
122
|
+
]
|
|
123
|
+
(transcripts / f"agent-{AGENT_ID}.jsonl").write_text(
|
|
124
|
+
"\n".join(lines) + "\n", encoding="utf-8")
|
|
125
|
+
return root
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def start_server(root: Path, port: int):
|
|
129
|
+
state_root = root / "state"
|
|
130
|
+
real_state = Path.home() / "aesop" / "state"
|
|
131
|
+
if state_root.resolve() == real_state.resolve():
|
|
132
|
+
raise RuntimeError("state dir resolved to real repo state (~aesop/state)")
|
|
133
|
+
env = dict(os.environ,
|
|
134
|
+
AESOP_ROOT=str(root),
|
|
135
|
+
AESOP_STATE_ROOT=str(state_root),
|
|
136
|
+
AESOP_TRANSCRIPTS_ROOT=str(root / "transcripts"),
|
|
137
|
+
AESOP_UI_COLLECT_INTERVAL="0.3",
|
|
138
|
+
PORT=str(port))
|
|
139
|
+
server = subprocess.Popen([sys.executable, str(SERVE)], env=env,
|
|
140
|
+
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
141
|
+
for _ in range(SERVER_BOOT_TRIES):
|
|
142
|
+
try:
|
|
143
|
+
socket.create_connection(("127.0.0.1", port), timeout=0.2).close()
|
|
144
|
+
return server
|
|
145
|
+
except OSError:
|
|
146
|
+
time.sleep(SERVER_BOOT_SLEEP)
|
|
147
|
+
server.kill()
|
|
148
|
+
raise RuntimeError("server never came up")
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def stop_server(server):
|
|
152
|
+
server.terminate()
|
|
153
|
+
try:
|
|
154
|
+
server.wait(timeout=5)
|
|
155
|
+
except subprocess.TimeoutExpired:
|
|
156
|
+
server.kill()
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def run_populated(pw, failures):
|
|
160
|
+
root = build_root()
|
|
161
|
+
port = free_port()
|
|
162
|
+
server = start_server(root, port)
|
|
163
|
+
browser = pw.chromium.launch(headless=True)
|
|
164
|
+
page = browser.new_page()
|
|
165
|
+
console_errors, failed_urls = [], []
|
|
166
|
+
page.on("console", lambda m: console_errors.append(m.text) if m.type == "error" else None)
|
|
167
|
+
page.on("pageerror", lambda e: console_errors.append(str(e)))
|
|
168
|
+
page.on("response", lambda r: failed_urls.append(r.url) if r.status >= 400 else None)
|
|
169
|
+
try:
|
|
170
|
+
page.goto(f"http://127.0.0.1:{port}/", wait_until="domcontentloaded")
|
|
171
|
+
try:
|
|
172
|
+
page.wait_for_selector("[data-testid='health-header']", timeout=SEL_TIMEOUT_MS)
|
|
173
|
+
page.evaluate("location.hash = '#/'")
|
|
174
|
+
# Agents panel is on Overview; the Inspect trigger appears per row.
|
|
175
|
+
page.wait_for_selector("[data-testid='agent-inspect-open']", timeout=SEL_TIMEOUT_MS)
|
|
176
|
+
except Exception as e:
|
|
177
|
+
failures.append(f"(b) agent row / inspect trigger never mounted: {e}")
|
|
178
|
+
return
|
|
179
|
+
|
|
180
|
+
# (b) open the drawer
|
|
181
|
+
try:
|
|
182
|
+
page.click("[data-testid='agent-inspect-open']")
|
|
183
|
+
page.wait_for_selector("[data-testid='agent-inspector']", timeout=SEL_TIMEOUT_MS)
|
|
184
|
+
dialog = page.locator("[data-testid='agent-inspector']")
|
|
185
|
+
assert dialog.get_attribute("role") == "dialog", "drawer is not role=dialog"
|
|
186
|
+
assert dialog.get_attribute("aria-modal") == "true", "drawer not aria-modal"
|
|
187
|
+
except Exception as e:
|
|
188
|
+
failures.append(f"(b) drawer did not open as a modal dialog: {e}")
|
|
189
|
+
return
|
|
190
|
+
|
|
191
|
+
# (c) transcript tail rendered (wait for the fetched detail)
|
|
192
|
+
try:
|
|
193
|
+
page.wait_for_selector("[data-testid='agent-inspector-transcript']", timeout=SEL_TIMEOUT_MS)
|
|
194
|
+
tail_text = page.inner_text("[data-testid='agent-inspector-transcript']")
|
|
195
|
+
assert TAIL_MARKER in tail_text, f"tail marker missing; got: {tail_text[:200]!r}"
|
|
196
|
+
except Exception as e:
|
|
197
|
+
failures.append(f"(c) transcript tail not rendered: {e}")
|
|
198
|
+
|
|
199
|
+
# (d) status shown as TEXT (not color alone)
|
|
200
|
+
try:
|
|
201
|
+
status_text = page.inner_text("[data-testid='agent-inspector-status']")
|
|
202
|
+
assert "running" in status_text.lower(), f"status text missing: {status_text!r}"
|
|
203
|
+
except Exception as e:
|
|
204
|
+
failures.append(f"(d) status not shown as text: {e}")
|
|
205
|
+
|
|
206
|
+
# (e) XSS payload is inert: no injected element, no side effect, text present
|
|
207
|
+
try:
|
|
208
|
+
xss_fired = page.evaluate("window.__xss_fired === true")
|
|
209
|
+
assert not xss_fired, "XSS onerror/script side effect fired"
|
|
210
|
+
injected = page.evaluate(
|
|
211
|
+
"document.querySelectorAll('[data-testid=\"agent-inspector-transcript\"] img,"
|
|
212
|
+
" [data-testid=\"agent-inspector-transcript\"] script').length")
|
|
213
|
+
assert injected == 0, f"{injected} raw HTML nodes injected from transcript"
|
|
214
|
+
body_has_text = page.evaluate(
|
|
215
|
+
"document.querySelector('[data-testid=\"agent-inspector-transcript\"]')"
|
|
216
|
+
".textContent.includes('onerror')")
|
|
217
|
+
assert body_has_text, "XSS payload not rendered as visible text"
|
|
218
|
+
except Exception as e:
|
|
219
|
+
failures.append(f"(e) XSS not neutralised: {e}")
|
|
220
|
+
|
|
221
|
+
# (f) focus moved INTO the dialog on open
|
|
222
|
+
try:
|
|
223
|
+
focus_inside = page.evaluate(
|
|
224
|
+
"(() => { const d = document.querySelector('[data-testid=\"agent-inspector\"]');"
|
|
225
|
+
" return !!d && d.contains(document.activeElement); })()")
|
|
226
|
+
assert focus_inside, "focus did not move into the dialog"
|
|
227
|
+
except Exception as e:
|
|
228
|
+
failures.append(f"(f) focus not trapped into dialog: {e}")
|
|
229
|
+
|
|
230
|
+
# (g) Escape closes AND focus returns to the Inspect trigger
|
|
231
|
+
try:
|
|
232
|
+
page.keyboard.press("Escape")
|
|
233
|
+
page.wait_for_selector("[data-testid='agent-inspector']", state="detached", timeout=10000)
|
|
234
|
+
focus_on_trigger = page.evaluate(
|
|
235
|
+
"document.activeElement && document.activeElement.getAttribute('data-testid')"
|
|
236
|
+
" === 'agent-inspect-open'")
|
|
237
|
+
assert focus_on_trigger, "focus did not return to the Inspect trigger after close"
|
|
238
|
+
except Exception as e:
|
|
239
|
+
failures.append(f"(g) Escape-close / focus-restore failed: {e}")
|
|
240
|
+
|
|
241
|
+
# (a) console clean
|
|
242
|
+
time.sleep(0.4)
|
|
243
|
+
real = _real_console_errors(console_errors, failed_urls)
|
|
244
|
+
if real:
|
|
245
|
+
failures.append(f"(a) console errors: {real[:3]}")
|
|
246
|
+
finally:
|
|
247
|
+
browser.close()
|
|
248
|
+
stop_server(server)
|
|
249
|
+
shutil.rmtree(root, ignore_errors=True)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def main():
|
|
253
|
+
parser = argparse.ArgumentParser(description="Browser-level proof for the Agent Inspector drawer")
|
|
254
|
+
parser.add_argument("--allow-skip", action="store_true",
|
|
255
|
+
help="Allow skipping if playwright/chromium is unavailable")
|
|
256
|
+
args = parser.parse_args()
|
|
257
|
+
|
|
258
|
+
try:
|
|
259
|
+
from playwright.sync_api import sync_playwright
|
|
260
|
+
except ImportError:
|
|
261
|
+
msg = "playwright missing — run `python -m playwright install chromium`, or pass --allow-skip"
|
|
262
|
+
print(f"SKIP: {msg}" if args.allow_skip else f"FAIL: {msg}")
|
|
263
|
+
return 0 if args.allow_skip else 1
|
|
264
|
+
|
|
265
|
+
failures = []
|
|
266
|
+
with sync_playwright() as pw:
|
|
267
|
+
try:
|
|
268
|
+
pw.chromium.launch(headless=True).close()
|
|
269
|
+
except Exception as e:
|
|
270
|
+
msg = f"chromium unavailable ({e}); run: python -m playwright install chromium"
|
|
271
|
+
print(f"SKIP: {msg}" if args.allow_skip else f"FAIL: {msg}")
|
|
272
|
+
return 0 if args.allow_skip else 1
|
|
273
|
+
run_populated(pw, failures)
|
|
274
|
+
|
|
275
|
+
if failures:
|
|
276
|
+
print("FAIL:")
|
|
277
|
+
for f in failures:
|
|
278
|
+
print(" -", f)
|
|
279
|
+
return 1
|
|
280
|
+
|
|
281
|
+
print("PROVEN: (a) console clean (b) Inspect opens a modal dialog "
|
|
282
|
+
"(c) transcript tail renders (d) status shown as text "
|
|
283
|
+
"(e) XSS payload rendered inert (no injected node/side effect) "
|
|
284
|
+
"(f) focus trapped into dialog (g) Escape closes + focus restored")
|
|
285
|
+
return 0
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
if __name__ == "__main__":
|
|
289
|
+
sys.exit(main())
|
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
"""Browser-level proof for the Wave PR Board view (ui/web/dist/ + /api/wave/prs).
|
|
2
|
+
|
|
3
|
+
Drives the BUILT React app served by `python ui/serve.py` and exercises the
|
|
4
|
+
full stack of the PR board — route wiring, component mount, the real
|
|
5
|
+
GET /api/wave/prs endpoint (which runs `gh`), and rendering — in a real
|
|
6
|
+
Chromium via Playwright. `gh` is stubbed through the AESOP_GH_BIN seam so the
|
|
7
|
+
proof is deterministic regardless of the box's real GitHub state.
|
|
8
|
+
|
|
9
|
+
Three phases, each a fresh server boot with its own fake gh:
|
|
10
|
+
|
|
11
|
+
Populated: fake gh emits fixture PRs (passing/failing/pending/draft) →
|
|
12
|
+
(a) console clean
|
|
13
|
+
(b) #/prs route renders the view + table with one row per PR
|
|
14
|
+
(c) CI status is color-INDEPENDENT: the words Passing/Failing/Pending are
|
|
15
|
+
present as text (not conveyed by color alone)
|
|
16
|
+
(d) top-blocker text ("CI failing") is shown
|
|
17
|
+
(e) a PR title is a real, keyboard-focusable <a> to the PR url
|
|
18
|
+
(f) no javascript: href leaks into the DOM
|
|
19
|
+
|
|
20
|
+
Empty (gh ok, zero PRs): fake gh emits [] →
|
|
21
|
+
(g) the "No open PRs" empty state renders, no table, clean console
|
|
22
|
+
|
|
23
|
+
Unavailable (gh un-authenticated): fake gh exits non-zero with an auth error →
|
|
24
|
+
(h) the "GitHub CLI unavailable" callout renders with the backend reason,
|
|
25
|
+
clean console
|
|
26
|
+
|
|
27
|
+
Run: python tools/verify_prboard.py (exit 0 = proven, 1 = failed)
|
|
28
|
+
python tools/verify_prboard.py --allow-skip (exit 0 = proven or skipped)
|
|
29
|
+
|
|
30
|
+
Fails with exit 1 if playwright/chromium is unavailable (unless --allow-skip).
|
|
31
|
+
"""
|
|
32
|
+
import argparse
|
|
33
|
+
import os
|
|
34
|
+
import shutil
|
|
35
|
+
import socket
|
|
36
|
+
import stat
|
|
37
|
+
import subprocess
|
|
38
|
+
import sys
|
|
39
|
+
import tempfile
|
|
40
|
+
import time
|
|
41
|
+
from pathlib import Path
|
|
42
|
+
|
|
43
|
+
REPO = Path(__file__).resolve().parent.parent
|
|
44
|
+
SERVE = REPO / "ui" / "serve.py"
|
|
45
|
+
|
|
46
|
+
# Generous, CI-safe waits. A cold ubuntu runner (cold chromium, cold python
|
|
47
|
+
# import, shared CPU) is much slower than a warm dev box, so every server/
|
|
48
|
+
# selector wait below is sized for the slow path, overridable via env.
|
|
49
|
+
SERVER_BOOT_TRIES = int(os.environ.get("AESOP_VERIFY_BOOT_TRIES", "150")) # *0.2s = 30s
|
|
50
|
+
SERVER_BOOT_SLEEP = 0.2
|
|
51
|
+
SEL_TIMEOUT_MS = int(os.environ.get("AESOP_VERIFY_SEL_TIMEOUT_MS", "30000"))
|
|
52
|
+
|
|
53
|
+
# Fixture PRs the fake `gh pr list` emits in the populated phase. Covers every
|
|
54
|
+
# CI rollup state + a draft so the board's whole status vocabulary is exercised.
|
|
55
|
+
FIXTURE_PR_JSON = """import json
|
|
56
|
+
print(json.dumps([
|
|
57
|
+
{"number": 501, "title": "feat: PR board passing case", "headRefName": "feat/wave30-a",
|
|
58
|
+
"mergeable": "MERGEABLE", "isDraft": False, "url": "https://github.com/example/aesop/pull/501",
|
|
59
|
+
"createdAt": "2026-07-17T08:00:00Z", "reviewDecision": "REVIEW_REQUIRED",
|
|
60
|
+
"statusCheckRollup": [{"conclusion": "SUCCESS"}, {"conclusion": "SUCCESS"}]},
|
|
61
|
+
{"number": 502, "title": "fix: PR board failing case", "headRefName": "feat/wave30-b",
|
|
62
|
+
"mergeable": "CONFLICTING", "isDraft": False, "url": "https://github.com/example/aesop/pull/502",
|
|
63
|
+
"createdAt": "2026-07-16T18:00:00Z", "reviewDecision": "",
|
|
64
|
+
"statusCheckRollup": [{"conclusion": "SUCCESS"}, {"conclusion": "FAILURE"}]},
|
|
65
|
+
{"number": 503, "title": "wip: PR board pending draft", "headRefName": "feat/wave30-c",
|
|
66
|
+
"mergeable": "MERGEABLE", "isDraft": True, "url": "https://github.com/example/aesop/pull/503",
|
|
67
|
+
"createdAt": "2026-07-17T07:30:00Z", "reviewDecision": "",
|
|
68
|
+
"statusCheckRollup": [{"status": "IN_PROGRESS"}]}
|
|
69
|
+
]))
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
FIXTURE_EMPTY_JSON = "print('[]')\n"
|
|
73
|
+
|
|
74
|
+
FIXTURE_AUTH_FAIL = """import sys
|
|
75
|
+
sys.stderr.write("gh: To get started with GitHub CLI, please run: gh auth login\\n")
|
|
76
|
+
sys.exit(1)
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _real_console_errors(console_errors, failed_urls):
|
|
81
|
+
"""Drop favicon/urlless-resource noise; surface real broken assets."""
|
|
82
|
+
non_favicon = [u for u in failed_urls if "favicon" not in u.lower()]
|
|
83
|
+
real = []
|
|
84
|
+
for e in console_errors:
|
|
85
|
+
low = e.lower()
|
|
86
|
+
if "favicon" in low:
|
|
87
|
+
continue
|
|
88
|
+
if "failed to load resource" in low and not non_favicon:
|
|
89
|
+
continue
|
|
90
|
+
real.append(e)
|
|
91
|
+
real.extend(f"failed resource: {u}" for u in non_favicon)
|
|
92
|
+
return real
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def free_port():
|
|
96
|
+
s = socket.socket()
|
|
97
|
+
s.bind(("127.0.0.1", 0))
|
|
98
|
+
port = s.getsockname()[1]
|
|
99
|
+
s.close()
|
|
100
|
+
return port
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def copy_dist(root: Path):
|
|
104
|
+
real_dist = REPO / "ui" / "web" / "dist"
|
|
105
|
+
if real_dist.is_dir():
|
|
106
|
+
shutil.copytree(real_dist, root / "ui" / "web" / "dist")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def make_fake_gh(bin_dir: Path, script_body: str) -> Path:
|
|
110
|
+
"""Write a fake `gh` executable and return its path — Linux-CI robust.
|
|
111
|
+
|
|
112
|
+
wave_prs.py invokes AESOP_GH_BIN directly as ``argv[0]`` via subprocess, so
|
|
113
|
+
the stub must be a file the *current* OS can exec on its own:
|
|
114
|
+
|
|
115
|
+
* Windows: a full-path ``gh.cmd`` batch that shells out to a python stub
|
|
116
|
+
(a bare extension-less script is not runnable by subprocess on Windows).
|
|
117
|
+
* POSIX (ubuntu CI): a python script whose first line is a ``#!`` shebang
|
|
118
|
+
pointing at this very interpreter, marked executable (chmod +x). The
|
|
119
|
+
previous ``gh.cmd`` was NOT executable on Linux — the kernel refused it
|
|
120
|
+
with EACCES, ``gh pr list`` failed, /api/wave/prs degraded to empty, and
|
|
121
|
+
the PR-table selector timed out. That was the CI failure this fixes.
|
|
122
|
+
|
|
123
|
+
Either way the stub ignores its args and emits the fixture, which is all
|
|
124
|
+
``gh pr list`` needs for the proof.
|
|
125
|
+
"""
|
|
126
|
+
bin_dir.mkdir(parents=True, exist_ok=True)
|
|
127
|
+
if os.name == "nt":
|
|
128
|
+
stub = bin_dir / "gh_stub.py"
|
|
129
|
+
stub.write_text(script_body, encoding="utf-8")
|
|
130
|
+
gh_cmd = bin_dir / "gh.cmd"
|
|
131
|
+
gh_cmd.write_text('@python "%~dp0gh_stub.py" %*\n', encoding="utf-8")
|
|
132
|
+
return gh_cmd
|
|
133
|
+
# POSIX: shebang + executable bit so the ubuntu runner can exec it directly.
|
|
134
|
+
gh = bin_dir / "gh"
|
|
135
|
+
gh.write_text(f"#!{sys.executable}\n{script_body}", encoding="utf-8")
|
|
136
|
+
gh.chmod(gh.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
|
137
|
+
return gh
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def build_root(gh_script: str):
|
|
141
|
+
"""Fresh temp root with dist + a fake gh; returns (root, gh_path)."""
|
|
142
|
+
root = Path(tempfile.mkdtemp(prefix="aesop-verify-prboard-"))
|
|
143
|
+
(root / "state").mkdir(exist_ok=True)
|
|
144
|
+
(root / "transcripts").mkdir(exist_ok=True)
|
|
145
|
+
(root / "dash").mkdir(exist_ok=True)
|
|
146
|
+
copy_dist(root)
|
|
147
|
+
(root / "dash" / "dash-extra.mjs").write_text(
|
|
148
|
+
"console.log(JSON.stringify([]));\n", encoding="utf-8")
|
|
149
|
+
gh_path = make_fake_gh(root / "fakebin", gh_script)
|
|
150
|
+
return root, gh_path
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def start_server(root: Path, port: int, gh_path: Path):
|
|
154
|
+
state_root = root / "state"
|
|
155
|
+
real_state = Path.home() / "aesop" / "state"
|
|
156
|
+
if state_root.resolve() == real_state.resolve():
|
|
157
|
+
raise RuntimeError("state dir resolved to real repo state (~aesop/state)")
|
|
158
|
+
env = dict(os.environ,
|
|
159
|
+
AESOP_ROOT=str(root),
|
|
160
|
+
AESOP_STATE_ROOT=str(state_root),
|
|
161
|
+
AESOP_TRANSCRIPTS_ROOT=str(root / "transcripts"),
|
|
162
|
+
AESOP_UI_COLLECT_INTERVAL="0.3",
|
|
163
|
+
AESOP_GH_BIN=str(gh_path),
|
|
164
|
+
PORT=str(port))
|
|
165
|
+
server = subprocess.Popen([sys.executable, str(SERVE)], env=env,
|
|
166
|
+
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
167
|
+
for _ in range(SERVER_BOOT_TRIES):
|
|
168
|
+
try:
|
|
169
|
+
socket.create_connection(("127.0.0.1", port), timeout=0.2).close()
|
|
170
|
+
return server
|
|
171
|
+
except OSError:
|
|
172
|
+
time.sleep(SERVER_BOOT_SLEEP)
|
|
173
|
+
server.kill()
|
|
174
|
+
raise RuntimeError("server never came up")
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def stop_server(server):
|
|
178
|
+
server.terminate()
|
|
179
|
+
try:
|
|
180
|
+
server.wait(timeout=5)
|
|
181
|
+
except subprocess.TimeoutExpired:
|
|
182
|
+
server.kill()
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _boot(pw, gh_script):
|
|
186
|
+
"""Boot a fresh server+page for one phase. Returns (server, root, browser,
|
|
187
|
+
page, console_errors, failed_urls). Caller must goto + assert + teardown."""
|
|
188
|
+
root, gh_path = build_root(gh_script)
|
|
189
|
+
port = free_port()
|
|
190
|
+
server = start_server(root, port, gh_path)
|
|
191
|
+
browser = pw.chromium.launch(headless=True)
|
|
192
|
+
page = browser.new_page()
|
|
193
|
+
console_errors, failed_urls = [], []
|
|
194
|
+
page.on("console", lambda m: console_errors.append(m.text) if m.type == "error" else None)
|
|
195
|
+
page.on("pageerror", lambda e: console_errors.append(str(e)))
|
|
196
|
+
page.on("response", lambda r: failed_urls.append(r.url) if r.status >= 400 else None)
|
|
197
|
+
page.goto(f"http://127.0.0.1:{port}/", wait_until="domcontentloaded")
|
|
198
|
+
return server, root, browser, page, console_errors, failed_urls, port
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def run_populated(pw, failures):
|
|
202
|
+
server, root, browser, page, console_errors, failed_urls, _ = _boot(pw, FIXTURE_PR_JSON)
|
|
203
|
+
try:
|
|
204
|
+
try:
|
|
205
|
+
page.wait_for_selector("[data-testid='health-header']", timeout=SEL_TIMEOUT_MS)
|
|
206
|
+
page.evaluate("location.hash = '#/prs'")
|
|
207
|
+
page.wait_for_selector("[data-testid='view-prboard']", timeout=SEL_TIMEOUT_MS)
|
|
208
|
+
except Exception as e:
|
|
209
|
+
failures.append(f"(b) PR board view never mounted: {e}")
|
|
210
|
+
return
|
|
211
|
+
# (b) table + one row per PR
|
|
212
|
+
try:
|
|
213
|
+
page.wait_for_selector("[data-testid='prboard-table']", timeout=SEL_TIMEOUT_MS)
|
|
214
|
+
rows = page.locator("[data-testid='prboard-row']").count()
|
|
215
|
+
assert rows == 3, f"expected 3 rows, got {rows}"
|
|
216
|
+
except Exception as e:
|
|
217
|
+
failures.append(f"(b) PR board table/rows wrong: {e}")
|
|
218
|
+
# (c) color-independent CI text labels present
|
|
219
|
+
try:
|
|
220
|
+
body = page.inner_text("[data-testid='prboard-table']")
|
|
221
|
+
for word in ("Passing", "Failing", "Pending"):
|
|
222
|
+
assert word in body, f"CI label '{word}' not shown as text"
|
|
223
|
+
except Exception as e:
|
|
224
|
+
failures.append(f"(c) CI status not color-independent: {e}")
|
|
225
|
+
# (d) top blocker text
|
|
226
|
+
try:
|
|
227
|
+
body = page.inner_text("[data-testid='prboard-table']")
|
|
228
|
+
assert "CI failing" in body, "top blocker 'CI failing' not shown"
|
|
229
|
+
except Exception as e:
|
|
230
|
+
failures.append(f"(d) blocker not shown: {e}")
|
|
231
|
+
# (e) PR title is a real, keyboard-focusable link to the url
|
|
232
|
+
try:
|
|
233
|
+
link = page.get_by_role("link", name="feat: PR board passing case")
|
|
234
|
+
assert link.get_attribute("href") == "https://github.com/example/aesop/pull/501"
|
|
235
|
+
link.focus()
|
|
236
|
+
focused_tag = page.evaluate("document.activeElement && document.activeElement.tagName")
|
|
237
|
+
assert focused_tag == "A", f"PR link not keyboard-focusable (active={focused_tag})"
|
|
238
|
+
except Exception as e:
|
|
239
|
+
failures.append(f"(e) PR title link/focus failed: {e}")
|
|
240
|
+
# (f) no javascript: href leaked
|
|
241
|
+
try:
|
|
242
|
+
bad = page.evaluate(
|
|
243
|
+
"Array.from(document.querySelectorAll('a[href]'))"
|
|
244
|
+
".filter(a => a.href.toLowerCase().startsWith('javascript:')).length")
|
|
245
|
+
assert bad == 0, f"{bad} javascript: anchors present"
|
|
246
|
+
except Exception as e:
|
|
247
|
+
failures.append(f"(f) javascript: href leaked: {e}")
|
|
248
|
+
# (a) console clean
|
|
249
|
+
time.sleep(0.4)
|
|
250
|
+
real = _real_console_errors(console_errors, failed_urls)
|
|
251
|
+
if real:
|
|
252
|
+
failures.append(f"(a) populated console errors: {real[:3]}")
|
|
253
|
+
finally:
|
|
254
|
+
browser.close()
|
|
255
|
+
stop_server(server)
|
|
256
|
+
shutil.rmtree(root, ignore_errors=True)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def run_empty(pw, failures):
|
|
260
|
+
server, root, browser, page, console_errors, failed_urls, _ = _boot(pw, FIXTURE_EMPTY_JSON)
|
|
261
|
+
try:
|
|
262
|
+
try:
|
|
263
|
+
page.wait_for_selector("[data-testid='health-header']", timeout=SEL_TIMEOUT_MS)
|
|
264
|
+
page.evaluate("location.hash = '#/prs'")
|
|
265
|
+
page.wait_for_selector("[data-testid='prboard-empty']", timeout=SEL_TIMEOUT_MS)
|
|
266
|
+
text = page.inner_text("[data-testid='prboard-empty']")
|
|
267
|
+
assert "No open PRs" in text or "feature branch" in text, \
|
|
268
|
+
f"empty state text unexpected: {text!r}"
|
|
269
|
+
assert page.locator("[data-testid='prboard-table']").count() == 0, \
|
|
270
|
+
"table should not render in the empty state"
|
|
271
|
+
except Exception as e:
|
|
272
|
+
failures.append(f"(g) empty state failed: {e}")
|
|
273
|
+
time.sleep(0.4)
|
|
274
|
+
real = _real_console_errors(console_errors, failed_urls)
|
|
275
|
+
if real:
|
|
276
|
+
failures.append(f"(g) empty console errors: {real[:3]}")
|
|
277
|
+
finally:
|
|
278
|
+
browser.close()
|
|
279
|
+
stop_server(server)
|
|
280
|
+
shutil.rmtree(root, ignore_errors=True)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def run_unavailable(pw, failures):
|
|
284
|
+
server, root, browser, page, console_errors, failed_urls, _ = _boot(pw, FIXTURE_AUTH_FAIL)
|
|
285
|
+
try:
|
|
286
|
+
try:
|
|
287
|
+
page.wait_for_selector("[data-testid='health-header']", timeout=SEL_TIMEOUT_MS)
|
|
288
|
+
page.evaluate("location.hash = '#/prs'")
|
|
289
|
+
page.wait_for_selector("[data-testid='prboard-empty']", timeout=SEL_TIMEOUT_MS)
|
|
290
|
+
text = page.inner_text("[data-testid='prboard-empty']")
|
|
291
|
+
assert "GitHub CLI" in text, f"gh-unavailable callout missing: {text!r}"
|
|
292
|
+
assert "authenticated" in text.lower(), f"backend reason not surfaced: {text!r}"
|
|
293
|
+
except Exception as e:
|
|
294
|
+
failures.append(f"(h) gh-unavailable state failed: {e}")
|
|
295
|
+
time.sleep(0.4)
|
|
296
|
+
real = _real_console_errors(console_errors, failed_urls)
|
|
297
|
+
if real:
|
|
298
|
+
failures.append(f"(h) unavailable console errors: {real[:3]}")
|
|
299
|
+
finally:
|
|
300
|
+
browser.close()
|
|
301
|
+
stop_server(server)
|
|
302
|
+
shutil.rmtree(root, ignore_errors=True)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def main():
|
|
306
|
+
parser = argparse.ArgumentParser(description="Browser-level proof for the Wave PR Board")
|
|
307
|
+
parser.add_argument("--allow-skip", action="store_true",
|
|
308
|
+
help="Allow skipping if playwright/chromium is unavailable")
|
|
309
|
+
args = parser.parse_args()
|
|
310
|
+
|
|
311
|
+
try:
|
|
312
|
+
from playwright.sync_api import sync_playwright
|
|
313
|
+
except ImportError:
|
|
314
|
+
msg = "playwright missing — run `python -m playwright install chromium`, or pass --allow-skip"
|
|
315
|
+
print(f"SKIP: {msg}" if args.allow_skip else f"FAIL: {msg}")
|
|
316
|
+
return 0 if args.allow_skip else 1
|
|
317
|
+
|
|
318
|
+
failures = []
|
|
319
|
+
with sync_playwright() as pw:
|
|
320
|
+
try:
|
|
321
|
+
pw.chromium.launch(headless=True).close()
|
|
322
|
+
except Exception as e:
|
|
323
|
+
msg = f"chromium unavailable ({e}); run: python -m playwright install chromium"
|
|
324
|
+
print(f"SKIP: {msg}" if args.allow_skip else f"FAIL: {msg}")
|
|
325
|
+
return 0 if args.allow_skip else 1
|
|
326
|
+
run_populated(pw, failures)
|
|
327
|
+
run_empty(pw, failures)
|
|
328
|
+
run_unavailable(pw, failures)
|
|
329
|
+
|
|
330
|
+
if failures:
|
|
331
|
+
print("FAIL:")
|
|
332
|
+
for f in failures:
|
|
333
|
+
print(" -", f)
|
|
334
|
+
return 1
|
|
335
|
+
|
|
336
|
+
print("PROVEN: (a) console clean (b) #/prs table renders row-per-PR "
|
|
337
|
+
"(c) CI status color-independent text (d) top blocker shown "
|
|
338
|
+
"(e) PR title keyboard-focusable link (f) no javascript: href "
|
|
339
|
+
"(g) empty state (no PRs) (h) gh-unavailable callout with reason")
|
|
340
|
+
return 0
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
if __name__ == "__main__":
|
|
344
|
+
sys.exit(main())
|
package/tools/watch.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Aesop watch — Launch the watchdog daemon
|
|
5
|
+
*
|
|
6
|
+
* Spawns bash daemons/run-watchdog.sh with inherited stdio (foreground mode).
|
|
7
|
+
* The daemon will run continuously and inherit the parent's stdin/stdout/stderr.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const fs = require('fs');
|
|
11
|
+
const path = require('path');
|
|
12
|
+
const { spawn } = require('child_process');
|
|
13
|
+
|
|
14
|
+
const CURRENT_DIR = process.cwd();
|
|
15
|
+
|
|
16
|
+
(async function main() {
|
|
17
|
+
try {
|
|
18
|
+
// Verify we're in an aesop repo
|
|
19
|
+
const watchdogScript = path.join(CURRENT_DIR, 'daemons', 'run-watchdog.sh');
|
|
20
|
+
if (!fs.existsSync(watchdogScript)) {
|
|
21
|
+
console.error(`Error: watchdog script not found at ${watchdogScript}`);
|
|
22
|
+
console.error('Are you running this from the aesop root directory?');
|
|
23
|
+
process.exitCode = 1;
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
console.log('Starting aesop watchdog daemon...\n');
|
|
28
|
+
|
|
29
|
+
// Spawn the watchdog script in foreground (inherit stdio)
|
|
30
|
+
const proc = spawn('bash', [watchdogScript], {
|
|
31
|
+
cwd: CURRENT_DIR,
|
|
32
|
+
stdio: 'inherit',
|
|
33
|
+
shell: true
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
// Exit with the watchdog process's exit code
|
|
37
|
+
proc.on('exit', (code) => {
|
|
38
|
+
process.exitCode = code || 0;
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
proc.on('error', (err) => {
|
|
42
|
+
console.error(`Error spawning watchdog: ${err.message}`);
|
|
43
|
+
process.exitCode = 1;
|
|
44
|
+
});
|
|
45
|
+
} catch (err) {
|
|
46
|
+
console.error(`Error launching watchdog: ${err.message}`);
|
|
47
|
+
process.exitCode = 1;
|
|
48
|
+
}
|
|
49
|
+
})();
|