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,504 @@
1
+ /**
2
+ * Component tests for Terminal — the live take-over pane for a `coord-*`
3
+ * session (#1068). `WebSocket` is mocked end-to-end (no real network/PTY);
4
+ * this covers mount -> connect -> render -> teardown per the issue's
5
+ * acceptance criterion. Full browser/terminal-protocol coverage (raw mode,
6
+ * SGR mouse) is out of `TestBackend`-equivalent reach here and is the
7
+ * Playwright E2E issue in group C.
8
+ */
9
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
10
+ import { render, screen, waitFor, act, fireEvent } from '@testing-library/react'
11
+ import userEvent from '@testing-library/user-event'
12
+ import { MemoryRouter, Routes, Route } from 'react-router-dom'
13
+ import Terminal from '@/components/Terminal'
14
+
15
+ // ── xterm.js constructor spy ─────────────────────────────────────────────────
16
+ // Wraps @xterm/xterm's Terminal in a transparent subclass that captures:
17
+ // • the options object passed to the constructor (for convertEol tests)
18
+ // • every onResize handler registered by the component (so tests can
19
+ // trigger the callback with arbitrary {cols, rows} without needing a
20
+ // real layout engine)
21
+ //
22
+ // vi.hoisted() runs before vi.mock() hoisting, so the reference is available
23
+ // inside the factory. The wrapper calls super() unconditionally, so all real
24
+ // xterm functionality (text rendering, etc.) still works for the existing tests.
25
+ const xtermCapture = vi.hoisted(() => ({
26
+ lastOptions: undefined as Record<string, unknown> | undefined,
27
+ onResizeHandlers: [] as Array<(data: { cols: number; rows: number }) => void>,
28
+ }))
29
+
30
+ vi.mock('@xterm/xterm', async (importOriginal) => {
31
+ const actual = await importOriginal<typeof import('@xterm/xterm')>()
32
+ return {
33
+ ...actual,
34
+ Terminal: class MockTerminal extends actual.Terminal {
35
+ constructor(opts: ConstructorParameters<typeof actual.Terminal>[0]) {
36
+ super(opts)
37
+ xtermCapture.lastOptions = opts as Record<string, unknown>
38
+ // `onResize` is a getter-only accessor on the prototype (configurable:
39
+ // true, no setter). Direct assignment throws "has only a getter".
40
+ // Shadow it with an OWN data property on this instance so the component
41
+ // can subscribe via `term.onResize(handler)` while we capture the handler.
42
+ // `orig` is the IEvent function returned by the getter — it closes over
43
+ // xterm's internal EventEmitter, so it doesn't need explicit `this` binding.
44
+ const orig = (this as unknown as { onResize: (h: (d: { cols: number; rows: number }) => void) => { dispose(): void } }).onResize
45
+ Object.defineProperty(this, 'onResize', {
46
+ value: (handler: (data: { cols: number; rows: number }) => void): { dispose(): void } => {
47
+ xtermCapture.onResizeHandlers.push(handler)
48
+ return orig(handler)
49
+ },
50
+ writable: true,
51
+ configurable: true,
52
+ enumerable: true,
53
+ })
54
+ }
55
+ },
56
+ }
57
+ })
58
+
59
+ // ── Fake WebSocket ───────────────────────────────────────────────────────────
60
+
61
+ class FakeWebSocket {
62
+ static readonly CONNECTING = 0
63
+ static readonly OPEN = 1
64
+ static readonly CLOSING = 2
65
+ static readonly CLOSED = 3
66
+ static instances: FakeWebSocket[] = []
67
+
68
+ url: string
69
+ readyState = FakeWebSocket.CONNECTING
70
+ binaryType = 'blob'
71
+ sent: Array<string | ArrayBuffer | Uint8Array> = []
72
+ closed = false
73
+ onopen: (() => void) | null = null
74
+ onclose: ((event: { code: number }) => void) | null = null
75
+ onerror: (() => void) | null = null
76
+ onmessage: ((event: { data: unknown }) => void) | null = null
77
+
78
+ constructor(url: string) {
79
+ this.url = url
80
+ FakeWebSocket.instances.push(this)
81
+ }
82
+
83
+ send(data: string | ArrayBuffer | Uint8Array) {
84
+ this.sent.push(data)
85
+ }
86
+
87
+ // A locally-initiated close (the ✕ button / unmount calling ws.close())
88
+ // -- code defaults to the normal-closure value real browsers use.
89
+ close(code = 1000) {
90
+ this.closed = true
91
+ this.readyState = FakeWebSocket.CLOSED
92
+ this.onclose?.({ code })
93
+ }
94
+
95
+ // ── Test helpers, driving the fake from the "server" side. Wrapped in
96
+ // act() since they synchronously trigger a React state update
97
+ // (setState('open'/'reconnecting'/'ended')) outside of React's own event
98
+ // handling. ──
99
+ simulateOpen() {
100
+ act(() => {
101
+ this.readyState = FakeWebSocket.OPEN
102
+ this.onopen?.()
103
+ })
104
+ }
105
+
106
+ simulateMessage(data: unknown) {
107
+ act(() => {
108
+ this.onmessage?.({ data })
109
+ })
110
+ }
111
+
112
+ // Simulates the "server"/network dropping the connection out from under
113
+ // the client -- e.g. a transient network drop (no particular code, or an
114
+ // abnormal-closure-ish one) vs. the bridge's session-gone signal (4404).
115
+ simulateClose(code: number) {
116
+ act(() => {
117
+ this.closed = true
118
+ this.readyState = FakeWebSocket.CLOSED
119
+ this.onclose?.({ code })
120
+ })
121
+ }
122
+ }
123
+
124
+ function renderTerminal(sessionId: string) {
125
+ return render(
126
+ <MemoryRouter initialEntries={[`/terminal/${sessionId}`]}>
127
+ <Routes>
128
+ <Route path="/terminal/:sessionId" element={<Terminal />} />
129
+ </Routes>
130
+ </MemoryRouter>,
131
+ )
132
+ }
133
+
134
+ beforeEach(() => {
135
+ FakeWebSocket.instances = []
136
+ vi.stubGlobal('WebSocket', FakeWebSocket)
137
+ // Reset xterm capture state so tests don't see handlers/options from a
138
+ // previous test's Terminal instance.
139
+ xtermCapture.lastOptions = undefined
140
+ xtermCapture.onResizeHandlers.length = 0
141
+ })
142
+
143
+ describe('Terminal', () => {
144
+ // ── Constructor options ────────────────────────────────────────────────────
145
+
146
+ it('constructs xterm.js with convertEol: false so tmux CR+LF is not double-converted', () => {
147
+ // tmux sends real CR+LF (\r\n) sequences. xterm.js's convertEol:true
148
+ // converts the \r to a second \n, producing double newlines. We must
149
+ // pass convertEol:false and let tmux control line endings.
150
+ renderTerminal('work-2')
151
+ expect(xtermCapture.lastOptions?.convertEol).toBe(false)
152
+ })
153
+
154
+ // ── Resize frame emission and deduplication ────────────────────────────────
155
+
156
+ it('emits a resize WS frame with the new cols/rows when the terminal size changes', () => {
157
+ // This exercises the term.onResize → sendResize path that fires when
158
+ // fit.fit() computes a new terminal size (e.g. after a viewport change).
159
+ // In jsdom, fit.fit() can't compute real sizes, so we trigger the handler
160
+ // directly via the captured spy — simulating what xterm fires when the
161
+ // real DOM has a non-zero layout.
162
+ renderTerminal('work-2')
163
+ const ws = FakeWebSocket.instances[0]
164
+ ws.simulateOpen()
165
+
166
+ // After open, lastCols/lastRows are whatever the terminal reported
167
+ // (80×24 in jsdom). Trigger onResize with *different* dims so the
168
+ // dedup guard allows the frame through.
169
+ act(() => {
170
+ xtermCapture.onResizeHandlers.forEach((h) => h({ cols: 100, rows: 30 }))
171
+ })
172
+
173
+ const resizeFrames = ws.sent.filter(
174
+ (f): f is string => typeof f === 'string' && f.includes('"type":"resize"'),
175
+ )
176
+ // At minimum: one from onopen, one from the simulated onResize
177
+ expect(resizeFrames.length).toBeGreaterThanOrEqual(2)
178
+ const lastFrame = JSON.parse(resizeFrames[resizeFrames.length - 1]) as Record<
179
+ string,
180
+ unknown
181
+ >
182
+ expect(lastFrame).toMatchObject({ type: 'resize', cols: 100, rows: 30 })
183
+ })
184
+
185
+ it('dedup guard: identical cols/rows after open do not produce a second resize WS frame', () => {
186
+ // If the layout event fires but the terminal size hasn't changed (common
187
+ // during rapid window-resize events that don't cross a character-cell
188
+ // boundary), no duplicate frame should be sent — the bridge would call
189
+ // TIOCSWINSZ with the same values, and tmux would noop anyway.
190
+ renderTerminal('work-2')
191
+ const ws = FakeWebSocket.instances[0]
192
+ ws.simulateOpen()
193
+
194
+ const countAfterOpen = ws.sent.filter(
195
+ (f): f is string => typeof f === 'string' && f.includes('"type":"resize"'),
196
+ ).length
197
+
198
+ // Trigger onResize with the same size the terminal reported on open
199
+ // (80×24 is xterm.js's default when jsdom has no real layout).
200
+ act(() => {
201
+ xtermCapture.onResizeHandlers.forEach((h) => h({ cols: 80, rows: 24 }))
202
+ })
203
+
204
+ const countAfterDup = ws.sent.filter(
205
+ (f): f is string => typeof f === 'string' && f.includes('"type":"resize"'),
206
+ ).length
207
+ expect(countAfterDup).toBe(countAfterOpen)
208
+ })
209
+
210
+ it('mounts and connects a WebSocket to /ws/terminal/{sessionId}', () => {
211
+ renderTerminal('work-2')
212
+
213
+ expect(screen.getByTestId('xterm-container')).toBeInTheDocument()
214
+ expect(FakeWebSocket.instances).toHaveLength(1)
215
+ expect(FakeWebSocket.instances[0].url).toContain('/ws/terminal/work-2')
216
+ })
217
+
218
+ it('sends a resize control frame once the socket opens', () => {
219
+ renderTerminal('work-2')
220
+ const ws = FakeWebSocket.instances[0]
221
+
222
+ ws.simulateOpen()
223
+
224
+ const resizeFrame = ws.sent.find(
225
+ (frame) => typeof frame === 'string' && frame.includes('"type":"resize"'),
226
+ )
227
+ expect(resizeFrame).toBeDefined()
228
+ })
229
+
230
+ it('renders PTY output pushed over the WebSocket', async () => {
231
+ renderTerminal('work-2')
232
+ const ws = FakeWebSocket.instances[0]
233
+ ws.simulateOpen()
234
+
235
+ ws.simulateMessage(new TextEncoder().encode('hello from pty').buffer)
236
+
237
+ await waitFor(() =>
238
+ expect(screen.getByTestId('xterm-container')).toHaveTextContent('hello from pty'),
239
+ )
240
+ })
241
+
242
+ it('closes the WebSocket (hitting the bridge detach path) when the close control is pressed', async () => {
243
+ renderTerminal('work-2')
244
+ const ws = FakeWebSocket.instances[0]
245
+ ws.simulateOpen()
246
+
247
+ await userEvent.click(screen.getByRole('button', { name: 'Close terminal' }))
248
+
249
+ expect(ws.closed).toBe(true)
250
+ })
251
+
252
+ it('closes the WebSocket on unmount', () => {
253
+ const { unmount } = renderTerminal('work-2')
254
+ const ws = FakeWebSocket.instances[0]
255
+ ws.simulateOpen()
256
+
257
+ unmount()
258
+
259
+ expect(ws.closed).toBe(true)
260
+ })
261
+
262
+ it('wires MobileKeyBar soft keys to the same WebSocket write path as typed input (#1070)', async () => {
263
+ renderTerminal('work-2')
264
+ const ws = FakeWebSocket.instances[0]
265
+ ws.simulateOpen()
266
+
267
+ await userEvent.click(screen.getByRole('button', { name: 'Ctrl-C' }))
268
+
269
+ // `TextEncoder` (used by both `term.onData` and `MobileKeyBar`'s
270
+ // wiring) returns a Uint8Array from the environment's own realm, which
271
+ // isn't always `instanceof` the test file's `Uint8Array` under jsdom --
272
+ // decode it back instead of asserting the constructor identity.
273
+ const sent = ws.sent[ws.sent.length - 1] as ArrayBufferLike
274
+ expect(new TextDecoder().decode(sent)).toBe('\x03')
275
+ })
276
+
277
+ it('does not write to a WebSocket that is not open yet', async () => {
278
+ renderTerminal('work-2')
279
+ const ws = FakeWebSocket.instances[0]
280
+ // Still 'connecting' -- no simulateOpen().
281
+
282
+ await userEvent.click(screen.getByRole('button', { name: 'Escape' }))
283
+
284
+ expect(ws.sent).toHaveLength(0)
285
+ })
286
+
287
+ // ── #1071: reconnect / detach resilience ──────────────────────────────
288
+
289
+ describe('reconnect resilience (#1071)', () => {
290
+ beforeEach(() => {
291
+ vi.useFakeTimers()
292
+ })
293
+
294
+ afterEach(() => {
295
+ vi.useRealTimers()
296
+ })
297
+
298
+ it('reconnects with backoff after an unexpected drop and resumes streaming', async () => {
299
+ renderTerminal('work-2')
300
+ const first = FakeWebSocket.instances[0]
301
+ first.simulateOpen()
302
+
303
+ // Abnormal closure -- e.g. wifi<->cellular handoff, tab backgrounded.
304
+ first.simulateClose(1006)
305
+
306
+ expect(screen.getByText(/work-2/)).toHaveTextContent('Reconnecting…')
307
+ expect(FakeWebSocket.instances).toHaveLength(1)
308
+
309
+ // First backoff step (1000ms) elapses -- a fresh WebSocket opens to
310
+ // the *same* session_id.
311
+ await act(async () => {
312
+ await vi.advanceTimersByTimeAsync(1000)
313
+ })
314
+
315
+ expect(FakeWebSocket.instances).toHaveLength(2)
316
+ const second = FakeWebSocket.instances[1]
317
+ expect(second.url).toContain('/ws/terminal/work-2')
318
+
319
+ second.simulateOpen()
320
+ expect(screen.getByText(/work-2/)).toHaveTextContent('Live')
321
+
322
+ second.simulateMessage(new TextEncoder().encode('resumed').buffer)
323
+ // xterm.js flushes writes via its own internal queue (a timer under
324
+ // the hood), so nudge fake time forward to let it drain -- `waitFor`'s
325
+ // poll loop also relies on real timers and would stall here.
326
+ await act(async () => {
327
+ await vi.advanceTimersByTimeAsync(50)
328
+ })
329
+ expect(screen.getByTestId('xterm-container')).toHaveTextContent('resumed')
330
+ })
331
+
332
+ it('backs off exponentially across repeated drops', async () => {
333
+ renderTerminal('work-2')
334
+ FakeWebSocket.instances[0].simulateOpen()
335
+ FakeWebSocket.instances[0].simulateClose(1006)
336
+
337
+ // First retry fires at 1000ms...
338
+ await act(async () => {
339
+ await vi.advanceTimersByTimeAsync(1000)
340
+ })
341
+ expect(FakeWebSocket.instances).toHaveLength(2)
342
+
343
+ // ...and drops again without ever reaching 'open' (attempt count
344
+ // keeps climbing), so the *next* retry waits 2000ms, not 1000ms.
345
+ FakeWebSocket.instances[1].simulateClose(1006)
346
+
347
+ await act(async () => {
348
+ await vi.advanceTimersByTimeAsync(1000)
349
+ })
350
+ expect(FakeWebSocket.instances).toHaveLength(2) // not yet -- only 1s of a 2s wait
351
+
352
+ await act(async () => {
353
+ await vi.advanceTimersByTimeAsync(1000)
354
+ })
355
+ expect(FakeWebSocket.instances).toHaveLength(3)
356
+ })
357
+
358
+ it('keeps climbing backoff when connections flap open-then-immediately-closed (live bug: accept() succeeds, attach fails right after)', async () => {
359
+ // Reproduces the real-backend failure found in live smoke testing:
360
+ // the bridge accept()s the WebSocket (so the client's `onopen` fires)
361
+ // and then closes it almost immediately (e.g. the tmux attach failing
362
+ // right after accept) -- as opposed to every other test in this file,
363
+ // which either never reaches 'open' before the next close or stays
364
+ // open indefinitely. A naive "reset the counter on every onopen"
365
+ // implementation never backs off here since each cycle reopens and
366
+ // immediately resets to attempt 0 before closing again.
367
+ renderTerminal('work-2')
368
+
369
+ const first = FakeWebSocket.instances[0]
370
+ first.simulateOpen()
371
+ first.simulateClose(1011) // opened, then closed right away -- no time elapses
372
+
373
+ // First retry still fires at the base delay (1000ms) -- this is
374
+ // attempt 0.
375
+ await act(async () => {
376
+ await vi.advanceTimersByTimeAsync(1000)
377
+ })
378
+ expect(FakeWebSocket.instances).toHaveLength(2)
379
+
380
+ const second = FakeWebSocket.instances[1]
381
+ second.simulateOpen()
382
+ second.simulateClose(1011) // same flap: open then immediately closed
383
+
384
+ // If backoff had wrongly reset on `onopen`, this retry would also
385
+ // fire at 1000ms. Instead it must wait the full 2000ms (attempt 1).
386
+ await act(async () => {
387
+ await vi.advanceTimersByTimeAsync(1000)
388
+ })
389
+ expect(FakeWebSocket.instances).toHaveLength(2) // not yet -- only 1s of a 2s wait
390
+
391
+ await act(async () => {
392
+ await vi.advanceTimersByTimeAsync(1000)
393
+ })
394
+ expect(FakeWebSocket.instances).toHaveLength(3)
395
+ })
396
+
397
+ it('resets backoff once a connection stays open long enough to be a genuine, working attach', async () => {
398
+ renderTerminal('work-2')
399
+
400
+ // Two rapid flaps climb the backoff ladder to attempt 2 (4000ms next).
401
+ const first = FakeWebSocket.instances[0]
402
+ first.simulateOpen()
403
+ first.simulateClose(1006)
404
+ await act(async () => {
405
+ await vi.advanceTimersByTimeAsync(1000)
406
+ })
407
+ const second = FakeWebSocket.instances[1]
408
+ second.simulateOpen()
409
+ second.simulateClose(1006)
410
+ await act(async () => {
411
+ await vi.advanceTimersByTimeAsync(2000)
412
+ })
413
+ expect(FakeWebSocket.instances).toHaveLength(3)
414
+
415
+ // This time the connection stays open past the stability threshold
416
+ // before dropping -- a genuine, working session that later hit a
417
+ // transient drop, not a flap.
418
+ const third = FakeWebSocket.instances[2]
419
+ third.simulateOpen()
420
+ await act(async () => {
421
+ await vi.advanceTimersByTimeAsync(2000)
422
+ })
423
+ third.simulateClose(1006)
424
+
425
+ // Backoff reset to the base delay (1000ms), not the 8000ms the
426
+ // unreset ladder (attempt 3) would have demanded.
427
+ await act(async () => {
428
+ await vi.advanceTimersByTimeAsync(1000)
429
+ })
430
+ expect(FakeWebSocket.instances).toHaveLength(4)
431
+ })
432
+
433
+ // The bridge accept()s the handshake and *then* closes 4404 -- it cannot
434
+ // deliver an application close code any other way (closing pre-accept
435
+ // aborts the upgrade and reaches the browser as a bare 403 with no code
436
+ // at all, which is exactly the live #1071 bug: every unknown session
437
+ // looked like a transient drop and retried forever). So this test opens
438
+ // first, mirroring the real server. The server half of that contract is
439
+ // pinned in tests/test_dashboard_terminal.py::
440
+ // test_unknown_session_accepts_before_closing_4404 -- the two must stay
441
+ // in sync: if the bridge ever regresses to closing pre-accept, this
442
+ // component silently falls back into the infinite-retry branch below.
443
+ it('shows a session-ended state and never retries when the bridge reports the session is gone (4404)', async () => {
444
+ renderTerminal('work-2')
445
+ const ws = FakeWebSocket.instances[0]
446
+ ws.simulateOpen()
447
+
448
+ ws.simulateClose(4404)
449
+
450
+ expect(screen.getByText(/work-2/)).toHaveTextContent('Session ended')
451
+ expect(screen.getByRole('status')).toHaveTextContent('Session ended')
452
+
453
+ // No reconnect attempt now or ever, however long we wait.
454
+ await act(async () => {
455
+ await vi.advanceTimersByTimeAsync(60_000)
456
+ })
457
+ expect(FakeWebSocket.instances).toHaveLength(1)
458
+ })
459
+
460
+ it('treats a codeless handshake rejection as transient and keeps retrying', async () => {
461
+ // A rejected *upgrade* (HTTP 403 -- e.g. the bridge closing pre-accept,
462
+ // or a proxy refusing the connection) never fires `open` and surfaces
463
+ // as an abnormal-closure 1006 with no application code. That is
464
+ // indistinguishable from a network drop from here, so the only correct
465
+ // behaviour is to retry with backoff -- which is precisely why the
466
+ // server must accept() before it can say "session gone" (4404) and be
467
+ // believed. This test documents the fallback the server-side fix
468
+ // exists to keep us out of.
469
+ renderTerminal('gone-forever')
470
+ const ws = FakeWebSocket.instances[0]
471
+
472
+ // No simulateOpen() -- the handshake itself was rejected.
473
+ ws.simulateClose(1006)
474
+
475
+ // Header label only -- the role="status" overlay is exclusive to the
476
+ // terminal 'ended' state, which this path must NOT reach.
477
+ expect(screen.getByText(/gone-forever/)).toHaveTextContent('Reconnecting…')
478
+ expect(screen.queryByRole('status')).toBeNull()
479
+
480
+ await act(async () => {
481
+ await vi.advanceTimersByTimeAsync(1000)
482
+ })
483
+ expect(FakeWebSocket.instances).toHaveLength(2)
484
+ })
485
+
486
+ it('does not reconnect after a deliberate close (close button)', async () => {
487
+ renderTerminal('work-2')
488
+ const ws = FakeWebSocket.instances[0]
489
+ ws.simulateOpen()
490
+
491
+ // fireEvent, not userEvent -- userEvent's own internal delays use
492
+ // real timers under the hood and would hang against vi.useFakeTimers().
493
+ act(() => {
494
+ fireEvent.click(screen.getByRole('button', { name: 'Close terminal' }))
495
+ })
496
+ expect(ws.closed).toBe(true)
497
+
498
+ await act(async () => {
499
+ await vi.advanceTimersByTimeAsync(60_000)
500
+ })
501
+ expect(FakeWebSocket.instances).toHaveLength(1)
502
+ })
503
+ })
504
+ })
@@ -0,0 +1,41 @@
1
+ import * as React from 'react'
2
+ import { cva, type VariantProps } from 'class-variance-authority'
3
+ import { cn } from '@/lib/utils'
4
+
5
+ // eslint-disable-next-line react-refresh/only-export-components -- cva() returns a function, not the primitive constant `allowConstantExport` permits, so this still needs the escape hatch even though it never re-renders.
6
+ export const badgeVariants = cva(
7
+ 'inline-flex items-center gap-1 rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
8
+ {
9
+ variants: {
10
+ variant: {
11
+ default: 'border-transparent bg-primary text-primary-foreground',
12
+ secondary: 'border-transparent bg-secondary text-secondary-foreground',
13
+ outline: 'border-line-strong text-foreground',
14
+ // The mocks never fill a badge solid with a status colour and put
15
+ // white/black text on top -- at these desaturated, mid-lightness
16
+ // state colours that fails contrast in one theme or the other (see
17
+ // src/index.css). Instead they use a translucent "wash" of the
18
+ // colour as the background and the solid colour as the text, same
19
+ // as `.tag.ok` / `.tag.warn` / `.tag.bad` and the stage-verdict
20
+ // text in docs/mocks/web/*.html -- and it happens to read AA-clean
21
+ // in both themes for free.
22
+ success: 'border-transparent bg-pass-wash text-pass',
23
+ warning: 'border-transparent bg-attn-wash text-attn',
24
+ destructive: 'border-transparent bg-fail-wash text-fail',
25
+ },
26
+ },
27
+ defaultVariants: {
28
+ variant: 'default',
29
+ },
30
+ },
31
+ )
32
+
33
+ export interface BadgeProps
34
+ extends React.HTMLAttributes<HTMLDivElement>,
35
+ VariantProps<typeof badgeVariants> {}
36
+
37
+ function Badge({ className, variant, ...props }: BadgeProps) {
38
+ return <div className={cn(badgeVariants({ variant }), className)} {...props} />
39
+ }
40
+
41
+ export { Badge }
@@ -0,0 +1,54 @@
1
+ import * as React from 'react'
2
+ import { Slot } from '@radix-ui/react-slot'
3
+ import { cva, type VariantProps } from 'class-variance-authority'
4
+ import { cn } from '@/lib/utils'
5
+
6
+ // eslint-disable-next-line react-refresh/only-export-components -- cva() returns a function, not the primitive constant `allowConstantExport` permits, so this still needs the escape hatch even though it never re-renders.
7
+ export const buttonVariants = cva(
8
+ "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50 [&_svg]:size-4 [&_svg]:shrink-0",
9
+ {
10
+ variants: {
11
+ // "default" is the mocks' `.btn-primary` (solid brand cyan — reserve
12
+ // for the one primary action, e.g. "Drive"). "outline" is the mocks'
13
+ // base `.btn` (neutral border, e.g. "Attach") and is what most
14
+ // toolbar actions should use.
15
+ variant: {
16
+ default: 'bg-primary text-primary-foreground hover:opacity-90',
17
+ outline: 'border border-line-strong text-foreground hover:bg-secondary',
18
+ secondary: 'bg-secondary text-secondary-foreground hover:bg-surface-3',
19
+ ghost: 'text-foreground hover:bg-secondary',
20
+ destructive: 'bg-destructive text-destructive-foreground hover:opacity-90',
21
+ link: 'text-primary underline-offset-4 hover:underline',
22
+ },
23
+ size: {
24
+ default: 'h-9 px-4 py-2',
25
+ sm: 'h-8 rounded-sm px-3 text-xs',
26
+ lg: 'h-10 rounded-md px-6',
27
+ icon: 'h-9 w-9 shrink-0 p-0',
28
+ },
29
+ },
30
+ defaultVariants: {
31
+ variant: 'default',
32
+ size: 'default',
33
+ },
34
+ },
35
+ )
36
+
37
+ export interface ButtonProps
38
+ extends React.ButtonHTMLAttributes<HTMLButtonElement>,
39
+ VariantProps<typeof buttonVariants> {
40
+ /** Render as the single child element (e.g. a router `<Link>`) instead of a `<button>`. */
41
+ asChild?: boolean
42
+ }
43
+
44
+ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
45
+ ({ className, variant, size, asChild = false, ...props }, ref) => {
46
+ const Comp = asChild ? Slot : 'button'
47
+ return (
48
+ <Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />
49
+ )
50
+ },
51
+ )
52
+ Button.displayName = 'Button'
53
+
54
+ export { Button }
@@ -0,0 +1,55 @@
1
+ import * as React from 'react'
2
+ import { cn } from '@/lib/utils'
3
+
4
+ const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
5
+ ({ className, ...props }, ref) => (
6
+ <div
7
+ ref={ref}
8
+ className={cn('rounded-lg border bg-card text-card-foreground', className)}
9
+ {...props}
10
+ />
11
+ ),
12
+ )
13
+ Card.displayName = 'Card'
14
+
15
+ const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
16
+ ({ className, ...props }, ref) => (
17
+ <div ref={ref} className={cn('flex flex-col space-y-1.5 p-4', className)} {...props} />
18
+ ),
19
+ )
20
+ CardHeader.displayName = 'CardHeader'
21
+
22
+ const CardTitle = React.forwardRef<HTMLHeadingElement, React.HTMLAttributes<HTMLHeadingElement>>(
23
+ ({ className, ...props }, ref) => (
24
+ <h3
25
+ ref={ref}
26
+ className={cn('text-base font-semibold leading-none tracking-tight', className)}
27
+ {...props}
28
+ />
29
+ ),
30
+ )
31
+ CardTitle.displayName = 'CardTitle'
32
+
33
+ const CardDescription = React.forwardRef<
34
+ HTMLParagraphElement,
35
+ React.HTMLAttributes<HTMLParagraphElement>
36
+ >(({ className, ...props }, ref) => (
37
+ <p ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />
38
+ ))
39
+ CardDescription.displayName = 'CardDescription'
40
+
41
+ const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
42
+ ({ className, ...props }, ref) => (
43
+ <div ref={ref} className={cn('p-4 pt-0', className)} {...props} />
44
+ ),
45
+ )
46
+ CardContent.displayName = 'CardContent'
47
+
48
+ const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
49
+ ({ className, ...props }, ref) => (
50
+ <div ref={ref} className={cn('flex items-center p-4 pt-0', className)} {...props} />
51
+ ),
52
+ )
53
+ CardFooter.displayName = 'CardFooter'
54
+
55
+ export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter }