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,520 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration tests for the responsive shell (#1547) and its route tree
|
|
3
|
+
* (#1548).
|
|
4
|
+
*
|
|
5
|
+
* These mount the *real* composition — a route tree shaped like `App.tsx`'s,
|
|
6
|
+
* `ShellLayout`, `AppShell`, `ActivityRail` and the real Home/Detail panels —
|
|
7
|
+
* with only the API client mocked, because the thing under test is precisely
|
|
8
|
+
* how those fit together at each breakpoint and how the URL drives them.
|
|
9
|
+
* Anything that asserted against a hand-rolled fixture shell would pass while
|
|
10
|
+
* the app was broken.
|
|
11
|
+
*
|
|
12
|
+
* jsdom has no layout, so `stubViewport` drives `matchMedia` instead; see
|
|
13
|
+
* `stubViewport.ts` for why that is the only lever available here.
|
|
14
|
+
*/
|
|
15
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
16
|
+
import { render, screen, waitFor, fireEvent, within } from '@testing-library/react'
|
|
17
|
+
import userEvent from '@testing-library/user-event'
|
|
18
|
+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
|
19
|
+
import { MemoryRouter, Navigate, Routes, Route, useLocation } from 'react-router-dom'
|
|
20
|
+
|
|
21
|
+
import { ThemeProvider } from '@/components/ui/theme-provider'
|
|
22
|
+
import Detail from '@/components/Detail'
|
|
23
|
+
import SessionDetail from '@/components/SessionDetail'
|
|
24
|
+
import { type PipelineView, type SessionInfo } from '@/api/client'
|
|
25
|
+
import { paths } from '@/routes/paths'
|
|
26
|
+
import { ShellLayout } from '../ShellLayout'
|
|
27
|
+
import { EmptyDetail } from '../EmptyDetail'
|
|
28
|
+
import { LIST_WIDTH_DEFAULT_PX, SHELL_STORAGE_KEY } from '../shellState'
|
|
29
|
+
import { MEDIUM_PX, NARROW_PX, WIDE_PX, restoreViewport, stubViewportWidth } from './stubViewport'
|
|
30
|
+
|
|
31
|
+
vi.mock('@/api/client', () => ({
|
|
32
|
+
fetchPipeline: vi.fn(),
|
|
33
|
+
fetchSessions: vi.fn(),
|
|
34
|
+
fetchDiff: vi.fn(),
|
|
35
|
+
pipelineAction: vi.fn(),
|
|
36
|
+
}))
|
|
37
|
+
|
|
38
|
+
import { fetchPipeline, fetchSessions } from '@/api/client'
|
|
39
|
+
|
|
40
|
+
// ── fixtures ──────────────────────────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
function makeView(overrides: Partial<PipelineView> = {}): PipelineView {
|
|
43
|
+
return {
|
|
44
|
+
assignment_id: 'work-1',
|
|
45
|
+
issue_number: 42,
|
|
46
|
+
issue_title: 'Fix the thing',
|
|
47
|
+
repo_name: 'myrepo',
|
|
48
|
+
machine_name: 'laptop',
|
|
49
|
+
current_stage: 'coding',
|
|
50
|
+
stages: [
|
|
51
|
+
{ name: 'coding', status: 'active', is_current: true },
|
|
52
|
+
{ name: 'review', status: 'waiting', is_current: false },
|
|
53
|
+
{ name: 'merge', status: 'waiting', is_current: false },
|
|
54
|
+
],
|
|
55
|
+
available_gates: [],
|
|
56
|
+
progress_pct: 10,
|
|
57
|
+
review_findings_pending: false,
|
|
58
|
+
review_verdict: null,
|
|
59
|
+
review_verdict_original: null,
|
|
60
|
+
review_verdict_override_reason: null,
|
|
61
|
+
review_findings_body: null,
|
|
62
|
+
test_verdict: null,
|
|
63
|
+
needs_attention: false,
|
|
64
|
+
needs_attention_reason: null,
|
|
65
|
+
needs_attention_detail: null,
|
|
66
|
+
finished_at: null,
|
|
67
|
+
...overrides,
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function makeSession(overrides: Partial<SessionInfo> = {}): SessionInfo {
|
|
72
|
+
return {
|
|
73
|
+
session_id: 'sess-1',
|
|
74
|
+
session_name: 'coord-sess-1',
|
|
75
|
+
machine: 'dellserver',
|
|
76
|
+
host: 'dellserver.tailnet.ts.net',
|
|
77
|
+
repo: 'otherrepo',
|
|
78
|
+
issue: 7,
|
|
79
|
+
issue_title: 'Live session issue',
|
|
80
|
+
stage: 'work',
|
|
81
|
+
status: 'running',
|
|
82
|
+
attached: false,
|
|
83
|
+
pane_dead: false,
|
|
84
|
+
...overrides,
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Surfaces the router's current path in the DOM, for deep-link / back-forward assertions. */
|
|
89
|
+
function LocationProbe() {
|
|
90
|
+
const location = useLocation()
|
|
91
|
+
return <div data-testid="location-pathname">{location.pathname}</div>
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function renderShell(initialPath = paths.pipeline()) {
|
|
95
|
+
const queryClient = new QueryClient({
|
|
96
|
+
defaultOptions: { queries: { retry: false, refetchOnWindowFocus: false } },
|
|
97
|
+
})
|
|
98
|
+
return render(
|
|
99
|
+
<QueryClientProvider client={queryClient}>
|
|
100
|
+
<ThemeProvider>
|
|
101
|
+
<MemoryRouter initialEntries={[initialPath]}>
|
|
102
|
+
<LocationProbe />
|
|
103
|
+
<Routes>
|
|
104
|
+
{/* Deliberately a *sibling* of ShellLayout, not a child of it —
|
|
105
|
+
see App.tsx's doc comment: AppShell only mounts the detail
|
|
106
|
+
slot (where a child route's element renders) when an item is
|
|
107
|
+
selected, so a redirect nested under the shell would silently
|
|
108
|
+
never fire on a narrow cold load at /. This mirrors the real
|
|
109
|
+
route tree exactly so this test file can catch that class of
|
|
110
|
+
bug instead of hiding it. */}
|
|
111
|
+
<Route path="/" element={<Navigate to={paths.pipeline()} replace />} />
|
|
112
|
+
<Route element={<ShellLayout />}>
|
|
113
|
+
<Route path="/pipeline" element={<EmptyDetail />} />
|
|
114
|
+
<Route path="/pipeline/:repo/:issue" element={<Detail />} />
|
|
115
|
+
<Route path="/pipeline/:repo/:issue/:tab" element={<Detail />} />
|
|
116
|
+
<Route path="/sessions" element={<EmptyDetail />} />
|
|
117
|
+
<Route path="/sessions/:id" element={<SessionDetail />} />
|
|
118
|
+
<Route path="/board" element={null} />
|
|
119
|
+
<Route path="*" element={null} />
|
|
120
|
+
</Route>
|
|
121
|
+
</Routes>
|
|
122
|
+
</MemoryRouter>
|
|
123
|
+
</ThemeProvider>
|
|
124
|
+
</QueryClientProvider>,
|
|
125
|
+
)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const listRegion = () => screen.queryByRole('region', { name: 'List' })
|
|
129
|
+
const detailRegion = () => screen.queryByRole('main', { name: 'Detail' })
|
|
130
|
+
const railRegion = () => screen.getByRole('navigation', { name: 'Views' })
|
|
131
|
+
const locationPath = () => screen.getByTestId('location-pathname').textContent
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* The list panel by DOM presence rather than by role.
|
|
135
|
+
*
|
|
136
|
+
* `queryByRole` walks the accessibility tree, so it cannot see the list once
|
|
137
|
+
* the medium overlay marks it `aria-hidden` — which is exactly the state the
|
|
138
|
+
* overlay tests need to assert *about*.
|
|
139
|
+
*/
|
|
140
|
+
const listElement = () => document.querySelector('[data-region="list"]')
|
|
141
|
+
|
|
142
|
+
beforeEach(() => {
|
|
143
|
+
vi.clearAllMocks()
|
|
144
|
+
window.localStorage.clear()
|
|
145
|
+
vi.mocked(fetchPipeline).mockResolvedValue([makeView()])
|
|
146
|
+
vi.mocked(fetchSessions).mockResolvedValue([makeSession()])
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
afterEach(() => {
|
|
150
|
+
restoreViewport()
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
// ── wide ──────────────────────────────────────────────────────────────────────
|
|
154
|
+
|
|
155
|
+
describe('shell — wide (>= 1024px)', () => {
|
|
156
|
+
beforeEach(() => stubViewportWidth(WIDE_PX))
|
|
157
|
+
|
|
158
|
+
it('renders rail, list and detail simultaneously', async () => {
|
|
159
|
+
renderShell()
|
|
160
|
+
|
|
161
|
+
expect(railRegion()).toBeInTheDocument()
|
|
162
|
+
expect(listRegion()).toBeInTheDocument()
|
|
163
|
+
expect(detailRegion()).toBeInTheDocument()
|
|
164
|
+
expect(await screen.findByText('Fix the thing')).toBeInTheDocument()
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
it('shows the whole programme in the rail, with unbuilt panels marked "soon"', async () => {
|
|
168
|
+
renderShell()
|
|
169
|
+
|
|
170
|
+
const rail = railRegion()
|
|
171
|
+
// Built.
|
|
172
|
+
expect(await screen.findByRole('button', { name: /Pipeline/ })).toBeInTheDocument()
|
|
173
|
+
// Not built yet, but visibly coming rather than silently absent.
|
|
174
|
+
for (const label of ['Board', 'Machines', 'Merge queue', 'Milestones', 'Audit', 'Spend']) {
|
|
175
|
+
const item = screen.getByRole('button', { name: new RegExp(label) })
|
|
176
|
+
expect(item).toHaveAttribute('aria-disabled', 'true')
|
|
177
|
+
}
|
|
178
|
+
expect(rail.textContent).toContain('soon')
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
it('badges the Pipeline entry with the in-flight count', async () => {
|
|
182
|
+
vi.mocked(fetchPipeline).mockResolvedValue([
|
|
183
|
+
makeView({ assignment_id: 'a' }),
|
|
184
|
+
makeView({ assignment_id: 'b' }),
|
|
185
|
+
makeView({ assignment_id: 'c', current_stage: 'merged' }),
|
|
186
|
+
])
|
|
187
|
+
renderShell()
|
|
188
|
+
|
|
189
|
+
// Two in flight; the merged one doesn't count.
|
|
190
|
+
await waitFor(() =>
|
|
191
|
+
expect(screen.getByRole('button', { name: /^Pipeline/ })).toHaveTextContent('2'),
|
|
192
|
+
)
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
it('shows the empty-selection placeholder in the detail column at /pipeline', async () => {
|
|
196
|
+
renderShell()
|
|
197
|
+
expect(await screen.findByText('Nothing selected')).toBeInTheDocument()
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
it('fills the detail column from the route without unmounting the list', async () => {
|
|
201
|
+
renderShell(paths.pipelineItem('myrepo', 42))
|
|
202
|
+
|
|
203
|
+
expect(listRegion()).toBeInTheDocument()
|
|
204
|
+
// The row in the list *and* the heading in the detail — the wide layout's
|
|
205
|
+
// whole point is that both are on screen at once.
|
|
206
|
+
await waitFor(() => expect(screen.getAllByText('Fix the thing').length).toBe(2))
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
it('cycles focus rail -> list -> detail -> rail on F6', async () => {
|
|
210
|
+
renderShell()
|
|
211
|
+
await screen.findByText('Fix the thing')
|
|
212
|
+
|
|
213
|
+
fireEvent.keyDown(window, { key: 'F6' })
|
|
214
|
+
expect(document.activeElement).toBe(railRegion())
|
|
215
|
+
|
|
216
|
+
fireEvent.keyDown(window, { key: 'F6' })
|
|
217
|
+
expect(document.activeElement).toBe(listRegion())
|
|
218
|
+
|
|
219
|
+
fireEvent.keyDown(window, { key: 'F6' })
|
|
220
|
+
expect(document.activeElement).toBe(detailRegion())
|
|
221
|
+
|
|
222
|
+
fireEvent.keyDown(window, { key: 'F6' })
|
|
223
|
+
expect(document.activeElement).toBe(railRegion())
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
it('cycles backwards on Shift+F6', async () => {
|
|
227
|
+
renderShell()
|
|
228
|
+
await screen.findByText('Fix the thing')
|
|
229
|
+
|
|
230
|
+
fireEvent.keyDown(window, { key: 'F6' })
|
|
231
|
+
expect(document.activeElement).toBe(railRegion())
|
|
232
|
+
|
|
233
|
+
fireEvent.keyDown(window, { key: 'F6', shiftKey: true })
|
|
234
|
+
expect(document.activeElement).toBe(detailRegion())
|
|
235
|
+
|
|
236
|
+
fireEvent.keyDown(window, { key: 'F6', shiftKey: true })
|
|
237
|
+
expect(document.activeElement).toBe(listRegion())
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
it('resizes the list from the keyboard and persists the new width', async () => {
|
|
241
|
+
renderShell()
|
|
242
|
+
const separator = await screen.findByRole('separator', { name: 'Resize list panel' })
|
|
243
|
+
expect(separator).toHaveAttribute('aria-valuenow', String(LIST_WIDTH_DEFAULT_PX))
|
|
244
|
+
|
|
245
|
+
fireEvent.keyDown(separator, { key: 'ArrowRight' })
|
|
246
|
+
expect(separator).toHaveAttribute('aria-valuenow', String(LIST_WIDTH_DEFAULT_PX + 16))
|
|
247
|
+
|
|
248
|
+
fireEvent.keyDown(separator, { key: 'End' })
|
|
249
|
+
const persisted = JSON.parse(window.localStorage.getItem(SHELL_STORAGE_KEY) ?? '{}')
|
|
250
|
+
expect(persisted.listWidthPx).toBe(640)
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
it('restores a persisted panel width from localStorage; the URL (not localStorage) picks the view', async () => {
|
|
254
|
+
window.localStorage.setItem(
|
|
255
|
+
SHELL_STORAGE_KEY,
|
|
256
|
+
JSON.stringify({ listWidthPx: 480, railCollapsed: true }),
|
|
257
|
+
)
|
|
258
|
+
renderShell(paths.sessions())
|
|
259
|
+
|
|
260
|
+
const separator = await screen.findByRole('separator', { name: 'Resize list panel' })
|
|
261
|
+
expect(separator).toHaveAttribute('aria-valuenow', '480')
|
|
262
|
+
// The Sessions view, because that's what the URL says — not because
|
|
263
|
+
// anything was persisted about "the view" (that concept no longer exists,
|
|
264
|
+
// see shellState.ts).
|
|
265
|
+
expect(await screen.findByRole('heading', { name: 'Sessions' })).toBeInTheDocument()
|
|
266
|
+
})
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
// ── narrow ────────────────────────────────────────────────────────────────────
|
|
270
|
+
|
|
271
|
+
describe('shell — narrow (< 768px): the phone app, preserved', () => {
|
|
272
|
+
beforeEach(() => stubViewportWidth(NARROW_PX))
|
|
273
|
+
|
|
274
|
+
it('mounts only the list at /pipeline', async () => {
|
|
275
|
+
renderShell()
|
|
276
|
+
|
|
277
|
+
expect(listRegion()).toBeInTheDocument()
|
|
278
|
+
expect(detailRegion()).not.toBeInTheDocument()
|
|
279
|
+
expect(await screen.findByText('Fix the thing')).toBeInTheDocument()
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
it('mounts only the detail after a cold-loaded deep link', async () => {
|
|
283
|
+
renderShell(paths.pipelineItem('myrepo', 42))
|
|
284
|
+
|
|
285
|
+
await waitFor(() => expect(detailRegion()).toBeInTheDocument())
|
|
286
|
+
expect(listRegion()).not.toBeInTheDocument()
|
|
287
|
+
// Exactly one — the list is gone, so no duplicated row text.
|
|
288
|
+
expect(await screen.findAllByText('Fix the thing')).toHaveLength(1)
|
|
289
|
+
})
|
|
290
|
+
|
|
291
|
+
it('drill-in via the list pushes a history entry, and Back goes up (not out of the app)', async () => {
|
|
292
|
+
renderShell()
|
|
293
|
+
expect(locationPath()).toBe(paths.pipeline())
|
|
294
|
+
|
|
295
|
+
await userEvent.click(await screen.findByText('Fix the thing'))
|
|
296
|
+
await waitFor(() => expect(detailRegion()).toBeInTheDocument())
|
|
297
|
+
expect(listRegion()).not.toBeInTheDocument()
|
|
298
|
+
expect(locationPath()).toBe(paths.pipelineItem('myrepo', 42))
|
|
299
|
+
|
|
300
|
+
const back = screen.getByLabelText('Back')
|
|
301
|
+
await userEvent.click(back)
|
|
302
|
+
|
|
303
|
+
// Back peels one level -- to the list at /pipeline -- not out of the SPA.
|
|
304
|
+
await waitFor(() => expect(listRegion()).toBeInTheDocument())
|
|
305
|
+
expect(detailRegion()).not.toBeInTheDocument()
|
|
306
|
+
expect(locationPath()).toBe(paths.pipeline())
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
it('shows only built views in the bottom row', async () => {
|
|
310
|
+
renderShell()
|
|
311
|
+
await screen.findByText('Fix the thing')
|
|
312
|
+
|
|
313
|
+
expect(screen.getByRole('button', { name: /Pipeline/ })).toBeInTheDocument()
|
|
314
|
+
expect(screen.getByRole('button', { name: /Sessions/ })).toBeInTheDocument()
|
|
315
|
+
// A dimmed, un-tappable placeholder is noise on a phone's bottom nav.
|
|
316
|
+
expect(screen.queryByRole('button', { name: /Milestones/ })).not.toBeInTheDocument()
|
|
317
|
+
expect(screen.queryByRole('button', { name: /^Spend/ })).not.toBeInTheDocument()
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
it('offers no panel separator or rail collapse', async () => {
|
|
321
|
+
renderShell()
|
|
322
|
+
await screen.findByText('Fix the thing')
|
|
323
|
+
|
|
324
|
+
expect(screen.queryByRole('separator', { name: 'Resize list panel' })).not.toBeInTheDocument()
|
|
325
|
+
expect(screen.queryByRole('button', { name: /Collapse rail/ })).not.toBeInTheDocument()
|
|
326
|
+
})
|
|
327
|
+
|
|
328
|
+
it('cycles F6 over only the two mounted regions', async () => {
|
|
329
|
+
renderShell()
|
|
330
|
+
await screen.findByText('Fix the thing')
|
|
331
|
+
|
|
332
|
+
fireEvent.keyDown(window, { key: 'F6' })
|
|
333
|
+
expect(document.activeElement).toBe(railRegion())
|
|
334
|
+
fireEvent.keyDown(window, { key: 'F6' })
|
|
335
|
+
expect(document.activeElement).toBe(listRegion())
|
|
336
|
+
fireEvent.keyDown(window, { key: 'F6' })
|
|
337
|
+
expect(document.activeElement).toBe(railRegion())
|
|
338
|
+
})
|
|
339
|
+
})
|
|
340
|
+
|
|
341
|
+
// ── medium ────────────────────────────────────────────────────────────────────
|
|
342
|
+
|
|
343
|
+
describe('shell — medium (768–1023px)', () => {
|
|
344
|
+
beforeEach(() => stubViewportWidth(MEDIUM_PX))
|
|
345
|
+
|
|
346
|
+
it('keeps the list mounted under the detail overlay instead of replacing it', async () => {
|
|
347
|
+
renderShell(paths.pipelineItem('myrepo', 42))
|
|
348
|
+
|
|
349
|
+
const list = listElement()
|
|
350
|
+
expect(list).toBeInTheDocument()
|
|
351
|
+
expect(detailRegion()).toBeInTheDocument()
|
|
352
|
+
// The list is behind the sheet: hidden from assistive tech and taken out
|
|
353
|
+
// of the tab order, not merely painted over.
|
|
354
|
+
expect(list).toHaveAttribute('aria-hidden', 'true')
|
|
355
|
+
expect(list).toHaveAttribute('inert')
|
|
356
|
+
})
|
|
357
|
+
|
|
358
|
+
it('leaves the list interactive when nothing is selected', async () => {
|
|
359
|
+
renderShell()
|
|
360
|
+
await screen.findByText('Fix the thing')
|
|
361
|
+
|
|
362
|
+
expect(listElement()).not.toHaveAttribute('aria-hidden')
|
|
363
|
+
expect(listElement()).not.toHaveAttribute('inert')
|
|
364
|
+
})
|
|
365
|
+
|
|
366
|
+
it('pins the rail to its icon strip — no labels, no collapse toggle', async () => {
|
|
367
|
+
renderShell()
|
|
368
|
+
await screen.findByText('Fix the thing')
|
|
369
|
+
|
|
370
|
+
// Icon-only: the entry is still there and still named, but the visible
|
|
371
|
+
// label text is gone, as is the toggle that would do nothing here.
|
|
372
|
+
expect(screen.getByRole('button', { name: /Pipeline/ })).toBeInTheDocument()
|
|
373
|
+
expect(railRegion().textContent).not.toContain('soon')
|
|
374
|
+
expect(screen.queryByRole('button', { name: /Collapse rail/ })).not.toBeInTheDocument()
|
|
375
|
+
expect(screen.queryByRole('separator', { name: 'Resize list panel' })).not.toBeInTheDocument()
|
|
376
|
+
})
|
|
377
|
+
})
|
|
378
|
+
|
|
379
|
+
// ── view selection ────────────────────────────────────────────────────────────
|
|
380
|
+
|
|
381
|
+
describe('shell — activity rail view selection', () => {
|
|
382
|
+
beforeEach(() => stubViewportWidth(WIDE_PX))
|
|
383
|
+
|
|
384
|
+
it("swaps the list panel by navigating to the view's own URL", async () => {
|
|
385
|
+
const user = userEvent.setup()
|
|
386
|
+
renderShell()
|
|
387
|
+
await screen.findByRole('heading', { name: 'Pipeline' })
|
|
388
|
+
expect(locationPath()).toBe(paths.pipeline())
|
|
389
|
+
|
|
390
|
+
await user.click(screen.getByRole('button', { name: /^Sessions/ }))
|
|
391
|
+
|
|
392
|
+
expect(await screen.findByRole('heading', { name: 'Sessions' })).toBeInTheDocument()
|
|
393
|
+
expect(screen.queryByRole('heading', { name: 'Pipeline' })).not.toBeInTheDocument()
|
|
394
|
+
expect(locationPath()).toBe(paths.sessions())
|
|
395
|
+
})
|
|
396
|
+
|
|
397
|
+
it('ignores a click on an unbuilt view', async () => {
|
|
398
|
+
const user = userEvent.setup()
|
|
399
|
+
renderShell()
|
|
400
|
+
await screen.findByRole('heading', { name: 'Pipeline' })
|
|
401
|
+
|
|
402
|
+
await user.click(screen.getByRole('button', { name: /Board/ }))
|
|
403
|
+
|
|
404
|
+
expect(screen.getByRole('heading', { name: 'Pipeline' })).toBeInTheDocument()
|
|
405
|
+
expect(locationPath()).toBe(paths.pipeline())
|
|
406
|
+
})
|
|
407
|
+
|
|
408
|
+
it('collapses and restores the list column from the rail', async () => {
|
|
409
|
+
const user = userEvent.setup()
|
|
410
|
+
renderShell()
|
|
411
|
+
await screen.findByRole('heading', { name: 'Pipeline' })
|
|
412
|
+
|
|
413
|
+
await user.click(screen.getByRole('button', { name: 'Minimize list panel' }))
|
|
414
|
+
await waitFor(() => expect(listRegion()).not.toBeInTheDocument())
|
|
415
|
+
expect(detailRegion()).toBeInTheDocument()
|
|
416
|
+
|
|
417
|
+
await user.click(screen.getByRole('button', { name: 'Show list panel' }))
|
|
418
|
+
await waitFor(() => expect(listRegion()).toBeInTheDocument())
|
|
419
|
+
})
|
|
420
|
+
|
|
421
|
+
it('collapses the rail to icons and remembers it', async () => {
|
|
422
|
+
const user = userEvent.setup()
|
|
423
|
+
renderShell()
|
|
424
|
+
await screen.findByRole('heading', { name: 'Pipeline' })
|
|
425
|
+
|
|
426
|
+
await user.click(screen.getByRole('button', { name: 'Collapse rail' }))
|
|
427
|
+
|
|
428
|
+
expect(screen.getByRole('button', { name: 'Expand rail' })).toBeInTheDocument()
|
|
429
|
+
const persisted = JSON.parse(window.localStorage.getItem(SHELL_STORAGE_KEY) ?? '{}')
|
|
430
|
+
expect(persisted.railCollapsed).toBe(true)
|
|
431
|
+
})
|
|
432
|
+
})
|
|
433
|
+
|
|
434
|
+
// ── deep links (#1548) ───────────────────────────────────────────────────────
|
|
435
|
+
|
|
436
|
+
describe('shell — deep links and route tree', () => {
|
|
437
|
+
beforeEach(() => stubViewportWidth(WIDE_PX))
|
|
438
|
+
|
|
439
|
+
it('redirects a cold load of / to /pipeline', async () => {
|
|
440
|
+
renderShell('/')
|
|
441
|
+
await waitFor(() => expect(locationPath()).toBe(paths.pipeline()))
|
|
442
|
+
expect(await screen.findByRole('heading', { name: 'Pipeline' })).toBeInTheDocument()
|
|
443
|
+
})
|
|
444
|
+
|
|
445
|
+
it('cold-loads a pipeline item deep link straight into the detail column', async () => {
|
|
446
|
+
renderShell(paths.pipelineItem('myrepo', 42))
|
|
447
|
+
|
|
448
|
+
const detail = detailRegion()
|
|
449
|
+
expect(detail).toBeInTheDocument()
|
|
450
|
+
expect(await within(detail!).findByText('Fix the thing')).toBeInTheDocument()
|
|
451
|
+
// The list is on screen too (wide) -- both agree on the same item.
|
|
452
|
+
expect(listRegion()).toBeInTheDocument()
|
|
453
|
+
})
|
|
454
|
+
|
|
455
|
+
it('cold-loads a deep link with a tab segment without crashing or losing the item', async () => {
|
|
456
|
+
renderShell(paths.pipelineItem('myrepo', 42, 'log'))
|
|
457
|
+
|
|
458
|
+
expect(locationPath()).toBe('/pipeline/myrepo/42/log')
|
|
459
|
+
// Tab *content* is M-W2 scope -- the acceptance surface for this story is
|
|
460
|
+
// that the route resolves to the right item and doesn't 404 or crash.
|
|
461
|
+
await waitFor(() => expect(screen.getAllByText('Fix the thing').length).toBeGreaterThan(0))
|
|
462
|
+
})
|
|
463
|
+
|
|
464
|
+
it('cold-loads a session deep link into the detail column', async () => {
|
|
465
|
+
renderShell(paths.session('sess-1'))
|
|
466
|
+
|
|
467
|
+
const detail = detailRegion()
|
|
468
|
+
expect(detail).toBeInTheDocument()
|
|
469
|
+
expect(await within(detail!).findByText('Live session issue')).toBeInTheDocument()
|
|
470
|
+
expect(within(detail!).getByRole('button', { name: 'Take over' })).toBeInTheDocument()
|
|
471
|
+
})
|
|
472
|
+
|
|
473
|
+
it('shows a real not-found state for a stale issue rather than a blank panel', async () => {
|
|
474
|
+
renderShell(paths.pipelineItem('myrepo', 999))
|
|
475
|
+
|
|
476
|
+
expect(await screen.findByText(/not found in the pipeline/i)).toBeInTheDocument()
|
|
477
|
+
// The list is still there and still usable -- a stale link doesn't take
|
|
478
|
+
// down the rest of the shell.
|
|
479
|
+
expect(listRegion()).toBeInTheDocument()
|
|
480
|
+
})
|
|
481
|
+
|
|
482
|
+
it('shows a real not-found state for a session id that is no longer live', async () => {
|
|
483
|
+
renderShell(paths.session('does-not-exist'))
|
|
484
|
+
|
|
485
|
+
expect(await screen.findByText(/no longer live/i)).toBeInTheDocument()
|
|
486
|
+
})
|
|
487
|
+
|
|
488
|
+
it('renders a not-found state, not a blank panel or a crash, for an unknown route', async () => {
|
|
489
|
+
renderShell('/this/route/does/not/exist')
|
|
490
|
+
|
|
491
|
+
expect(await screen.findByText('Page not found')).toBeInTheDocument()
|
|
492
|
+
expect(railRegion()).toBeInTheDocument()
|
|
493
|
+
})
|
|
494
|
+
})
|
|
495
|
+
|
|
496
|
+
// ── deep links at narrow (regression coverage for the / redirect) ───────────
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* `AppShell` only mounts the detail slot — the thing a child route's
|
|
500
|
+
* `element` renders into — when `showDetail` is true, and on narrow that's
|
|
501
|
+
* `false` until an item is selected. A `/` -> `/pipeline` redirect declared
|
|
502
|
+
* as a *child* of `ShellLayout` would therefore silently never fire on a
|
|
503
|
+
* phone-sized cold load: `<Navigate>`'s effect never runs because it's never
|
|
504
|
+
* mounted. This exact bug shipped past an all-wide-viewport test suite
|
|
505
|
+
* (every other deep-link test in this file stubs `WIDE_PX`, where the detail
|
|
506
|
+
* slot is unconditionally mounted) and only showed up in a real browser at a
|
|
507
|
+
* phone width. `App.tsx` fixes it by declaring the redirect as a *sibling* of
|
|
508
|
+
* `ShellLayout`; this test (at `NARROW_PX`) is what would have caught the
|
|
509
|
+
* regression before it shipped.
|
|
510
|
+
*/
|
|
511
|
+
describe('shell — deep links at narrow', () => {
|
|
512
|
+
beforeEach(() => stubViewportWidth(NARROW_PX))
|
|
513
|
+
|
|
514
|
+
it('redirects a cold load of / to /pipeline on narrow, where the detail slot starts unmounted', async () => {
|
|
515
|
+
renderShell('/')
|
|
516
|
+
await waitFor(() => expect(locationPath()).toBe(paths.pipeline()))
|
|
517
|
+
expect(await screen.findByText('Fix the thing')).toBeInTheDocument()
|
|
518
|
+
expect(screen.queryByText('Page not found')).not.toBeInTheDocument()
|
|
519
|
+
})
|
|
520
|
+
})
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the shell's persisted geometry (#1547, trimmed by #1548 —
|
|
3
|
+
* the selected view moved to the URL, see `routes/__tests__/paths.test.ts`).
|
|
4
|
+
*
|
|
5
|
+
* `parseShellState` is the trust boundary: the blob it reads is user-writable
|
|
6
|
+
* (devtools) and will be read by future builds after the schema grows, so a
|
|
7
|
+
* garbage field must degrade to the default rather than reach the grid as
|
|
8
|
+
* `NaN` columns.
|
|
9
|
+
*/
|
|
10
|
+
import { describe, it, expect } from 'vitest'
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
LIST_WIDTH_DEFAULT_PX,
|
|
14
|
+
LIST_WIDTH_MAX_PX,
|
|
15
|
+
LIST_WIDTH_MIN_PX,
|
|
16
|
+
clampListWidth,
|
|
17
|
+
parseShellState,
|
|
18
|
+
} from '../shellState'
|
|
19
|
+
|
|
20
|
+
describe('clampListWidth', () => {
|
|
21
|
+
it('keeps a sane width untouched', () => {
|
|
22
|
+
expect(clampListWidth(400)).toBe(400)
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('clamps to the min and max bounds', () => {
|
|
26
|
+
expect(clampListWidth(10)).toBe(LIST_WIDTH_MIN_PX)
|
|
27
|
+
expect(clampListWidth(99_999)).toBe(LIST_WIDTH_MAX_PX)
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('rejects non-finite input rather than passing NaN into a grid template', () => {
|
|
31
|
+
expect(clampListWidth(Number.NaN)).toBe(LIST_WIDTH_DEFAULT_PX)
|
|
32
|
+
expect(clampListWidth(Number.POSITIVE_INFINITY)).toBe(LIST_WIDTH_DEFAULT_PX)
|
|
33
|
+
expect(clampListWidth(Number.NEGATIVE_INFINITY)).toBe(LIST_WIDTH_DEFAULT_PX)
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('rounds to whole pixels so a drag does not persist sub-pixel noise', () => {
|
|
37
|
+
expect(clampListWidth(400.4)).toBe(400)
|
|
38
|
+
expect(clampListWidth(400.6)).toBe(401)
|
|
39
|
+
})
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
describe('parseShellState', () => {
|
|
43
|
+
it('returns defaults for a missing key', () => {
|
|
44
|
+
expect(parseShellState(null)).toEqual({
|
|
45
|
+
railCollapsed: false,
|
|
46
|
+
listCollapsed: false,
|
|
47
|
+
listWidthPx: LIST_WIDTH_DEFAULT_PX,
|
|
48
|
+
})
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('returns defaults for unparseable JSON', () => {
|
|
52
|
+
expect(parseShellState('{not json')).toEqual(parseShellState(null))
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('returns defaults for a non-object payload', () => {
|
|
56
|
+
expect(parseShellState('"pipeline"')).toEqual(parseShellState(null))
|
|
57
|
+
expect(parseShellState('null')).toEqual(parseShellState(null))
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('round-trips a well-formed blob', () => {
|
|
61
|
+
const raw = JSON.stringify({
|
|
62
|
+
railCollapsed: true,
|
|
63
|
+
listCollapsed: true,
|
|
64
|
+
listWidthPx: 420,
|
|
65
|
+
})
|
|
66
|
+
expect(parseShellState(raw)).toEqual({
|
|
67
|
+
railCollapsed: true,
|
|
68
|
+
listCollapsed: true,
|
|
69
|
+
listWidthPx: 420,
|
|
70
|
+
})
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('clamps a persisted width that is out of bounds', () => {
|
|
74
|
+
const raw = JSON.stringify({ listWidthPx: 5_000 })
|
|
75
|
+
expect(parseShellState(raw).listWidthPx).toBe(LIST_WIDTH_MAX_PX)
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('ignores a leftover `view` field from a pre-#1548 build rather than choking on it', () => {
|
|
79
|
+
const raw = JSON.stringify({ view: 'sessions', listWidthPx: 420 })
|
|
80
|
+
expect(parseShellState(raw)).toEqual({
|
|
81
|
+
railCollapsed: false,
|
|
82
|
+
listCollapsed: false,
|
|
83
|
+
listWidthPx: 420,
|
|
84
|
+
})
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('ignores fields of the wrong type field-by-field', () => {
|
|
88
|
+
const raw = JSON.stringify({ railCollapsed: 'yes', listWidthPx: 'wide' })
|
|
89
|
+
expect(parseShellState(raw)).toEqual({
|
|
90
|
+
railCollapsed: false,
|
|
91
|
+
listCollapsed: false,
|
|
92
|
+
listWidthPx: LIST_WIDTH_DEFAULT_PX,
|
|
93
|
+
})
|
|
94
|
+
})
|
|
95
|
+
})
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `window.matchMedia` stub that answers `(min-width: Npx)` queries as if the
|
|
3
|
+
* viewport were a given width (#1547).
|
|
4
|
+
*
|
|
5
|
+
* jsdom has no layout, so `useShellMode()` has nothing real to read: without
|
|
6
|
+
* this every shell test would run in the fallback `narrow` mode and the wide
|
|
7
|
+
* composition — three columns, F6 across three regions, the resize separator —
|
|
8
|
+
* would be entirely untested. `src/test-setup.ts` installs a stub that always
|
|
9
|
+
* answers `matches: false`; this replaces it for the duration of a test and
|
|
10
|
+
* `restoreViewport()` puts the original back.
|
|
11
|
+
*/
|
|
12
|
+
const originals: Array<typeof window.matchMedia> = []
|
|
13
|
+
|
|
14
|
+
/** Widths that land squarely inside each shell mode. */
|
|
15
|
+
export const NARROW_PX = 390
|
|
16
|
+
export const MEDIUM_PX = 820
|
|
17
|
+
export const WIDE_PX = 1440
|
|
18
|
+
|
|
19
|
+
export function stubViewportWidth(width: number): void {
|
|
20
|
+
originals.push(window.matchMedia)
|
|
21
|
+
window.matchMedia = ((query: string) => {
|
|
22
|
+
const match = /min-width:\s*(\d+)px/.exec(query)
|
|
23
|
+
const minWidth = match ? Number(match[1]) : 0
|
|
24
|
+
return {
|
|
25
|
+
matches: width >= minWidth,
|
|
26
|
+
media: query,
|
|
27
|
+
onchange: null,
|
|
28
|
+
addListener: () => {},
|
|
29
|
+
removeListener: () => {},
|
|
30
|
+
addEventListener: () => {},
|
|
31
|
+
removeEventListener: () => {},
|
|
32
|
+
dispatchEvent: () => false,
|
|
33
|
+
}
|
|
34
|
+
}) as typeof window.matchMedia
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function restoreViewport(): void {
|
|
38
|
+
const original = originals.pop()
|
|
39
|
+
if (original) window.matchMedia = original
|
|
40
|
+
}
|