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.
- code_coordinator-0.5.46.dist-info/METADATA +625 -0
- code_coordinator-0.5.46.dist-info/RECORD +295 -0
- code_coordinator-0.5.46.dist-info/WHEEL +5 -0
- code_coordinator-0.5.46.dist-info/entry_points.txt +2 -0
- code_coordinator-0.5.46.dist-info/licenses/LICENSE +110 -0
- code_coordinator-0.5.46.dist-info/top_level.txt +1 -0
- coord/__init__.py +176 -0
- coord/_board_mapping.py +229 -0
- coord/acceptance.py +468 -0
- coord/acceptance_drivers.py +632 -0
- coord/agent.py +7517 -0
- coord/agent_app.py +1555 -0
- coord/agent_update.py +417 -0
- coord/agents/opencode/.gitignore +13 -0
- coord/agents/opencode/agents/work.md +129 -0
- coord/agents/opencode/routing.jsonc +49 -0
- coord/audit.py +301 -0
- coord/auto_loop.py +1440 -0
- coord/board_bool_guard.py +72 -0
- coord/board_service.py +141 -0
- coord/board_wire.py +309 -0
- coord/brain.py +581 -0
- coord/branch_model.py +214 -0
- coord/cargo_cache.py +258 -0
- coord/ci_github.py +386 -0
- coord/ci_store.py +560 -0
- coord/claim.py +353 -0
- coord/cli.py +454 -0
- coord/client.py +610 -0
- coord/commands/__init__.py +1 -0
- coord/commands/_common.py +329 -0
- coord/commands/acceptance.py +916 -0
- coord/commands/agent_ops.py +1339 -0
- coord/commands/audit.py +131 -0
- coord/commands/chat.py +320 -0
- coord/commands/dispatch.py +1780 -0
- coord/commands/dispatch_workers.py +4894 -0
- coord/commands/drive.py +616 -0
- coord/commands/drive_queue.py +1203 -0
- coord/commands/gate_a.py +217 -0
- coord/commands/gates.py +89 -0
- coord/commands/issues.py +681 -0
- coord/commands/lifecycle.py +513 -0
- coord/commands/merge.py +1900 -0
- coord/commands/milestone.py +2081 -0
- coord/commands/plan_followup.py +1243 -0
- coord/commands/plans.py +156 -0
- coord/commands/release.py +2232 -0
- coord/commands/report.py +341 -0
- coord/commands/review.py +1523 -0
- coord/commands/scorecard.py +252 -0
- coord/commands/sessions.py +1930 -0
- coord/commands/setup.py +576 -0
- coord/commands/status.py +2089 -0
- coord/commands/terminal.py +385 -0
- coord/commands/test_gate.py +775 -0
- coord/commands/tui.py +288 -0
- coord/comments.py +718 -0
- coord/config.py +3032 -0
- coord/conflict_fix.py +633 -0
- coord/dao.py +483 -0
- coord/dashboard/__init__.py +0 -0
- coord/dashboard/fixture.py +376 -0
- coord/dashboard/index.html +658 -0
- coord/dashboard/server.py +1894 -0
- coord/dashboard/terminal.py +382 -0
- coord/dashboard/webapp/.gitignore +9 -0
- coord/dashboard/webapp/components.json +17 -0
- coord/dashboard/webapp/dist/assets/Gallery-da3qNiIw.js +71 -0
- coord/dashboard/webapp/dist/assets/Terminal-9CEnUXvW.css +32 -0
- coord/dashboard/webapp/dist/assets/Terminal-skVFCxPU.js +63 -0
- coord/dashboard/webapp/dist/assets/index-DltfZR5f.js +184 -0
- coord/dashboard/webapp/dist/assets/index-Dq4kwTdw.css +1 -0
- coord/dashboard/webapp/dist/assets/workbox-window.prod.es5-BqEJf4Xk.js +2 -0
- coord/dashboard/webapp/dist/icons/icon-192.png +0 -0
- coord/dashboard/webapp/dist/icons/icon-512.png +0 -0
- coord/dashboard/webapp/dist/icons/icon.svg +5 -0
- coord/dashboard/webapp/dist/index.html +38 -0
- coord/dashboard/webapp/dist/manifest.webmanifest +1 -0
- coord/dashboard/webapp/dist/sw.js +1 -0
- coord/dashboard/webapp/dist/workbox-e4022e15.js +1 -0
- coord/dashboard/webapp/e2e/available-gates-terminal.spec.ts +75 -0
- coord/dashboard/webapp/e2e/deep-link.spec.ts +172 -0
- coord/dashboard/webapp/e2e/fixtureServer.ts +155 -0
- coord/dashboard/webapp/e2e/live-update-fixture.spec.ts +113 -0
- coord/dashboard/webapp/e2e/realtime.spec.ts +238 -0
- coord/dashboard/webapp/e2e/shell.spec.ts +309 -0
- coord/dashboard/webapp/e2e/smoke.spec.ts +191 -0
- coord/dashboard/webapp/e2e/terminal.spec.ts +420 -0
- coord/dashboard/webapp/e2e/theme.spec.ts +138 -0
- coord/dashboard/webapp/eslint.config.js +20 -0
- coord/dashboard/webapp/index.html +37 -0
- coord/dashboard/webapp/node_modules/flatted/python/flatted.py +144 -0
- coord/dashboard/webapp/package-lock.json +10584 -0
- coord/dashboard/webapp/package.json +63 -0
- coord/dashboard/webapp/playwright.acceptance.config.ts +166 -0
- coord/dashboard/webapp/playwright.config.ts +93 -0
- coord/dashboard/webapp/postcss.config.js +6 -0
- coord/dashboard/webapp/public/icons/icon-192.png +0 -0
- coord/dashboard/webapp/public/icons/icon-512.png +0 -0
- coord/dashboard/webapp/public/icons/icon.svg +5 -0
- coord/dashboard/webapp/src/App.tsx +140 -0
- coord/dashboard/webapp/src/api/client.ts +199 -0
- coord/dashboard/webapp/src/api/generated.ts +176 -0
- coord/dashboard/webapp/src/components/ConnectionBadge.tsx +52 -0
- coord/dashboard/webapp/src/components/Detail.tsx +800 -0
- coord/dashboard/webapp/src/components/Gallery.tsx +341 -0
- coord/dashboard/webapp/src/components/Home.tsx +435 -0
- coord/dashboard/webapp/src/components/MobileKeyBar.tsx +280 -0
- coord/dashboard/webapp/src/components/PanelHeader.tsx +59 -0
- coord/dashboard/webapp/src/components/PipelineCard.tsx +168 -0
- coord/dashboard/webapp/src/components/SessionCard.tsx +99 -0
- coord/dashboard/webapp/src/components/SessionDetail.tsx +140 -0
- coord/dashboard/webapp/src/components/SessionsList.tsx +81 -0
- coord/dashboard/webapp/src/components/Terminal.tsx +376 -0
- coord/dashboard/webapp/src/components/__tests__/ConnectionBadge.test.tsx +81 -0
- coord/dashboard/webapp/src/components/__tests__/Detail.test.tsx +680 -0
- coord/dashboard/webapp/src/components/__tests__/Gallery.test.tsx +83 -0
- coord/dashboard/webapp/src/components/__tests__/Home.test.tsx +271 -0
- coord/dashboard/webapp/src/components/__tests__/MobileKeyBar.test.tsx +197 -0
- coord/dashboard/webapp/src/components/__tests__/PipelineCard.test.tsx +143 -0
- coord/dashboard/webapp/src/components/__tests__/SessionCard.test.tsx +106 -0
- coord/dashboard/webapp/src/components/__tests__/Terminal.test.tsx +504 -0
- coord/dashboard/webapp/src/components/ui/badge.tsx +41 -0
- coord/dashboard/webapp/src/components/ui/button.tsx +54 -0
- coord/dashboard/webapp/src/components/ui/card.tsx +55 -0
- coord/dashboard/webapp/src/components/ui/dialog.tsx +99 -0
- coord/dashboard/webapp/src/components/ui/dropdown-menu.tsx +189 -0
- coord/dashboard/webapp/src/components/ui/empty-state.tsx +35 -0
- coord/dashboard/webapp/src/components/ui/sheet.tsx +123 -0
- coord/dashboard/webapp/src/components/ui/skeleton.tsx +9 -0
- coord/dashboard/webapp/src/components/ui/tabs.tsx +55 -0
- coord/dashboard/webapp/src/components/ui/theme-provider.tsx +78 -0
- coord/dashboard/webapp/src/components/ui/theme-toggle.tsx +20 -0
- coord/dashboard/webapp/src/components/ui/toast.tsx +123 -0
- coord/dashboard/webapp/src/components/ui/toaster.tsx +30 -0
- coord/dashboard/webapp/src/components/ui/tooltip.tsx +26 -0
- coord/dashboard/webapp/src/components/ui/use-toast.ts +134 -0
- coord/dashboard/webapp/src/index.css +210 -0
- coord/dashboard/webapp/src/lib/pipeline.ts +29 -0
- coord/dashboard/webapp/src/lib/utils.ts +6 -0
- coord/dashboard/webapp/src/main.tsx +46 -0
- coord/dashboard/webapp/src/realtime/RealtimeProvider.tsx +112 -0
- coord/dashboard/webapp/src/realtime/__tests__/RealtimeProvider.test.tsx +189 -0
- coord/dashboard/webapp/src/realtime/__tests__/connection.test.ts +255 -0
- coord/dashboard/webapp/src/realtime/connection.ts +227 -0
- coord/dashboard/webapp/src/realtime/events.ts +100 -0
- coord/dashboard/webapp/src/routes/__tests__/paths.test.ts +92 -0
- coord/dashboard/webapp/src/routes/paths.ts +92 -0
- coord/dashboard/webapp/src/shell/ActivityRail.tsx +335 -0
- coord/dashboard/webapp/src/shell/AppShell.tsx +276 -0
- coord/dashboard/webapp/src/shell/ComingSoon.tsx +33 -0
- coord/dashboard/webapp/src/shell/EmptyDetail.tsx +26 -0
- coord/dashboard/webapp/src/shell/RouteNotFound.tsx +33 -0
- coord/dashboard/webapp/src/shell/ShellLayout.tsx +147 -0
- coord/dashboard/webapp/src/shell/StatusBar.tsx +46 -0
- coord/dashboard/webapp/src/shell/__tests__/ShellLayout.test.tsx +520 -0
- coord/dashboard/webapp/src/shell/__tests__/shellState.test.ts +95 -0
- coord/dashboard/webapp/src/shell/__tests__/stubViewport.ts +40 -0
- coord/dashboard/webapp/src/shell/breakpoints.ts +87 -0
- coord/dashboard/webapp/src/shell/railItems.ts +105 -0
- coord/dashboard/webapp/src/shell/shellState.ts +174 -0
- coord/dashboard/webapp/src/shell/useRegionFocus.ts +95 -0
- coord/dashboard/webapp/src/test-setup.ts +41 -0
- coord/dashboard/webapp/src/vite-env.d.ts +2 -0
- coord/dashboard/webapp/tailwind.config.js +140 -0
- coord/dashboard/webapp/tsconfig.json +25 -0
- coord/dashboard/webapp/tsconfig.node.json +11 -0
- coord/dashboard/webapp/vite.config.ts +71 -0
- coord/db.py +1076 -0
- coord/dead_end.py +332 -0
- coord/deploy/README.md +33 -0
- coord/deploy/coord-agent.service +89 -0
- coord/deploy/coord-db-backup.service +60 -0
- coord/deploy/coord-db-backup.sh +74 -0
- coord/deploy/coord-db-backup.timer +18 -0
- coord/deploy/coord-drive-queue.service +117 -0
- coord/deploy/coord-drive-queue.timer +39 -0
- coord/deploy/coord-notify.service +48 -0
- coord/deploy/coord-notify.timer +24 -0
- coord/deploy/coord-release-propagate.service +83 -0
- coord/deploy/coord-release-propagate.timer +38 -0
- coord/deploy/coord-release-window.service +119 -0
- coord/deploy/coord-release-window.timer +36 -0
- coord/deploy/coord-serve.service +82 -0
- coord/deploy/coord-web-dist-build.service +43 -0
- coord/deploy/coord-web-dist-build.timer +36 -0
- coord/deploy/coord-web.service +125 -0
- coord/deploy_manifest.py +80 -0
- coord/deploy_units.py +384 -0
- coord/deps.py +115 -0
- coord/diagnose.py +1623 -0
- coord/dispatch.py +1009 -0
- coord/dist_name.py +123 -0
- coord/drive.py +3101 -0
- coord/drive_queue.py +2298 -0
- coord/drive_state.py +870 -0
- coord/events.py +381 -0
- coord/failure_class.py +914 -0
- coord/filelock.py +168 -0
- coord/fleet_config_health.py +300 -0
- coord/freshness.py +206 -0
- coord/gate_a.py +469 -0
- coord/gate_b.py +411 -0
- coord/gate_snapshot.py +385 -0
- coord/gates.py +582 -0
- coord/github_ops.py +1954 -0
- coord/goal.py +125 -0
- coord/graph_health.py +348 -0
- coord/health/__init__.py +69 -0
- coord/health/aggregate.py +129 -0
- coord/health/checks/__init__.py +13 -0
- coord/health/checks/agent_install.py +280 -0
- coord/health/checks/cargo_targets.py +171 -0
- coord/health/checks/claude_binary.py +65 -0
- coord/health/checks/deploy_lane_facts.py +458 -0
- coord/health/checks/disk.py +99 -0
- coord/health/checks/fleet_board.py +89 -0
- coord/health/checks/fleet_deploy_lanes.py +469 -0
- coord/health/checks/fleet_phantom.py +69 -0
- coord/health/checks/fleet_unit_drift.py +151 -0
- coord/health/checks/graph.py +192 -0
- coord/health/checks/plan_usage.py +88 -0
- coord/health/checks/repo_state.py +161 -0
- coord/health/checks/spawned_coord.py +465 -0
- coord/health/checks/timer_active.py +254 -0
- coord/health/checks/toolchain.py +547 -0
- coord/health/checks/unit_drift.py +648 -0
- coord/health/checks/unit_enablement.py +171 -0
- coord/health/checks/worktrees.py +96 -0
- coord/health/cli.py +121 -0
- coord/health/context.py +106 -0
- coord/health/fleet_snapshot.py +477 -0
- coord/health/models.py +250 -0
- coord/health/pypi.py +231 -0
- coord/health/registry.py +240 -0
- coord/health/render.py +82 -0
- coord/health/units.py +60 -0
- coord/hooks.py +106 -0
- coord/housekeeping.py +204 -0
- coord/interactive.py +4286 -0
- coord/issue_store.py +1496 -0
- coord/liveness_auditor.py +293 -0
- coord/machine_pause.py +755 -0
- coord/merge_queue.py +4681 -0
- coord/milestone_chat.py +600 -0
- coord/milestone_dispatch.py +943 -0
- coord/milestone_gate.py +709 -0
- coord/milestone_order.py +840 -0
- coord/mock_author.py +334 -0
- coord/models.py +891 -0
- coord/network.py +269 -0
- coord/new_issue_chat.py +229 -0
- coord/notify.py +3226 -0
- coord/openapi.py +404 -0
- coord/overlap_fence.py +133 -0
- coord/parentage.py +200 -0
- coord/parentage_github.py +58 -0
- coord/pipeline.py +481 -0
- coord/plan_parser.py +266 -0
- coord/plans.py +543 -0
- coord/platform_paths.py +43 -0
- coord/pr_body_lint.py +67 -0
- coord/prereqs.py +533 -0
- coord/progress.py +425 -0
- coord/providers/__init__.py +683 -0
- coord/providers/base.py +218 -0
- coord/providers/claude.py +284 -0
- coord/providers/claude_pty.py +610 -0
- coord/providers/opencode.py +896 -0
- coord/reconcile.py +2233 -0
- coord/refine_chat.py +485 -0
- coord/release_cordon.py +525 -0
- coord/release_propagate.py +1176 -0
- coord/release_verify.py +777 -0
- coord/release_window.py +322 -0
- coord/reports.py +1643 -0
- coord/revalidate.py +1101 -0
- coord/review.py +3317 -0
- coord/scorecard.py +484 -0
- coord/serve_app.py +7192 -0
- coord/skills/update-issue/SKILL.md +93 -0
- coord/smoke.py +1030 -0
- coord/split_work.py +210 -0
- coord/stage_projection.py +650 -0
- coord/state.py +5720 -0
- coord/test_author.py +1064 -0
- coord/test_chat.py +352 -0
- coord/test_orchestrator.py +494 -0
- coord/test_report.py +178 -0
- coord/tui_release.py +271 -0
- coord/usage.py +753 -0
- coord/usage_limits.py +358 -0
- coord/usage_rollup.py +709 -0
- 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 }
|