code-coordinator 0.5.46__py3-none-any.whl

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.
Files changed (295) hide show
  1. code_coordinator-0.5.46.dist-info/METADATA +625 -0
  2. code_coordinator-0.5.46.dist-info/RECORD +295 -0
  3. code_coordinator-0.5.46.dist-info/WHEEL +5 -0
  4. code_coordinator-0.5.46.dist-info/entry_points.txt +2 -0
  5. code_coordinator-0.5.46.dist-info/licenses/LICENSE +110 -0
  6. code_coordinator-0.5.46.dist-info/top_level.txt +1 -0
  7. coord/__init__.py +176 -0
  8. coord/_board_mapping.py +229 -0
  9. coord/acceptance.py +468 -0
  10. coord/acceptance_drivers.py +632 -0
  11. coord/agent.py +7517 -0
  12. coord/agent_app.py +1555 -0
  13. coord/agent_update.py +417 -0
  14. coord/agents/opencode/.gitignore +13 -0
  15. coord/agents/opencode/agents/work.md +129 -0
  16. coord/agents/opencode/routing.jsonc +49 -0
  17. coord/audit.py +301 -0
  18. coord/auto_loop.py +1440 -0
  19. coord/board_bool_guard.py +72 -0
  20. coord/board_service.py +141 -0
  21. coord/board_wire.py +309 -0
  22. coord/brain.py +581 -0
  23. coord/branch_model.py +214 -0
  24. coord/cargo_cache.py +258 -0
  25. coord/ci_github.py +386 -0
  26. coord/ci_store.py +560 -0
  27. coord/claim.py +353 -0
  28. coord/cli.py +454 -0
  29. coord/client.py +610 -0
  30. coord/commands/__init__.py +1 -0
  31. coord/commands/_common.py +329 -0
  32. coord/commands/acceptance.py +916 -0
  33. coord/commands/agent_ops.py +1339 -0
  34. coord/commands/audit.py +131 -0
  35. coord/commands/chat.py +320 -0
  36. coord/commands/dispatch.py +1780 -0
  37. coord/commands/dispatch_workers.py +4894 -0
  38. coord/commands/drive.py +616 -0
  39. coord/commands/drive_queue.py +1203 -0
  40. coord/commands/gate_a.py +217 -0
  41. coord/commands/gates.py +89 -0
  42. coord/commands/issues.py +681 -0
  43. coord/commands/lifecycle.py +513 -0
  44. coord/commands/merge.py +1900 -0
  45. coord/commands/milestone.py +2081 -0
  46. coord/commands/plan_followup.py +1243 -0
  47. coord/commands/plans.py +156 -0
  48. coord/commands/release.py +2232 -0
  49. coord/commands/report.py +341 -0
  50. coord/commands/review.py +1523 -0
  51. coord/commands/scorecard.py +252 -0
  52. coord/commands/sessions.py +1930 -0
  53. coord/commands/setup.py +576 -0
  54. coord/commands/status.py +2089 -0
  55. coord/commands/terminal.py +385 -0
  56. coord/commands/test_gate.py +775 -0
  57. coord/commands/tui.py +288 -0
  58. coord/comments.py +718 -0
  59. coord/config.py +3032 -0
  60. coord/conflict_fix.py +633 -0
  61. coord/dao.py +483 -0
  62. coord/dashboard/__init__.py +0 -0
  63. coord/dashboard/fixture.py +376 -0
  64. coord/dashboard/index.html +658 -0
  65. coord/dashboard/server.py +1894 -0
  66. coord/dashboard/terminal.py +382 -0
  67. coord/dashboard/webapp/.gitignore +9 -0
  68. coord/dashboard/webapp/components.json +17 -0
  69. coord/dashboard/webapp/dist/assets/Gallery-da3qNiIw.js +71 -0
  70. coord/dashboard/webapp/dist/assets/Terminal-9CEnUXvW.css +32 -0
  71. coord/dashboard/webapp/dist/assets/Terminal-skVFCxPU.js +63 -0
  72. coord/dashboard/webapp/dist/assets/index-DltfZR5f.js +184 -0
  73. coord/dashboard/webapp/dist/assets/index-Dq4kwTdw.css +1 -0
  74. coord/dashboard/webapp/dist/assets/workbox-window.prod.es5-BqEJf4Xk.js +2 -0
  75. coord/dashboard/webapp/dist/icons/icon-192.png +0 -0
  76. coord/dashboard/webapp/dist/icons/icon-512.png +0 -0
  77. coord/dashboard/webapp/dist/icons/icon.svg +5 -0
  78. coord/dashboard/webapp/dist/index.html +38 -0
  79. coord/dashboard/webapp/dist/manifest.webmanifest +1 -0
  80. coord/dashboard/webapp/dist/sw.js +1 -0
  81. coord/dashboard/webapp/dist/workbox-e4022e15.js +1 -0
  82. coord/dashboard/webapp/e2e/available-gates-terminal.spec.ts +75 -0
  83. coord/dashboard/webapp/e2e/deep-link.spec.ts +172 -0
  84. coord/dashboard/webapp/e2e/fixtureServer.ts +155 -0
  85. coord/dashboard/webapp/e2e/live-update-fixture.spec.ts +113 -0
  86. coord/dashboard/webapp/e2e/realtime.spec.ts +238 -0
  87. coord/dashboard/webapp/e2e/shell.spec.ts +309 -0
  88. coord/dashboard/webapp/e2e/smoke.spec.ts +191 -0
  89. coord/dashboard/webapp/e2e/terminal.spec.ts +420 -0
  90. coord/dashboard/webapp/e2e/theme.spec.ts +138 -0
  91. coord/dashboard/webapp/eslint.config.js +20 -0
  92. coord/dashboard/webapp/index.html +37 -0
  93. coord/dashboard/webapp/node_modules/flatted/python/flatted.py +144 -0
  94. coord/dashboard/webapp/package-lock.json +10584 -0
  95. coord/dashboard/webapp/package.json +63 -0
  96. coord/dashboard/webapp/playwright.acceptance.config.ts +166 -0
  97. coord/dashboard/webapp/playwright.config.ts +93 -0
  98. coord/dashboard/webapp/postcss.config.js +6 -0
  99. coord/dashboard/webapp/public/icons/icon-192.png +0 -0
  100. coord/dashboard/webapp/public/icons/icon-512.png +0 -0
  101. coord/dashboard/webapp/public/icons/icon.svg +5 -0
  102. coord/dashboard/webapp/src/App.tsx +140 -0
  103. coord/dashboard/webapp/src/api/client.ts +199 -0
  104. coord/dashboard/webapp/src/api/generated.ts +176 -0
  105. coord/dashboard/webapp/src/components/ConnectionBadge.tsx +52 -0
  106. coord/dashboard/webapp/src/components/Detail.tsx +800 -0
  107. coord/dashboard/webapp/src/components/Gallery.tsx +341 -0
  108. coord/dashboard/webapp/src/components/Home.tsx +435 -0
  109. coord/dashboard/webapp/src/components/MobileKeyBar.tsx +280 -0
  110. coord/dashboard/webapp/src/components/PanelHeader.tsx +59 -0
  111. coord/dashboard/webapp/src/components/PipelineCard.tsx +168 -0
  112. coord/dashboard/webapp/src/components/SessionCard.tsx +99 -0
  113. coord/dashboard/webapp/src/components/SessionDetail.tsx +140 -0
  114. coord/dashboard/webapp/src/components/SessionsList.tsx +81 -0
  115. coord/dashboard/webapp/src/components/Terminal.tsx +376 -0
  116. coord/dashboard/webapp/src/components/__tests__/ConnectionBadge.test.tsx +81 -0
  117. coord/dashboard/webapp/src/components/__tests__/Detail.test.tsx +680 -0
  118. coord/dashboard/webapp/src/components/__tests__/Gallery.test.tsx +83 -0
  119. coord/dashboard/webapp/src/components/__tests__/Home.test.tsx +271 -0
  120. coord/dashboard/webapp/src/components/__tests__/MobileKeyBar.test.tsx +197 -0
  121. coord/dashboard/webapp/src/components/__tests__/PipelineCard.test.tsx +143 -0
  122. coord/dashboard/webapp/src/components/__tests__/SessionCard.test.tsx +106 -0
  123. coord/dashboard/webapp/src/components/__tests__/Terminal.test.tsx +504 -0
  124. coord/dashboard/webapp/src/components/ui/badge.tsx +41 -0
  125. coord/dashboard/webapp/src/components/ui/button.tsx +54 -0
  126. coord/dashboard/webapp/src/components/ui/card.tsx +55 -0
  127. coord/dashboard/webapp/src/components/ui/dialog.tsx +99 -0
  128. coord/dashboard/webapp/src/components/ui/dropdown-menu.tsx +189 -0
  129. coord/dashboard/webapp/src/components/ui/empty-state.tsx +35 -0
  130. coord/dashboard/webapp/src/components/ui/sheet.tsx +123 -0
  131. coord/dashboard/webapp/src/components/ui/skeleton.tsx +9 -0
  132. coord/dashboard/webapp/src/components/ui/tabs.tsx +55 -0
  133. coord/dashboard/webapp/src/components/ui/theme-provider.tsx +78 -0
  134. coord/dashboard/webapp/src/components/ui/theme-toggle.tsx +20 -0
  135. coord/dashboard/webapp/src/components/ui/toast.tsx +123 -0
  136. coord/dashboard/webapp/src/components/ui/toaster.tsx +30 -0
  137. coord/dashboard/webapp/src/components/ui/tooltip.tsx +26 -0
  138. coord/dashboard/webapp/src/components/ui/use-toast.ts +134 -0
  139. coord/dashboard/webapp/src/index.css +210 -0
  140. coord/dashboard/webapp/src/lib/pipeline.ts +29 -0
  141. coord/dashboard/webapp/src/lib/utils.ts +6 -0
  142. coord/dashboard/webapp/src/main.tsx +46 -0
  143. coord/dashboard/webapp/src/realtime/RealtimeProvider.tsx +112 -0
  144. coord/dashboard/webapp/src/realtime/__tests__/RealtimeProvider.test.tsx +189 -0
  145. coord/dashboard/webapp/src/realtime/__tests__/connection.test.ts +255 -0
  146. coord/dashboard/webapp/src/realtime/connection.ts +227 -0
  147. coord/dashboard/webapp/src/realtime/events.ts +100 -0
  148. coord/dashboard/webapp/src/routes/__tests__/paths.test.ts +92 -0
  149. coord/dashboard/webapp/src/routes/paths.ts +92 -0
  150. coord/dashboard/webapp/src/shell/ActivityRail.tsx +335 -0
  151. coord/dashboard/webapp/src/shell/AppShell.tsx +276 -0
  152. coord/dashboard/webapp/src/shell/ComingSoon.tsx +33 -0
  153. coord/dashboard/webapp/src/shell/EmptyDetail.tsx +26 -0
  154. coord/dashboard/webapp/src/shell/RouteNotFound.tsx +33 -0
  155. coord/dashboard/webapp/src/shell/ShellLayout.tsx +147 -0
  156. coord/dashboard/webapp/src/shell/StatusBar.tsx +46 -0
  157. coord/dashboard/webapp/src/shell/__tests__/ShellLayout.test.tsx +520 -0
  158. coord/dashboard/webapp/src/shell/__tests__/shellState.test.ts +95 -0
  159. coord/dashboard/webapp/src/shell/__tests__/stubViewport.ts +40 -0
  160. coord/dashboard/webapp/src/shell/breakpoints.ts +87 -0
  161. coord/dashboard/webapp/src/shell/railItems.ts +105 -0
  162. coord/dashboard/webapp/src/shell/shellState.ts +174 -0
  163. coord/dashboard/webapp/src/shell/useRegionFocus.ts +95 -0
  164. coord/dashboard/webapp/src/test-setup.ts +41 -0
  165. coord/dashboard/webapp/src/vite-env.d.ts +2 -0
  166. coord/dashboard/webapp/tailwind.config.js +140 -0
  167. coord/dashboard/webapp/tsconfig.json +25 -0
  168. coord/dashboard/webapp/tsconfig.node.json +11 -0
  169. coord/dashboard/webapp/vite.config.ts +71 -0
  170. coord/db.py +1076 -0
  171. coord/dead_end.py +332 -0
  172. coord/deploy/README.md +33 -0
  173. coord/deploy/coord-agent.service +89 -0
  174. coord/deploy/coord-db-backup.service +60 -0
  175. coord/deploy/coord-db-backup.sh +74 -0
  176. coord/deploy/coord-db-backup.timer +18 -0
  177. coord/deploy/coord-drive-queue.service +117 -0
  178. coord/deploy/coord-drive-queue.timer +39 -0
  179. coord/deploy/coord-notify.service +48 -0
  180. coord/deploy/coord-notify.timer +24 -0
  181. coord/deploy/coord-release-propagate.service +83 -0
  182. coord/deploy/coord-release-propagate.timer +38 -0
  183. coord/deploy/coord-release-window.service +119 -0
  184. coord/deploy/coord-release-window.timer +36 -0
  185. coord/deploy/coord-serve.service +82 -0
  186. coord/deploy/coord-web-dist-build.service +43 -0
  187. coord/deploy/coord-web-dist-build.timer +36 -0
  188. coord/deploy/coord-web.service +125 -0
  189. coord/deploy_manifest.py +80 -0
  190. coord/deploy_units.py +384 -0
  191. coord/deps.py +115 -0
  192. coord/diagnose.py +1623 -0
  193. coord/dispatch.py +1009 -0
  194. coord/dist_name.py +123 -0
  195. coord/drive.py +3101 -0
  196. coord/drive_queue.py +2298 -0
  197. coord/drive_state.py +870 -0
  198. coord/events.py +381 -0
  199. coord/failure_class.py +914 -0
  200. coord/filelock.py +168 -0
  201. coord/fleet_config_health.py +300 -0
  202. coord/freshness.py +206 -0
  203. coord/gate_a.py +469 -0
  204. coord/gate_b.py +411 -0
  205. coord/gate_snapshot.py +385 -0
  206. coord/gates.py +582 -0
  207. coord/github_ops.py +1954 -0
  208. coord/goal.py +125 -0
  209. coord/graph_health.py +348 -0
  210. coord/health/__init__.py +69 -0
  211. coord/health/aggregate.py +129 -0
  212. coord/health/checks/__init__.py +13 -0
  213. coord/health/checks/agent_install.py +280 -0
  214. coord/health/checks/cargo_targets.py +171 -0
  215. coord/health/checks/claude_binary.py +65 -0
  216. coord/health/checks/deploy_lane_facts.py +458 -0
  217. coord/health/checks/disk.py +99 -0
  218. coord/health/checks/fleet_board.py +89 -0
  219. coord/health/checks/fleet_deploy_lanes.py +469 -0
  220. coord/health/checks/fleet_phantom.py +69 -0
  221. coord/health/checks/fleet_unit_drift.py +151 -0
  222. coord/health/checks/graph.py +192 -0
  223. coord/health/checks/plan_usage.py +88 -0
  224. coord/health/checks/repo_state.py +161 -0
  225. coord/health/checks/spawned_coord.py +465 -0
  226. coord/health/checks/timer_active.py +254 -0
  227. coord/health/checks/toolchain.py +547 -0
  228. coord/health/checks/unit_drift.py +648 -0
  229. coord/health/checks/unit_enablement.py +171 -0
  230. coord/health/checks/worktrees.py +96 -0
  231. coord/health/cli.py +121 -0
  232. coord/health/context.py +106 -0
  233. coord/health/fleet_snapshot.py +477 -0
  234. coord/health/models.py +250 -0
  235. coord/health/pypi.py +231 -0
  236. coord/health/registry.py +240 -0
  237. coord/health/render.py +82 -0
  238. coord/health/units.py +60 -0
  239. coord/hooks.py +106 -0
  240. coord/housekeeping.py +204 -0
  241. coord/interactive.py +4286 -0
  242. coord/issue_store.py +1496 -0
  243. coord/liveness_auditor.py +293 -0
  244. coord/machine_pause.py +755 -0
  245. coord/merge_queue.py +4681 -0
  246. coord/milestone_chat.py +600 -0
  247. coord/milestone_dispatch.py +943 -0
  248. coord/milestone_gate.py +709 -0
  249. coord/milestone_order.py +840 -0
  250. coord/mock_author.py +334 -0
  251. coord/models.py +891 -0
  252. coord/network.py +269 -0
  253. coord/new_issue_chat.py +229 -0
  254. coord/notify.py +3226 -0
  255. coord/openapi.py +404 -0
  256. coord/overlap_fence.py +133 -0
  257. coord/parentage.py +200 -0
  258. coord/parentage_github.py +58 -0
  259. coord/pipeline.py +481 -0
  260. coord/plan_parser.py +266 -0
  261. coord/plans.py +543 -0
  262. coord/platform_paths.py +43 -0
  263. coord/pr_body_lint.py +67 -0
  264. coord/prereqs.py +533 -0
  265. coord/progress.py +425 -0
  266. coord/providers/__init__.py +683 -0
  267. coord/providers/base.py +218 -0
  268. coord/providers/claude.py +284 -0
  269. coord/providers/claude_pty.py +610 -0
  270. coord/providers/opencode.py +896 -0
  271. coord/reconcile.py +2233 -0
  272. coord/refine_chat.py +485 -0
  273. coord/release_cordon.py +525 -0
  274. coord/release_propagate.py +1176 -0
  275. coord/release_verify.py +777 -0
  276. coord/release_window.py +322 -0
  277. coord/reports.py +1643 -0
  278. coord/revalidate.py +1101 -0
  279. coord/review.py +3317 -0
  280. coord/scorecard.py +484 -0
  281. coord/serve_app.py +7192 -0
  282. coord/skills/update-issue/SKILL.md +93 -0
  283. coord/smoke.py +1030 -0
  284. coord/split_work.py +210 -0
  285. coord/stage_projection.py +650 -0
  286. coord/state.py +5720 -0
  287. coord/test_author.py +1064 -0
  288. coord/test_chat.py +352 -0
  289. coord/test_orchestrator.py +494 -0
  290. coord/test_report.py +178 -0
  291. coord/tui_release.py +271 -0
  292. coord/usage.py +753 -0
  293. coord/usage_limits.py +358 -0
  294. coord/usage_rollup.py +709 -0
  295. coord/worker_events.py +954 -0
