|
5 | 5 | browsertrace demo # create a deterministic failed demo run |
6 | 6 | browsertrace list # list runs in the terminal |
7 | 7 | browsertrace show <run_id> # print a run's timeline |
| 8 | + browsertrace compare <failed_id> <success_id> # find the first step divergence |
8 | 9 | browsertrace doctor # print local install and trace-store status |
9 | 10 | browsertrace export <id> # write a portable HTML bundle to ./<id>.html |
10 | 11 | browsertrace export <id> --redact # omit model I/O from the HTML export |
@@ -251,6 +252,126 @@ def cmd_show(args) -> int: |
251 | 252 | return 0 |
252 | 253 |
|
253 | 254 |
|
| 255 | +def _run_summary(run: sqlite3.Row) -> dict[str, str]: |
| 256 | + return { |
| 257 | + "id": run["id"], |
| 258 | + "name": run["name"] or "", |
| 259 | + "status": run["status"], |
| 260 | + } |
| 261 | + |
| 262 | + |
| 263 | +def _step_for_compare(step: sqlite3.Row | None) -> dict[str, object] | None: |
| 264 | + if step is None: |
| 265 | + return None |
| 266 | + return { |
| 267 | + "step_index": step["step_index"], |
| 268 | + "action": step["action"] or "", |
| 269 | + "url": step["url"] or "", |
| 270 | + "status": step["status"] or "ok", |
| 271 | + "error": step["error"], |
| 272 | + } |
| 273 | + |
| 274 | + |
| 275 | +def _compare_runs( |
| 276 | + left_run: sqlite3.Row, |
| 277 | + left_steps: list[sqlite3.Row], |
| 278 | + right_run: sqlite3.Row, |
| 279 | + right_steps: list[sqlite3.Row], |
| 280 | +) -> dict[str, object]: |
| 281 | + payload: dict[str, object] = { |
| 282 | + "left": _run_summary(left_run), |
| 283 | + "right": _run_summary(right_run), |
| 284 | + "step_counts": {"left": len(left_steps), "right": len(right_steps)}, |
| 285 | + "first_divergence": None, |
| 286 | + } |
| 287 | + |
| 288 | + fields = ["action", "url", "status", "error"] |
| 289 | + for i in range(max(len(left_steps), len(right_steps))): |
| 290 | + left_step = _step_for_compare(left_steps[i] if i < len(left_steps) else None) |
| 291 | + right_step = _step_for_compare(right_steps[i] if i < len(right_steps) else None) |
| 292 | + |
| 293 | + if left_step is None or right_step is None: |
| 294 | + payload["first_divergence"] = { |
| 295 | + "step_index": i, |
| 296 | + "fields": { |
| 297 | + "presence": { |
| 298 | + "left": left_step is not None, |
| 299 | + "right": right_step is not None, |
| 300 | + } |
| 301 | + }, |
| 302 | + "left_step": left_step, |
| 303 | + "right_step": right_step, |
| 304 | + } |
| 305 | + break |
| 306 | + |
| 307 | + changed = { |
| 308 | + field: {"left": left_step[field], "right": right_step[field]} |
| 309 | + for field in fields |
| 310 | + if left_step[field] != right_step[field] |
| 311 | + } |
| 312 | + if changed: |
| 313 | + payload["first_divergence"] = { |
| 314 | + "step_index": left_step["step_index"], |
| 315 | + "fields": changed, |
| 316 | + "left_step": left_step, |
| 317 | + "right_step": right_step, |
| 318 | + } |
| 319 | + break |
| 320 | + |
| 321 | + return payload |
| 322 | + |
| 323 | + |
| 324 | +def _print_compare_human(payload: dict[str, object]) -> None: |
| 325 | + left = payload["left"] |
| 326 | + right = payload["right"] |
| 327 | + assert isinstance(left, dict) |
| 328 | + assert isinstance(right, dict) |
| 329 | + |
| 330 | + print(f"Left: {left['id']} {_fmt_status(str(left['status']))} {left['name'] or '(unnamed)'}") |
| 331 | + print(f"Right: {right['id']} {_fmt_status(str(right['status']))} {right['name'] or '(unnamed)'}") |
| 332 | + step_counts = payload["step_counts"] |
| 333 | + assert isinstance(step_counts, dict) |
| 334 | + print(f"Steps: left={step_counts['left']} right={step_counts['right']}") |
| 335 | + |
| 336 | + divergence = payload["first_divergence"] |
| 337 | + if divergence is None: |
| 338 | + print("No step divergence found.") |
| 339 | + return |
| 340 | + |
| 341 | + assert isinstance(divergence, dict) |
| 342 | + print(f"First divergence at step {divergence['step_index']}") |
| 343 | + fields = divergence["fields"] |
| 344 | + assert isinstance(fields, dict) |
| 345 | + for field, values in fields.items(): |
| 346 | + assert isinstance(values, dict) |
| 347 | + print(f"{field}:") |
| 348 | + print(f" left: {values['left']}") |
| 349 | + print(f" right: {values['right']}") |
| 350 | + |
| 351 | + |
| 352 | +def cmd_compare(args) -> int: |
| 353 | + with _open() as c: |
| 354 | + left_run, rc = _find_run(c, args.left_run_id) |
| 355 | + if left_run is None: |
| 356 | + return rc |
| 357 | + right_run, rc = _find_run(c, args.right_run_id) |
| 358 | + if right_run is None: |
| 359 | + return rc |
| 360 | + left_steps = c.execute( |
| 361 | + "SELECT * FROM steps WHERE run_id=? ORDER BY step_index", (left_run["id"],) |
| 362 | + ).fetchall() |
| 363 | + right_steps = c.execute( |
| 364 | + "SELECT * FROM steps WHERE run_id=? ORDER BY step_index", (right_run["id"],) |
| 365 | + ).fetchall() |
| 366 | + |
| 367 | + payload = _compare_runs(left_run, left_steps, right_run, right_steps) |
| 368 | + if args.json: |
| 369 | + print(json.dumps(payload, indent=2)) |
| 370 | + else: |
| 371 | + _print_compare_human(payload) |
| 372 | + return 0 |
| 373 | + |
| 374 | + |
254 | 375 | def cmd_export(args) -> int: |
255 | 376 | """Write a self-contained HTML bundle for a run (screenshots inline as base64).""" |
256 | 377 | import base64 |
@@ -396,6 +517,12 @@ def main(argv: Optional[list[str]] = None) -> int: |
396 | 517 | p_show.add_argument("--json", action="store_true", help="Print the run timeline as JSON") |
397 | 518 | p_show.set_defaults(func=cmd_show) |
398 | 519 |
|
| 520 | + p_compare = sub.add_parser("compare", help="Compare two run timelines") |
| 521 | + p_compare.add_argument("left_run_id", help="Full id or unique prefix for the left run") |
| 522 | + p_compare.add_argument("right_run_id", help="Full id or unique prefix for the right run") |
| 523 | + p_compare.add_argument("--json", action="store_true", help="Print the comparison as JSON") |
| 524 | + p_compare.set_defaults(func=cmd_compare) |
| 525 | + |
399 | 526 | p_export = sub.add_parser("export", help="Write a self-contained HTML bundle for a run") |
400 | 527 | p_export.add_argument("run_id") |
401 | 528 | p_export.add_argument("-o", "--out", help="Output path (default: <run_id>.html)") |
|
0 commit comments