shipwright-cli 2.3.0 → 2.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/README.md +3 -3
  2. package/dashboard/public/index.html +1 -1
  3. package/dashboard/src/core/api.test.ts +362 -0
  4. package/dashboard/src/core/router.test.ts +266 -0
  5. package/dashboard/src/core/state.test.ts +235 -0
  6. package/dashboard/src/core/ws.test.ts +216 -0
  7. package/dashboard/src/design/icons.test.ts +105 -0
  8. package/dashboard/src/design/tokens.test.ts +204 -0
  9. package/dashboard/tsconfig.json +1 -1
  10. package/dashboard/vitest.config.ts +27 -0
  11. package/package.json +10 -3
  12. package/scripts/lib/pipeline-stages.sh +59 -0
  13. package/scripts/sw +1 -1
  14. package/scripts/sw-activity.sh +1 -1
  15. package/scripts/sw-adaptive.sh +1 -1
  16. package/scripts/sw-adversarial.sh +1 -1
  17. package/scripts/sw-architecture-enforcer.sh +1 -1
  18. package/scripts/sw-auth.sh +1 -1
  19. package/scripts/sw-autonomous.sh +230 -13
  20. package/scripts/sw-changelog.sh +1 -1
  21. package/scripts/sw-checkpoint.sh +1 -1
  22. package/scripts/sw-ci.sh +1 -1
  23. package/scripts/sw-cleanup.sh +1 -1
  24. package/scripts/sw-code-review.sh +1 -1
  25. package/scripts/sw-connect.sh +1 -1
  26. package/scripts/sw-context.sh +1 -1
  27. package/scripts/sw-cost.sh +1 -1
  28. package/scripts/sw-daemon.sh +1 -1
  29. package/scripts/sw-dashboard.sh +1 -1
  30. package/scripts/sw-db.sh +1 -1
  31. package/scripts/sw-decompose.sh +1 -1
  32. package/scripts/sw-deps.sh +1 -1
  33. package/scripts/sw-developer-simulation.sh +1 -1
  34. package/scripts/sw-discovery.sh +1 -1
  35. package/scripts/sw-doc-fleet.sh +1 -1
  36. package/scripts/sw-docs-agent.sh +1 -1
  37. package/scripts/sw-docs.sh +1 -1
  38. package/scripts/sw-doctor.sh +1 -1
  39. package/scripts/sw-dora.sh +1 -1
  40. package/scripts/sw-durable.sh +1 -1
  41. package/scripts/sw-e2e-orchestrator.sh +1 -1
  42. package/scripts/sw-eventbus.sh +1 -1
  43. package/scripts/sw-feedback.sh +1 -1
  44. package/scripts/sw-fix.sh +1 -1
  45. package/scripts/sw-fleet-discover.sh +1 -1
  46. package/scripts/sw-fleet-viz.sh +1 -1
  47. package/scripts/sw-fleet.sh +1 -1
  48. package/scripts/sw-github-app.sh +1 -1
  49. package/scripts/sw-github-checks.sh +1 -1
  50. package/scripts/sw-github-deploy.sh +1 -1
  51. package/scripts/sw-github-graphql.sh +1 -1
  52. package/scripts/sw-guild.sh +1 -1
  53. package/scripts/sw-heartbeat.sh +1 -1
  54. package/scripts/sw-hygiene.sh +1 -1
  55. package/scripts/sw-incident.sh +1 -1
  56. package/scripts/sw-init.sh +1 -1
  57. package/scripts/sw-instrument.sh +1 -1
  58. package/scripts/sw-intelligence.sh +1 -1
  59. package/scripts/sw-jira.sh +1 -1
  60. package/scripts/sw-launchd.sh +1 -1
  61. package/scripts/sw-linear.sh +1 -1
  62. package/scripts/sw-logs.sh +1 -1
  63. package/scripts/sw-loop.sh +1 -1
  64. package/scripts/sw-memory.sh +1 -1
  65. package/scripts/sw-mission-control.sh +1 -1
  66. package/scripts/sw-model-router.sh +1 -1
  67. package/scripts/sw-otel.sh +1 -1
  68. package/scripts/sw-oversight.sh +1 -1
  69. package/scripts/sw-pipeline-composer.sh +1 -1
  70. package/scripts/sw-pipeline-vitals.sh +1 -1
  71. package/scripts/sw-pipeline.sh +1 -1
  72. package/scripts/sw-pm.sh +1 -1
  73. package/scripts/sw-pr-lifecycle.sh +1 -1
  74. package/scripts/sw-predictive.sh +1 -1
  75. package/scripts/sw-prep.sh +1 -1
  76. package/scripts/sw-ps.sh +1 -1
  77. package/scripts/sw-public-dashboard.sh +1 -1
  78. package/scripts/sw-quality.sh +1 -1
  79. package/scripts/sw-reaper.sh +1 -1
  80. package/scripts/sw-regression.sh +1 -1
  81. package/scripts/sw-release-manager.sh +1 -1
  82. package/scripts/sw-release.sh +1 -1
  83. package/scripts/sw-remote.sh +1 -1
  84. package/scripts/sw-replay.sh +1 -1
  85. package/scripts/sw-retro.sh +4 -1
  86. package/scripts/sw-scale.sh +1 -1
  87. package/scripts/sw-security-audit.sh +1 -1
  88. package/scripts/sw-self-optimize.sh +99 -1
  89. package/scripts/sw-session.sh +1 -1
  90. package/scripts/sw-setup.sh +1 -1
  91. package/scripts/sw-standup.sh +1 -1
  92. package/scripts/sw-status.sh +1 -1
  93. package/scripts/sw-strategic.sh +1 -1
  94. package/scripts/sw-stream.sh +1 -1
  95. package/scripts/sw-swarm.sh +1 -1
  96. package/scripts/sw-team-stages.sh +1 -1
  97. package/scripts/sw-templates.sh +1 -1
  98. package/scripts/sw-testgen.sh +1 -1
  99. package/scripts/sw-tmux-pipeline.sh +1 -1
  100. package/scripts/sw-tmux.sh +1 -1
  101. package/scripts/sw-trace.sh +1 -1
  102. package/scripts/sw-tracker.sh +1 -1
  103. package/scripts/sw-triage.sh +198 -11
  104. package/scripts/sw-upgrade.sh +1 -1
  105. package/scripts/sw-ux.sh +1 -1
  106. package/scripts/sw-webhook.sh +1 -1
  107. package/scripts/sw-widgets.sh +1 -1
  108. package/scripts/sw-worktree.sh +1 -1
