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,113 @@
1
+ /**
2
+ * Live update against a REAL `coord web --fixture` process (#1551, M-W1's
3
+ * exit gate).
4
+ *
5
+ * `realtime.spec.ts` proves the app reacts correctly to the SSE wire shape
6
+ * `coord/events.py` / `coord/dashboard/fixture.py` claim to emit, using a
7
+ * hand-rolled fake `EventSource` -- fast and deterministic, but it can never
8
+ * catch the wire shape itself drifting (a rename, a missing `event:` line, a
9
+ * JSON encoding bug) because nothing on the other end is real. This file is
10
+ * the one spec in the repo that boots the actual `coord web --fixture`
11
+ * server (`fixtureServer.ts`) and drives a real browser `EventSource`
12
+ * against it end to end:
13
+ *
14
+ * 1. a genuine `/events` connection reaches "Live" -- proves the real wire
15
+ * format parses;
16
+ * 2. `POST /api/fixture/events/replay` (the fixture's scripted-event
17
+ * trigger, `coord/dashboard/fixture.py`'s `ScriptedEvent`) causes a
18
+ * real, unprompted `GET /api/pipeline` refetch -- "no manual refresh";
19
+ * 3. killing the server process (not a route interception -- the actual
20
+ * subprocess) makes the badge honestly report the stream is gone.
21
+ *
22
+ * What this does NOT (and structurally cannot) prove: that the refetched
23
+ * data itself changes. `FixtureServer.board()` rebuilds the same static
24
+ * `board_payload` on every call by design (#1538's whole point is a board
25
+ * that "does not move under" an assertion) -- a scripted event announces a
26
+ * change, it does not enact one. So step 2 asserts the real live-update
27
+ * *mechanism* (event -> invalidation -> refetch, with no test-driven
28
+ * reload/click), not a visible content diff; `realtime.spec.ts` already
29
+ * covers "the badge/card text actually changes" against a fake transport
30
+ * that CAN mutate its own seed mid-test.
31
+ *
32
+ * Slower than the rest of this suite -- a production build plus a real
33
+ * subprocess boot, on top of Playwright's own browser launch. Recorded here
34
+ * rather than hidden: this file alone typically adds several seconds to
35
+ * `npm run test:e2e`. If that ever becomes disruptive, split it into its own
36
+ * `npm run test:e2e:live` script rather than skipping the coverage.
37
+ *
38
+ * Run: npm run test:e2e (requires `coord` on $PATH, see fixtureServer.ts)
39
+ */
40
+ import { test, expect } from '@playwright/test'
41
+ import { startFixtureServer, type FixtureServerHandle } from './fixtureServer'
42
+
43
+ test.describe.configure({ mode: 'serial' })
44
+
45
+ test.describe('live update against a real coord web --fixture process (#1551)', () => {
46
+ let server: FixtureServerHandle
47
+
48
+ test.beforeAll(async () => {
49
+ server = await startFixtureServer()
50
+ })
51
+
52
+ test.afterAll(async () => {
53
+ await server?.stop()
54
+ })
55
+
56
+ test('a real SSE connection reaches Live, and a scripted event triggers an unprompted refetch', async ({
57
+ page,
58
+ }) => {
59
+ const pipelineRequests: number[] = []
60
+ page.on('request', (req) => {
61
+ if (new URL(req.url()).pathname === '/api/pipeline') pipelineRequests.push(Date.now())
62
+ })
63
+
64
+ await page.goto(server.baseUrl)
65
+
66
+ // Seeded fixture: one running item (#4101), among others -- proves the
67
+ // real dist bundle booted against the real fixture data. `#4101` shows up
68
+ // twice (the Live sessions card and the pipeline card both surface the
69
+ // same seeded assignment) -- `.first()` only cares that at least one is
70
+ // there, not which.
71
+ await expect(page.getByText('#4101').first()).toBeVisible()
72
+
73
+ const badge = page.getByRole('status', { name: /Connection:/ })
74
+ await expect(badge).toHaveText('Live', { timeout: 10_000 })
75
+
76
+ const requestsBeforeReplay = pipelineRequests.length
77
+ expect(requestsBeforeReplay).toBeGreaterThan(0)
78
+
79
+ // The fixture's scripted sequence (tests/fixtures/board-pipeline-basic.json
80
+ // `events`) -- played over the REAL /events stream, not injected into the
81
+ // page. Nothing here reloads the page or clicks a refresh control.
82
+ const replay = await page.request.post(`${server.baseUrl}/api/fixture/events/replay`)
83
+ expect(replay.ok()).toBe(true)
84
+
85
+ await expect
86
+ .poll(() => pipelineRequests.length, { timeout: 5_000 })
87
+ .toBeGreaterThan(requestsBeforeReplay)
88
+ })
89
+
90
+ test('killing the server surfaces the disconnected state', async ({ page }) => {
91
+ await page.goto(server.baseUrl)
92
+
93
+ const badge = page.getByRole('status', { name: /Connection:/ })
94
+ await expect(badge).toHaveText('Live', { timeout: 10_000 })
95
+
96
+ // A real process death, not a route interception -- the honest-disconnect
97
+ // criterion this test exists to prove is that the browser's own
98
+ // `EventSource` onerror fires against a genuinely severed connection.
99
+ server.kill()
100
+
101
+ await expect(badge).toContainText('Stale since', { timeout: 10_000 })
102
+
103
+ // Deliberately not also asserting "Reconnecting…" here: against a truly
104
+ // dead port every retry fails via an essentially instant connection
105
+ // refusal, so `connection.ts`'s 'reconnecting' state is a single-tick
106
+ // flash between backoff timers rather than something Playwright's poll
107
+ // can reliably sample -- flaky by construction, not by mistake. The
108
+ // disconnected -> reconnecting -> live transition is already pinned
109
+ // deterministically by `realtime.spec.ts`'s fake-EventSource test, where
110
+ // the test (not a dead socket) controls exactly when each state change
111
+ // happens.
112
+ })
113
+ })
@@ -0,0 +1,238 @@
1
+ /**
2
+ * E2E tests for the SSE-driven live data layer (#1549).
3
+ *
4
+ * There's no Playwright `routeEventSource()` (unlike `routeWebSocket()`,
5
+ * which `terminal.spec.ts` uses for the PTY bridge) and `page.route()` can
6
+ * only fulfil a static, connection-closing body -- not a genuinely long-lived
7
+ * push stream. So this suite injects a controllable fake `EventSource` as
8
+ * `window.EventSource` via `page.addInitScript()`, the same technique
9
+ * `terminal.spec.ts` uses for its fake WebSocket, just hand-rolled since
10
+ * Playwright has no built-in for this transport. Every assertion here is
11
+ * driven by explicit `emitOpen`/`emitError`/`emitMessage` calls standing in
12
+ * for a real `coord web --fixture <board.json>` scripted SSE sequence
13
+ * (`coord/dashboard/fixture.py`'s `ScriptedEvent` -- `{type, data, after}`
14
+ * played over `/events`); the wire shape (`event: <type>` + JSON `data:`)
15
+ * is identical, only the transport is faked, exactly like every other spec
16
+ * in this suite fakes its own backend per playwright.config.ts's contract.
17
+ *
18
+ * Run: npm run test:e2e
19
+ */
20
+
21
+ import { test, expect, type Page } from '@playwright/test'
22
+
23
+ // ── Fake EventSource injection ──────────────────────────────────────────────
24
+
25
+ /**
26
+ * Installs `window.EventSource` before any app script runs, and a
27
+ * `window.__sse` control surface the test drives from Node via
28
+ * `page.evaluate()`. Must be called before `page.goto()`.
29
+ */
30
+ async function installFakeEventSource(page: Page): Promise<void> {
31
+ await page.addInitScript(() => {
32
+ class FakeEventSource {
33
+ url: string
34
+ closed = false
35
+ onopen: ((ev: unknown) => void) | null = null
36
+ onerror: ((ev: unknown) => void) | null = null
37
+ private listeners: Record<string, Array<(ev: { data: string }) => void>> = {}
38
+
39
+ constructor(url: string) {
40
+ this.url = url
41
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
42
+ ;(window as any).__sse.instances.push(this)
43
+ }
44
+
45
+ addEventListener(type: string, cb: (ev: { data: string }) => void): void {
46
+ ;(this.listeners[type] ??= []).push(cb)
47
+ }
48
+
49
+ close(): void {
50
+ this.closed = true
51
+ }
52
+
53
+ emitOpen(): void {
54
+ this.onopen?.({})
55
+ }
56
+
57
+ emitError(): void {
58
+ this.onerror?.({})
59
+ }
60
+
61
+ emitMessage(type: string, data: unknown): void {
62
+ for (const cb of this.listeners[type] ?? []) cb({ data: JSON.stringify(data) })
63
+ }
64
+ }
65
+
66
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
67
+ ;(window as any).__sse = { instances: [] as FakeEventSource[] }
68
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
69
+ ;(window as any).EventSource = FakeEventSource
70
+ })
71
+ }
72
+
73
+ /** Number of currently-open (non-superseded) fake EventSource instances. */
74
+ function openCount(page: Page): Promise<number> {
75
+ return page.evaluate(() => {
76
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
77
+ const sse = (window as any).__sse
78
+ return sse.instances.filter((i: { closed: boolean }) => !i.closed).length
79
+ })
80
+ }
81
+
82
+ /** Runs `fn` (by name) against the current open instance -- 'open' | 'error' | ['message', type, data]. */
83
+ async function emit(
84
+ page: Page,
85
+ action: 'open' | 'error' | { message: [string, unknown] },
86
+ ): Promise<void> {
87
+ await page.waitForFunction(() => {
88
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
89
+ const sse = (window as any).__sse
90
+ return sse.instances.filter((i: { closed: boolean }) => !i.closed).length === 1
91
+ })
92
+ await page.evaluate((act) => {
93
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
94
+ const sse = (window as any).__sse
95
+ const open = sse.instances.filter((i: { closed: boolean }) => !i.closed)
96
+ const inst = open[open.length - 1]
97
+ if (act === 'open') inst.emitOpen()
98
+ else if (act === 'error') inst.emitError()
99
+ else inst.emitMessage(act.message[0], act.message[1])
100
+ }, action)
101
+ }
102
+
103
+ // ── Seeded pipeline data (mutable across the fetch lifetime) ────────────────
104
+
105
+ function makeItem(currentStage: string) {
106
+ return {
107
+ assignment_id: 'work-1',
108
+ issue_number: 42,
109
+ issue_title: 'Fix the dashboard rendering',
110
+ repo_name: 'api',
111
+ machine_name: 'laptop',
112
+ current_stage: currentStage,
113
+ stages: [
114
+ { name: 'coding', status: currentStage === 'coding' ? 'active' : 'completed', is_current: currentStage === 'coding' },
115
+ { name: 'review', status: 'waiting', is_current: false },
116
+ { name: 'smoke', status: 'waiting', is_current: false },
117
+ { name: 'merge', status: 'waiting', is_current: false },
118
+ ],
119
+ available_gates: [],
120
+ progress_pct: 20,
121
+ review_findings_pending: false,
122
+ review_verdict: null,
123
+ review_verdict_original: null,
124
+ review_verdict_override_reason: null,
125
+ review_findings_body: null,
126
+ test_verdict: null,
127
+ needs_attention: false,
128
+ needs_attention_reason: null,
129
+ needs_attention_detail: null,
130
+ finished_at: null,
131
+ }
132
+ }
133
+
134
+ /** Serves /api/pipeline from a mutable box so a test can change it mid-run. */
135
+ async function mockPipeline(page: Page, box: { stage: string }): Promise<void> {
136
+ await page.route('**/api/pipeline', (route) =>
137
+ route.fulfill({
138
+ status: 200,
139
+ contentType: 'application/json',
140
+ body: JSON.stringify([makeItem(box.stage)]),
141
+ }),
142
+ )
143
+ await page.route('**/api/sessions', (route) =>
144
+ route.fulfill({ status: 200, contentType: 'application/json', body: '[]' }),
145
+ )
146
+ await page.route('**/api/board', (route) =>
147
+ route.fulfill({
148
+ status: 200,
149
+ contentType: 'application/json',
150
+ body: JSON.stringify({ round_number: 1, active: [], completed: [] }),
151
+ }),
152
+ )
153
+ }
154
+
155
+ // ── Tests ────────────────────────────────────────────────────────────────────
156
+
157
+ test.describe('SSE live data layer (#1549)', () => {
158
+ /**
159
+ * The board-change acceptance criterion: a scripted SSE event changes what
160
+ * the *next* /api/pipeline fetch would return, and the card updates with
161
+ * no manual refresh action from the test -- only the invalidation the
162
+ * event triggers.
163
+ */
164
+ test('a board change (assignment_failed) is reflected on Home with no manual refresh', async ({
165
+ page,
166
+ }) => {
167
+ const pipelineState = { stage: 'coding' }
168
+ await installFakeEventSource(page)
169
+ await mockPipeline(page, pipelineState)
170
+
171
+ await page.goto('/')
172
+
173
+ // Initial state: running.
174
+ await expect(page.getByText('running', { exact: true })).toBeVisible()
175
+
176
+ await emit(page, 'open')
177
+
178
+ // The server-side state changes (as if a worker just failed) and the
179
+ // scripted event announces it -- exactly the shape
180
+ // coord/events.py publish_assignment_failed / fixture.py ScriptedEvent
181
+ // put on the wire.
182
+ pipelineState.stage = 'failed'
183
+ await emit(page, { message: ['assignment_failed', { assignment_id: 'work-1' }] })
184
+
185
+ // No reload, no click -- the badge flips from the SSE-triggered refetch alone.
186
+ await expect(page.getByText('failed', { exact: true })).toBeVisible({ timeout: 5_000 })
187
+ })
188
+
189
+ /**
190
+ * The honest connection-state criterion: killing and restoring the stream
191
+ * shows disconnected -> reconnecting -> live, via the header's
192
+ * ConnectionBadge (role="status").
193
+ */
194
+ test('killing and restoring the stream shows disconnected -> reconnecting -> live', async ({
195
+ page,
196
+ }) => {
197
+ await installFakeEventSource(page)
198
+ await mockPipeline(page, { stage: 'coding' })
199
+
200
+ await page.goto('/')
201
+
202
+ const badge = page.getByRole('status', { name: /Connection:/ })
203
+ await expect(badge).toBeVisible()
204
+
205
+ await emit(page, 'open')
206
+ await expect(badge).toHaveText('Live', { timeout: 5_000 })
207
+
208
+ // Kill it.
209
+ await emit(page, 'error')
210
+ await expect(badge).toContainText('Stale since', { timeout: 5_000 })
211
+
212
+ // Backoff fires (default schedule's first delay, ~1s +/- jitter) and a
213
+ // fresh attempt starts.
214
+ await expect(badge).toHaveText('Reconnecting…', { timeout: 5_000 })
215
+
216
+ // Restore it.
217
+ await emit(page, 'open')
218
+ await expect(badge).toHaveText('Live', { timeout: 5_000 })
219
+ })
220
+
221
+ /**
222
+ * Guards the StrictMode double-mount trap this module's doc comments
223
+ * describe (connection.ts, RealtimeProvider.tsx): the dev server runs
224
+ * React 18 StrictMode, so mounting Home for real must settle to exactly
225
+ * one open stream, not leak a socket per re-render.
226
+ */
227
+ test('settles to exactly one open stream under StrictMode dev double-mount', async ({
228
+ page,
229
+ }) => {
230
+ await installFakeEventSource(page)
231
+ await mockPipeline(page, { stage: 'coding' })
232
+
233
+ await page.goto('/')
234
+ await expect(page.getByText('running', { exact: true })).toBeVisible()
235
+
236
+ expect(await openCount(page)).toBe(1)
237
+ })
238
+ })
@@ -0,0 +1,309 @@
1
+ /**
2
+ * E2E coverage for the responsive shell (#1547) — "acceptance coverage at both
3
+ * breakpoints (Playwright viewport sizes)".
4
+ *
5
+ * `smoke.spec.ts` is pinned to a phone and guards the narrow app's existing
6
+ * flows; this file drives the layouts that only exist in a real browser with
7
+ * real layout: the three-column wide composition, the mid-width icon rail +
8
+ * detail overlay, and the persistence of panel geometry across a reload.
9
+ *
10
+ * Everything is asserted through `[data-region="…"]`-scoped locators rather
11
+ * than page-wide text, because the point of the wide layout is that the same
12
+ * issue title is legitimately on screen twice (once as a list row, once as the
13
+ * detail heading) — a page-wide `getByText` would be a strict-mode violation
14
+ * *by design*.
15
+ *
16
+ * Run: npm run test:e2e
17
+ */
18
+
19
+ import { test, expect, type Page } from '@playwright/test'
20
+
21
+ const PHONE = { width: 390, height: 844 }
22
+ const TABLET = { width: 900, height: 900 }
23
+ const DESKTOP = { width: 1440, height: 900 }
24
+
25
+ const SEEDED_PIPELINE = [
26
+ {
27
+ assignment_id: 'work-1',
28
+ issue_number: 42,
29
+ issue_title: 'Fix the dashboard rendering',
30
+ repo_name: 'api',
31
+ machine_name: 'laptop',
32
+ current_stage: 'coding',
33
+ stages: [
34
+ { name: 'coding', status: 'active', is_current: true },
35
+ { name: 'review', status: 'waiting', is_current: false },
36
+ { name: 'merge', status: 'waiting', is_current: false },
37
+ ],
38
+ available_gates: [],
39
+ progress_pct: 20,
40
+ review_findings_pending: false,
41
+ review_verdict: null,
42
+ review_findings_body: null,
43
+ test_verdict: null,
44
+ },
45
+ {
46
+ assignment_id: 'work-2',
47
+ issue_number: 99,
48
+ issue_title: 'Refactor merge queue',
49
+ repo_name: 'api',
50
+ machine_name: 'server',
51
+ current_stage: 'review_running',
52
+ stages: [
53
+ { name: 'coding', status: 'completed', is_current: false },
54
+ { name: 'review', status: 'active', is_current: true },
55
+ { name: 'merge', status: 'waiting', is_current: false },
56
+ ],
57
+ available_gates: [],
58
+ progress_pct: 60,
59
+ review_findings_pending: false,
60
+ review_verdict: null,
61
+ review_findings_body: null,
62
+ test_verdict: null,
63
+ },
64
+ ]
65
+
66
+ // Deliberately a *different* issue title from anything in SEEDED_PIPELINE:
67
+ // Home surfaces live sessions above the pipeline cards, so a shared title
68
+ // would resolve twice inside the list region and every row locator below
69
+ // would trip strict mode for reasons that have nothing to do with the shell.
70
+ const SEEDED_SESSIONS = [
71
+ {
72
+ session_id: 'sess-1',
73
+ session_name: 'coord-sess-1',
74
+ machine: 'dellserver',
75
+ host: 'dellserver.local',
76
+ repo: 'api',
77
+ issue: 7,
78
+ issue_title: 'Live session takeover',
79
+ stage: 'work',
80
+ status: 'running',
81
+ attached: false,
82
+ pane_dead: false,
83
+ },
84
+ ]
85
+
86
+ async function mockApi(page: Page): Promise<void> {
87
+ await page.route('**/api/pipeline', (route) =>
88
+ route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(SEEDED_PIPELINE) }),
89
+ )
90
+ await page.route('**/api/sessions', (route) =>
91
+ route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(SEEDED_SESSIONS) }),
92
+ )
93
+ await page.route('**/api/board', (route) =>
94
+ route.fulfill({
95
+ status: 200,
96
+ contentType: 'application/json',
97
+ body: JSON.stringify({ round_number: 1, active: [], completed: [] }),
98
+ }),
99
+ )
100
+ await page.route('**/api/diff/**', (route) =>
101
+ route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ diff: '', source: 'compare' }) }),
102
+ )
103
+ }
104
+
105
+ const shell = (page: Page) => page.locator('[data-shell-mode]')
106
+ const rail = (page: Page) => page.locator('[data-region="rail"]')
107
+ const list = (page: Page) => page.locator('[data-region="list"]')
108
+ const detail = (page: Page) => page.locator('[data-region="detail"]')
109
+
110
+ // ── wide ──────────────────────────────────────────────────────────────────────
111
+
112
+ test.describe('shell — wide viewport', () => {
113
+ test.use({ viewport: DESKTOP })
114
+
115
+ test('renders rail, list and detail side by side', async ({ page }) => {
116
+ await mockApi(page)
117
+ await page.goto('/')
118
+
119
+ await expect(shell(page)).toHaveAttribute('data-shell-mode', 'wide')
120
+ await expect(rail(page)).toBeVisible()
121
+ await expect(list(page)).toBeVisible()
122
+ await expect(detail(page)).toBeVisible()
123
+
124
+ // The list is the pipeline; the detail is the empty-selection placeholder.
125
+ await expect(list(page).getByText('Fix the dashboard rendering')).toBeVisible()
126
+ await expect(detail(page).getByText('Nothing selected')).toBeVisible()
127
+
128
+ // Left to right, actually laid out as three columns.
129
+ const [railBox, listBox, detailBox] = await Promise.all([
130
+ rail(page).boundingBox(),
131
+ list(page).boundingBox(),
132
+ detail(page).boundingBox(),
133
+ ])
134
+ expect(railBox!.x).toBeLessThan(listBox!.x)
135
+ expect(listBox!.x).toBeLessThan(detailBox!.x)
136
+ })
137
+
138
+ test('shows unbuilt panels in the rail as "soon" rather than hiding them', async ({ page }) => {
139
+ await mockApi(page)
140
+ await page.goto('/')
141
+
142
+ await expect(rail(page).getByRole('button', { name: /Milestones/ })).toHaveAttribute(
143
+ 'aria-disabled',
144
+ 'true',
145
+ )
146
+ await expect(rail(page).getByText('soon').first()).toBeVisible()
147
+ })
148
+
149
+ test('selecting a row fills the detail column and keeps the list', async ({ page }) => {
150
+ await mockApi(page)
151
+ await page.goto('/')
152
+
153
+ await list(page).getByText('Fix the dashboard rendering').click()
154
+ // #1548: the URL is keyed on repo + issue (`api`/`42`), not the
155
+ // assignment id (`work-1`) — an issue outlives any one assignment.
156
+ await expect(page).toHaveURL(/\/pipeline\/api\/42/)
157
+
158
+ await expect(detail(page).getByText('Fix the dashboard rendering')).toBeVisible()
159
+ // The list did not go anywhere — that's what makes this the wide layout.
160
+ await expect(list(page).getByText('Refactor merge queue')).toBeVisible()
161
+ })
162
+
163
+ test('F6 cycles focus rail -> list -> detail', async ({ page }) => {
164
+ await mockApi(page)
165
+ await page.goto('/')
166
+ await expect(list(page).getByText('Fix the dashboard rendering')).toBeVisible()
167
+
168
+ const focusedRegion = () =>
169
+ page.evaluate(() => document.activeElement?.getAttribute('data-region') ?? null)
170
+
171
+ await page.keyboard.press('F6')
172
+ expect(await focusedRegion()).toBe('rail')
173
+ await page.keyboard.press('F6')
174
+ expect(await focusedRegion()).toBe('list')
175
+ await page.keyboard.press('F6')
176
+ expect(await focusedRegion()).toBe('detail')
177
+ await page.keyboard.press('F6')
178
+ expect(await focusedRegion()).toBe('rail')
179
+
180
+ await page.keyboard.press('Shift+F6')
181
+ expect(await focusedRegion()).toBe('detail')
182
+ })
183
+
184
+ test('dragging the separator resizes the list, and the width survives a reload', async ({
185
+ page,
186
+ }) => {
187
+ await mockApi(page)
188
+ await page.goto('/')
189
+ await expect(list(page).getByText('Fix the dashboard rendering')).toBeVisible()
190
+
191
+ const before = (await list(page).boundingBox())!.width
192
+ const handle = page.getByRole('separator', { name: 'Resize list panel' })
193
+ const handleBox = (await handle.boundingBox())!
194
+
195
+ await page.mouse.move(handleBox.x + handleBox.width / 2, handleBox.y + handleBox.height / 2)
196
+ await page.mouse.down()
197
+ await page.mouse.move(handleBox.x + handleBox.width / 2 + 90, handleBox.y + handleBox.height / 2, {
198
+ steps: 8,
199
+ })
200
+ await page.mouse.up()
201
+
202
+ await expect
203
+ .poll(async () => (await list(page).boundingBox())!.width)
204
+ .toBeGreaterThan(before + 60)
205
+
206
+ const widened = (await list(page).boundingBox())!.width
207
+ await page.reload()
208
+ await expect(list(page).getByText('Fix the dashboard rendering')).toBeVisible()
209
+ expect(Math.abs((await list(page).boundingBox())!.width - widened)).toBeLessThan(2)
210
+ })
211
+
212
+ test('the selected view survives a reload', async ({ page }) => {
213
+ await mockApi(page)
214
+ await page.goto('/')
215
+
216
+ await rail(page).getByRole('button', { name: /^Sessions/ }).click()
217
+ await expect(list(page).getByRole('heading', { name: 'Sessions' })).toBeVisible()
218
+
219
+ await page.reload()
220
+ await expect(list(page).getByRole('heading', { name: 'Sessions' })).toBeVisible()
221
+ })
222
+
223
+ test('minimising the list gives the whole width to the detail', async ({ page }) => {
224
+ await mockApi(page)
225
+ await page.goto('/')
226
+ await expect(list(page)).toBeVisible()
227
+
228
+ await rail(page).getByRole('button', { name: 'Minimize list panel' }).click()
229
+ await expect(list(page)).toHaveCount(0)
230
+ await expect(shell(page)).toHaveAttribute('data-panel', 'collapsed')
231
+
232
+ await rail(page).getByRole('button', { name: 'Show list panel' }).click()
233
+ await expect(list(page)).toBeVisible()
234
+ })
235
+ })
236
+
237
+ // ── mid ───────────────────────────────────────────────────────────────────────
238
+
239
+ test.describe('shell — mid viewport', () => {
240
+ test.use({ viewport: TABLET })
241
+
242
+ test('collapses the rail to icons and overlays the detail on the list', async ({ page }) => {
243
+ await mockApi(page)
244
+ await page.goto('/')
245
+
246
+ await expect(shell(page)).toHaveAttribute('data-shell-mode', 'medium')
247
+ // Icon strip: narrow, and the labels are gone.
248
+ expect((await rail(page).boundingBox())!.width).toBeLessThan(80)
249
+ await expect(rail(page).getByText('soon')).toHaveCount(0)
250
+
251
+ await list(page).getByText('Fix the dashboard rendering').click()
252
+
253
+ // The list is still mounted underneath, but inert — a drill-in that
254
+ // doesn't cost you the list you drilled in from.
255
+ await expect(list(page)).toHaveAttribute('inert', '')
256
+ const [listBox, detailBox] = await Promise.all([
257
+ list(page).boundingBox(),
258
+ detail(page).boundingBox(),
259
+ ])
260
+ expect(Math.abs(listBox!.x - detailBox!.x)).toBeLessThan(2)
261
+ })
262
+ })
263
+
264
+ // ── narrow ────────────────────────────────────────────────────────────────────
265
+
266
+ test.describe('shell — narrow viewport', () => {
267
+ test.use({ viewport: PHONE })
268
+
269
+ test('keeps the phone drill-in: list, then detail, then back', async ({ page }) => {
270
+ await mockApi(page)
271
+ await page.goto('/')
272
+
273
+ await expect(shell(page)).toHaveAttribute('data-shell-mode', 'narrow')
274
+ await expect(list(page)).toBeVisible()
275
+ await expect(detail(page)).toHaveCount(0)
276
+
277
+ await list(page).getByText('Fix the dashboard rendering').click()
278
+ await expect(detail(page)).toBeVisible()
279
+ // Drill-in: the list is gone, not merely covered.
280
+ await expect(list(page)).toHaveCount(0)
281
+
282
+ await page.getByLabel('Back').click()
283
+ await expect(list(page)).toBeVisible()
284
+ await expect(detail(page)).toHaveCount(0)
285
+ })
286
+
287
+ test('puts the rail at the bottom of the screen with only built views', async ({ page }) => {
288
+ await mockApi(page)
289
+ await page.goto('/')
290
+
291
+ const [railBox, listBox] = await Promise.all([rail(page).boundingBox(), list(page).boundingBox()])
292
+ expect(railBox!.y).toBeGreaterThan(listBox!.y)
293
+ expect(railBox!.width).toBeGreaterThan(300)
294
+
295
+ await expect(rail(page).getByRole('button', { name: /Pipeline/ })).toBeVisible()
296
+ await expect(rail(page).getByRole('button', { name: /Milestones/ })).toHaveCount(0)
297
+ })
298
+
299
+ test('switches views from the bottom row', async ({ page }) => {
300
+ await mockApi(page)
301
+ await page.goto('/')
302
+
303
+ await rail(page).getByRole('button', { name: /Sessions/ }).click()
304
+ await expect(list(page).getByRole('heading', { name: 'Sessions' })).toBeVisible()
305
+
306
+ await rail(page).getByRole('button', { name: /Pipeline/ }).click()
307
+ await expect(list(page).getByRole('heading', { name: 'Pipeline' })).toBeVisible()
308
+ })
309
+ })