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,191 @@
1
+ /**
2
+ * E2E smoke tests for the coord phone webapp.
3
+ *
4
+ * These form the **Playwright acceptance gate** called out in issue #741 and
5
+ * CLAUDE.md. The suite drives a real headless browser against the Vite dev
6
+ * server; API calls are intercepted by Playwright's `page.route()` so no live
7
+ * Python daemon is required.
8
+ *
9
+ * Run: npm run test:e2e
10
+ *
11
+ * Prerequisites (one-time per machine):
12
+ * npx playwright install chromium
13
+ *
14
+ * Add new tests here as the webapp grows — one test per new behaviour-changing
15
+ * PR. Coverage should ratchet upward, never shrink.
16
+ */
17
+
18
+ import { test, expect, type Page } from '@playwright/test'
19
+
20
+ // ── Shared seed data (mirrors BoardData / PipelineView shapes) ────────────────
21
+
22
+ /**
23
+ * A minimal /api/pipeline response with two active items and one completed.
24
+ * Mirrors the `make_test_app(BoardData)` pattern from coord-tui.
25
+ *
26
+ * #1724: every real item has FOUR stages — coding, smoke ("test" gate,
27
+ * ordered before review per config.pipeline.default_gates =
28
+ * ["test","review","merge"]), review, merge. This fixture used to have only
29
+ * three (no smoke/test stage at all), so it never rendered a Test pill and
30
+ * was blind to both #1724 defects (the Test pill stuck "skipped", and the
31
+ * displayed order having Review before Test). Kept at four stages, in the
32
+ * real order, so this fixture stays representative.
33
+ */
34
+ const SEEDED_PIPELINE = [
35
+ {
36
+ assignment_id: 'work-1',
37
+ issue_number: 42,
38
+ issue_title: 'Fix the dashboard rendering',
39
+ repo_name: 'api',
40
+ machine_name: 'laptop',
41
+ current_stage: 'coding',
42
+ stages: [
43
+ { name: 'coding', status: 'active', is_current: true },
44
+ { name: 'smoke', status: 'waiting', is_current: false },
45
+ { name: 'review', status: 'waiting', is_current: false },
46
+ { name: 'merge', status: 'waiting', is_current: false },
47
+ ],
48
+ available_gates: [],
49
+ progress_pct: 20,
50
+ review_findings_pending: false,
51
+ review_verdict: null,
52
+ review_findings_body: null,
53
+ test_verdict: null,
54
+ },
55
+ {
56
+ assignment_id: 'work-2',
57
+ issue_number: 99,
58
+ issue_title: 'Refactor merge queue',
59
+ repo_name: 'api',
60
+ machine_name: 'server',
61
+ current_stage: 'review_running',
62
+ stages: [
63
+ { name: 'coding', status: 'completed', is_current: false },
64
+ { name: 'smoke', status: 'completed', is_current: false },
65
+ { name: 'review', status: 'active', is_current: true },
66
+ { name: 'merge', status: 'waiting', is_current: false },
67
+ ],
68
+ available_gates: [],
69
+ progress_pct: 60,
70
+ review_findings_pending: false,
71
+ review_verdict: null,
72
+ review_findings_body: null,
73
+ test_verdict: 'passed',
74
+ },
75
+ ]
76
+
77
+ /**
78
+ * Install API route mocks on the page so no live backend is required.
79
+ * Call this at the top of every test that navigates to the webapp.
80
+ */
81
+ async function mockApi(page: Page): Promise<void> {
82
+ // Pipeline list
83
+ await page.route('**/api/pipeline', async route => {
84
+ await route.fulfill({
85
+ status: 200,
86
+ contentType: 'application/json',
87
+ body: JSON.stringify(SEEDED_PIPELINE),
88
+ })
89
+ })
90
+ // Board (used by some views)
91
+ await page.route('**/api/board', async route => {
92
+ await route.fulfill({
93
+ status: 200,
94
+ contentType: 'application/json',
95
+ body: JSON.stringify({ round_number: 1, active: [], completed: [] }),
96
+ })
97
+ })
98
+ }
99
+
100
+ // ── Tests ─────────────────────────────────────────────────────────────────────
101
+
102
+ test.describe('coord webapp smoke suite (#741)', () => {
103
+ /**
104
+ * Pinned to a phone viewport (#1547).
105
+ *
106
+ * This suite is, and always was, the *phone* app's regression net — it was
107
+ * simply running at Playwright's `Desktop Chrome` default (1280×720) by
108
+ * accident, because until the responsive shell landed the app rendered the
109
+ * same single column at every width. Now 1280px is the wide layout, where
110
+ * list and detail are both on screen and "clicking a card opens its detail
111
+ * view" would resolve the issue title twice and trip Playwright's strict
112
+ * mode. Making the viewport explicit says what these three tests are for;
113
+ * the wide and mid layouts get their own coverage in `shell.spec.ts`.
114
+ */
115
+ test.use({ viewport: { width: 390, height: 844 } })
116
+
117
+ /**
118
+ * Core smoke 1: the home screen must render at least one PipelineCard for
119
+ * each active item in the seeded /api/pipeline response.
120
+ *
121
+ * This guards the full data-fetch → React render → DOM paint path.
122
+ * A structural rename of `PipelineCard` or `fetchPipeline()` that breaks
123
+ * the render would fail here.
124
+ */
125
+ test('home screen renders pipeline cards from seeded API', async ({ page }) => {
126
+ await mockApi(page)
127
+ await page.goto('/')
128
+
129
+ // Both active items must appear as cards.
130
+ await expect(page.getByText('Fix the dashboard rendering')).toBeVisible()
131
+ await expect(page.getByText('Refactor merge queue')).toBeVisible()
132
+
133
+ // Issue numbers must be visible.
134
+ await expect(page.getByText('#42')).toBeVisible()
135
+ await expect(page.getByText('#99')).toBeVisible()
136
+ })
137
+
138
+ /**
139
+ * Core smoke 2: the "Active" filter tab must be the default, and the
140
+ * "Needs me" tab must switch the visible set to only items with available
141
+ * gate actions. With the seeded data (no gates), "Needs me" renders empty.
142
+ *
143
+ * Guards the filter-tab click → state update → re-render path.
144
+ */
145
+ test('filter tabs switch between Active and Needs-me', async ({ page }) => {
146
+ await mockApi(page)
147
+ await page.goto('/')
148
+
149
+ // Default: both items visible under "Active".
150
+ await expect(page.getByText('Fix the dashboard rendering')).toBeVisible()
151
+
152
+ // Click "Needs me" — seeded items have no gates → list should be empty.
153
+ // The filter tabs render with role="tab" (inside role="tablist").
154
+ await page.getByRole('tab', { name: /needs.me/i }).click()
155
+
156
+ // Neither card should be visible.
157
+ await expect(page.getByText('Fix the dashboard rendering')).not.toBeVisible()
158
+ await expect(page.getByText('Refactor merge queue')).not.toBeVisible()
159
+ })
160
+
161
+ /**
162
+ * Core smoke 3: clicking a pipeline card must navigate to the Detail view
163
+ * for that issue. The URL must be the repo/issue route (#1548), and the
164
+ * detail panel must render the issue title.
165
+ *
166
+ * Guards the click → react-router navigate → Detail component mount path.
167
+ */
168
+ test('clicking a pipeline card opens its detail view', async ({ page }) => {
169
+ await mockApi(page)
170
+ // Also mock the diff endpoint so the Detail view doesn't get a network error.
171
+ await page.route('**/api/diff/**', async route => {
172
+ await route.fulfill({
173
+ status: 200,
174
+ contentType: 'application/json',
175
+ body: JSON.stringify({ diff: '', source: 'compare' }),
176
+ })
177
+ })
178
+
179
+ await page.goto('/')
180
+
181
+ // Click the first card.
182
+ await page.getByText('Fix the dashboard rendering').click()
183
+
184
+ // The URL must be the addressable repo/issue route (#1548), not the
185
+ // assignment id — an issue outlives any one assignment.
186
+ await expect(page).toHaveURL(/\/pipeline\/api\/42/)
187
+
188
+ // The detail view must render the issue title.
189
+ await expect(page.getByText('Fix the dashboard rendering')).toBeVisible()
190
+ })
191
+ })
@@ -0,0 +1,420 @@
1
+ /**
2
+ * E2E tests for the phone terminal takeover flow (#1072).
3
+ *
4
+ * Drives the complete open → PTY output renders → key-bar key → detach
5
+ * flow against a fake WebSocket — no real ssh/tmux or Python daemon required.
6
+ *
7
+ * Uses page.routeWebSocket() (Playwright ≥ 1.48) to intercept
8
+ * /ws/terminal/{session_id}: the async handler keeps the connection alive,
9
+ * echoes a banner, and records incoming keystrokes. The async form is
10
+ * required: if the handler returns synchronously Playwright auto-closes the
11
+ * WS, which also triggers React 18 StrictMode's double-invoke cleanup.
12
+ *
13
+ * Run: npm run test:e2e
14
+ */
15
+
16
+ import { test, expect } from '@playwright/test'
17
+
18
+ // ── Seeded data ───────────────────────────────────────────────────────────────
19
+
20
+ const SESSION_ID = 'work-abc-1'
21
+
22
+ /** One in-progress interactive session surfaced by GET /api/sessions. */
23
+ const SEEDED_SESSION = {
24
+ session_id: SESSION_ID,
25
+ session_name: `coord-${SESSION_ID}`,
26
+ machine: 'laptop',
27
+ host: null,
28
+ repo: 'api',
29
+ issue: 42,
30
+ issue_title: 'Implement terminal takeover',
31
+ stage: 'work',
32
+ status: 'running',
33
+ attached: false,
34
+ pane_dead: false,
35
+ }
36
+
37
+ /**
38
+ * Banner the fake PTY sends immediately on connect.
39
+ * Chosen to be unambiguous in the xterm-rows DOM (no HTML metacharacters).
40
+ */
41
+ const FAKE_PTY_BANNER = 'PTY-E2E-BANNER-OK\r\n'
42
+
43
+ // ── Helpers ───────────────────────────────────────────────────────────────────
44
+
45
+ /** Silence /api/pipeline and /api/board so Home renders without errors. */
46
+ async function mockBasicApis(page: import('@playwright/test').Page): Promise<void> {
47
+ await page.route('**/api/pipeline', (route) =>
48
+ route.fulfill({ status: 200, contentType: 'application/json', body: '[]' }),
49
+ )
50
+ await page.route('**/api/board', (route) =>
51
+ route.fulfill({
52
+ status: 200,
53
+ contentType: 'application/json',
54
+ body: JSON.stringify({ round_number: 1, active: [], completed: [] }),
55
+ }),
56
+ )
57
+ }
58
+
59
+ /**
60
+ * Register an async fake-PTY WebSocket route for SESSION_ID.
61
+ *
62
+ * The handler is async and awaits a Promise that resolves when the browser
63
+ * closes the WS — this is critical: if the handler returns synchronously,
64
+ * Playwright closes the route (and therefore the WS) immediately, which
65
+ * causes React 18's StrictMode double-invoke to see both mounts hit a
66
+ * "Disconnected" state before the test can observe "Live".
67
+ *
68
+ * With the async handler, the lifecycle is:
69
+ * StrictMode mount #1 → WS opens → handler awaits → StrictMode cleanup
70
+ * closes WS → onClose callback fires → Promise resolves → handler returns
71
+ * StrictMode mount #2 → WS opens → handler awaits → WS stays alive
72
+ * (no more cleanups) → test proceeds normally
73
+ *
74
+ * Returns shared state updated by the route callbacks.
75
+ */
76
+ async function routeFakePty(
77
+ page: import('@playwright/test').Page,
78
+ opts: { banner?: string } = {},
79
+ ): Promise<{ receivedMessages: (string | Buffer)[]; closeCount: number[] }> {
80
+ const banner = opts.banner ?? FAKE_PTY_BANNER
81
+ const receivedMessages: (string | Buffer)[] = []
82
+ const closeCount = [0] // boxed so the route callback mutates it in-place
83
+
84
+ await page.routeWebSocket(`**/ws/terminal/${SESSION_ID}`, async (ws) => {
85
+ // Clear per-connection state so each StrictMode re-mount starts fresh.
86
+ receivedMessages.length = 0
87
+
88
+ ws.send(banner)
89
+ ws.onMessage((msg) => receivedMessages.push(msg))
90
+
91
+ // Await closure — this keeps the WS alive until the browser closes it.
92
+ await new Promise<void>((resolve) => {
93
+ ws.onClose(() => {
94
+ closeCount[0]++
95
+ resolve()
96
+ })
97
+ })
98
+ })
99
+
100
+ return { receivedMessages, closeCount }
101
+ }
102
+
103
+ // ── Tests ─────────────────────────────────────────────────────────────────────
104
+
105
+ test.describe('terminal takeover flow (#1072)', () => {
106
+ /**
107
+ * Core flow: home → session card → terminal pane → PTY output visible →
108
+ * key-bar Esc → byte reaches PTY → close → clean detach (WS closed, no
109
+ * tmux-session kill).
110
+ *
111
+ * Guards the entire open-terminal path as described in the issue:
112
+ * - SessionCard click navigates to /terminal/:id
113
+ * - Binary bytes pushed by the fake PTY appear in the xterm DOM
114
+ * - MobileKeyBar soft keys transmit the correct byte sequences
115
+ * - Closing the pane fires a clean WebSocket close (= detach) rather than
116
+ * an abrupt drop, and navigates the user back to the home screen.
117
+ *
118
+ * The "session NOT killed" invariant is enforced server-side by
119
+ * `attached.detach()` never calling `tmux kill-session` (#1065); here we
120
+ * verify the client-side half: ws.close() is called (clean close) rather
121
+ * than the page being destroyed, which is what the server's `finally` block
122
+ * relies on to trigger detach().
123
+ */
124
+ test('open-terminal → PTY output renders → key-bar Esc → detach on close', async ({
125
+ page,
126
+ }) => {
127
+ // ── 1. Seed REST APIs ────────────────────────────────────────────────────
128
+
129
+ await mockBasicApis(page)
130
+
131
+ // The home screen Live sessions section calls GET /api/sessions.
132
+ await page.route('**/api/sessions', (route) =>
133
+ route.fulfill({
134
+ status: 200,
135
+ contentType: 'application/json',
136
+ body: JSON.stringify([SEEDED_SESSION]),
137
+ }),
138
+ )
139
+
140
+ // ── 2. Intercept the WebSocket with a fake PTY ───────────────────────────
141
+
142
+ const { receivedMessages, closeCount } = await routeFakePty(page)
143
+
144
+ // ── 3. Home screen: Live sessions section must show the session card ─────
145
+
146
+ await page.goto('/')
147
+
148
+ // The SessionCard renders the issue_title as its button text.
149
+ const sessionCard = page.getByRole('button', { name: 'Implement terminal takeover' })
150
+ await expect(sessionCard).toBeVisible()
151
+
152
+ // The "live" status badge (lowercase, exact to avoid matching the "Live
153
+ // sessions" section heading — strict mode forbids multi-element matches).
154
+ await expect(page.getByText('live', { exact: true })).toBeVisible()
155
+
156
+ // Clicking the card navigates to /terminal/:sessionId.
157
+ await sessionCard.click()
158
+
159
+ // ── 4. Terminal view: URL and status label ───────────────────────────────
160
+
161
+ await expect(page).toHaveURL(new RegExp(SESSION_ID))
162
+
163
+ // "Live" appears once the WebSocket opens (ws.onopen → setState('open')).
164
+ // The async route handler keeps WS #2 alive through React StrictMode's
165
+ // double-invoke so this state is stable by the time we assert it.
166
+ await expect(page.getByText('Live')).toBeVisible({ timeout: 5_000 })
167
+
168
+ // ── 5. PTY output must render in the xterm DOM ───────────────────────────
169
+
170
+ // xterm.js (v6, DOM renderer — no renderer addon loaded) writes each
171
+ // character into spans inside .xterm-rows. toContainText() walks the full
172
+ // subtree so it finds the text even when it's split across cells.
173
+ await expect(page.locator('[data-testid="xterm-container"]')).toContainText(
174
+ 'PTY-E2E-BANNER-OK',
175
+ { timeout: 5_000 },
176
+ )
177
+
178
+ // ── 6. MobileKeyBar: Esc key must reach the fake PTY ─────────────────────
179
+
180
+ await page.getByRole('button', { name: 'Escape' }).click()
181
+
182
+ // The Esc key sends '\x1b' via TextEncoder → binary WS frame.
183
+ // Playwright delivers binary frames as Buffers. poll() re-evaluates
184
+ // until the message arrives (short round-trip delay through the WS
185
+ // routing layer).
186
+ await expect
187
+ .poll(
188
+ () =>
189
+ receivedMessages.some((m) => {
190
+ const buf: Buffer = typeof m === 'string' ? Buffer.from(m, 'utf8') : (m as Buffer)
191
+ return buf.includes(0x1b) // ESC byte
192
+ }),
193
+ { timeout: 2_000, message: 'expected ESC (0x1b) byte in received WS messages' },
194
+ )
195
+ .toBe(true)
196
+
197
+ // ── 7. Close → clean detach ──────────────────────────────────────────────
198
+
199
+ // The ✕ button in Terminal.tsx calls ws.close() (detach) then navigate(-1).
200
+ // We verify: (a) another WS close event fires, (b) URL reverts.
201
+ const closeCountBefore = closeCount[0]
202
+
203
+ await page.getByRole('button', { name: 'Close terminal' }).click()
204
+
205
+ // A new WS close must happen beyond any StrictMode-induced close.
206
+ await expect
207
+ .poll(
208
+ () => closeCount[0] > closeCountBefore,
209
+ { timeout: 2_000, message: 'expected WebSocket close event after clicking ✕' },
210
+ )
211
+ .toBe(true)
212
+
213
+ // navigate(-1) must take the user back to the home screen.
214
+ await expect(page).not.toHaveURL(new RegExp(SESSION_ID))
215
+ })
216
+
217
+ /**
218
+ * Session-ended path: when the bridge closes the WebSocket with the
219
+ * "session is gone for good" code (4404 / SESSION_GONE_CODE — see
220
+ * coord/dashboard/server.py:terminal_ws, sent when resolve_session_target
221
+ * no longer finds a live assignment), the status label must flip to
222
+ * "Session ended".
223
+ *
224
+ * Guards the ws.onclose → (event.code === 4404) → setState('ended') →
225
+ * STATUS_LABEL['ended'] path in Terminal.tsx — the user should always know
226
+ * when the session has ended for good rather than seeing a frozen terminal
227
+ * with no feedback. 4404 is a terminal state: unlike other closes it is NOT
228
+ * retried, so this asserts the deterministic end-of-session feedback (a
229
+ * plain close, by contrast, is a transient drop → "Reconnecting…").
230
+ */
231
+ test('terminal shows "Session ended" when the bridge closes with 4404', async ({
232
+ page,
233
+ }) => {
234
+ // Fake bridge that delivers one line then closes with the session-gone
235
+ // code (4404), the way server.py signals a session that no longer resolves.
236
+ await page.routeWebSocket(`**/ws/terminal/${SESSION_ID}`, async (ws) => {
237
+ ws.send('session has ended\r\n')
238
+
239
+ // Await a timer then close — gives the banner time to land before the
240
+ // close frame races it in the browser's event loop.
241
+ await new Promise<void>((resolve) => {
242
+ setTimeout(() => {
243
+ ws.close({ code: 4404 })
244
+ resolve()
245
+ }, 80)
246
+ })
247
+ })
248
+
249
+ // Navigate directly to the terminal view (no need to go through Home).
250
+ await page.goto(`/terminal/${SESSION_ID}`)
251
+
252
+ // The terminal must surface the end-of-session state once the 4404 close
253
+ // arrives (ws.onclose → event.code === SESSION_GONE_CODE → setState('ended')):
254
+ // a role="status" overlay tells the user the session is gone for good
255
+ // instead of leaving a frozen pane. ("Session ended" also appears in the
256
+ // header status label, so scope to the overlay to stay unambiguous.)
257
+ await expect(page.getByRole('status')).toContainText('Session ended', {
258
+ timeout: 5_000,
259
+ })
260
+ })
261
+
262
+ /**
263
+ * Reconnect path: a plain (non-4404) server close is a *transient* drop, so
264
+ * the client must surface "Reconnecting…" and retry with backoff — never a
265
+ * terminal state. This is the exact behavior the Terminal.tsx onclose guards
266
+ * protect (the `intentionalCloseRef` reset in connect() + the superseded-
267
+ * socket `wsRef.current !== ws` check): before that fix, React StrictMode's
268
+ * dev remount left the surviving socket's onclose short-circuited, so a real
269
+ * drop froze the pane at "Live" and never reconnected. Complements the 4404
270
+ * "Session ended" (terminal, no-retry) test above — this is the transient,
271
+ * retrying counterpart, and it fails-on-revert of the connect() reset.
272
+ */
273
+ test('terminal shows "Reconnecting…" after a plain (transient) server close', async ({
274
+ page,
275
+ }) => {
276
+ // Fake bridge that drops each connection with a plain close shortly after
277
+ // it opens. A plain close (no 4404) is a transient drop, so the client
278
+ // keeps cycling into "Reconnecting…" (→ backoff → retry) rather than a
279
+ // terminal state — reliably observable within the timeout.
280
+ await page.routeWebSocket(`**/ws/terminal/${SESSION_ID}`, async (ws) => {
281
+ ws.send(FAKE_PTY_BANNER)
282
+ await new Promise<void>((resolve) => {
283
+ setTimeout(() => {
284
+ ws.close() // plain close (no code) => transient => must reconnect
285
+ resolve()
286
+ }, 120)
287
+ })
288
+ })
289
+
290
+ // Navigate directly to the terminal view (no need to go through Home).
291
+ await page.goto(`/terminal/${SESSION_ID}`)
292
+
293
+ // The status label flips to "Reconnecting…" once the surviving connection's
294
+ // onclose reaches the transient-drop branch (setState('reconnecting')).
295
+ // "Reconnecting…" appears only in the header label (no overlay), so a plain
296
+ // text locator is unambiguous.
297
+ await expect(page.getByText('Reconnecting…')).toBeVisible({ timeout: 5_000 })
298
+ })
299
+
300
+ /**
301
+ * Scroll mode flow (#1299): Scroll → Page Up → Exit.
302
+ *
303
+ * Guards the full copy-mode round-trip from the phone's perspective:
304
+ * 1. Tap "Scroll" → fake WS receives `{"type":"copy-mode","action":"enter"}`
305
+ * 2. Tap "Page Up" → `{"type":"copy-mode","action":"page-up"}`
306
+ * 3. Tap "Exit Scroll" → `{"type":"copy-mode","action":"exit"}`
307
+ *
308
+ * Captures text frames (JSON) separately from binary frames (keystrokes) so
309
+ * the assertion is unambiguous. The harness runs against Vite dev with React
310
+ * StrictMode double-mount active; `routeFakePty` already handles that
311
+ * correctly (see the routeFakePty docstring above).
312
+ */
313
+ test('Scroll mode: enter → page-up → exit sends correct copy-mode frames', async ({
314
+ page,
315
+ }) => {
316
+ // Fake PTY that records both binary and text frames.
317
+ const textFrames: string[] = []
318
+
319
+ await page.routeWebSocket(`**/ws/terminal/${SESSION_ID}`, async (ws) => {
320
+ textFrames.length = 0 // reset per StrictMode re-mount
321
+
322
+ ws.send(FAKE_PTY_BANNER)
323
+ ws.onMessage((msg) => {
324
+ if (typeof msg === 'string') {
325
+ textFrames.push(msg)
326
+ }
327
+ })
328
+
329
+ await new Promise<void>((resolve) => {
330
+ ws.onClose(() => resolve())
331
+ })
332
+ })
333
+
334
+ await page.goto(`/terminal/${SESSION_ID}`)
335
+
336
+ // Wait for live connection before interacting.
337
+ await expect(page.getByText('Live')).toBeVisible({ timeout: 5_000 })
338
+
339
+ // 1. Tap Scroll — enters copy-mode.
340
+ await page.getByRole('button', { name: 'Scroll' }).click()
341
+
342
+ await expect
343
+ .poll(
344
+ () => textFrames.some((f) => f === JSON.stringify({ type: 'copy-mode', action: 'enter' })),
345
+ { timeout: 2_000, message: 'expected copy-mode enter frame' },
346
+ )
347
+ .toBe(true)
348
+
349
+ // SCROLL badge must appear.
350
+ await expect(page.getByRole('status', { name: 'Scroll mode active' })).toBeVisible()
351
+
352
+ // 2. Tap Page Up.
353
+ await page.getByRole('button', { name: 'Page Up' }).click()
354
+
355
+ await expect
356
+ .poll(
357
+ () =>
358
+ textFrames.some((f) => f === JSON.stringify({ type: 'copy-mode', action: 'page-up' })),
359
+ { timeout: 2_000, message: 'expected copy-mode page-up frame' },
360
+ )
361
+ .toBe(true)
362
+
363
+ // 3. Tap Exit Scroll — exits copy-mode and hides badge.
364
+ await page.getByRole('button', { name: 'Exit Scroll' }).click()
365
+
366
+ await expect
367
+ .poll(
368
+ () => textFrames.some((f) => f === JSON.stringify({ type: 'copy-mode', action: 'exit' })),
369
+ { timeout: 2_000, message: 'expected copy-mode exit frame' },
370
+ )
371
+ .toBe(true)
372
+
373
+ // Badge must be gone; normal Scroll button is back.
374
+ await expect(page.getByRole('status', { name: 'Scroll mode active' })).not.toBeVisible()
375
+ await expect(page.getByRole('button', { name: 'Scroll' })).toBeVisible()
376
+ })
377
+
378
+ /**
379
+ * Key-bar completeness: all four arrow keys must transmit their ANSI escape
380
+ * sequences to the fake PTY.
381
+ *
382
+ * Guards the four arrow SoftKeys in MobileKeyBar and their KEY_BYTES mappings
383
+ * (\x1b[A/B/C/D). A single key being wired to the wrong sequence, or the
384
+ * onSend prop not being threaded correctly, would fail here without cluttering
385
+ * the main flow test.
386
+ */
387
+ test('MobileKeyBar arrow keys transmit correct ANSI sequences', async ({ page }) => {
388
+ const { receivedMessages } = await routeFakePty(page, { banner: 'ready\r\n' })
389
+
390
+ await page.goto(`/terminal/${SESSION_ID}`)
391
+
392
+ // Wait for the WS to be open (state = 'Live') before sending keys.
393
+ await expect(page.getByText('Live')).toBeVisible({ timeout: 5_000 })
394
+
395
+ const arrowCases: Array<{ label: string; seq: number[] }> = [
396
+ { label: 'Up', seq: [0x1b, 0x5b, 0x41] }, // \x1b[A
397
+ { label: 'Down', seq: [0x1b, 0x5b, 0x42] }, // \x1b[B
398
+ { label: 'Right', seq: [0x1b, 0x5b, 0x43] }, // \x1b[C
399
+ { label: 'Left', seq: [0x1b, 0x5b, 0x44] }, // \x1b[D
400
+ ]
401
+
402
+ for (const { label, seq } of arrowCases) {
403
+ const before = receivedMessages.length
404
+ await page.getByRole('button', { name: label }).click()
405
+
406
+ await expect
407
+ .poll(
408
+ () => {
409
+ const fresh = receivedMessages.slice(before)
410
+ return fresh.some((m) => {
411
+ const buf: Buffer = typeof m === 'string' ? Buffer.from(m, 'utf8') : (m as Buffer)
412
+ return seq.every((byte, i) => buf[i] === byte)
413
+ })
414
+ },
415
+ { timeout: 2_000, message: `expected ${label} arrow ANSI sequence in WS messages` },
416
+ )
417
+ .toBe(true)
418
+ }
419
+ })
420
+ })