package/README.md CHANGED
@@ -13,7 +13,7 @@
13
13
  <a href="https://github.com/sethdford/shipwright/actions/workflows/test.yml"><img src="https://github.com/sethdford/shipwright/actions/workflows/test.yml/badge.svg" alt="Tests"></a>
14
14
  <a href="https://github.com/sethdford/shipwright/actions/workflows/shipwright-pipeline.yml"><img src="https://github.com/sethdford/shipwright/actions/workflows/shipwright-pipeline.yml/badge.svg" alt="Pipeline"></a>
15
15
  <img src="https://img.shields.io/badge/tests-103_suites_passing-4ade80?style=flat-square" alt="103 suites">
16
- <img src="https://img.shields.io/badge/version-2.3.0-00d4ff?style=flat-square" alt="v2.3.0">
16
+ <img src="https://img.shields.io/badge/version-2.3.1-00d4ff?style=flat-square" alt="v2.3.1">
17
17
  <img src="https://img.shields.io/badge/license-MIT-green?style=flat-square" alt="MIT License">
18
18
  <img src="https://img.shields.io/badge/bash-3.2%2B-7c3aed?style=flat-square" alt="Bash 3.2+">
19
19
  </p>
@@ -23,7 +23,7 @@
23
23
  ## Table of Contents
24
24
 
