create-caspian-app 1.0.0 → 1.0.2
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/dist/caspian.js +1 -1
- package/dist/index.js +1 -1
- package/dist/public/js/main.js +12 -12
- package/dist/settings/_component_imports.py +70 -70
- package/dist/settings/browser_log.py +498 -498
- package/dist/settings/bs-config.json +6 -6
- package/dist/settings/build-static.py +290 -290
- package/dist/settings/check.py +415 -415
- package/dist/settings/check_templates.py +274 -274
- package/dist/settings/component-map.ts +6 -6
- package/dist/settings/dev-log-bridge.ts +585 -585
- package/dist/settings/fix.py +95 -95
- package/dist/settings/python-server.ts +31 -31
- package/dist/settings/run-postcss.ts +317 -317
- package/dist/settings/serve-static.py +188 -188
- package/dist/tests/README.md +135 -135
- package/dist/tests/conftest.py +89 -89
- package/dist/tests/test_health_route.py +44 -44
- package/dist/tests/test_main_helpers.py +112 -112
- package/package.json +1 -1
|
@@ -1,498 +1,498 @@
|
|
|
1
|
-
"""Read the dev-session browser log and report per-route front-end health.
|
|
2
|
-
|
|
3
|
-
Why this exists
|
|
4
|
-
---------------
|
|
5
|
-
`settings/dev-log-bridge.ts` forwards browser-side PulsePoint errors into the
|
|
6
|
-
`npm run dev` terminal. That only helps whoever owns that terminal: an AI agent
|
|
7
|
-
working in a different session cannot see stdout, and spawning a second
|
|
8
|
-
`npm run dev` to get its own copy would bind different ports and orphan the
|
|
9
|
-
browser tab the developer is actually looking at.
|
|
10
|
-
|
|
11
|
-
So the bridge also appends every event to `.casp/browser-log.jsonl`, and this
|
|
12
|
-
script renders it. `npm run logs`.
|
|
13
|
-
|
|
14
|
-
The hard part is not reading errors, it is not lying about their absence. Three
|
|
15
|
-
ways a naive error log misleads a reader, and how the format answers each:
|
|
16
|
-
|
|
17
|
-
* **Empty is ambiguous.** No errors could mean the route is fine, or that nobody
|
|
18
|
-
ever opened it, or that the dev server is not running. The log records `load`
|
|
19
|
-
events and a `session` header, so those three are distinguishable and this
|
|
20
|
-
script names them separately.
|
|
21
|
-
* **Fixed errors look current.** A clean reload writes nothing, so an error from
|
|
22
|
-
before the fix would sit in the file forever. Because every page load is
|
|
23
|
-
recorded, a route's state is whatever happened during its *most recent* load.
|
|
24
|
-
* **A reload does not re-test everything.** It re-runs mount, so it genuinely
|
|
25
|
-
clears a mount-phase error -- but it never clicks a button. An error from a
|
|
26
|
-
click handler survives the reload as NEEDS RECHECK instead of being reported
|
|
27
|
-
CLEAN, which is how a live bug would otherwise get signed off.
|
|
28
|
-
* **Reports race.** Two POSTs can arrive out of order, so an error is tied to its
|
|
29
|
-
load by the client-generated `page` id, never by arrival time.
|
|
30
|
-
|
|
31
|
-
Anyone reading the raw JSONL would see a fixed error as current, so the `session`
|
|
32
|
-
line carries a `readme` explaining the supersession rule and a clean reload
|
|
33
|
-
appends an explicit `resolved` event. An error whose `page` never produced a
|
|
34
|
-
`load` in this log -- a tab left open across a dev restart -- is reported as
|
|
35
|
-
UNCONFIRMED rather than as a fresh failure.
|
|
36
|
-
|
|
37
|
-
Every source change compacts the file down to the session header, a `restart`
|
|
38
|
-
marker, and the errors still open, so a dev session that runs for hours without a
|
|
39
|
-
restart cannot grow an unbounded log. Errors that survive a compaction are marked
|
|
40
|
-
`carried` and dropped at the next one, so a stale interaction error cannot haunt
|
|
41
|
-
the log forever.
|
|
42
|
-
|
|
43
|
-
Usage:
|
|
44
|
-
|
|
45
|
-
python settings/browser_log.py # human-readable digest
|
|
46
|
-
python settings/browser_log.py --json # machine-readable status
|
|
47
|
-
python settings/browser_log.py --fail-on-error # exit 1 if any route is dirty
|
|
48
|
-
|
|
49
|
-
Exit code is 0 by default even when routes are failing: whether a route has been
|
|
50
|
-
exercised depends on someone clicking around in a browser, so this must never
|
|
51
|
-
become a flaky pass/fail gate. `settings/check.py` prints it, and does not let it
|
|
52
|
-
change the gate's exit code.
|
|
53
|
-
"""
|
|
54
|
-
|
|
55
|
-
from __future__ import annotations
|
|
56
|
-
|
|
57
|
-
import argparse
|
|
58
|
-
import json
|
|
59
|
-
import socket
|
|
60
|
-
import sys
|
|
61
|
-
from dataclasses import dataclass, field
|
|
62
|
-
from datetime import datetime, timezone
|
|
63
|
-
from pathlib import Path
|
|
64
|
-
from typing import Any
|
|
65
|
-
|
|
66
|
-
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
|
67
|
-
LOG_FILE = PROJECT_ROOT / ".casp" / "browser-log.jsonl"
|
|
68
|
-
|
|
69
|
-
try:
|
|
70
|
-
sys.stdout.reconfigure(encoding="utf-8", errors="replace") # type: ignore[union-attr]
|
|
71
|
-
except (AttributeError, ValueError):
|
|
72
|
-
pass
|
|
73
|
-
|
|
74
|
-
_TTY = sys.stdout.isatty()
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
def _c(code: str, text: str) -> str:
|
|
78
|
-
return f"\033[{code}m{text}\033[0m" if _TTY else text
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
def red(t: str) -> str:
|
|
82
|
-
return _c("31", t)
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
def green(t: str) -> str:
|
|
86
|
-
return _c("32", t)
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
def yellow(t: str) -> str:
|
|
90
|
-
return _c("33", t)
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
def gray(t: str) -> str:
|
|
94
|
-
return _c("90", t)
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
def bold(t: str) -> str:
|
|
98
|
-
return _c("1", t)
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
def cyan(t: str) -> str:
|
|
102
|
-
return _c("36", t)
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
@dataclass
|
|
106
|
-
class PageLoad:
|
|
107
|
-
"""One browser page load and everything the runtime reported during it."""
|
|
108
|
-
|
|
109
|
-
page: str
|
|
110
|
-
route: str
|
|
111
|
-
at: str = ""
|
|
112
|
-
errors: list[dict[str, Any]] = field(default_factory=list)
|
|
113
|
-
warnings: list[dict[str, Any]] = field(default_factory=list)
|
|
114
|
-
#: True when the error arrived but the matching `load` event never did.
|
|
115
|
-
orphan: bool = False
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
@dataclass
|
|
119
|
-
class RouteStatus:
|
|
120
|
-
route: str
|
|
121
|
-
last_load: str
|
|
122
|
-
errors: list[dict[str, Any]]
|
|
123
|
-
warnings: list[dict[str, Any]]
|
|
124
|
-
#: Mount errors from *earlier* loads, retested and cleared by a later load.
|
|
125
|
-
healed: int
|
|
126
|
-
#: The newest errors came from a page with no `load` in this log -- typically
|
|
127
|
-
#: a tab opened before the last dev restart. Real, but possibly already fixed.
|
|
128
|
-
unconfirmed: bool = False
|
|
129
|
-
#: Interaction errors a reload could not retest, plus errors carried across a
|
|
130
|
-
#: source change. Not proof of a live bug, and not proof of a fix either.
|
|
131
|
-
recheck: list[dict[str, Any]] = field(default_factory=list)
|
|
132
|
-
|
|
133
|
-
@property
|
|
134
|
-
def clean(self) -> bool:
|
|
135
|
-
return not self.errors and not self.recheck
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
@dataclass
|
|
139
|
-
class LogReport:
|
|
140
|
-
"""Everything a caller needs to describe front-end health without guessing."""
|
|
141
|
-
|
|
142
|
-
#: "missing" (no dev session ever wrote), "live", "ended", "stale".
|
|
143
|
-
session: str
|
|
144
|
-
started: str = ""
|
|
145
|
-
pid: int = 0
|
|
146
|
-
port: int = 0
|
|
147
|
-
routes: list[RouteStatus] = field(default_factory=list)
|
|
148
|
-
#: When the log was last compacted because source files changed.
|
|
149
|
-
last_restart: str = ""
|
|
150
|
-
|
|
151
|
-
@property
|
|
152
|
-
def failing(self) -> list[RouteStatus]:
|
|
153
|
-
return [r for r in self.routes if not r.clean]
|
|
154
|
-
|
|
155
|
-
@property
|
|
156
|
-
def observed(self) -> bool:
|
|
157
|
-
return bool(self.routes)
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
def _read_events(path: Path) -> list[dict[str, Any]]:
|
|
161
|
-
if not path.exists():
|
|
162
|
-
return []
|
|
163
|
-
events: list[dict[str, Any]] = []
|
|
164
|
-
for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
|
|
165
|
-
line = line.strip()
|
|
166
|
-
if not line:
|
|
167
|
-
continue
|
|
168
|
-
try:
|
|
169
|
-
parsed = json.loads(line)
|
|
170
|
-
except json.JSONDecodeError:
|
|
171
|
-
# A torn final line (server killed mid-write) must not hide the rest.
|
|
172
|
-
continue
|
|
173
|
-
if isinstance(parsed, dict):
|
|
174
|
-
events.append(parsed)
|
|
175
|
-
return events
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
def _port_is_listening(port: int) -> bool:
|
|
179
|
-
if not port:
|
|
180
|
-
return False
|
|
181
|
-
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
|
182
|
-
sock.settimeout(0.25)
|
|
183
|
-
return sock.connect_ex(("127.0.0.1", port)) == 0
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
def build_report(path: Path = LOG_FILE) -> LogReport:
|
|
187
|
-
"""Collapse the raw event stream into current per-route status."""
|
|
188
|
-
events = _read_events(path)
|
|
189
|
-
if not events:
|
|
190
|
-
return LogReport(session="missing")
|
|
191
|
-
|
|
192
|
-
started = ""
|
|
193
|
-
pid = 0
|
|
194
|
-
port = 0
|
|
195
|
-
ended = False
|
|
196
|
-
last_restart = ""
|
|
197
|
-
for event in events:
|
|
198
|
-
if event.get("type") == "session":
|
|
199
|
-
started = str(event.get("t") or "")
|
|
200
|
-
pid = int(event.get("pid") or 0)
|
|
201
|
-
port = int(event.get("port") or 0)
|
|
202
|
-
ended = False
|
|
203
|
-
elif event.get("type") == "session-end":
|
|
204
|
-
ended = True
|
|
205
|
-
elif event.get("type") == "restart":
|
|
206
|
-
last_restart = str(event.get("t") or "")
|
|
207
|
-
|
|
208
|
-
# Group by the client's page id so an error is attributed to the load that
|
|
209
|
-
# produced it regardless of the order the two POSTs landed in.
|
|
210
|
-
pages: dict[str, PageLoad] = {}
|
|
211
|
-
order: list[str] = []
|
|
212
|
-
# Errors that survived a compaction. Their `load` event was dropped with the
|
|
213
|
-
# rest of the history, so they are tracked by route instead of by page.
|
|
214
|
-
carried: dict[str, list[dict[str, Any]]] = {}
|
|
215
|
-
|
|
216
|
-
def _page_for(event: dict[str, Any]) -> PageLoad:
|
|
217
|
-
key = str(event.get("page") or f"anon-{len(order)}")
|
|
218
|
-
if key not in pages:
|
|
219
|
-
pages[key] = PageLoad(
|
|
220
|
-
page=key,
|
|
221
|
-
route=str(event.get("route") or "?"),
|
|
222
|
-
at=str(event.get("t") or ""),
|
|
223
|
-
orphan=True,
|
|
224
|
-
)
|
|
225
|
-
order.append(key)
|
|
226
|
-
return pages[key]
|
|
227
|
-
|
|
228
|
-
for event in events:
|
|
229
|
-
kind = event.get("type")
|
|
230
|
-
if event.get("carried"):
|
|
231
|
-
carried.setdefault(str(event.get("route") or "?"), []).append(event)
|
|
232
|
-
elif kind == "load":
|
|
233
|
-
page = _page_for(event)
|
|
234
|
-
page.orphan = False
|
|
235
|
-
page.at = str(event.get("t") or page.at)
|
|
236
|
-
page.route = str(event.get("route") or page.route)
|
|
237
|
-
elif kind == "error":
|
|
238
|
-
_page_for(event).errors.append(event)
|
|
239
|
-
elif kind == "warn":
|
|
240
|
-
_page_for(event).warnings.append(event)
|
|
241
|
-
|
|
242
|
-
# Latest load wins: an error is only current if it happened on the most
|
|
243
|
-
# recent load of that route.
|
|
244
|
-
by_route: dict[str, list[PageLoad]] = {}
|
|
245
|
-
for key in order:
|
|
246
|
-
page = pages[key]
|
|
247
|
-
by_route.setdefault(page.route, []).append(page)
|
|
248
|
-
|
|
249
|
-
routes: list[RouteStatus] = []
|
|
250
|
-
for route in sorted(set(by_route) | set(carried)):
|
|
251
|
-
loads = by_route.get(route, [])
|
|
252
|
-
# Carried errors always need rechecking: the code changed under them.
|
|
253
|
-
recheck = list(carried.get(route, []))
|
|
254
|
-
|
|
255
|
-
if not loads:
|
|
256
|
-
routes.append(
|
|
257
|
-
RouteStatus(route=route, last_load="", errors=[], warnings=[], healed=0, recheck=recheck)
|
|
258
|
-
)
|
|
259
|
-
continue
|
|
260
|
-
|
|
261
|
-
latest = loads[-1]
|
|
262
|
-
healed = 0
|
|
263
|
-
for page in loads[:-1]:
|
|
264
|
-
for err in page.errors:
|
|
265
|
-
# A later load re-ran mount, so a mount error is genuinely
|
|
266
|
-
# retested. An interaction error is not: nothing reloaded here
|
|
267
|
-
# clicked anything, so it stays open rather than reading CLEAN.
|
|
268
|
-
if err.get("phase") == "interaction":
|
|
269
|
-
recheck.append(err)
|
|
270
|
-
else:
|
|
271
|
-
healed += 1
|
|
272
|
-
|
|
273
|
-
routes.append(
|
|
274
|
-
RouteStatus(
|
|
275
|
-
route=route,
|
|
276
|
-
last_load=latest.at,
|
|
277
|
-
errors=latest.errors,
|
|
278
|
-
warnings=latest.warnings,
|
|
279
|
-
healed=healed,
|
|
280
|
-
unconfirmed=latest.orphan,
|
|
281
|
-
recheck=recheck,
|
|
282
|
-
)
|
|
283
|
-
)
|
|
284
|
-
|
|
285
|
-
if ended:
|
|
286
|
-
session = "ended"
|
|
287
|
-
elif _port_is_listening(port):
|
|
288
|
-
session = "live"
|
|
289
|
-
else:
|
|
290
|
-
session = "stale"
|
|
291
|
-
|
|
292
|
-
return LogReport(
|
|
293
|
-
session=session,
|
|
294
|
-
started=started,
|
|
295
|
-
pid=pid,
|
|
296
|
-
port=port,
|
|
297
|
-
routes=routes,
|
|
298
|
-
last_restart=last_restart,
|
|
299
|
-
)
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
def _age(iso: str) -> str:
|
|
303
|
-
if not iso:
|
|
304
|
-
return ""
|
|
305
|
-
try:
|
|
306
|
-
moment = datetime.fromisoformat(iso.replace("Z", "+00:00"))
|
|
307
|
-
except ValueError:
|
|
308
|
-
return ""
|
|
309
|
-
seconds = int((datetime.now(timezone.utc) - moment).total_seconds())
|
|
310
|
-
if seconds < 60:
|
|
311
|
-
return f"{seconds}s ago"
|
|
312
|
-
if seconds < 3600:
|
|
313
|
-
return f"{seconds // 60}m ago"
|
|
314
|
-
return f"{seconds // 3600}h ago"
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
def _clock(iso: str) -> str:
|
|
318
|
-
if not iso:
|
|
319
|
-
return "?"
|
|
320
|
-
try:
|
|
321
|
-
return datetime.fromisoformat(iso.replace("Z", "+00:00")).astimezone().strftime("%H:%M:%S")
|
|
322
|
-
except ValueError:
|
|
323
|
-
return iso
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
_SESSION_LINES = {
|
|
327
|
-
"missing": (
|
|
328
|
-
"no browser log for this dev session",
|
|
329
|
-
"Nothing has been observed. Start `npm run dev` and open the route "
|
|
330
|
-
"in a browser before drawing any conclusion about the front end.",
|
|
331
|
-
),
|
|
332
|
-
"stale": (
|
|
333
|
-
"dev server is NOT running",
|
|
334
|
-
"This log is left over from an exited dev session. Treat every line "
|
|
335
|
-
"below as history, not as the current state of the app.",
|
|
336
|
-
),
|
|
337
|
-
"ended": (
|
|
338
|
-
"dev session ended cleanly",
|
|
339
|
-
"The dev server has shut down. The results below are from that "
|
|
340
|
-
"finished session.",
|
|
341
|
-
),
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
def _print_errors(errors: list[dict[str, Any]]) -> None:
|
|
346
|
-
"""Print each distinct message once, with the frames that locate it."""
|
|
347
|
-
seen: set[str] = set()
|
|
348
|
-
for err in errors:
|
|
349
|
-
message = str(err.get("message") or "").strip()
|
|
350
|
-
if message in seen:
|
|
351
|
-
continue
|
|
352
|
-
seen.add(message)
|
|
353
|
-
after = err.get("afterMs")
|
|
354
|
-
timing = ""
|
|
355
|
-
if err.get("phase") == "interaction" and isinstance(after, int):
|
|
356
|
-
timing = gray(f" (fired {after // 1000}s after load -- interaction, not mount)")
|
|
357
|
-
for line in message.split("\n"):
|
|
358
|
-
print(f" {red(line)}{timing}")
|
|
359
|
-
timing = ""
|
|
360
|
-
for frame in list(err.get("stack") or [])[:2]:
|
|
361
|
-
print(gray(f" {frame}"))
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
def print_report(report: LogReport) -> None:
|
|
365
|
-
print()
|
|
366
|
-
print(bold("Browser log") + gray(f" ({LOG_FILE.relative_to(PROJECT_ROOT)})"))
|
|
367
|
-
print("=" * 60)
|
|
368
|
-
|
|
369
|
-
if report.session == "live":
|
|
370
|
-
age = _age(report.started)
|
|
371
|
-
detail = f"pid {report.pid}, port {report.port}, started {_clock(report.started)}"
|
|
372
|
-
print(f" {green('LIVE')} dev session active {gray(f'({detail}{", " + age if age else ""})')}")
|
|
373
|
-
else:
|
|
374
|
-
headline, advice = _SESSION_LINES[report.session]
|
|
375
|
-
mark = yellow("WARN") if report.session != "missing" else gray("NONE")
|
|
376
|
-
print(f" {mark} {headline}")
|
|
377
|
-
print(gray(f" {advice}"))
|
|
378
|
-
if report.session == "missing":
|
|
379
|
-
print()
|
|
380
|
-
return
|
|
381
|
-
|
|
382
|
-
print()
|
|
383
|
-
|
|
384
|
-
if not report.observed:
|
|
385
|
-
print(yellow(" No page loads recorded yet."))
|
|
386
|
-
print(gray(" The log only knows about routes someone actually opened."))
|
|
387
|
-
print(gray(" An empty log is not evidence that the front end is healthy."))
|
|
388
|
-
print()
|
|
389
|
-
return
|
|
390
|
-
|
|
391
|
-
for status in sorted(report.routes, key=lambda r: (r.clean, r.route)):
|
|
392
|
-
# Compaction drops load events, so a carried-only route has no load time.
|
|
393
|
-
when = (
|
|
394
|
-
gray(f"last load {_clock(status.last_load)} {_age(status.last_load)}")
|
|
395
|
-
if status.last_load
|
|
396
|
-
else gray("no load since the last source change")
|
|
397
|
-
)
|
|
398
|
-
if status.clean:
|
|
399
|
-
healed = gray(f" ({status.healed} earlier error(s) resolved)") if status.healed else ""
|
|
400
|
-
warn = yellow(f" {len(status.warnings)} warning(s)") if status.warnings else ""
|
|
401
|
-
print(f" {green('CLEAN')} {cyan(status.route)} {when}{warn}{healed}")
|
|
402
|
-
continue
|
|
403
|
-
|
|
404
|
-
if status.errors:
|
|
405
|
-
label = "UNCONFIRMED" if status.unconfirmed else f"{len(status.errors)} ERROR(S)"
|
|
406
|
-
else:
|
|
407
|
-
label = "NEEDS RECHECK"
|
|
408
|
-
header = f" {red(bold(label))} {cyan(status.route)}"
|
|
409
|
-
print(header if status.unconfirmed else f"{header} {when}")
|
|
410
|
-
|
|
411
|
-
if status.unconfirmed:
|
|
412
|
-
# No matching load: the reporting tab was opened before this log
|
|
413
|
-
# existed, so the error may already be fixed. Say that plainly rather
|
|
414
|
-
# than sending someone hunting a bug that no longer exists.
|
|
415
|
-
print(
|
|
416
|
-
gray(
|
|
417
|
-
" Reported by a page loaded before this log started "
|
|
418
|
-
"(e.g. before the last dev restart)."
|
|
419
|
-
)
|
|
420
|
-
)
|
|
421
|
-
print(gray(" Reload the route and re-run to confirm whether it is still live."))
|
|
422
|
-
|
|
423
|
-
_print_errors(status.errors)
|
|
424
|
-
|
|
425
|
-
if status.recheck:
|
|
426
|
-
# The critical honesty case: a reload re-runs mount, so it clears a
|
|
427
|
-
# mount error -- but it never clicks a button. Reporting these as
|
|
428
|
-
# CLEAN is how a live bug gets signed off.
|
|
429
|
-
print(gray(" Not re-tested by the reloads since:"))
|
|
430
|
-
_print_errors(status.recheck)
|
|
431
|
-
if any(e.get("carried") for e in status.recheck):
|
|
432
|
-
print(gray(" Source changed since these fired; they may already be fixed."))
|
|
433
|
-
print(gray(" Repeat the interaction (click/submit) and re-run to confirm."))
|
|
434
|
-
|
|
435
|
-
print()
|
|
436
|
-
if report.failing:
|
|
437
|
-
print(red(bold(f"{len(report.failing)} route(s) failing in the browser.")))
|
|
438
|
-
else:
|
|
439
|
-
print(green(bold("Every route loaded this session is clean.")))
|
|
440
|
-
print(gray("Routes not listed were never opened -- that is no signal, not a pass."))
|
|
441
|
-
print()
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
def to_json(report: LogReport) -> str:
|
|
445
|
-
return json.dumps(
|
|
446
|
-
{
|
|
447
|
-
"session": report.session,
|
|
448
|
-
"started": report.started,
|
|
449
|
-
"pid": report.pid,
|
|
450
|
-
"port": report.port,
|
|
451
|
-
"lastRestart": report.last_restart,
|
|
452
|
-
"routes": [
|
|
453
|
-
{
|
|
454
|
-
"route": r.route,
|
|
455
|
-
"lastLoad": r.last_load,
|
|
456
|
-
"clean": r.clean,
|
|
457
|
-
"unconfirmed": r.unconfirmed,
|
|
458
|
-
"needsRecheck": [
|
|
459
|
-
{"message": e.get("message"), "phase": e.get("phase"), "carried": bool(e.get("carried"))}
|
|
460
|
-
for e in r.recheck
|
|
461
|
-
],
|
|
462
|
-
"errors": [
|
|
463
|
-
{"message": e.get("message"), "stack": e.get("stack")} for e in r.errors
|
|
464
|
-
],
|
|
465
|
-
"warnings": [{"message": w.get("message")} for w in r.warnings],
|
|
466
|
-
"healed": r.healed,
|
|
467
|
-
}
|
|
468
|
-
for r in report.routes
|
|
469
|
-
],
|
|
470
|
-
},
|
|
471
|
-
indent=2,
|
|
472
|
-
)
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
def main() -> int:
|
|
476
|
-
parser = argparse.ArgumentParser(description="Report browser-side health for this dev session.")
|
|
477
|
-
parser.add_argument("--json", action="store_true", help="Emit machine-readable status.")
|
|
478
|
-
parser.add_argument(
|
|
479
|
-
"--fail-on-error",
|
|
480
|
-
action="store_true",
|
|
481
|
-
help="Exit 1 when a route is currently failing (off by default: presence of "
|
|
482
|
-
"a signal depends on someone opening the page, so it is not a stable gate).",
|
|
483
|
-
)
|
|
484
|
-
args = parser.parse_args()
|
|
485
|
-
|
|
486
|
-
report = build_report()
|
|
487
|
-
if args.json:
|
|
488
|
-
print(to_json(report))
|
|
489
|
-
else:
|
|
490
|
-
print_report(report)
|
|
491
|
-
|
|
492
|
-
if args.fail_on_error and report.session == "live" and report.failing:
|
|
493
|
-
return 1
|
|
494
|
-
return 0
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
if __name__ == "__main__":
|
|
498
|
-
raise SystemExit(main())
|
|
1
|
+
"""Read the dev-session browser log and report per-route front-end health.
|
|
2
|
+
|
|
3
|
+
Why this exists
|
|
4
|
+
---------------
|
|
5
|
+
`settings/dev-log-bridge.ts` forwards browser-side PulsePoint errors into the
|
|
6
|
+
`npm run dev` terminal. That only helps whoever owns that terminal: an AI agent
|
|
7
|
+
working in a different session cannot see stdout, and spawning a second
|
|
8
|
+
`npm run dev` to get its own copy would bind different ports and orphan the
|
|
9
|
+
browser tab the developer is actually looking at.
|
|
10
|
+
|
|
11
|
+
So the bridge also appends every event to `.casp/browser-log.jsonl`, and this
|
|
12
|
+
script renders it. `npm run logs`.
|
|
13
|
+
|
|
14
|
+
The hard part is not reading errors, it is not lying about their absence. Three
|
|
15
|
+
ways a naive error log misleads a reader, and how the format answers each:
|
|
16
|
+
|
|
17
|
+
* **Empty is ambiguous.** No errors could mean the route is fine, or that nobody
|
|
18
|
+
ever opened it, or that the dev server is not running. The log records `load`
|
|
19
|
+
events and a `session` header, so those three are distinguishable and this
|
|
20
|
+
script names them separately.
|
|
21
|
+
* **Fixed errors look current.** A clean reload writes nothing, so an error from
|
|
22
|
+
before the fix would sit in the file forever. Because every page load is
|
|
23
|
+
recorded, a route's state is whatever happened during its *most recent* load.
|
|
24
|
+
* **A reload does not re-test everything.** It re-runs mount, so it genuinely
|
|
25
|
+
clears a mount-phase error -- but it never clicks a button. An error from a
|
|
26
|
+
click handler survives the reload as NEEDS RECHECK instead of being reported
|
|
27
|
+
CLEAN, which is how a live bug would otherwise get signed off.
|
|
28
|
+
* **Reports race.** Two POSTs can arrive out of order, so an error is tied to its
|
|
29
|
+
load by the client-generated `page` id, never by arrival time.
|
|
30
|
+
|
|
31
|
+
Anyone reading the raw JSONL would see a fixed error as current, so the `session`
|
|
32
|
+
line carries a `readme` explaining the supersession rule and a clean reload
|
|
33
|
+
appends an explicit `resolved` event. An error whose `page` never produced a
|
|
34
|
+
`load` in this log -- a tab left open across a dev restart -- is reported as
|
|
35
|
+
UNCONFIRMED rather than as a fresh failure.
|
|
36
|
+
|
|
37
|
+
Every source change compacts the file down to the session header, a `restart`
|
|
38
|
+
marker, and the errors still open, so a dev session that runs for hours without a
|
|
39
|
+
restart cannot grow an unbounded log. Errors that survive a compaction are marked
|
|
40
|
+
`carried` and dropped at the next one, so a stale interaction error cannot haunt
|
|
41
|
+
the log forever.
|
|
42
|
+
|
|
43
|
+
Usage:
|
|
44
|
+
|
|
45
|
+
python settings/browser_log.py # human-readable digest
|
|
46
|
+
python settings/browser_log.py --json # machine-readable status
|
|
47
|
+
python settings/browser_log.py --fail-on-error # exit 1 if any route is dirty
|
|
48
|
+
|
|
49
|
+
Exit code is 0 by default even when routes are failing: whether a route has been
|
|
50
|
+
exercised depends on someone clicking around in a browser, so this must never
|
|
51
|
+
become a flaky pass/fail gate. `settings/check.py` prints it, and does not let it
|
|
52
|
+
change the gate's exit code.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
from __future__ import annotations
|
|
56
|
+
|
|
57
|
+
import argparse
|
|
58
|
+
import json
|
|
59
|
+
import socket
|
|
60
|
+
import sys
|
|
61
|
+
from dataclasses import dataclass, field
|
|
62
|
+
from datetime import datetime, timezone
|
|
63
|
+
from pathlib import Path
|
|
64
|
+
from typing import Any
|
|
65
|
+
|
|
66
|
+
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
|
67
|
+
LOG_FILE = PROJECT_ROOT / ".casp" / "browser-log.jsonl"
|
|
68
|
+
|
|
69
|
+
try:
|
|
70
|
+
sys.stdout.reconfigure(encoding="utf-8", errors="replace") # type: ignore[union-attr]
|
|
71
|
+
except (AttributeError, ValueError):
|
|
72
|
+
pass
|
|
73
|
+
|
|
74
|
+
_TTY = sys.stdout.isatty()
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _c(code: str, text: str) -> str:
|
|
78
|
+
return f"\033[{code}m{text}\033[0m" if _TTY else text
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def red(t: str) -> str:
|
|
82
|
+
return _c("31", t)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def green(t: str) -> str:
|
|
86
|
+
return _c("32", t)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def yellow(t: str) -> str:
|
|
90
|
+
return _c("33", t)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def gray(t: str) -> str:
|
|
94
|
+
return _c("90", t)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def bold(t: str) -> str:
|
|
98
|
+
return _c("1", t)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def cyan(t: str) -> str:
|
|
102
|
+
return _c("36", t)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@dataclass
|
|
106
|
+
class PageLoad:
|
|
107
|
+
"""One browser page load and everything the runtime reported during it."""
|
|
108
|
+
|
|
109
|
+
page: str
|
|
110
|
+
route: str
|
|
111
|
+
at: str = ""
|
|
112
|
+
errors: list[dict[str, Any]] = field(default_factory=list)
|
|
113
|
+
warnings: list[dict[str, Any]] = field(default_factory=list)
|
|
114
|
+
#: True when the error arrived but the matching `load` event never did.
|
|
115
|
+
orphan: bool = False
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@dataclass
|
|
119
|
+
class RouteStatus:
|
|
120
|
+
route: str
|
|
121
|
+
last_load: str
|
|
122
|
+
errors: list[dict[str, Any]]
|
|
123
|
+
warnings: list[dict[str, Any]]
|
|
124
|
+
#: Mount errors from *earlier* loads, retested and cleared by a later load.
|
|
125
|
+
healed: int
|
|
126
|
+
#: The newest errors came from a page with no `load` in this log -- typically
|
|
127
|
+
#: a tab opened before the last dev restart. Real, but possibly already fixed.
|
|
128
|
+
unconfirmed: bool = False
|
|
129
|
+
#: Interaction errors a reload could not retest, plus errors carried across a
|
|
130
|
+
#: source change. Not proof of a live bug, and not proof of a fix either.
|
|
131
|
+
recheck: list[dict[str, Any]] = field(default_factory=list)
|
|
132
|
+
|
|
133
|
+
@property
|
|
134
|
+
def clean(self) -> bool:
|
|
135
|
+
return not self.errors and not self.recheck
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@dataclass
|
|
139
|
+
class LogReport:
|
|
140
|
+
"""Everything a caller needs to describe front-end health without guessing."""
|
|
141
|
+
|
|
142
|
+
#: "missing" (no dev session ever wrote), "live", "ended", "stale".
|
|
143
|
+
session: str
|
|
144
|
+
started: str = ""
|
|
145
|
+
pid: int = 0
|
|
146
|
+
port: int = 0
|
|
147
|
+
routes: list[RouteStatus] = field(default_factory=list)
|
|
148
|
+
#: When the log was last compacted because source files changed.
|
|
149
|
+
last_restart: str = ""
|
|
150
|
+
|
|
151
|
+
@property
|
|
152
|
+
def failing(self) -> list[RouteStatus]:
|
|
153
|
+
return [r for r in self.routes if not r.clean]
|
|
154
|
+
|
|
155
|
+
@property
|
|
156
|
+
def observed(self) -> bool:
|
|
157
|
+
return bool(self.routes)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _read_events(path: Path) -> list[dict[str, Any]]:
|
|
161
|
+
if not path.exists():
|
|
162
|
+
return []
|
|
163
|
+
events: list[dict[str, Any]] = []
|
|
164
|
+
for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
|
|
165
|
+
line = line.strip()
|
|
166
|
+
if not line:
|
|
167
|
+
continue
|
|
168
|
+
try:
|
|
169
|
+
parsed = json.loads(line)
|
|
170
|
+
except json.JSONDecodeError:
|
|
171
|
+
# A torn final line (server killed mid-write) must not hide the rest.
|
|
172
|
+
continue
|
|
173
|
+
if isinstance(parsed, dict):
|
|
174
|
+
events.append(parsed)
|
|
175
|
+
return events
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _port_is_listening(port: int) -> bool:
|
|
179
|
+
if not port:
|
|
180
|
+
return False
|
|
181
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
|
182
|
+
sock.settimeout(0.25)
|
|
183
|
+
return sock.connect_ex(("127.0.0.1", port)) == 0
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def build_report(path: Path = LOG_FILE) -> LogReport:
|
|
187
|
+
"""Collapse the raw event stream into current per-route status."""
|
|
188
|
+
events = _read_events(path)
|
|
189
|
+
if not events:
|
|
190
|
+
return LogReport(session="missing")
|
|
191
|
+
|
|
192
|
+
started = ""
|
|
193
|
+
pid = 0
|
|
194
|
+
port = 0
|
|
195
|
+
ended = False
|
|
196
|
+
last_restart = ""
|
|
197
|
+
for event in events:
|
|
198
|
+
if event.get("type") == "session":
|
|
199
|
+
started = str(event.get("t") or "")
|
|
200
|
+
pid = int(event.get("pid") or 0)
|
|
201
|
+
port = int(event.get("port") or 0)
|
|
202
|
+
ended = False
|
|
203
|
+
elif event.get("type") == "session-end":
|
|
204
|
+
ended = True
|
|
205
|
+
elif event.get("type") == "restart":
|
|
206
|
+
last_restart = str(event.get("t") or "")
|
|
207
|
+
|
|
208
|
+
# Group by the client's page id so an error is attributed to the load that
|
|
209
|
+
# produced it regardless of the order the two POSTs landed in.
|
|
210
|
+
pages: dict[str, PageLoad] = {}
|
|
211
|
+
order: list[str] = []
|
|
212
|
+
# Errors that survived a compaction. Their `load` event was dropped with the
|
|
213
|
+
# rest of the history, so they are tracked by route instead of by page.
|
|
214
|
+
carried: dict[str, list[dict[str, Any]]] = {}
|
|
215
|
+
|
|
216
|
+
def _page_for(event: dict[str, Any]) -> PageLoad:
|
|
217
|
+
key = str(event.get("page") or f"anon-{len(order)}")
|
|
218
|
+
if key not in pages:
|
|
219
|
+
pages[key] = PageLoad(
|
|
220
|
+
page=key,
|
|
221
|
+
route=str(event.get("route") or "?"),
|
|
222
|
+
at=str(event.get("t") or ""),
|
|
223
|
+
orphan=True,
|
|
224
|
+
)
|
|
225
|
+
order.append(key)
|
|
226
|
+
return pages[key]
|
|
227
|
+
|
|
228
|
+
for event in events:
|
|
229
|
+
kind = event.get("type")
|
|
230
|
+
if event.get("carried"):
|
|
231
|
+
carried.setdefault(str(event.get("route") or "?"), []).append(event)
|
|
232
|
+
elif kind == "load":
|
|
233
|
+
page = _page_for(event)
|
|
234
|
+
page.orphan = False
|
|
235
|
+
page.at = str(event.get("t") or page.at)
|
|
236
|
+
page.route = str(event.get("route") or page.route)
|
|
237
|
+
elif kind == "error":
|
|
238
|
+
_page_for(event).errors.append(event)
|
|
239
|
+
elif kind == "warn":
|
|
240
|
+
_page_for(event).warnings.append(event)
|
|
241
|
+
|
|
242
|
+
# Latest load wins: an error is only current if it happened on the most
|
|
243
|
+
# recent load of that route.
|
|
244
|
+
by_route: dict[str, list[PageLoad]] = {}
|
|
245
|
+
for key in order:
|
|
246
|
+
page = pages[key]
|
|
247
|
+
by_route.setdefault(page.route, []).append(page)
|
|
248
|
+
|
|
249
|
+
routes: list[RouteStatus] = []
|
|
250
|
+
for route in sorted(set(by_route) | set(carried)):
|
|
251
|
+
loads = by_route.get(route, [])
|
|
252
|
+
# Carried errors always need rechecking: the code changed under them.
|
|
253
|
+
recheck = list(carried.get(route, []))
|
|
254
|
+
|
|
255
|
+
if not loads:
|
|
256
|
+
routes.append(
|
|
257
|
+
RouteStatus(route=route, last_load="", errors=[], warnings=[], healed=0, recheck=recheck)
|
|
258
|
+
)
|
|
259
|
+
continue
|
|
260
|
+
|
|
261
|
+
latest = loads[-1]
|
|
262
|
+
healed = 0
|
|
263
|
+
for page in loads[:-1]:
|
|
264
|
+
for err in page.errors:
|
|
265
|
+
# A later load re-ran mount, so a mount error is genuinely
|
|
266
|
+
# retested. An interaction error is not: nothing reloaded here
|
|
267
|
+
# clicked anything, so it stays open rather than reading CLEAN.
|
|
268
|
+
if err.get("phase") == "interaction":
|
|
269
|
+
recheck.append(err)
|
|
270
|
+
else:
|
|
271
|
+
healed += 1
|
|
272
|
+
|
|
273
|
+
routes.append(
|
|
274
|
+
RouteStatus(
|
|
275
|
+
route=route,
|
|
276
|
+
last_load=latest.at,
|
|
277
|
+
errors=latest.errors,
|
|
278
|
+
warnings=latest.warnings,
|
|
279
|
+
healed=healed,
|
|
280
|
+
unconfirmed=latest.orphan,
|
|
281
|
+
recheck=recheck,
|
|
282
|
+
)
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
if ended:
|
|
286
|
+
session = "ended"
|
|
287
|
+
elif _port_is_listening(port):
|
|
288
|
+
session = "live"
|
|
289
|
+
else:
|
|
290
|
+
session = "stale"
|
|
291
|
+
|
|
292
|
+
return LogReport(
|
|
293
|
+
session=session,
|
|
294
|
+
started=started,
|
|
295
|
+
pid=pid,
|
|
296
|
+
port=port,
|
|
297
|
+
routes=routes,
|
|
298
|
+
last_restart=last_restart,
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _age(iso: str) -> str:
|
|
303
|
+
if not iso:
|
|
304
|
+
return ""
|
|
305
|
+
try:
|
|
306
|
+
moment = datetime.fromisoformat(iso.replace("Z", "+00:00"))
|
|
307
|
+
except ValueError:
|
|
308
|
+
return ""
|
|
309
|
+
seconds = int((datetime.now(timezone.utc) - moment).total_seconds())
|
|
310
|
+
if seconds < 60:
|
|
311
|
+
return f"{seconds}s ago"
|
|
312
|
+
if seconds < 3600:
|
|
313
|
+
return f"{seconds // 60}m ago"
|
|
314
|
+
return f"{seconds // 3600}h ago"
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _clock(iso: str) -> str:
|
|
318
|
+
if not iso:
|
|
319
|
+
return "?"
|
|
320
|
+
try:
|
|
321
|
+
return datetime.fromisoformat(iso.replace("Z", "+00:00")).astimezone().strftime("%H:%M:%S")
|
|
322
|
+
except ValueError:
|
|
323
|
+
return iso
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
_SESSION_LINES = {
|
|
327
|
+
"missing": (
|
|
328
|
+
"no browser log for this dev session",
|
|
329
|
+
"Nothing has been observed. Start `npm run dev` and open the route "
|
|
330
|
+
"in a browser before drawing any conclusion about the front end.",
|
|
331
|
+
),
|
|
332
|
+
"stale": (
|
|
333
|
+
"dev server is NOT running",
|
|
334
|
+
"This log is left over from an exited dev session. Treat every line "
|
|
335
|
+
"below as history, not as the current state of the app.",
|
|
336
|
+
),
|
|
337
|
+
"ended": (
|
|
338
|
+
"dev session ended cleanly",
|
|
339
|
+
"The dev server has shut down. The results below are from that "
|
|
340
|
+
"finished session.",
|
|
341
|
+
),
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _print_errors(errors: list[dict[str, Any]]) -> None:
|
|
346
|
+
"""Print each distinct message once, with the frames that locate it."""
|
|
347
|
+
seen: set[str] = set()
|
|
348
|
+
for err in errors:
|
|
349
|
+
message = str(err.get("message") or "").strip()
|
|
350
|
+
if message in seen:
|
|
351
|
+
continue
|
|
352
|
+
seen.add(message)
|
|
353
|
+
after = err.get("afterMs")
|
|
354
|
+
timing = ""
|
|
355
|
+
if err.get("phase") == "interaction" and isinstance(after, int):
|
|
356
|
+
timing = gray(f" (fired {after // 1000}s after load -- interaction, not mount)")
|
|
357
|
+
for line in message.split("\n"):
|
|
358
|
+
print(f" {red(line)}{timing}")
|
|
359
|
+
timing = ""
|
|
360
|
+
for frame in list(err.get("stack") or [])[:2]:
|
|
361
|
+
print(gray(f" {frame}"))
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def print_report(report: LogReport) -> None:
|
|
365
|
+
print()
|
|
366
|
+
print(bold("Browser log") + gray(f" ({LOG_FILE.relative_to(PROJECT_ROOT)})"))
|
|
367
|
+
print("=" * 60)
|
|
368
|
+
|
|
369
|
+
if report.session == "live":
|
|
370
|
+
age = _age(report.started)
|
|
371
|
+
detail = f"pid {report.pid}, port {report.port}, started {_clock(report.started)}"
|
|
372
|
+
print(f" {green('LIVE')} dev session active {gray(f'({detail}{", " + age if age else ""})')}")
|
|
373
|
+
else:
|
|
374
|
+
headline, advice = _SESSION_LINES[report.session]
|
|
375
|
+
mark = yellow("WARN") if report.session != "missing" else gray("NONE")
|
|
376
|
+
print(f" {mark} {headline}")
|
|
377
|
+
print(gray(f" {advice}"))
|
|
378
|
+
if report.session == "missing":
|
|
379
|
+
print()
|
|
380
|
+
return
|
|
381
|
+
|
|
382
|
+
print()
|
|
383
|
+
|
|
384
|
+
if not report.observed:
|
|
385
|
+
print(yellow(" No page loads recorded yet."))
|
|
386
|
+
print(gray(" The log only knows about routes someone actually opened."))
|
|
387
|
+
print(gray(" An empty log is not evidence that the front end is healthy."))
|
|
388
|
+
print()
|
|
389
|
+
return
|
|
390
|
+
|
|
391
|
+
for status in sorted(report.routes, key=lambda r: (r.clean, r.route)):
|
|
392
|
+
# Compaction drops load events, so a carried-only route has no load time.
|
|
393
|
+
when = (
|
|
394
|
+
gray(f"last load {_clock(status.last_load)} {_age(status.last_load)}")
|
|
395
|
+
if status.last_load
|
|
396
|
+
else gray("no load since the last source change")
|
|
397
|
+
)
|
|
398
|
+
if status.clean:
|
|
399
|
+
healed = gray(f" ({status.healed} earlier error(s) resolved)") if status.healed else ""
|
|
400
|
+
warn = yellow(f" {len(status.warnings)} warning(s)") if status.warnings else ""
|
|
401
|
+
print(f" {green('CLEAN')} {cyan(status.route)} {when}{warn}{healed}")
|
|
402
|
+
continue
|
|
403
|
+
|
|
404
|
+
if status.errors:
|
|
405
|
+
label = "UNCONFIRMED" if status.unconfirmed else f"{len(status.errors)} ERROR(S)"
|
|
406
|
+
else:
|
|
407
|
+
label = "NEEDS RECHECK"
|
|
408
|
+
header = f" {red(bold(label))} {cyan(status.route)}"
|
|
409
|
+
print(header if status.unconfirmed else f"{header} {when}")
|
|
410
|
+
|
|
411
|
+
if status.unconfirmed:
|
|
412
|
+
# No matching load: the reporting tab was opened before this log
|
|
413
|
+
# existed, so the error may already be fixed. Say that plainly rather
|
|
414
|
+
# than sending someone hunting a bug that no longer exists.
|
|
415
|
+
print(
|
|
416
|
+
gray(
|
|
417
|
+
" Reported by a page loaded before this log started "
|
|
418
|
+
"(e.g. before the last dev restart)."
|
|
419
|
+
)
|
|
420
|
+
)
|
|
421
|
+
print(gray(" Reload the route and re-run to confirm whether it is still live."))
|
|
422
|
+
|
|
423
|
+
_print_errors(status.errors)
|
|
424
|
+
|
|
425
|
+
if status.recheck:
|
|
426
|
+
# The critical honesty case: a reload re-runs mount, so it clears a
|
|
427
|
+
# mount error -- but it never clicks a button. Reporting these as
|
|
428
|
+
# CLEAN is how a live bug gets signed off.
|
|
429
|
+
print(gray(" Not re-tested by the reloads since:"))
|
|
430
|
+
_print_errors(status.recheck)
|
|
431
|
+
if any(e.get("carried") for e in status.recheck):
|
|
432
|
+
print(gray(" Source changed since these fired; they may already be fixed."))
|
|
433
|
+
print(gray(" Repeat the interaction (click/submit) and re-run to confirm."))
|
|
434
|
+
|
|
435
|
+
print()
|
|
436
|
+
if report.failing:
|
|
437
|
+
print(red(bold(f"{len(report.failing)} route(s) failing in the browser.")))
|
|
438
|
+
else:
|
|
439
|
+
print(green(bold("Every route loaded this session is clean.")))
|
|
440
|
+
print(gray("Routes not listed were never opened -- that is no signal, not a pass."))
|
|
441
|
+
print()
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def to_json(report: LogReport) -> str:
|
|
445
|
+
return json.dumps(
|
|
446
|
+
{
|
|
447
|
+
"session": report.session,
|
|
448
|
+
"started": report.started,
|
|
449
|
+
"pid": report.pid,
|
|
450
|
+
"port": report.port,
|
|
451
|
+
"lastRestart": report.last_restart,
|
|
452
|
+
"routes": [
|
|
453
|
+
{
|
|
454
|
+
"route": r.route,
|
|
455
|
+
"lastLoad": r.last_load,
|
|
456
|
+
"clean": r.clean,
|
|
457
|
+
"unconfirmed": r.unconfirmed,
|
|
458
|
+
"needsRecheck": [
|
|
459
|
+
{"message": e.get("message"), "phase": e.get("phase"), "carried": bool(e.get("carried"))}
|
|
460
|
+
for e in r.recheck
|
|
461
|
+
],
|
|
462
|
+
"errors": [
|
|
463
|
+
{"message": e.get("message"), "stack": e.get("stack")} for e in r.errors
|
|
464
|
+
],
|
|
465
|
+
"warnings": [{"message": w.get("message")} for w in r.warnings],
|
|
466
|
+
"healed": r.healed,
|
|
467
|
+
}
|
|
468
|
+
for r in report.routes
|
|
469
|
+
],
|
|
470
|
+
},
|
|
471
|
+
indent=2,
|
|
472
|
+
)
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def main() -> int:
|
|
476
|
+
parser = argparse.ArgumentParser(description="Report browser-side health for this dev session.")
|
|
477
|
+
parser.add_argument("--json", action="store_true", help="Emit machine-readable status.")
|
|
478
|
+
parser.add_argument(
|
|
479
|
+
"--fail-on-error",
|
|
480
|
+
action="store_true",
|
|
481
|
+
help="Exit 1 when a route is currently failing (off by default: presence of "
|
|
482
|
+
"a signal depends on someone opening the page, so it is not a stable gate).",
|
|
483
|
+
)
|
|
484
|
+
args = parser.parse_args()
|
|
485
|
+
|
|
486
|
+
report = build_report()
|
|
487
|
+
if args.json:
|
|
488
|
+
print(to_json(report))
|
|
489
|
+
else:
|
|
490
|
+
print_report(report)
|
|
491
|
+
|
|
492
|
+
if args.fail_on_error and report.session == "live" and report.failing:
|
|
493
|
+
return 1
|
|
494
|
+
return 0
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
if __name__ == "__main__":
|
|
498
|
+
raise SystemExit(main())
|