@@ -0,0 +1,632 @@
1
+ """Framework driver adapters for ``coord acceptance`` (#944,
2
+ docs/ORACLE_LOOP.md).
3
+
4
+ ``coord acceptance`` is a thin, framework-agnostic orchestrator; this module
5
+ is the one seam that varies per medium — TUI (quadraui ``TuiDriver``), CLI
6
+ (pytest), web (Playwright), native, etc. Each driver knows how to *run* a
7
+ repo's declared acceptance suite and *parse* its raw output into a
8
+ normalized list of ``{"id": str, "status": "pass"|"fail"|"skip", "message":
9
+ str}`` dicts (``cli-pytest`` additionally carries ``"expected"``/``"got"``
10
+ on a failing test — see :func:`parse_pytest_junit_xml`). ``tui-tuidriver``,
11
+ ``cli-pytest`` (#1125), and ``web-playwright`` (#1539) are implemented;
12
+ other ``kind`` values are declared in ``coordinator.yml`` (see
13
+ :class:`coord.config.AcceptanceConfig`) but rejected here with a clear "not
14
+ yet implemented" error until their issues land (native).
15
+
16
+ ``cli-pytest`` parses pytest's built-in ``--junit-xml`` report (a core
17
+ pytest flag, not a plugin — no extra dependency required in the driven
18
+ repo, unlike ``pytest-json-report``/``pytest-reportlog``) rather than
19
+ stdout, since junit-xml already carries a structured per-test
20
+ pass/fail/skip verdict plus each failure's message.
21
+
22
+ ``web-playwright`` parses Playwright Test's built-in ``--reporter=json``
23
+ report rather than its built-in ``--reporter=junit`` one — see
24
+ :func:`parse_playwright_json_report` for why (short version: junit
25
+ collapses retries into one opaque CDATA blob with no per-attempt status,
26
+ which loses the "did this flake" signal this driver exists to capture; json
27
+ keeps a ``results[]`` entry per attempt).
28
+
29
+ :func:`run_driver` also runs a driver's optional ``setup:`` provisioning
30
+ command (#1733, ``AcceptanceDriverConfig.setup``) once before its suite —
31
+ e.g. ``npm ci`` for ``web-playwright``, which otherwise fails with a bare
32
+ ``exit 127`` (playwright not found) the first time it runs against ``coord
33
+ acceptance record``'s throwaway, dependency-less worktree.
34
+ """
35
+
36
+ from __future__ import annotations
37
+
38
+ import json
39
+ import os
40
+ import re
41
+ import subprocess
42
+ import tempfile
43
+ import xml.etree.ElementTree as ET
44
+ from dataclasses import dataclass, field
45
+ from pathlib import Path
46
+
47
+ # Driver kinds this module knows how to run. Keep in sync with the adapters
48
+ # implemented below — a kind can be *declared* in coordinator.yml ahead of its
49
+ # adapter landing, but running it must fail loudly rather than silently no-op.
50
+ SUPPORTED_KINDS = ("tui-tuidriver", "cli-pytest", "web-playwright")
51
+
52
+ # libtest's ``--format json`` per-line test-event stream (`cargo test -- -Z
53
+ # unstable-options --format json`) event -> our normalized status.
54
+ _LIBTEST_EVENT_STATUS = {"ok": "pass", "failed": "fail", "ignored": "skip"}
55
+
56
+ # A junit-xml <failure>/<error> "message" attribute for a plain
57
+ # ``assert got == expected`` AssertionError has ``assert <got> ==
58
+ # <expected>`` on its first line (typically prefixed with the exception
59
+ # class, e.g. ``AssertionError: assert 'a' == 'b'``) — this is the common
60
+ # shape a cli-pytest test comparing actual CLI stdout to a `*.out` mock
61
+ # produces. Anything else (multi-line diffs, non-equality asserts, a raised
62
+ # exception with no ``assert``) is left unparsed rather than guessed at.
63
+ _ASSERT_EQ_RE = re.compile(r"assert\s+(.*?)\s+==\s+(.*)$")
64
+
65
+ # Playwright's JSON reporter bakes ANSI color/style codes (SGR sequences)
66
+ # straight into `error.message` regardless of whether stdout is a tty or
67
+ # `NO_COLOR`/`FORCE_COLOR=0` is set (verified empirically against 1.61 — the
68
+ # assertion diff formatter colors unconditionally) — strip them so a stored
69
+ # verdict message doesn't carry raw escape bytes.
70
+ _ANSI_RE = re.compile(r"\x1b\[[0-9;]*[A-Za-z]")
71
+
72
+ # Playwright JSON reporter's per-test `status` (already reconciled against
73
+ # retries and `expectedStatus` — e.g. a `test.fail()`-annotated test that
74
+ # fails as expected is "expected", not "unexpected") -> our normalized
75
+ # status. "flaky" (failed at least once, then passed within `retries`) is a
76
+ # "pass" for gating purposes but callers should still care it happened — see
77
+ # :func:`parse_playwright_json_report`, which folds that into the message.
78
+ _PLAYWRIGHT_STATUS = {
79
+ "expected": "pass",
80
+ "flaky": "pass",
81
+ "unexpected": "fail",
82
+ "skipped": "skip",
83
+ }
84
+
85
+
86
+ class DriverError(Exception):
87
+ """Raised when a driver can't run its suite or the ``kind`` is unknown."""
88
+
89
+
90
+ @dataclass
91
+ class DriverResult:
92
+ """The outcome of running one driver invocation."""
93
+
94
+ exit_code: int
95
+ tests: list[dict] = field(default_factory=list)
96
+ raw_output: str = ""
97
+
98
+ @property
99
+ def ok(self) -> bool:
100
+ """True when the run command itself exited 0.
101
+
102
+ This is distinct from "all tests passed" — a driver can exit 0 while
103
+ reporting individual test failures (cargo's own exit code already
104
+ reflects failures, but a hand-rolled ``run:`` wrapper might not), so
105
+ callers should judge pass/fail from ``tests`` rather than this alone.
106
+ """
107
+ return self.exit_code == 0
108
+
109
+
110
+ def render_run_command(run_command: str, *, ms: str | None = None) -> str:
111
+ """Substitute the ``{ms}`` template in *run_command* with *ms* (the
112
+ ``ms-NN`` milestone dirname — see :func:`coord.acceptance.ms_dirname`),
113
+ e.g. ``"pytest tests/acceptance/{ms}"`` -> ``"pytest
114
+ tests/acceptance/ms-37"``.
115
+
116
+ Left unsubstituted when *ms* is ``None`` — callers that aren't scoping to
117
+ a milestone (or a driver's ``run:`` that never references ``{ms}`` at
118
+ all, e.g. today's single-driver ``tui-tuidriver`` configs) pass the
119
+ command through unchanged.
120
+ """
121
+ if ms is None:
122
+ return run_command
123
+ return run_command.replace("{ms}", ms)
124
+
125
+
126
+ def run_driver(
127
+ kind: str, run_command: str, cwd: str, *, timeout: int = 900, ms: str | None = None,
128
+ setup_command: str = "",
129
+ ) -> DriverResult:
130
+ """Execute *run_command* in *cwd* and parse its output for *kind*.
131
+
132
+ Raises :class:`DriverError` for an unsupported *kind* or a timeout. A
133
+ non-zero exit from the command is NOT raised — it's folded into the
134
+ returned :class:`DriverResult` so callers can still inspect whatever
135
+ partial JSON the suite printed before dying.
136
+
137
+ *ms*, when given, renders the ``{ms}`` template in *run_command* first
138
+ (see :func:`render_run_command`).
139
+
140
+ *setup_command* (#1733, ``AcceptanceDriverConfig.setup``), when
141
+ non-empty, runs ONCE in *cwd* before *run_command* — the provisioning
142
+ step a driver needs a bare checkout doesn't provide (e.g. ``npm ci`` for
143
+ ``web-playwright`` in ``coord acceptance record``'s throwaway,
144
+ dependency-less worktree). Unlike a non-zero *run_command* exit, a
145
+ failing *setup_command* DOES raise :class:`DriverError` — immediately,
146
+ before *run_command* ever executes — with a message that names it as a
147
+ provisioning failure so it isn't mistaken for a test failure or folded
148
+ into a driver's own "wrote no report" crash message.
149
+ """
150
+ if kind not in SUPPORTED_KINDS:
151
+ raise DriverError(
152
+ f"acceptance driver kind {kind!r} is not implemented yet "
153
+ f"(supported: {', '.join(SUPPORTED_KINDS)}). The native adapter "
154
+ "lands in a later oracle-loop issue — see docs/ORACLE_LOOP.md."
155
+ )
156
+
157
+ if setup_command:
158
+ _run_setup(setup_command, cwd, timeout=timeout)
159
+
160
+ run_command = render_run_command(run_command, ms=ms)
161
+
162
+ if kind == "cli-pytest":
163
+ return _run_cli_pytest(run_command, cwd, timeout=timeout)
164
+ if kind == "web-playwright":
165
+ return _run_web_playwright(run_command, cwd, timeout=timeout)
166
+ return _run_generic(run_command, cwd, timeout=timeout)
167
+
168
+
169
+ def _run_setup(setup_command: str, cwd: str, *, timeout: int) -> None:
170
+ """Run a driver's ``setup:`` provisioning command (#1733) in *cwd*,
171
+ before its suite ever runs.
172
+
173
+ Raises :class:`DriverError` — distinctly worded as a provisioning
174
+ failure, not a test failure — for a non-zero exit, a timeout, or the
175
+ command failing to start at all. Callers must not proceed to
176
+ ``run_command`` when this raises: a driver whose dependencies never
177
+ installed cannot produce a meaningful verdict.
178
+ """
179
+ try:
180
+ proc = subprocess.run(
181
+ setup_command,
182
+ shell=True,
183
+ cwd=cwd,
184
+ capture_output=True,
185
+ text=True,
186
+ timeout=timeout,
187
+ )
188
+ except subprocess.TimeoutExpired as e:
189
+ raise DriverError(
190
+ f"acceptance driver provisioning timed out after {timeout}s: "
191
+ f"{setup_command!r}"
192
+ ) from e
193
+ except OSError as e:
194
+ raise DriverError(
195
+ f"acceptance driver provisioning failed to start: {setup_command!r}: {e}"
196
+ ) from e
197
+
198
+ if proc.returncode != 0:
199
+ stderr_tail = "\n".join((proc.stderr or "").splitlines()[-20:])
200
+ raise DriverError(
201
+ f"acceptance driver provisioning failed (exit {proc.returncode}): "
202
+ f"{setup_command!r}\n{stderr_tail}"
203
+ )
204
+
205
+
206
+ def _run_generic(run_command: str, cwd: str, *, timeout: int) -> DriverResult:
207
+ """The ``tui-tuidriver`` (and any future stdout-native) shape: the
208
+ command itself is responsible for printing structured verdicts to
209
+ stdout — this just runs it and hands the raw stdout to
210
+ :func:`parse_test_output`."""
211
+ try:
212
+ proc = subprocess.run(
213
+ run_command,
214
+ shell=True,
215
+ cwd=cwd,
216
+ capture_output=True,
217
+ text=True,
218
+ timeout=timeout,
219
+ )
220
+ except subprocess.TimeoutExpired as e:
221
+ raise DriverError(
222
+ f"acceptance run command timed out after {timeout}s: {run_command!r}"
223
+ ) from e
224
+ except OSError as e:
225
+ raise DriverError(f"acceptance run command failed to start: {e}") from e
226
+
227
+ tests = parse_test_output(proc.stdout)
228
+ return DriverResult(
229
+ exit_code=proc.returncode,
230
+ tests=tests,
231
+ raw_output=(proc.stdout or "") + (proc.stderr or ""),
232
+ )
233
+
234
+
235
+ def _run_cli_pytest(run_command: str, cwd: str, *, timeout: int) -> DriverResult:
236
+ """The ``cli-pytest`` shape: append pytest's own built-in
237
+ ``--junit-xml=<path>`` (a core pytest flag — no extra plugin required in
238
+ the driven repo) so structured per-test verdicts are always produced
239
+ regardless of what *run_command* itself prints, then parse that XML
240
+ report with :func:`parse_pytest_junit_xml`.
241
+ """
242
+ with tempfile.TemporaryDirectory() as tmp_dir:
243
+ report_path = Path(tmp_dir) / "coord-acceptance-junit.xml"
244
+ full_command = f"{run_command} --junit-xml={report_path}"
245
+ try:
246
+ proc = subprocess.run(
247
+ full_command,
248
+ shell=True,
249
+ cwd=cwd,
250
+ capture_output=True,
251
+ text=True,
252
+ timeout=timeout,
253
+ )
254
+ except subprocess.TimeoutExpired as e:
255
+ raise DriverError(
256
+ f"acceptance run command timed out after {timeout}s: {full_command!r}"
257
+ ) from e
258
+ except OSError as e:
259
+ raise DriverError(f"acceptance run command failed to start: {e}") from e
260
+
261
+ report_text = report_path.read_text() if report_path.exists() else ""
262
+ tests = parse_pytest_junit_xml(report_text)
263
+ return DriverResult(
264
+ exit_code=proc.returncode,
265
+ tests=tests,
266
+ raw_output=(proc.stdout or "") + (proc.stderr or ""),
267
+ )
268
+
269
+
270
+ def _run_web_playwright(run_command: str, cwd: str, *, timeout: int) -> DriverResult:
271
+ """The ``web-playwright`` shape: force Playwright Test's built-in
272
+ ``json`` reporter to a known path via ``--reporter=json`` plus the
273
+ ``PLAYWRIGHT_JSON_OUTPUT_FILE`` env var it honors (the json-reporter
274
+ twin of the documented ``PLAYWRIGHT_JUNIT_OUTPUT_NAME``) — so a
275
+ structured report is always produced at a path we control regardless of
276
+ what reporters the driven repo's own ``playwright.config.ts`` declares,
277
+ the same trick :func:`_run_cli_pytest` plays with ``--junit-xml``.
278
+
279
+ Unlike ``_run_cli_pytest`` (which treats "no report file" as a benign
280
+ zero-tests result — see its own crash test), a missing or corrupt
281
+ report here always raises :class:`DriverError`. Per #1539: "a crashed
282
+ run ... must surface as a DriverError or an explicit failure — never as
283
+ an empty pass list." Playwright can die before the json reporter ever
284
+ flushes (a bad config file, a browser that never launches, `--grep`
285
+ matching nothing without `--pass-with-no-tests`) and an empty list from
286
+ that is indistinguishable from "the suite legitimately has zero tests
287
+ right now" — exactly the silent-green failure mode this driver must not
288
+ produce.
289
+ """
290
+ with tempfile.TemporaryDirectory() as tmp_dir:
291
+ report_path = Path(tmp_dir) / "coord-acceptance-playwright.json"
292
+ full_command = f"{run_command} --reporter=json"
293
+ env = {**os.environ, "PLAYWRIGHT_JSON_OUTPUT_FILE": str(report_path)}
294
+ try:
295
+ proc = subprocess.run(
296
+ full_command,
297
+ shell=True,
298
+ cwd=cwd,
299
+ capture_output=True,
300
+ text=True,
301
+ timeout=timeout,
302
+ env=env,
303
+ )
304
+ except subprocess.TimeoutExpired as e:
305
+ raise DriverError(
306
+ f"acceptance run command timed out after {timeout}s: {full_command!r}"
307
+ ) from e
308
+ except OSError as e:
309
+ raise DriverError(f"acceptance run command failed to start: {e}") from e
310
+
311
+ if not report_path.exists():
312
+ stderr_tail = "\n".join((proc.stderr or "").splitlines()[-20:])
313
+ raise DriverError(
314
+ f"web-playwright run wrote no report (exit {proc.returncode}): "
315
+ f"{full_command!r}\n{stderr_tail}"
316
+ )
317
+ tests = parse_playwright_json_report(report_path.read_text())
318
+ return DriverResult(
319
+ exit_code=proc.returncode,
320
+ tests=tests,
321
+ raw_output=(proc.stdout or "") + (proc.stderr or ""),
322
+ )
323
+
324
+
325
+ def parse_test_output(output: str) -> list[dict]:
326
+ """Parse a driver's stdout into normalized ``{"id", "status", "message"}``.
327
+
328
+ Two shapes are recognized:
329
+
330
+ 1. A single JSON blob whose whole stdout is one object of the form
331
+ ``{"tests": [{"id": ..., "status": "pass"|"fail"|"skip", "message":
332
+ ...}, ...]}`` — the direct contract for a driver that already speaks
333
+ it natively.
334
+ 2. libtest's JSON-lines test-event stream (``cargo test -- -Z
335
+ unstable-options --format json``): one JSON object per line, only
336
+ ``{"type": "test", "event": "ok"|"failed"|"ignored", "name": ...}``
337
+ lines carry a verdict. Non-JSON lines (cargo build progress,
338
+ warnings) and ``"type": "suite"``/``"type": "bench"`` lines are
339
+ skipped.
340
+
341
+ Unparsable input returns an empty list rather than raising — a failed
342
+ parse is surfaced by the caller as "0 tests found", not a crash.
343
+ """
344
+ stripped = (output or "").strip()
345
+ if stripped.startswith("{"):
346
+ blob = _try_json(stripped)
347
+ if isinstance(blob, dict) and isinstance(blob.get("tests"), list):
348
+ tests: list[dict] = []
349
+ for t in blob["tests"]:
350
+ if not isinstance(t, dict) or "id" not in t or "status" not in t:
351
+ continue
352
+ tests.append({
353
+ "id": str(t["id"]),
354
+ "status": str(t["status"]),
355
+ "message": str(t.get("message", "")),
356
+ })
357
+ return tests
358
+
359
+ tests = []
360
+ for line in (output or "").splitlines():
361
+ line = line.strip()
362
+ if not line or not line.startswith("{"):
363
+ continue
364
+ obj = _try_json(line)
365
+ if not isinstance(obj, dict) or obj.get("type") != "test":
366
+ continue
367
+ event = obj.get("event")
368
+ name = obj.get("name")
369
+ if not name or event not in _LIBTEST_EVENT_STATUS:
370
+ continue
371
+ entry = {"id": str(name), "status": _LIBTEST_EVENT_STATUS[event], "message": ""}
372
+ stdout_msg = obj.get("stdout")
373
+ if stdout_msg:
374
+ entry["message"] = str(stdout_msg)
375
+ tests.append(entry)
376
+ return tests
377
+
378
+
379
+ def parse_pytest_junit_xml(xml_text: str) -> list[dict]:
380
+ """Parse pytest's built-in ``--junit-xml=<path>`` report (a core pytest
381
+ flag — no extra plugin required in the driven repo, unlike
382
+ ``pytest-json-report``/``pytest-reportlog``) into normalized ``{"id",
383
+ "status", "message", "expected", "got"}`` dicts — the same ``id``/
384
+ ``status`` shape :func:`parse_test_output` returns for
385
+ ``tui-tuidriver``, so :func:`coord.acceptance.build_verdict` /
386
+ ``_scoped_verdict`` / :func:`coord.acceptance.load_manifest` work
387
+ unchanged regardless of which driver kind produced the verdicts.
388
+
389
+ Each ``<testcase classname="..." name="...">`` becomes one entry with
390
+ ``id = "{classname}::{name}"``. A ``<failure>`` or ``<error>`` child
391
+ means ``"fail"``; a ``<skipped>`` child means ``"skip"``; otherwise
392
+ ``"pass"``. ``"expected"``/``"got"`` are populated only for a failing
393
+ test, and only when the failure's ``message`` attribute's first line is
394
+ pytest's own plain ``assert <got> == <expected>`` rendering (the shape a
395
+ cli-pytest test comparing actual CLI stdout to a ``*.out`` mock
396
+ produces) — anything else (a raised exception, a multi-line diff with no
397
+ single ``==``) leaves them empty rather than guessing.
398
+
399
+ Unparsable / empty input returns an empty list rather than raising —
400
+ mirrors :func:`parse_test_output`'s "0 tests found, not a crash"
401
+ contract.
402
+ """
403
+ text = (xml_text or "").strip()
404
+ if not text:
405
+ return []
406
+ try:
407
+ root = ET.fromstring(text)
408
+ except ET.ParseError:
409
+ return []
410
+
411
+ tests = []
412
+ for testcase in root.iter("testcase"):
413
+ classname = testcase.get("classname", "")
414
+ name = testcase.get("name", "")
415
+ if not name:
416
+ continue
417
+ nodeid = f"{classname}::{name}" if classname else name
418
+
419
+ failure = testcase.find("failure")
420
+ if failure is None:
421
+ failure = testcase.find("error")
422
+ skipped = testcase.find("skipped")
423
+
424
+ entry = {
425
+ "id": nodeid, "status": "pass", "message": "",
426
+ "expected": "", "got": "",
427
+ }
428
+ if failure is not None:
429
+ entry["status"] = "fail"
430
+ message = failure.get("message", "") or (failure.text or "")
431
+ entry["message"] = message
432
+ first_line = message.splitlines()[0] if message else ""
433
+ m = _ASSERT_EQ_RE.search(first_line)
434
+ if m:
435
+ entry["got"] = m.group(1).strip()
436
+ entry["expected"] = m.group(2).strip()
437
+ elif skipped is not None:
438
+ entry["status"] = "skip"
439
+ entry["message"] = skipped.get("message", "") or (skipped.text or "")
440
+ tests.append(entry)
441
+ return tests
442
+
443
+
444
+ def parse_playwright_json_report(json_text: str) -> list[dict]:
445
+ """Parse Playwright Test's built-in ``--reporter=json`` report (a core
446
+ reporter, not a plugin — no extra npm dependency required in the driven
447
+ repo) into normalized ``{"id", "status", "message"}`` dicts.
448
+
449
+ **Why json, not junit** (Playwright ships both as built-ins): junit
450
+ collapses every retry attempt of one test into a single ``<testcase>``
451
+ with no per-attempt status — a test that failed once then passed on
452
+ retry renders as a plain, silent pass, identical to a test that never
453
+ failed at all. That's a real gap verified against Playwright 1.61
454
+ output, not a hypothetical: see the recorded
455
+ ``tests/fixtures/playwright/retry_then_pass.json`` fixture and its junit
456
+ sibling this module's tests compare it against. Since #1539 explicitly
457
+ calls out retries as signal ("flake is signal for this program"), junit
458
+ can't carry the contract this driver needs. json's per-test ``results``
459
+ array keeps one entry per attempt (with its own ``status`` and
460
+ ``errors``), and its per-test ``projectName`` disambiguates the same
461
+ test title run under multiple ``projects:`` entries — junit only
462
+ distinguishes projects via a ``<testsuite hostname="...">`` attribute
463
+ shared by every testcase in that project, not the id.
464
+
465
+ **id shape**: ``"[{project}] {file} › {describe path} › {test title}"``
466
+ — stable across reruns (no timestamps/durations), unique across
467
+ multiple ``projects:`` in one config (see above), and matches the
468
+ ``[project] › file:line › describe › title`` shape Playwright's own
469
+ ``list`` reporter prints, so it's recognizable when cross-referencing a
470
+ human's terminal output.
471
+
472
+ **status**: taken from Playwright's own reconciled per-test ``status``
473
+ (``"expected"``/``"flaky"``/``"unexpected"``/``"skipped"``) rather than
474
+ re-deriving it from the raw per-attempt results — that field already
475
+ accounts for retries *and* ``test.fail()``-style "expected to fail"
476
+ annotations, so re-implementing it here would just be a worse copy.
477
+ ``"expected"``/``"flaky"`` -> ``"pass"`` (flaky is still a pass for
478
+ gating, but the message says so — see below), ``"unexpected"`` ->
479
+ ``"fail"``, ``"skipped"`` (covers both ``test.skip()`` and
480
+ ``test.fixme()``) -> ``"skip"``.
481
+
482
+ **message**: the last attempt's error text for a ``"fail"``; for a
483
+ ``"flaky"`` pass, a summary noting the flake plus the first failed
484
+ attempt's error (the signal #1539 asks this driver to preserve); the
485
+ annotation ``description`` (the ``fixme`` reason, when given) for a
486
+ ``"skip"``; empty for a clean pass. Error text has Playwright's
487
+ baked-in ANSI color codes stripped (see :data:`_ANSI_RE` — verified
488
+ these survive even with ``NO_COLOR``/piped-non-tty stdout, so stripping
489
+ is mandatory, not a courtesy).
490
+
491
+ **Never silently empty on a crash** — unlike this module's other parse_*
492
+ functions, which return ``[]`` on unparsable input to keep "0 tests
493
+ found" from ever raising. #1539 requires the opposite here: "a crashed
494
+ run ... must surface as a DriverError ... never as an empty pass list."
495
+ So this raises :class:`DriverError` for: empty/whitespace-only input (a
496
+ truncated-to-nothing or never-written report); invalid JSON (a report
497
+ cut off mid-write, e.g. the process was killed before flushing —
498
+ ``tests/fixtures/playwright/truncated.json`` is a real report truncated
499
+ this way); a JSON body missing the ``"suites"`` list (wrong shape
500
+ entirely); and zero tests parsed *while Playwright's own top-level
501
+ ``"errors"`` is non-empty* — verified empirically to be exactly how a
502
+ thrown ``globalSetup`` hook or a ``--grep`` matching nothing report
503
+ (``tests/fixtures/playwright/global_setup_crash.json``): a
504
+ well-formed, zero-test report that must not be mistaken for "the suite
505
+ is just empty right now". Zero tests with an empty top-level
506
+ ``"errors"`` (Playwright's own ``--pass-with-no-tests`` opt-in) is left
507
+ as a plain ``[]`` — callers (:func:`coord.acceptance.build_verdict`)
508
+ already treat a zero-test list as not-green rather than a false "all
509
+ green", which is the actual guarantee #1539 is protecting.
510
+ """
511
+ text = (json_text or "").strip()
512
+ if not text:
513
+ raise DriverError(
514
+ "web-playwright report is empty — the run crashed before "
515
+ "writing a report"
516
+ )
517
+ try:
518
+ report = json.loads(text)
519
+ except json.JSONDecodeError as e:
520
+ raise DriverError(
521
+ f"web-playwright report is not valid JSON (truncated or "
522
+ f"corrupted run?): {e}"
523
+ ) from e
524
+ if not isinstance(report, dict) or not isinstance(report.get("suites"), list):
525
+ raise DriverError(
526
+ "web-playwright report has an unrecognized shape (missing a "
527
+ "'suites' list) — this reporter version may be incompatible"
528
+ )
529
+
530
+ tests: list[dict] = []
531
+ for suite in report["suites"]:
532
+ if isinstance(suite, dict):
533
+ tests.extend(_playwright_specs(suite, []))
534
+
535
+ if not tests and report.get("errors"):
536
+ first = report["errors"][0] if isinstance(report["errors"], list) else report["errors"]
537
+ detail = first.get("message", "") if isinstance(first, dict) else str(first)
538
+ raise DriverError(
539
+ f"web-playwright run produced zero tests and reported a "
540
+ f"top-level error (bad config, browser launch failure, or a "
541
+ f"run: command matching no tests): {_strip_ansi(detail)}"
542
+ )
543
+ return tests
544
+
545
+
546
+ def _playwright_specs(suite: dict, ancestors: list[str]) -> list[dict]:
547
+ """Recursively walk one Playwright json-report suite tree, returning one
548
+ normalized dict per ``(spec, project)`` pair.
549
+
550
+ A suite nests: the outermost suite per spec file (``title == file``,
551
+ skipped from the id's describe-path since it's redundant with the
552
+ ``file`` already in the id), then one nested suite per ``describe()``
553
+ block, down to leaf ``specs`` (one per ``test()``/``it()``).
554
+ """
555
+ title = suite.get("title", "")
556
+ is_file_suite = bool(suite.get("file")) and title == suite.get("file")
557
+ path = ancestors if is_file_suite else [*ancestors, title]
558
+
559
+ tests: list[dict] = []
560
+ for spec in suite.get("specs") or []:
561
+ if isinstance(spec, dict):
562
+ tests.extend(_playwright_spec_entries(spec, path))
563
+ for sub in suite.get("suites") or []:
564
+ if isinstance(sub, dict):
565
+ tests.extend(_playwright_specs(sub, path))
566
+ return tests
567
+
568
+
569
+ def _playwright_spec_entries(spec: dict, ancestors: list[str]) -> list[dict]:
570
+ """One normalized entry per project a leaf ``spec`` (a single
571
+ ``test()``) ran under."""
572
+ spec_title = spec.get("title", "")
573
+ title_path = " › ".join([*ancestors, spec_title]) if spec_title else " › ".join(ancestors)
574
+ file = spec.get("file", "")
575
+
576
+ entries = []
577
+ for t in spec.get("tests") or []:
578
+ if not isinstance(t, dict):
579
+ continue
580
+ project = t.get("projectName", "")
581
+ nodeid = f"[{project}] {file} › {title_path}" if project else f"{file} › {title_path}"
582
+ raw_status = t.get("status")
583
+ status = _PLAYWRIGHT_STATUS.get(raw_status, "fail")
584
+ results = t.get("results") or []
585
+
586
+ message = ""
587
+ if status == "fail":
588
+ message = _playwright_error_text(results[-1]) if results else ""
589
+ elif raw_status == "flaky":
590
+ failed = [r for r in results if r.get("status") not in ("passed", "skipped")]
591
+ first_failure = _playwright_error_text(failed[0]) if failed else ""
592
+ message = f"flaky: passed after {len(failed)} failed attempt(s)"
593
+ if first_failure:
594
+ message += f" — first failure: {first_failure}"
595
+ elif status == "skip":
596
+ message = _playwright_skip_reason(t.get("annotations") or [])
597
+
598
+ entries.append({"id": nodeid, "status": status, "message": message})
599
+ return entries
600
+
601
+
602
+ def _playwright_error_text(result: dict) -> str:
603
+ """The (ANSI-stripped) error message(s) of one ``results[]`` attempt."""
604
+ if not isinstance(result, dict):
605
+ return ""
606
+ errors = result.get("errors") or []
607
+ parts = [str(e.get("message", "")) for e in errors if isinstance(e, dict) and e.get("message")]
608
+ return _strip_ansi("\n".join(parts))
609
+
610
+
611
+ def _playwright_skip_reason(annotations: list) -> str:
612
+ """The ``fixme``/``skip`` annotation's ``description`` (the reason
613
+ string a caller passed, e.g. ``test.fixme(true, "blocked on #1541")``),
614
+ or ``""`` when a bare ``test.skip()``/``.skip(true)`` gave no reason —
615
+ mirrors :func:`parse_pytest_junit_xml`'s "empty message when no reason
616
+ given" convention.
617
+ """
618
+ for a in annotations:
619
+ if isinstance(a, dict) and a.get("type") in ("skip", "fixme") and a.get("description"):
620
+ return str(a["description"])
621
+ return ""
622
+
623
+
624
+ def _strip_ansi(text: str) -> str:
625
+ return _ANSI_RE.sub("", text or "")
626
+
627
+
628
+ def _try_json(text: str):
629
+ try:
630
+ return json.loads(text)
631
+ except (json.JSONDecodeError, TypeError):
632
+ return None