25
25
  - [Shipwright Builds Itself](#shipwright-builds-itself)
26
- - [What's New in v2.3.0](#whats-new-in-v230)
26
+ - [What's New in v2.3.1](#whats-new-in-v231)
27
27
  - [How It Works](#how-it-works)
28
28
  - [Install](#install)
29
29
  - [Quick Start](#quick-start)
@@ -46,7 +46,7 @@ This repo uses Shipwright to process its own issues. Label a GitHub issue with `
46
46
 
47
47
  ---
48
48
 
49
- ## What's New in v2.3.0
49
+ ## What's New in v2.3.1
50
50
 
51
51
  **Docs & platform polish** — doc-fleet, shared libs, policy schema, release infra:
52
52
 
@@ -733,7 +733,7 @@
733
733
 
734
734
  <!-- Footer -->
735
735
  <footer class="footer">
736
- <span>Shipwright Fleet Command v2.3.0</span>
736
+ <span>Shipwright Fleet Command v2.3.1</span>
737
737
  <span>Dashboard refreshes via WebSocket</span>
738
738
  </footer>
739
739
 
@@ -0,0 +1,362 @@
1
+ import { describe, it, expect, beforeEach, vi, afterEach } from "vitest";
2
+ import * as api from "./api";
3
+
4
+ // Mock global fetch
5
+ const mockFetch = vi.fn();
6
+ global.fetch = mockFetch;
7
+
8
+ function jsonResponse(data: unknown, status = 200) {
9
+ return Promise.resolve({
10
+ ok: status >= 200 && status < 300,
11
+ status,
12
+ json: () => Promise.resolve(data),
13
+ });
14
+ }
15
+
16
+ function errorResponse(status: number, body?: unknown) {
17
+ return Promise.resolve({
18
+ ok: false,
19
+ status,
20
+ json: () => Promise.resolve(body || { error: `HTTP ${status}` }),
21
+ });
22
+ }
23
+
24
+ describe("API Client", () => {
25
+ beforeEach(() => {
26
+ mockFetch.mockReset();
27
+ });
28
+
29
+ afterEach(() => {
30
+ vi.restoreAllMocks();
31
+ });
32
+
33
+ describe("fetchMe", () => {
34
+ it("calls GET /api/me", async () => {
35
+ const userData = { username: "test", role: "admin" };
36
+ mockFetch.mockReturnValueOnce(jsonResponse(userData));
37
+
38
+ const result = await api.fetchMe();
39
+ expect(result).toEqual(userData);
40
+ expect(mockFetch).toHaveBeenCalledWith("/api/me", undefined);
41
+ });
42
+ });
43
+
44
+ describe("fetchPipelineDetail", () => {
45
+ it("calls GET /api/pipeline/:issue", async () => {
46
+ const detail = { issue: 42, status: "building" };
47
+ mockFetch.mockReturnValueOnce(jsonResponse(detail));
48
+
49
+ const result = await api.fetchPipelineDetail(42);
50
+ expect(result).toEqual(detail);
51
+ expect(mockFetch).toHaveBeenCalledWith("/api/pipeline/42", undefined);
52
+ });
53
+
54
+ it("encodes special characters in issue", async () => {
55
+ mockFetch.mockReturnValueOnce(jsonResponse({}));
56
+ await api.fetchPipelineDetail("test/123");
57
+ expect(mockFetch).toHaveBeenCalledWith(
58
+ "/api/pipeline/test%2F123",
59
+ undefined,
60
+ );
61
+ });
62
+ });
63
+
64
+ describe("fetchMetricsHistory", () => {
65
+ it("defaults to 30 day period", async () => {
66
+ mockFetch.mockReturnValueOnce(jsonResponse({ history: [] }));
67
+ await api.fetchMetricsHistory();
68
+ expect(mockFetch).toHaveBeenCalledWith(
69
+ "/api/metrics/history?period=30",
70
+ undefined,
71
+ );
72
+ });
73
+
74
+ it("respects custom period", async () => {
75
+ mockFetch.mockReturnValueOnce(jsonResponse({ history: [] }));
76
+ await api.fetchMetricsHistory(7);
77
+ expect(mockFetch).toHaveBeenCalledWith(
78
+ "/api/metrics/history?period=7",
79
+ undefined,
80
+ );
81
+ });
82
+ });
83
+
84
+ describe("fetchTimeline", () => {
85
+ it("defaults to 24h range", async () => {
86
+ mockFetch.mockReturnValueOnce(jsonResponse([]));
87
+ await api.fetchTimeline();
88
+ expect(mockFetch).toHaveBeenCalledWith(
89
+ "/api/timeline?range=24h",
90
+ undefined,
91
+ );
92
+ });
93
+ });
94
+
95
+ describe("fetchActivity", () => {
96
+ it("builds query string from params", async () => {
97
+ mockFetch.mockReturnValueOnce(
98
+ jsonResponse({ events: [], hasMore: false }),
99
+ );
100
+ await api.fetchActivity({ limit: 50, offset: 10, type: "error" });
101
+
102
+ const url = mockFetch.mock.calls[0][0] as string;
103
+ expect(url).toContain("/api/activity?");
104
+ expect(url).toContain("limit=50");
105
+ expect(url).toContain("offset=10");
106
+ expect(url).toContain("type=error");
107
+ });
108
+
109
+ it("excludes type=all from query string", async () => {
110
+ mockFetch.mockReturnValueOnce(
111
+ jsonResponse({ events: [], hasMore: false }),
112
+ );
113
+ await api.fetchActivity({ type: "all" });
114
+
115
+ const url = mockFetch.mock.calls[0][0] as string;
116
+ expect(url).not.toContain("type=");
117
+ });
118
+ });
119
+
120
+ describe("machine operations", () => {
121
+ it("fetchMachines calls GET /api/machines", async () => {
122
+ mockFetch.mockReturnValueOnce(jsonResponse([]));
123
+ await api.fetchMachines();
124
+ expect(mockFetch).toHaveBeenCalledWith("/api/machines", undefined);
125
+ });
126
+
127
+ it("addMachine calls POST /api/machines", async () => {
128
+ mockFetch.mockReturnValueOnce(jsonResponse({ name: "m1" }));
129
+ await api.addMachine({ name: "m1", host: "localhost" });
130
+
131
+ expect(mockFetch).toHaveBeenCalledWith("/api/machines", {
132
+ method: "POST",
133
+ headers: { "Content-Type": "application/json" },
134
+ body: JSON.stringify({ name: "m1", host: "localhost" }),
135
+ });
136
+ });
137
+
138
+ it("updateMachine calls PATCH /api/machines/:name", async () => {
139
+ mockFetch.mockReturnValueOnce(jsonResponse({ name: "m1" }));
140
+ await api.updateMachine("m1", { status: "active" });
141
+
142
+ expect(mockFetch).toHaveBeenCalledWith("/api/machines/m1", {
143
+ method: "PATCH",
144
+ headers: { "Content-Type": "application/json" },
145
+ body: JSON.stringify({ status: "active" }),
146
+ });
147
+ });
148
+
149
+ it("removeMachine calls DELETE /api/machines/:name", async () => {
150
+ mockFetch.mockReturnValueOnce(jsonResponse({ ok: true }));
151
+ await api.removeMachine("m1");
152
+
153
+ expect(mockFetch).toHaveBeenCalledWith("/api/machines/m1", {
154
+ method: "DELETE",
155
+ headers: { "Content-Type": "application/json" },
156
+ });
157
+ });
158
+ });
159
+
160
+ describe("fetchQueueDetailed", () => {
161
+ it("transforms queue property to items", async () => {
162
+ mockFetch.mockReturnValueOnce(
163
+ jsonResponse({ queue: [{ id: 1 }, { id: 2 }] }),
164
+ );
165
+ const result = await api.fetchQueueDetailed();
166
+ expect(result).toEqual({ items: [{ id: 1 }, { id: 2 }] });
167
+ });
168
+
169
+ it("handles missing queue property gracefully", async () => {
170
+ mockFetch.mockReturnValueOnce(jsonResponse({}));
171
+ const result = await api.fetchQueueDetailed();
172
+ expect(result).toEqual({ items: [] });
173
+ });
174
+ });
175
+
176
+ describe("emergency brake", () => {
177
+ it("calls POST /api/emergency-brake", async () => {
178
+ mockFetch.mockReturnValueOnce(jsonResponse({ ok: true }));
179
+ await api.emergencyBrake();
180
+
181
+ expect(mockFetch).toHaveBeenCalledWith("/api/emergency-brake", {
182
+ method: "POST",
183
+ headers: { "Content-Type": "application/json" },
184
+ });
185
+ });
186
+ });
187
+
188
+ describe("sendIntervention", () => {
189
+ it("calls POST /api/intervention/:issue/:action with body", async () => {
190
+ mockFetch.mockReturnValueOnce(jsonResponse({ ok: true }));
191
+ await api.sendIntervention(42, "pause", { reason: "testing" });
192
+
193
+ expect(mockFetch).toHaveBeenCalledWith("/api/intervention/42/pause", {
194
+ method: "POST",
195
+ headers: { "Content-Type": "application/json" },
196
+ body: JSON.stringify({ reason: "testing" }),
197
+ });
198
+ });
199
+ });
200
+
201
+ describe("insights endpoints", () => {
202
+ it("fetchPatterns returns empty array on error", async () => {
203
+ mockFetch.mockReturnValueOnce(errorResponse(500));
204
+ const result = await api.fetchPatterns();
205
+ expect(result).toEqual({ patterns: [] });
206
+ });
207
+
208
+ it("fetchDecisions returns empty array on error", async () => {
209
+ mockFetch.mockReturnValueOnce(errorResponse(500));
210
+ const result = await api.fetchDecisions();
211
+ expect(result).toEqual({ decisions: [] });
212
+ });
213
+
214
+ it("fetchHeatmap returns null on error", async () => {
215
+ mockFetch.mockReturnValueOnce(errorResponse(500));
216
+ const result = await api.fetchHeatmap();
217
+ expect(result).toBeNull();
218
+ });
219
+ });
220
+
221
+ describe("pipeline live changes", () => {
222
+ it("fetchPipelineDiff calls correct endpoint", async () => {
223
+ mockFetch.mockReturnValueOnce(
224
+ jsonResponse({
225
+ diff: "diff output",
226
+ stats: { files_changed: 1, insertions: 10, deletions: 5 },
227
+ worktree: "/path",
228
+ }),
229
+ );
230
+ await api.fetchPipelineDiff(142);
231
+ expect(mockFetch).toHaveBeenCalledWith(
232
+ "/api/pipeline/142/diff",
233
+ undefined,
234
+ );
235
+ });
236
+
237
+ it("fetchPipelineFiles calls correct endpoint", async () => {
238
+ mockFetch.mockReturnValueOnce(
239
+ jsonResponse({
240
+ files: [{ path: "src/main.ts", status: "modified" }],
241
+ }),
242
+ );
243
+ await api.fetchPipelineFiles(142);
244
+ expect(mockFetch).toHaveBeenCalledWith(
245
+ "/api/pipeline/142/files",
246
+ undefined,
247
+ );
248
+ });
249
+
250
+ it("fetchPipelineReasoning calls correct endpoint", async () => {
251
+ mockFetch.mockReturnValueOnce(jsonResponse({ reasoning: [] }));
252
+ await api.fetchPipelineReasoning(142);
253
+ expect(mockFetch).toHaveBeenCalledWith(
254
+ "/api/pipeline/142/reasoning",
255
+ undefined,
256
+ );
257
+ });
258
+
259
+ it("fetchPipelineFailures calls correct endpoint", async () => {
260
+ mockFetch.mockReturnValueOnce(jsonResponse({ failures: [] }));
261
+ await api.fetchPipelineFailures(142);
262
+ expect(mockFetch).toHaveBeenCalledWith(
263
+ "/api/pipeline/142/failures",
264
+ undefined,
265
+ );
266
+ });
267
+ });
268
+
269
+ describe("approval gates", () => {
270
+ it("approveGate sends POST with stage", async () => {
271
+ mockFetch.mockReturnValueOnce(jsonResponse({ ok: true }));
272
+ await api.approveGate(42, "build");
273
+
274
+ expect(mockFetch).toHaveBeenCalledWith("/api/approval-gates/42/approve", {
275
+ method: "POST",
276
+ body: JSON.stringify({ stage: "build" }),
277
+ });
278
+ });
279
+
280
+ it("rejectGate sends POST with stage and reason", async () => {
281
+ mockFetch.mockReturnValueOnce(jsonResponse({ ok: true }));
282
+ await api.rejectGate(42, "build", "Failed QA");
283
+
284
+ expect(mockFetch).toHaveBeenCalledWith("/api/approval-gates/42/reject", {
285
+ method: "POST",
286
+ body: JSON.stringify({ stage: "build", reason: "Failed QA" }),
287
+ });
288
+ });
289
+ });
290
+
291
+ describe("notifications", () => {
292
+ it("addWebhook sends POST", async () => {
293
+ mockFetch.mockReturnValueOnce(jsonResponse({ ok: true }));
294
+ await api.addWebhook("https://slack.com/hook", "Slack", ["failure"]);
295
+
296
+ expect(mockFetch).toHaveBeenCalledWith("/api/notifications/webhook", {
297
+ method: "POST",
298
+ body: JSON.stringify({
299
+ url: "https://slack.com/hook",
300
+ label: "Slack",
301
+ events: ["failure"],
302
+ }),
303
+ });
304
+ });
305
+
306
+ it("removeWebhook sends DELETE", async () => {
307
+ mockFetch.mockReturnValueOnce(jsonResponse({ ok: true }));
308
+ await api.removeWebhook("https://slack.com/hook");
309
+
310
+ expect(mockFetch).toHaveBeenCalledWith("/api/notifications/webhook", {
311
+ method: "DELETE",
312
+ body: JSON.stringify({ url: "https://slack.com/hook" }),
313
+ });
314
+ });
315
+ });
316
+
317
+ describe("machine claim/release", () => {
318
+ it("claimIssue sends POST with issue and machine", async () => {
319
+ mockFetch.mockReturnValueOnce(
320
+ jsonResponse({ approved: true, claimed_by: "m1" }),
321
+ );
322
+ const result = await api.claimIssue(42, "m1");
323
+ expect(result.approved).toBe(true);
324
+ });
325
+
326
+ it("releaseIssue sends POST", async () => {
327
+ mockFetch.mockReturnValueOnce(jsonResponse({ ok: true }));
328
+ await api.releaseIssue(42, "m1");
329
+
330
+ expect(mockFetch).toHaveBeenCalledWith("/api/claim/release", {
331
+ method: "POST",
332
+ body: JSON.stringify({ issue: 42, machine: "m1" }),
333
+ });
334
+ });
335
+ });
336
+
337
+ describe("error handling", () => {
338
+ it("throws on non-ok response", async () => {
339
+ mockFetch.mockReturnValueOnce(errorResponse(404, { error: "Not found" }));
340
+
341
+ await expect(api.fetchMe()).rejects.toThrow("Not found");
342
+ });
343
+
344
+ it("falls back to HTTP status on unparseable error", async () => {
345
+ mockFetch.mockReturnValueOnce(
346
+ Promise.resolve({
347
+ ok: false,
348
+ status: 500,
349
+ json: () => Promise.reject(new Error("parse error")),
350
+ }),
351
+ );
352
+
353
+ await expect(api.fetchMe()).rejects.toThrow("HTTP 500");
354
+ });
355
+
356
+ it("fetchPredictions returns empty object on error", async () => {
357
+ mockFetch.mockReturnValueOnce(errorResponse(500));
358
+ const result = await api.fetchPredictions(42);
359
+ expect(result).toEqual({});
360
+ });
361
+ });
362
+ });
@@ -0,0 +1,266 @@
1
+ import { describe, it, expect, beforeEach, vi, afterEach } from "vitest";
2
+
3
+ // We need to mock the DOM and store before importing the router
4
+ describe("Router", () => {
5
+ let store: any;
6
+ let router: typeof import("./router");
7
+
8
+ beforeEach(async () => {
9
+ // Reset DOM
10
+ document.body.innerHTML = `
11
+ <div class="tab-btn" data-tab="overview">Overview</div>
12
+ <div class="tab-btn" data-tab="pipelines">Pipelines</div>
13
+ <div class="tab-btn" data-tab="metrics">Metrics</div>
14
+ <div class="tab-btn" data-tab="team">Team</div>
15
+ <div class="tab-panel" id="panel-overview"></div>
16
+ <div class="tab-panel" id="panel-pipelines"></div>
17
+ <div class="tab-panel" id="panel-metrics"></div>
18
+ <div class="tab-panel" id="panel-team"></div>
19
+ `;
20
+
21
+ // Reset hash
22
+ location.hash = "";
23
+
24
+ // Fresh imports
25
+ vi.resetModules();
26
+ store = (await import("./state")).store;
27
+ router = await import("./router");
28
+ });
29
+
30
+ afterEach(() => {
31
+ vi.restoreAllMocks();
32
+ });
33
+
34
+ describe("registerView", () => {
35
+ it("registers a view for a tab", () => {
36
+ const mockView = {
37
+ init: vi.fn(),
38
+ render: vi.fn(),
39
+ destroy: vi.fn(),
40
+ };
41
+ router.registerView("overview", mockView);
42
+
43
+ const views = router.getRegisteredViews();
44
+ expect(views.get("overview")).toBe(mockView);
45
+ });
46
+ });
47
+
48
+ describe("switchTab", () => {
49
+ it("updates the active tab in the store", () => {
50
+ store.set("activeTab", "overview");
51
+ router.switchTab("pipelines");
52
+ expect(store.get("activeTab")).toBe("pipelines");
53
+ });
54
+
55
+ it("updates the location hash", () => {
56
+ store.set("activeTab", "overview");
57
+ router.switchTab("pipelines");
58
+ expect(location.hash).toBe("#pipelines");
59
+ });
60
+
61
+ it("adds active class to the correct tab button", () => {
62
+ store.set("activeTab", "overview");
63
+ router.switchTab("pipelines");
64
+
65
+ const btns = document.querySelectorAll(".tab-btn");
66
+ const pipelinesBtn = Array.from(btns).find(
67
+ (b) => b.getAttribute("data-tab") === "pipelines",
68
+ );
69
+ const overviewBtn = Array.from(btns).find(
70
+ (b) => b.getAttribute("data-tab") === "overview",
71
+ );
72
+
73
+ expect(pipelinesBtn?.classList.contains("active")).toBe(true);
74
+ expect(overviewBtn?.classList.contains("active")).toBe(false);
75
+ });
76
+
77
+ it("adds active class to the correct panel", () => {
78
+ store.set("activeTab", "overview");
79
+ router.switchTab("pipelines");
80
+
81
+ const pipelinesPanel = document.getElementById("panel-pipelines");
82
+ const overviewPanel = document.getElementById("panel-overview");
83
+
84
+ expect(pipelinesPanel?.classList.contains("active")).toBe(true);
85
+ expect(overviewPanel?.classList.contains("active")).toBe(false);
86
+ });
87
+
88
+ it("does nothing when switching to the current tab", () => {
89
+ store.set("activeTab", "overview");
90
+ const prevHash = location.hash;
91
+ router.switchTab("overview");
92
+ // Should not change anything
93
+ expect(store.get("activeTab")).toBe("overview");
94
+ });
95
+
96
+ it("initializes the view on first switch", () => {
97
+ const mockView = {
98
+ init: vi.fn(),
99
+ render: vi.fn(),
100
+ destroy: vi.fn(),
101
+ };
102
+ router.registerView("metrics", mockView);
103
+
104
+ store.set("activeTab", "overview");
105
+ router.switchTab("metrics");
106
+
107
+ expect(mockView.init).toHaveBeenCalledTimes(1);
108
+ });
109
+
110
+ it("destroys the previous view on tab switch", () => {
111
+ const overviewView = {
112
+ init: vi.fn(),
113
+ render: vi.fn(),
114
+ destroy: vi.fn(),
115
+ };
116
+ const metricsView = {
117
+ init: vi.fn(),
118
+ render: vi.fn(),
119
+ destroy: vi.fn(),
120
+ };
121
+
122
+ router.registerView("overview", overviewView);
123
+ router.registerView("metrics", metricsView);
124
+
125
+ // First, switch to overview so it gets initialized
126
+ store.set("activeTab", "pipelines");
127
+ router.switchTab("overview");
128
+ expect(overviewView.init).toHaveBeenCalledTimes(1);
129
+
130
+ // Now switch to metrics
131
+ router.switchTab("metrics");
132
+ expect(overviewView.destroy).toHaveBeenCalledTimes(1);
133
+ expect(metricsView.init).toHaveBeenCalledTimes(1);
134
+ });
135
+
136
+ it("renders with fleet state if available", () => {
137
+ const mockView = {
138
+ init: vi.fn(),
139
+ render: vi.fn(),
140
+ destroy: vi.fn(),
141
+ };
142
+ router.registerView("metrics", mockView);
143
+
144
+ const fakeState = { pipelines: [], machines: [] };
145
+ store.set("fleetState", fakeState);
146
+ store.set("activeTab", "overview");
147
+
148
+ router.switchTab("metrics");
149
+ expect(mockView.render).toHaveBeenCalledWith(fakeState);
150
+ });
151
+ });
152
+
153
+ describe("error boundaries", () => {
154
+ it("catches init errors and shows error boundary", () => {
155
+ const errorView = {
156
+ init: vi.fn(() => {
157
+ throw new Error("Init failed!");
158
+ }),
159
+ render: vi.fn(),
160
+ destroy: vi.fn(),
161
+ };
162
+ router.registerView("metrics", errorView);
163
+
164
+ // Suppress console.error for this test
165
+ const consoleSpy = vi
166
+ .spyOn(console, "error")
167
+ .mockImplementation(() => {});
168
+
169
+ store.set("activeTab", "overview");
170
+ router.switchTab("metrics");
171
+
172
+ const panel = document.getElementById("panel-metrics");
173
+ const errorBoundary = panel?.querySelector(".tab-error-boundary");
174
+ expect(errorBoundary).toBeTruthy();
175
+ expect(errorBoundary?.textContent).toContain("Init failed!");
176
+
177
+ consoleSpy.mockRestore();
178
+ });
179
+
180
+ it("catches render errors and shows error boundary", () => {
181
+ const errorView = {
182
+ init: vi.fn(),
183
+ render: vi.fn(() => {
184
+ throw new Error("Render exploded!");
185
+ }),
186
+ destroy: vi.fn(),
187
+ };
188
+ router.registerView("metrics", errorView);
189
+
190
+ const consoleSpy = vi
191
+ .spyOn(console, "error")
192
+ .mockImplementation(() => {});
193
+
194
+ store.set("fleetState", { pipelines: [] });
195
+ store.set("activeTab", "overview");
196
+ router.switchTab("metrics");
197
+
198
+ const panel = document.getElementById("panel-metrics");
199
+ const errorBoundary = panel?.querySelector(".tab-error-boundary");
200
+ expect(errorBoundary).toBeTruthy();
201
+ expect(errorBoundary?.textContent).toContain("Render exploded!");
202
+
203
+ consoleSpy.mockRestore();
204
+ });
205
+
206
+ it("does not stack multiple error boundaries", () => {
207
+ const errorView = {
208
+ init: vi.fn(() => {
209
+ throw new Error("Fail");
210
+ }),
211
+ render: vi.fn(),
212
+ destroy: vi.fn(),
213
+ };
214
+ router.registerView("metrics", errorView);
215
+
216
+ const consoleSpy = vi
217
+ .spyOn(console, "error")
218
+ .mockImplementation(() => {});
219
+
220
+ store.set("activeTab", "overview");
221
+ router.switchTab("metrics");
222
+
223
+ // Try to trigger again (should not add second boundary)
224
+ store.set("activeTab", "pipelines");
225
+ router.switchTab("metrics");
226
+
227
+ const panel = document.getElementById("panel-metrics");
228
+ const boundaries = panel?.querySelectorAll(".tab-error-boundary");
229
+ expect(boundaries?.length).toBeLessThanOrEqual(1);
230
+
231
+ consoleSpy.mockRestore();
232
+ });
233
+ });
234
+
235
+ describe("renderActiveView", () => {
236
+ it("renders the current active view with fleet state", () => {
237
+ const mockView = {
238
+ init: vi.fn(),
239
+ render: vi.fn(),
240
+ destroy: vi.fn(),
241
+ };
242
+ router.registerView("overview", mockView);
243
+
244
+ const fakeState = { pipelines: [] };
245
+ store.set("fleetState", fakeState);
246
+ store.set("activeTab", "overview");
247
+
248
+ router.renderActiveView();
249
+
250
+ expect(mockView.render).toHaveBeenCalledWith(fakeState);
251
+ });
252
+
253
+ it("does nothing when no fleet state is available", () => {
254
+ const mockView = {
255
+ init: vi.fn(),
256
+ render: vi.fn(),
257
+ destroy: vi.fn(),
258
+ };
259
+ router.registerView("overview", mockView);
260
+ store.set("activeTab", "overview");
261
+
262
+ router.renderActiveView();
263
+ expect(mockView.render).not.toHaveBeenCalled();
264
+ });
265
+ });
266
+ });