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,680 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Component tests for the Detail screen.
|
|
3
|
+
*
|
|
4
|
+
* Mocks @/api/client entirely; wraps renders in a QueryClientProvider +
|
|
5
|
+
* MemoryRouter so useQuery / useNavigate / useParams work correctly.
|
|
6
|
+
*/
|
|
7
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
8
|
+
import { render, screen, waitFor, within } from '@testing-library/react'
|
|
9
|
+
import userEvent from '@testing-library/user-event'
|
|
10
|
+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
|
11
|
+
import { MemoryRouter, Routes, Route } from 'react-router-dom'
|
|
12
|
+
import Detail from '@/components/Detail'
|
|
13
|
+
import { type PipelineView, type PipelineActionResult, type DiffResult } from '@/api/client'
|
|
14
|
+
|
|
15
|
+
// ── Mock API client ───────────────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
vi.mock('@/api/client', () => ({
|
|
18
|
+
fetchPipeline: vi.fn(),
|
|
19
|
+
fetchDiff: vi.fn(),
|
|
20
|
+
pipelineAction: vi.fn(),
|
|
21
|
+
}))
|
|
22
|
+
|
|
23
|
+
// Import after vi.mock so we get the mocked versions
|
|
24
|
+
import { fetchPipeline, fetchDiff, pipelineAction } from '@/api/client'
|
|
25
|
+
|
|
26
|
+
// Clear mock call history between every test so counts don't bleed across tests
|
|
27
|
+
beforeEach(() => {
|
|
28
|
+
vi.clearAllMocks()
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
// ── Test helpers ──────────────────────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
function makeView(overrides: Partial<PipelineView> = {}): PipelineView {
|
|
34
|
+
return {
|
|
35
|
+
assignment_id: 'work-1',
|
|
36
|
+
issue_number: 42,
|
|
37
|
+
issue_title: 'Fix the thing',
|
|
38
|
+
repo_name: 'myrepo',
|
|
39
|
+
machine_name: 'laptop',
|
|
40
|
+
current_stage: 'done',
|
|
41
|
+
stages: [
|
|
42
|
+
{ name: 'coding', status: 'completed', is_current: false },
|
|
43
|
+
{ name: 'smoke', status: 'waiting', is_current: false },
|
|
44
|
+
{ name: 'review', status: 'waiting', is_current: false },
|
|
45
|
+
{ name: 'merge', status: 'waiting', is_current: false },
|
|
46
|
+
],
|
|
47
|
+
available_gates: [],
|
|
48
|
+
progress_pct: 25,
|
|
49
|
+
review_findings_pending: false,
|
|
50
|
+
review_verdict: null,
|
|
51
|
+
// #1456: null = the reviewer's own verdict stands (the normal case).
|
|
52
|
+
review_verdict_original: null,
|
|
53
|
+
review_verdict_override_reason: null,
|
|
54
|
+
review_findings_body: null,
|
|
55
|
+
test_verdict: null,
|
|
56
|
+
needs_attention: false,
|
|
57
|
+
needs_attention_reason: null,
|
|
58
|
+
needs_attention_detail: null,
|
|
59
|
+
finished_at: null,
|
|
60
|
+
...overrides,
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function makeDiff(overrides: Partial<DiffResult> = {}): DiffResult {
|
|
65
|
+
return {
|
|
66
|
+
diff: '+added line\n-removed line\n unchanged line',
|
|
67
|
+
source: 'pr',
|
|
68
|
+
...overrides,
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function createTestQueryClient() {
|
|
73
|
+
return new QueryClient({
|
|
74
|
+
defaultOptions: {
|
|
75
|
+
queries: {
|
|
76
|
+
retry: false,
|
|
77
|
+
// Disable auto-refetch in tests so we control data
|
|
78
|
+
refetchInterval: false,
|
|
79
|
+
refetchOnWindowFocus: false,
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
})
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function renderDetail(viewOverride?: Partial<PipelineView>) {
|
|
86
|
+
const view = makeView(viewOverride)
|
|
87
|
+
vi.mocked(fetchPipeline).mockResolvedValue([view])
|
|
88
|
+
vi.mocked(fetchDiff).mockResolvedValue(makeDiff())
|
|
89
|
+
vi.mocked(pipelineAction).mockResolvedValue({ ok: true } satisfies PipelineActionResult)
|
|
90
|
+
|
|
91
|
+
const queryClient = createTestQueryClient()
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
view,
|
|
95
|
+
...render(
|
|
96
|
+
<QueryClientProvider client={queryClient}>
|
|
97
|
+
<MemoryRouter initialEntries={['/pipeline/myrepo/42']}>
|
|
98
|
+
<Routes>
|
|
99
|
+
<Route path="/pipeline/:repo/:issue" element={<Detail />} />
|
|
100
|
+
</Routes>
|
|
101
|
+
</MemoryRouter>
|
|
102
|
+
</QueryClientProvider>
|
|
103
|
+
),
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ── Loading / error / not-found ───────────────────────────────────────────────
|
|
108
|
+
|
|
109
|
+
describe('Detail — loading states', () => {
|
|
110
|
+
it('shows a loading indicator while pipeline is fetching', () => {
|
|
111
|
+
// Never resolves
|
|
112
|
+
vi.mocked(fetchPipeline).mockReturnValue(new Promise(() => undefined))
|
|
113
|
+
vi.mocked(fetchDiff).mockResolvedValue(makeDiff())
|
|
114
|
+
|
|
115
|
+
render(
|
|
116
|
+
<QueryClientProvider client={createTestQueryClient()}>
|
|
117
|
+
<MemoryRouter initialEntries={['/pipeline/myrepo/42']}>
|
|
118
|
+
<Routes>
|
|
119
|
+
<Route path="/pipeline/:repo/:issue" element={<Detail />} />
|
|
120
|
+
</Routes>
|
|
121
|
+
</MemoryRouter>
|
|
122
|
+
</QueryClientProvider>
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
expect(screen.getByText(/loading/i)).toBeInTheDocument()
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('shows an error message when the pipeline fetch fails', async () => {
|
|
129
|
+
vi.mocked(fetchPipeline).mockRejectedValue(new Error('network error'))
|
|
130
|
+
vi.mocked(fetchDiff).mockResolvedValue(makeDiff())
|
|
131
|
+
|
|
132
|
+
render(
|
|
133
|
+
<QueryClientProvider client={createTestQueryClient()}>
|
|
134
|
+
<MemoryRouter initialEntries={['/pipeline/myrepo/42']}>
|
|
135
|
+
<Routes>
|
|
136
|
+
<Route path="/pipeline/:repo/:issue" element={<Detail />} />
|
|
137
|
+
</Routes>
|
|
138
|
+
</MemoryRouter>
|
|
139
|
+
</QueryClientProvider>
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
await waitFor(() => {
|
|
143
|
+
expect(screen.getByText(/failed to load pipeline/i)).toBeInTheDocument()
|
|
144
|
+
})
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
it('shows not-found message when the repo/issue is absent from pipeline data', async () => {
|
|
148
|
+
// Pipeline has a different issue entirely
|
|
149
|
+
vi.mocked(fetchPipeline).mockResolvedValue([
|
|
150
|
+
makeView({ repo_name: 'other-repo', issue_number: 999 }),
|
|
151
|
+
])
|
|
152
|
+
vi.mocked(fetchDiff).mockResolvedValue(makeDiff())
|
|
153
|
+
|
|
154
|
+
render(
|
|
155
|
+
<QueryClientProvider client={createTestQueryClient()}>
|
|
156
|
+
<MemoryRouter initialEntries={['/pipeline/myrepo/42']}>
|
|
157
|
+
<Routes>
|
|
158
|
+
<Route path="/pipeline/:repo/:issue" element={<Detail />} />
|
|
159
|
+
</Routes>
|
|
160
|
+
</MemoryRouter>
|
|
161
|
+
</QueryClientProvider>
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
await waitFor(() => {
|
|
165
|
+
expect(screen.getByText(/not found in the pipeline/i)).toBeInTheDocument()
|
|
166
|
+
})
|
|
167
|
+
})
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
// ── Header ────────────────────────────────────────────────────────────────────
|
|
171
|
+
|
|
172
|
+
describe('Detail — header', () => {
|
|
173
|
+
it('renders repo, issue number, title, and machine after data loads', async () => {
|
|
174
|
+
renderDetail()
|
|
175
|
+
|
|
176
|
+
await waitFor(() => {
|
|
177
|
+
expect(screen.getByText('Fix the thing')).toBeInTheDocument()
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
expect(screen.getByText(/myrepo/)).toBeInTheDocument()
|
|
181
|
+
expect(screen.getByText(/#42/)).toBeInTheDocument()
|
|
182
|
+
expect(screen.getByText(/laptop/)).toBeInTheDocument()
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
it('renders a back button', async () => {
|
|
186
|
+
renderDetail()
|
|
187
|
+
|
|
188
|
+
await waitFor(() => screen.getByLabelText('Back'))
|
|
189
|
+
expect(screen.getByLabelText('Back')).toBeInTheDocument()
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
it('shows the overall status badge', async () => {
|
|
193
|
+
renderDetail({ current_stage: 'done' })
|
|
194
|
+
|
|
195
|
+
await waitFor(() => {
|
|
196
|
+
expect(screen.getByText('work done')).toBeInTheDocument()
|
|
197
|
+
})
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
it('renders stage chips', async () => {
|
|
201
|
+
renderDetail()
|
|
202
|
+
|
|
203
|
+
await waitFor(() => screen.getByText('work'))
|
|
204
|
+
expect(screen.getByText('work')).toBeInTheDocument()
|
|
205
|
+
expect(screen.getByText('review')).toBeInTheDocument()
|
|
206
|
+
expect(screen.getByText('test')).toBeInTheDocument()
|
|
207
|
+
expect(screen.getByText('merge')).toBeInTheDocument()
|
|
208
|
+
})
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
// ── Test gate ─────────────────────────────────────────────────────────────────
|
|
212
|
+
|
|
213
|
+
describe('Detail — test gate', () => {
|
|
214
|
+
it('shows Pass and Fail buttons when test-verdict gate is available', async () => {
|
|
215
|
+
renderDetail({
|
|
216
|
+
available_gates: [{ action: 'test-verdict', label: 'Record test verdict', endpoint: '/api/pipeline/action' }],
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
await waitFor(() => screen.getByText('Pass'))
|
|
220
|
+
expect(screen.getByText('Pass')).toBeInTheDocument()
|
|
221
|
+
expect(screen.getByText('Fail')).toBeInTheDocument()
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
it('hides test gate buttons when no test-verdict gate', async () => {
|
|
225
|
+
renderDetail({ available_gates: [] })
|
|
226
|
+
|
|
227
|
+
await waitFor(() => screen.getByText('Fix the thing'))
|
|
228
|
+
expect(screen.queryByText('Pass')).not.toBeInTheDocument()
|
|
229
|
+
expect(screen.queryByText('Fail')).not.toBeInTheDocument()
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
it('clicking Pass calls pipelineAction with passed verdict', async () => {
|
|
233
|
+
const user = userEvent.setup()
|
|
234
|
+
renderDetail({
|
|
235
|
+
available_gates: [{ action: 'test-verdict', label: 'Record test verdict', endpoint: '/api/pipeline/action' }],
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
await waitFor(() => screen.getByText('Pass'))
|
|
239
|
+
await user.click(screen.getByText('Pass'))
|
|
240
|
+
|
|
241
|
+
expect(vi.mocked(pipelineAction)).toHaveBeenCalledWith({
|
|
242
|
+
assignment_id: 'work-1',
|
|
243
|
+
action: 'test-verdict',
|
|
244
|
+
verdict: 'passed',
|
|
245
|
+
})
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
it('clicking Fail opens the fail dialog', async () => {
|
|
249
|
+
const user = userEvent.setup()
|
|
250
|
+
renderDetail({
|
|
251
|
+
available_gates: [{ action: 'test-verdict', label: 'Record test verdict', endpoint: '/api/pipeline/action' }],
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
await waitFor(() => screen.getByText('Fail'))
|
|
255
|
+
await user.click(screen.getByText('Fail'))
|
|
256
|
+
|
|
257
|
+
expect(screen.getByRole('dialog', { name: /record test failure/i })).toBeInTheDocument()
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
it('confirming the fail dialog calls pipelineAction with failed verdict and reason', async () => {
|
|
261
|
+
const user = userEvent.setup()
|
|
262
|
+
renderDetail({
|
|
263
|
+
available_gates: [{ action: 'test-verdict', label: 'Record test verdict', endpoint: '/api/pipeline/action' }],
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
await waitFor(() => screen.getByText('Fail'))
|
|
267
|
+
await user.click(screen.getByText('Fail'))
|
|
268
|
+
|
|
269
|
+
const dialog = screen.getByRole('dialog', { name: /record test failure/i })
|
|
270
|
+
const textarea = within(dialog).getByPlaceholderText(/what failed/i)
|
|
271
|
+
await user.type(textarea, 'build error')
|
|
272
|
+
await user.click(within(dialog).getByText('Confirm Fail'))
|
|
273
|
+
|
|
274
|
+
expect(vi.mocked(pipelineAction)).toHaveBeenCalledWith({
|
|
275
|
+
assignment_id: 'work-1',
|
|
276
|
+
action: 'test-verdict',
|
|
277
|
+
verdict: 'failed',
|
|
278
|
+
reason: 'build error',
|
|
279
|
+
})
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
it('cancelling the fail dialog dismisses it without calling pipelineAction', async () => {
|
|
283
|
+
const user = userEvent.setup()
|
|
284
|
+
renderDetail({
|
|
285
|
+
available_gates: [{ action: 'test-verdict', label: 'Record test verdict', endpoint: '/api/pipeline/action' }],
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
await waitFor(() => screen.getByText('Fail'))
|
|
289
|
+
await user.click(screen.getByText('Fail'))
|
|
290
|
+
await user.click(screen.getByText('Cancel'))
|
|
291
|
+
|
|
292
|
+
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
|
293
|
+
expect(vi.mocked(pipelineAction)).not.toHaveBeenCalled()
|
|
294
|
+
})
|
|
295
|
+
|
|
296
|
+
it('shows existing test verdict when set', async () => {
|
|
297
|
+
renderDetail({ test_verdict: 'passed', available_gates: [] })
|
|
298
|
+
|
|
299
|
+
await waitFor(() => {
|
|
300
|
+
expect(screen.getByText(/verdict: passed/i)).toBeInTheDocument()
|
|
301
|
+
})
|
|
302
|
+
})
|
|
303
|
+
|
|
304
|
+
it('shows Dispatch Fix when dispatch_fix gate is present and test failed', async () => {
|
|
305
|
+
renderDetail({
|
|
306
|
+
test_verdict: 'failed',
|
|
307
|
+
available_gates: [
|
|
308
|
+
{ action: 'dispatch_fix', label: 'Dispatch Fix', endpoint: '/api/pipeline/action' },
|
|
309
|
+
],
|
|
310
|
+
})
|
|
311
|
+
|
|
312
|
+
await waitFor(() => {
|
|
313
|
+
expect(screen.getByText('Dispatch Fix')).toBeInTheDocument()
|
|
314
|
+
})
|
|
315
|
+
})
|
|
316
|
+
|
|
317
|
+
it('does not render the test section when test_verdict is absent (undefined) and no gate', async () => {
|
|
318
|
+
// Simulates the real backend before test_verdict was added to PipelineView —
|
|
319
|
+
// dataclasses.asdict() omits undeclared fields so the frontend receives
|
|
320
|
+
// undefined rather than null. The !=/!== difference matters: `undefined !== null`
|
|
321
|
+
// is true but `undefined != null` is false, so only the loose check is safe.
|
|
322
|
+
const view = makeView()
|
|
323
|
+
const viewWithoutTestVerdict = { ...view } as Partial<PipelineView> & Record<string, unknown>
|
|
324
|
+
delete viewWithoutTestVerdict['test_verdict']
|
|
325
|
+
vi.mocked(fetchPipeline).mockResolvedValue([viewWithoutTestVerdict as PipelineView])
|
|
326
|
+
vi.mocked(fetchDiff).mockResolvedValue(makeDiff())
|
|
327
|
+
vi.mocked(pipelineAction).mockResolvedValue({ ok: true })
|
|
328
|
+
|
|
329
|
+
const queryClient = createTestQueryClient()
|
|
330
|
+
render(
|
|
331
|
+
<QueryClientProvider client={queryClient}>
|
|
332
|
+
<MemoryRouter initialEntries={['/pipeline/myrepo/42']}>
|
|
333
|
+
<Routes>
|
|
334
|
+
<Route path="/pipeline/:repo/:issue" element={<Detail />} />
|
|
335
|
+
</Routes>
|
|
336
|
+
</MemoryRouter>
|
|
337
|
+
</QueryClientProvider>
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
await waitFor(() => screen.getByText('Fix the thing'))
|
|
341
|
+
|
|
342
|
+
// Test gate section should NOT render because:
|
|
343
|
+
// - no test-verdict gate in available_gates
|
|
344
|
+
// - test_verdict is undefined (missing from backend), which != null is false
|
|
345
|
+
expect(screen.queryByRole('region', { name: /test gate/i })).not.toBeInTheDocument()
|
|
346
|
+
expect(screen.queryByText('Pass')).not.toBeInTheDocument()
|
|
347
|
+
expect(screen.queryByText('Fail')).not.toBeInTheDocument()
|
|
348
|
+
})
|
|
349
|
+
})
|
|
350
|
+
|
|
351
|
+
// ── Review ────────────────────────────────────────────────────────────────────
|
|
352
|
+
|
|
353
|
+
describe('Detail — review section', () => {
|
|
354
|
+
it('shows Start Review button when dispatch_review gate is available', async () => {
|
|
355
|
+
renderDetail({
|
|
356
|
+
available_gates: [{ action: 'dispatch_review', label: 'Start Review', endpoint: '/api/pipeline/action' }],
|
|
357
|
+
})
|
|
358
|
+
|
|
359
|
+
await waitFor(() => {
|
|
360
|
+
expect(screen.getByText('Start Review')).toBeInTheDocument()
|
|
361
|
+
})
|
|
362
|
+
})
|
|
363
|
+
|
|
364
|
+
it('renders review findings body when present', async () => {
|
|
365
|
+
renderDetail({
|
|
366
|
+
review_findings_body: 'Found 2 issues:\n1. Missing test\n2. Unused import',
|
|
367
|
+
})
|
|
368
|
+
|
|
369
|
+
await waitFor(() => {
|
|
370
|
+
expect(screen.getByText(/Found 2 issues/)).toBeInTheDocument()
|
|
371
|
+
})
|
|
372
|
+
})
|
|
373
|
+
|
|
374
|
+
it('shows review verdict when present', async () => {
|
|
375
|
+
renderDetail({ review_verdict: 'approve' })
|
|
376
|
+
|
|
377
|
+
await waitFor(() => {
|
|
378
|
+
expect(screen.getByText(/approved/i)).toBeInTheDocument()
|
|
379
|
+
})
|
|
380
|
+
})
|
|
381
|
+
|
|
382
|
+
it('shows Approve and Request Changes when record-review-verdict gate is available', async () => {
|
|
383
|
+
renderDetail({
|
|
384
|
+
available_gates: [{ action: 'record-review-verdict', label: 'Record verdict', endpoint: '/api/pipeline/action' }],
|
|
385
|
+
})
|
|
386
|
+
|
|
387
|
+
await waitFor(() => screen.getByText('Approve'))
|
|
388
|
+
expect(screen.getByText('Approve')).toBeInTheDocument()
|
|
389
|
+
expect(screen.getByText('Request Changes')).toBeInTheDocument()
|
|
390
|
+
})
|
|
391
|
+
|
|
392
|
+
it('Approve calls pipelineAction with record-review-verdict approve', async () => {
|
|
393
|
+
const user = userEvent.setup()
|
|
394
|
+
renderDetail({
|
|
395
|
+
available_gates: [{ action: 'record-review-verdict', label: 'Record verdict', endpoint: '/api/pipeline/action' }],
|
|
396
|
+
})
|
|
397
|
+
|
|
398
|
+
await waitFor(() => screen.getByText('Approve'))
|
|
399
|
+
await user.click(screen.getByText('Approve'))
|
|
400
|
+
|
|
401
|
+
expect(vi.mocked(pipelineAction)).toHaveBeenCalledWith({
|
|
402
|
+
assignment_id: 'work-1',
|
|
403
|
+
action: 'record-review-verdict',
|
|
404
|
+
verdict: 'approve',
|
|
405
|
+
})
|
|
406
|
+
})
|
|
407
|
+
|
|
408
|
+
it('Request Changes calls pipelineAction with dispatch_fix', async () => {
|
|
409
|
+
const user = userEvent.setup()
|
|
410
|
+
renderDetail({
|
|
411
|
+
available_gates: [{ action: 'record-review-verdict', label: 'Record verdict', endpoint: '/api/pipeline/action' }],
|
|
412
|
+
})
|
|
413
|
+
|
|
414
|
+
await waitFor(() => screen.getByText('Request Changes'))
|
|
415
|
+
await user.click(screen.getByText('Request Changes'))
|
|
416
|
+
|
|
417
|
+
expect(vi.mocked(pipelineAction)).toHaveBeenCalledWith({
|
|
418
|
+
assignment_id: 'work-1',
|
|
419
|
+
action: 'dispatch_fix',
|
|
420
|
+
})
|
|
421
|
+
})
|
|
422
|
+
|
|
423
|
+
it('shows Post button when post_findings gate is available', async () => {
|
|
424
|
+
renderDetail({
|
|
425
|
+
review_findings_pending: true,
|
|
426
|
+
available_gates: [{ action: 'post_findings', label: 'Post findings', endpoint: '/api/pipeline/action' }],
|
|
427
|
+
})
|
|
428
|
+
|
|
429
|
+
await waitFor(() => {
|
|
430
|
+
expect(screen.getByText('Post')).toBeInTheDocument()
|
|
431
|
+
})
|
|
432
|
+
})
|
|
433
|
+
})
|
|
434
|
+
|
|
435
|
+
// ── Merge ─────────────────────────────────────────────────────────────────────
|
|
436
|
+
|
|
437
|
+
describe('Detail — merge section', () => {
|
|
438
|
+
it('shows Merge button when merge gate is available', async () => {
|
|
439
|
+
renderDetail({
|
|
440
|
+
current_stage: 'merge_ready',
|
|
441
|
+
available_gates: [{ action: 'merge', label: 'Merge', endpoint: '/api/pipeline/action' }],
|
|
442
|
+
})
|
|
443
|
+
|
|
444
|
+
await waitFor(() => {
|
|
445
|
+
expect(screen.getByRole('button', { name: 'Merge' })).toBeInTheDocument()
|
|
446
|
+
})
|
|
447
|
+
})
|
|
448
|
+
|
|
449
|
+
it('shows Enqueue button when enqueue gate is available', async () => {
|
|
450
|
+
renderDetail({
|
|
451
|
+
available_gates: [{ action: 'enqueue', label: 'Enqueue', endpoint: '/api/pipeline/action' }],
|
|
452
|
+
})
|
|
453
|
+
|
|
454
|
+
await waitFor(() => {
|
|
455
|
+
expect(screen.getByRole('button', { name: 'Enqueue' })).toBeInTheDocument()
|
|
456
|
+
})
|
|
457
|
+
})
|
|
458
|
+
|
|
459
|
+
it('clicking Merge calls pipelineAction with merge action', async () => {
|
|
460
|
+
const user = userEvent.setup()
|
|
461
|
+
renderDetail({
|
|
462
|
+
current_stage: 'merge_ready',
|
|
463
|
+
available_gates: [{ action: 'merge', label: 'Merge', endpoint: '/api/pipeline/action' }],
|
|
464
|
+
})
|
|
465
|
+
|
|
466
|
+
const mergeBtn = await screen.findByRole('button', { name: 'Merge' })
|
|
467
|
+
await user.click(mergeBtn)
|
|
468
|
+
|
|
469
|
+
expect(vi.mocked(pipelineAction)).toHaveBeenCalledWith({
|
|
470
|
+
assignment_id: 'work-1',
|
|
471
|
+
action: 'merge',
|
|
472
|
+
})
|
|
473
|
+
})
|
|
474
|
+
|
|
475
|
+
it('force checkbox changes Merge button to Force Merge and adds force:true to payload', async () => {
|
|
476
|
+
const user = userEvent.setup()
|
|
477
|
+
renderDetail({
|
|
478
|
+
current_stage: 'merge_ready',
|
|
479
|
+
available_gates: [{ action: 'merge', label: 'Merge', endpoint: '/api/pipeline/action' }],
|
|
480
|
+
})
|
|
481
|
+
|
|
482
|
+
await screen.findByRole('button', { name: 'Merge' })
|
|
483
|
+
|
|
484
|
+
const checkbox = screen.getByRole('checkbox')
|
|
485
|
+
await user.click(checkbox)
|
|
486
|
+
|
|
487
|
+
const forceMergeBtn = screen.getByRole('button', { name: 'Force Merge' })
|
|
488
|
+
expect(forceMergeBtn).toBeInTheDocument()
|
|
489
|
+
await user.click(forceMergeBtn)
|
|
490
|
+
|
|
491
|
+
expect(vi.mocked(pipelineAction)).toHaveBeenCalledWith({
|
|
492
|
+
assignment_id: 'work-1',
|
|
493
|
+
action: 'merge',
|
|
494
|
+
force: true,
|
|
495
|
+
})
|
|
496
|
+
})
|
|
497
|
+
|
|
498
|
+
it('shows ✓ Merged when current_stage is merged', async () => {
|
|
499
|
+
renderDetail({ current_stage: 'merged' })
|
|
500
|
+
|
|
501
|
+
await waitFor(() => {
|
|
502
|
+
expect(screen.getByText(/✓ Merged/)).toBeInTheDocument()
|
|
503
|
+
})
|
|
504
|
+
})
|
|
505
|
+
|
|
506
|
+
it('shows merge section for merge_ready even without explicit gate', async () => {
|
|
507
|
+
renderDetail({ current_stage: 'merge_ready', available_gates: [] })
|
|
508
|
+
|
|
509
|
+
await waitFor(() => {
|
|
510
|
+
const mergeSection = screen.getByRole('region', { name: /merge/i })
|
|
511
|
+
expect(mergeSection).toBeInTheDocument()
|
|
512
|
+
})
|
|
513
|
+
})
|
|
514
|
+
})
|
|
515
|
+
|
|
516
|
+
// ── Diff viewer ───────────────────────────────────────────────────────────────
|
|
517
|
+
|
|
518
|
+
describe('Detail — diff viewer', () => {
|
|
519
|
+
it('diff section is collapsed by default', async () => {
|
|
520
|
+
renderDetail()
|
|
521
|
+
|
|
522
|
+
await waitFor(() => screen.getByText('Fix the thing'))
|
|
523
|
+
|
|
524
|
+
// Diff toggle is present but content not shown
|
|
525
|
+
const toggle = screen.getByRole('button', { name: /diff/i })
|
|
526
|
+
expect(toggle).toHaveAttribute('aria-expanded', 'false')
|
|
527
|
+
expect(screen.queryByText(/Loading diff/)).not.toBeInTheDocument()
|
|
528
|
+
expect(vi.mocked(fetchDiff)).not.toHaveBeenCalled()
|
|
529
|
+
})
|
|
530
|
+
|
|
531
|
+
it('expanding the diff triggers fetchDiff and shows content', async () => {
|
|
532
|
+
const user = userEvent.setup()
|
|
533
|
+
renderDetail()
|
|
534
|
+
|
|
535
|
+
await waitFor(() => screen.getByText('Fix the thing'))
|
|
536
|
+
|
|
537
|
+
const toggle = screen.getByRole('button', { name: /diff/i })
|
|
538
|
+
await user.click(toggle)
|
|
539
|
+
|
|
540
|
+
expect(toggle).toHaveAttribute('aria-expanded', 'true')
|
|
541
|
+
|
|
542
|
+
await waitFor(() => {
|
|
543
|
+
expect(vi.mocked(fetchDiff)).toHaveBeenCalledWith('work-1')
|
|
544
|
+
})
|
|
545
|
+
|
|
546
|
+
await waitFor(() => {
|
|
547
|
+
expect(screen.getByLabelText('Code diff')).toBeInTheDocument()
|
|
548
|
+
})
|
|
549
|
+
})
|
|
550
|
+
|
|
551
|
+
it('collapsing diff after expanding hides the content', async () => {
|
|
552
|
+
const user = userEvent.setup()
|
|
553
|
+
renderDetail()
|
|
554
|
+
|
|
555
|
+
await waitFor(() => screen.getByText('Fix the thing'))
|
|
556
|
+
|
|
557
|
+
const toggle = screen.getByRole('button', { name: /diff/i })
|
|
558
|
+
await user.click(toggle)
|
|
559
|
+
|
|
560
|
+
await waitFor(() => screen.getByLabelText('Code diff'))
|
|
561
|
+
|
|
562
|
+
await user.click(toggle)
|
|
563
|
+
expect(screen.queryByLabelText('Code diff')).not.toBeInTheDocument()
|
|
564
|
+
})
|
|
565
|
+
|
|
566
|
+
it('shows diff error when fetchDiff rejects', async () => {
|
|
567
|
+
const user = userEvent.setup()
|
|
568
|
+
renderDetail()
|
|
569
|
+
await waitFor(() => screen.getByText('Fix the thing'))
|
|
570
|
+
|
|
571
|
+
// Override diff mock to reject BEFORE the user expands the diff section
|
|
572
|
+
vi.mocked(fetchDiff).mockRejectedValue(new Error('diff unavailable'))
|
|
573
|
+
|
|
574
|
+
await user.click(screen.getByRole('button', { name: /diff/i }))
|
|
575
|
+
|
|
576
|
+
await waitFor(() => {
|
|
577
|
+
expect(screen.getByText(/Failed to load diff/)).toBeInTheDocument()
|
|
578
|
+
})
|
|
579
|
+
})
|
|
580
|
+
})
|
|
581
|
+
|
|
582
|
+
// ── Toast + in-flight ─────────────────────────────────────────────────────────
|
|
583
|
+
|
|
584
|
+
describe('Detail — toast and in-flight states', () => {
|
|
585
|
+
it('shows a success toast after a successful action', async () => {
|
|
586
|
+
const user = userEvent.setup()
|
|
587
|
+
vi.mocked(pipelineAction).mockResolvedValue({ ok: true })
|
|
588
|
+
|
|
589
|
+
renderDetail({
|
|
590
|
+
available_gates: [{ action: 'test-verdict', label: 'Record test verdict', endpoint: '/api/pipeline/action' }],
|
|
591
|
+
})
|
|
592
|
+
|
|
593
|
+
await waitFor(() => screen.getByText('Pass'))
|
|
594
|
+
await user.click(screen.getByText('Pass'))
|
|
595
|
+
|
|
596
|
+
await waitFor(() => {
|
|
597
|
+
expect(screen.getByRole('status', { name: 'Notifications' })).toHaveTextContent(/marked passed/i)
|
|
598
|
+
})
|
|
599
|
+
})
|
|
600
|
+
|
|
601
|
+
it('shows an error toast when the action returns ok:false', async () => {
|
|
602
|
+
const user = userEvent.setup()
|
|
603
|
+
renderDetail({
|
|
604
|
+
available_gates: [{ action: 'test-verdict', label: 'Record test verdict', endpoint: '/api/pipeline/action' }],
|
|
605
|
+
})
|
|
606
|
+
|
|
607
|
+
// Override AFTER data loads so renderDetail's default ok:true mock is replaced
|
|
608
|
+
await waitFor(() => screen.getByText('Pass'))
|
|
609
|
+
vi.mocked(pipelineAction).mockResolvedValue({ ok: false, error: 'Board locked' })
|
|
610
|
+
|
|
611
|
+
await user.click(screen.getByText('Pass'))
|
|
612
|
+
|
|
613
|
+
await waitFor(() => {
|
|
614
|
+
expect(screen.getByRole('status', { name: 'Notifications' })).toHaveTextContent('Board locked')
|
|
615
|
+
})
|
|
616
|
+
})
|
|
617
|
+
|
|
618
|
+
it('disables action buttons while an action is in-flight', async () => {
|
|
619
|
+
const user = userEvent.setup()
|
|
620
|
+
renderDetail({
|
|
621
|
+
available_gates: [{ action: 'test-verdict', label: 'Record test verdict', endpoint: '/api/pipeline/action' }],
|
|
622
|
+
})
|
|
623
|
+
|
|
624
|
+
// Override AFTER data loads so renderDetail's default ok:true mock is replaced
|
|
625
|
+
await waitFor(() => screen.getByText('Pass'))
|
|
626
|
+
// Hang forever so we can inspect the in-flight state
|
|
627
|
+
vi.mocked(pipelineAction).mockReturnValue(new Promise(() => undefined))
|
|
628
|
+
|
|
629
|
+
await user.click(screen.getByText('Pass'))
|
|
630
|
+
|
|
631
|
+
// The Pass button should show "Recording…" and be disabled
|
|
632
|
+
await waitFor(() => {
|
|
633
|
+
const passBtn = screen.getByRole('button', { name: /Recording/i })
|
|
634
|
+
expect(passBtn).toBeDisabled()
|
|
635
|
+
})
|
|
636
|
+
})
|
|
637
|
+
})
|
|
638
|
+
|
|
639
|
+
// ── Misc gates ────────────────────────────────────────────────────────────────
|
|
640
|
+
|
|
641
|
+
describe('Detail — misc gate actions', () => {
|
|
642
|
+
beforeEach(() => {
|
|
643
|
+
vi.mocked(pipelineAction).mockResolvedValue({ ok: true })
|
|
644
|
+
})
|
|
645
|
+
|
|
646
|
+
it('shows Start Smoke Test button for dispatch_smoke gate', async () => {
|
|
647
|
+
renderDetail({
|
|
648
|
+
available_gates: [{ action: 'dispatch_smoke', label: 'Start smoke', endpoint: '/api/pipeline/action' }],
|
|
649
|
+
})
|
|
650
|
+
|
|
651
|
+
await waitFor(() => {
|
|
652
|
+
expect(screen.getByText('Start Smoke Test')).toBeInTheDocument()
|
|
653
|
+
})
|
|
654
|
+
})
|
|
655
|
+
|
|
656
|
+
it('shows Cancel (unstick) button for unstick gate', async () => {
|
|
657
|
+
renderDetail({
|
|
658
|
+
available_gates: [{ action: 'unstick', label: 'Unstick', endpoint: '/api/pipeline/action' }],
|
|
659
|
+
})
|
|
660
|
+
|
|
661
|
+
await waitFor(() => {
|
|
662
|
+
expect(screen.getByText('Cancel (unstick)')).toBeInTheDocument()
|
|
663
|
+
})
|
|
664
|
+
})
|
|
665
|
+
|
|
666
|
+
it('clicking unstick calls pipelineAction with unstick action', async () => {
|
|
667
|
+
const user = userEvent.setup()
|
|
668
|
+
renderDetail({
|
|
669
|
+
available_gates: [{ action: 'unstick', label: 'Unstick', endpoint: '/api/pipeline/action' }],
|
|
670
|
+
})
|
|
671
|
+
|
|
672
|
+
await waitFor(() => screen.getByText('Cancel (unstick)'))
|
|
673
|
+
await user.click(screen.getByText('Cancel (unstick)'))
|
|
674
|
+
|
|
675
|
+
expect(vi.mocked(pipelineAction)).toHaveBeenCalledWith({
|
|
676
|
+
assignment_id: 'work-1',
|
|
677
|
+
action: 'unstick',
|
|
678
|
+
})
|
|
679
|
+
})
|
|
680
|
+
})
|