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.
@@ -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())