shipwright-cli 2.4.0 → 3.1.0

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 (169) hide show
  1. package/README.md +16 -11
  2. package/completions/_shipwright +248 -94
  3. package/completions/shipwright.bash +68 -19
  4. package/completions/shipwright.fish +310 -42
  5. package/config/decision-tiers.json +55 -0
  6. package/config/defaults.json +111 -0
  7. package/config/event-schema.json +218 -0
  8. package/config/policy.json +21 -18
  9. package/dashboard/coverage/coverage-summary.json +14 -0
  10. package/dashboard/public/index.html +1 -1
  11. package/dashboard/server.ts +306 -17
  12. package/dashboard/src/components/charts/bar.test.ts +79 -0
  13. package/dashboard/src/components/charts/donut.test.ts +68 -0
  14. package/dashboard/src/components/charts/pipeline-rail.test.ts +117 -0
  15. package/dashboard/src/components/charts/sparkline.test.ts +125 -0
  16. package/dashboard/src/core/api.test.ts +309 -0
  17. package/dashboard/src/core/helpers.test.ts +301 -0
  18. package/dashboard/src/core/router.test.ts +307 -0
  19. package/dashboard/src/core/router.ts +7 -0
  20. package/dashboard/src/core/sse.test.ts +144 -0
  21. package/dashboard/src/views/metrics.test.ts +186 -0
  22. package/dashboard/src/views/overview.test.ts +173 -0
  23. package/dashboard/src/views/pipelines.test.ts +183 -0
  24. package/dashboard/src/views/team.test.ts +253 -0
  25. package/dashboard/vitest.config.ts +14 -5
  26. package/docs/TIPS.md +1 -1
  27. package/docs/patterns/README.md +1 -1
  28. package/package.json +7 -9
  29. package/scripts/adapters/docker-deploy.sh +1 -1
  30. package/scripts/adapters/tmux-adapter.sh +11 -1
  31. package/scripts/adapters/wezterm-adapter.sh +1 -1
  32. package/scripts/check-version-consistency.sh +1 -1
  33. package/scripts/lib/architecture.sh +127 -0
  34. package/scripts/lib/bootstrap.sh +75 -0
  35. package/scripts/lib/compat.sh +89 -6
  36. package/scripts/lib/config.sh +91 -0
  37. package/scripts/lib/daemon-adaptive.sh +3 -3
  38. package/scripts/lib/daemon-dispatch.sh +63 -17
  39. package/scripts/lib/daemon-failure.sh +0 -0
  40. package/scripts/lib/daemon-health.sh +1 -1
  41. package/scripts/lib/daemon-patrol.sh +64 -17
  42. package/scripts/lib/daemon-poll.sh +54 -25
  43. package/scripts/lib/daemon-state.sh +125 -23
  44. package/scripts/lib/daemon-triage.sh +31 -9
  45. package/scripts/lib/decide-autonomy.sh +295 -0
  46. package/scripts/lib/decide-scoring.sh +228 -0
  47. package/scripts/lib/decide-signals.sh +462 -0
  48. package/scripts/lib/fleet-failover.sh +63 -0
  49. package/scripts/lib/helpers.sh +29 -6
  50. package/scripts/lib/pipeline-detection.sh +2 -2
  51. package/scripts/lib/pipeline-github.sh +9 -9
  52. package/scripts/lib/pipeline-intelligence.sh +105 -38
  53. package/scripts/lib/pipeline-quality-checks.sh +17 -16
  54. package/scripts/lib/pipeline-quality.sh +1 -1
  55. package/scripts/lib/pipeline-stages.sh +440 -59
  56. package/scripts/lib/pipeline-state.sh +54 -4
  57. package/scripts/lib/policy.sh +0 -0
  58. package/scripts/lib/test-helpers.sh +247 -0
  59. package/scripts/postinstall.mjs +78 -12
  60. package/scripts/signals/example-collector.sh +36 -0
  61. package/scripts/sw +17 -7
  62. package/scripts/sw-activity.sh +1 -11
  63. package/scripts/sw-adaptive.sh +109 -85
  64. package/scripts/sw-adversarial.sh +4 -14
  65. package/scripts/sw-architecture-enforcer.sh +1 -11
  66. package/scripts/sw-auth.sh +8 -17
  67. package/scripts/sw-autonomous.sh +111 -49
  68. package/scripts/sw-changelog.sh +1 -11
  69. package/scripts/sw-checkpoint.sh +144 -20
  70. package/scripts/sw-ci.sh +2 -12
  71. package/scripts/sw-cleanup.sh +13 -17
  72. package/scripts/sw-code-review.sh +16 -36
  73. package/scripts/sw-connect.sh +5 -12
  74. package/scripts/sw-context.sh +9 -26
  75. package/scripts/sw-cost.sh +17 -18
  76. package/scripts/sw-daemon.sh +76 -71
  77. package/scripts/sw-dashboard.sh +57 -17
  78. package/scripts/sw-db.sh +524 -26
  79. package/scripts/sw-decide.sh +685 -0
  80. package/scripts/sw-decompose.sh +1 -11
  81. package/scripts/sw-deps.sh +15 -25
  82. package/scripts/sw-developer-simulation.sh +1 -11
  83. package/scripts/sw-discovery.sh +138 -30
  84. package/scripts/sw-doc-fleet.sh +7 -17
  85. package/scripts/sw-docs-agent.sh +6 -16
  86. package/scripts/sw-docs.sh +4 -12
  87. package/scripts/sw-doctor.sh +134 -43
  88. package/scripts/sw-dora.sh +11 -19
  89. package/scripts/sw-durable.sh +35 -52
  90. package/scripts/sw-e2e-orchestrator.sh +11 -27
  91. package/scripts/sw-eventbus.sh +115 -115
  92. package/scripts/sw-evidence.sh +114 -30
  93. package/scripts/sw-feedback.sh +3 -13
  94. package/scripts/sw-fix.sh +2 -20
  95. package/scripts/sw-fleet-discover.sh +1 -11
  96. package/scripts/sw-fleet-viz.sh +10 -18
  97. package/scripts/sw-fleet.sh +13 -17
  98. package/scripts/sw-github-app.sh +6 -16
  99. package/scripts/sw-github-checks.sh +1 -11
  100. package/scripts/sw-github-deploy.sh +1 -11
  101. package/scripts/sw-github-graphql.sh +2 -12
  102. package/scripts/sw-guild.sh +1 -11
  103. package/scripts/sw-heartbeat.sh +49 -12
  104. package/scripts/sw-hygiene.sh +45 -43
  105. package/scripts/sw-incident.sh +48 -74
  106. package/scripts/sw-init.sh +35 -37
  107. package/scripts/sw-instrument.sh +1 -11
  108. package/scripts/sw-intelligence.sh +368 -53
  109. package/scripts/sw-jira.sh +5 -14
  110. package/scripts/sw-launchd.sh +2 -12
  111. package/scripts/sw-linear.sh +8 -17
  112. package/scripts/sw-logs.sh +4 -12
  113. package/scripts/sw-loop.sh +905 -104
  114. package/scripts/sw-memory.sh +263 -20
  115. package/scripts/sw-mission-control.sh +2 -12
  116. package/scripts/sw-model-router.sh +73 -34
  117. package/scripts/sw-otel.sh +15 -23
  118. package/scripts/sw-oversight.sh +1 -11
  119. package/scripts/sw-patrol-meta.sh +5 -11
  120. package/scripts/sw-pipeline-composer.sh +7 -17
  121. package/scripts/sw-pipeline-vitals.sh +1 -11
  122. package/scripts/sw-pipeline.sh +550 -122
  123. package/scripts/sw-pm.sh +2 -12
  124. package/scripts/sw-pr-lifecycle.sh +33 -28
  125. package/scripts/sw-predictive.sh +16 -22
  126. package/scripts/sw-prep.sh +6 -16
  127. package/scripts/sw-ps.sh +1 -11
  128. package/scripts/sw-public-dashboard.sh +2 -12
  129. package/scripts/sw-quality.sh +85 -14
  130. package/scripts/sw-reaper.sh +1 -11
  131. package/scripts/sw-recruit.sh +15 -25
  132. package/scripts/sw-regression.sh +11 -21
  133. package/scripts/sw-release-manager.sh +19 -28
  134. package/scripts/sw-release.sh +8 -16
  135. package/scripts/sw-remote.sh +1 -11
  136. package/scripts/sw-replay.sh +48 -44
  137. package/scripts/sw-retro.sh +70 -92
  138. package/scripts/sw-review-rerun.sh +1 -1
  139. package/scripts/sw-scale.sh +174 -41
  140. package/scripts/sw-security-audit.sh +12 -22
  141. package/scripts/sw-self-optimize.sh +239 -23
  142. package/scripts/sw-session.sh +5 -15
  143. package/scripts/sw-setup.sh +8 -18
  144. package/scripts/sw-standup.sh +5 -15
  145. package/scripts/sw-status.sh +32 -23
  146. package/scripts/sw-strategic.sh +129 -13
  147. package/scripts/sw-stream.sh +1 -11
  148. package/scripts/sw-swarm.sh +76 -36
  149. package/scripts/sw-team-stages.sh +10 -20
  150. package/scripts/sw-templates.sh +4 -14
  151. package/scripts/sw-testgen.sh +3 -13
  152. package/scripts/sw-tmux-pipeline.sh +1 -19
  153. package/scripts/sw-tmux-role-color.sh +0 -10
  154. package/scripts/sw-tmux-status.sh +3 -11
  155. package/scripts/sw-tmux.sh +2 -20
  156. package/scripts/sw-trace.sh +1 -19
  157. package/scripts/sw-tracker-github.sh +0 -10
  158. package/scripts/sw-tracker-jira.sh +1 -11
  159. package/scripts/sw-tracker-linear.sh +1 -11
  160. package/scripts/sw-tracker.sh +7 -24
  161. package/scripts/sw-triage.sh +29 -39
  162. package/scripts/sw-upgrade.sh +5 -23
  163. package/scripts/sw-ux.sh +1 -19
  164. package/scripts/sw-webhook.sh +18 -32
  165. package/scripts/sw-widgets.sh +3 -21
  166. package/scripts/sw-worktree.sh +11 -27
  167. package/scripts/update-homebrew-sha.sh +73 -0
  168. package/templates/pipelines/tdd.json +72 -0
  169. package/scripts/sw-pipeline.sh.mock +0 -7
@@ -0,0 +1,301 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import {
3
+ formatDuration,
4
+ formatTime,
5
+ escapeHtml,
6
+ fmtNum,
7
+ truncate,
8
+ padZero,
9
+ getBadgeClass,
10
+ getTypeShort,
11
+ animateValue,
12
+ timeAgo,
13
+ formatMarkdown,
14
+ } from "./helpers";
15
+
16
+ describe("helpers", () => {
17
+ describe("formatDuration", () => {
18
+ it("returns em dash for null/undefined", () => {
19
+ expect(formatDuration(null)).toBe("\u2014");
20
+ expect(formatDuration(undefined)).toBe("\u2014");
21
+ });
22
+
23
+ it("formats seconds (< 60)", () => {
24
+ expect(formatDuration(0)).toBe("0s");
25
+ expect(formatDuration(30)).toBe("30s");
26
+ expect(formatDuration(59)).toBe("59s");
27
+ });
28
+
29
+ it("formats minutes and seconds (60-3599)", () => {
30
+ expect(formatDuration(60)).toBe("1m 0s");
31
+ expect(formatDuration(90)).toBe("1m 30s");
32
+ expect(formatDuration(125)).toBe("2m 5s");
33
+ expect(formatDuration(3599)).toBe("59m 59s");
34
+ });
35
+
36
+ it("formats hours and minutes (>= 3600)", () => {
37
+ expect(formatDuration(3600)).toBe("1h 0m");
38
+ expect(formatDuration(3661)).toBe("1h 1m");
39
+ expect(formatDuration(7325)).toBe("2h 2m");
40
+ });
41
+
42
+ it("floors fractional seconds", () => {
43
+ expect(formatDuration(59.9)).toBe("59s");
44
+ expect(formatDuration(60.9)).toBe("1m 0s");
45
+ });
46
+
47
+ it("handles negative values (formats as seconds when abs < 60)", () => {
48
+ expect(formatDuration(-5)).toBe("-5s");
49
+ expect(formatDuration(-65)).toBe("-65s");
50
+ });
51
+ });
52
+
53
+ describe("formatTime", () => {
54
+ it("returns em dash for null/undefined/empty", () => {
55
+ expect(formatTime(null)).toBe("\u2014");
56
+ expect(formatTime(undefined)).toBe("\u2014");
57
+ expect(formatTime("")).toBe("\u2014");
58
+ });
59
+
60
+ it("formats ISO string as HH:MM:SS (local time)", () => {
61
+ const result = formatTime("2025-02-17T14:30:45Z");
62
+ expect(result).toMatch(/^\d{2}:\d{2}:\d{2}$/);
63
+ const [, , sec] = result.split(":");
64
+ expect(Number(sec)).toBe(45);
65
+ });
66
+ });
67
+
68
+ describe("escapeHtml", () => {
69
+ it("returns empty string for null/undefined", () => {
70
+ expect(escapeHtml(null)).toBe("");
71
+ expect(escapeHtml(undefined)).toBe("");
72
+ });
73
+
74
+ it("escapes XSS characters", () => {
75
+ expect(escapeHtml("<script>")).toBe("&lt;script&gt;");
76
+ expect(escapeHtml(">")).toBe("&gt;");
77
+ expect(escapeHtml("&")).toBe("&amp;");
78
+ expect(escapeHtml('"')).toBe("&quot;");
79
+ });
80
+
81
+ it("escapes combined characters", () => {
82
+ expect(escapeHtml('<img src="x">')).toBe("&lt;img src=&quot;x&quot;&gt;");
83
+ });
84
+
85
+ it("leaves single quote unchanged (not escaped in impl)", () => {
86
+ expect(escapeHtml("'")).toBe("'");
87
+ });
88
+ });
89
+
90
+ describe("fmtNum", () => {
91
+ it("returns 0 for null/undefined", () => {
92
+ expect(fmtNum(null)).toBe("0");
93
+ expect(fmtNum(undefined)).toBe("0");
94
+ });
95
+
96
+ it("formats numbers with locale string", () => {
97
+ expect(fmtNum(0)).toBe("0");
98
+ expect(fmtNum(1000)).toBe("1,000");
99
+ expect(fmtNum(1234567)).toBe("1,234,567");
100
+ });
101
+ });
102
+
103
+ describe("truncate", () => {
104
+ it("returns empty string for null/undefined", () => {
105
+ expect(truncate(null, 10)).toBe("");
106
+ expect(truncate(undefined, 10)).toBe("");
107
+ });
108
+
109
+ it("returns string as-is when within maxLen", () => {
110
+ expect(truncate("hello", 10)).toBe("hello");
111
+ expect(truncate("hello", 5)).toBe("hello");
112
+ });
113
+
114
+ it("truncates with ellipsis when exceeding maxLen", () => {
115
+ expect(truncate("hello world", 5)).toBe("hello…");
116
+ expect(truncate("abcdefghij", 8)).toBe("abcdefgh…");
117
+ });
118
+ });
119
+
120
+ describe("padZero", () => {
121
+ it("pads single digits with leading zero", () => {
122
+ expect(padZero(0)).toBe("00");
123
+ expect(padZero(5)).toBe("05");
124
+ expect(padZero(9)).toBe("09");
125
+ });
126
+
127
+ it("does not pad double digits", () => {
128
+ expect(padZero(10)).toBe("10");
129
+ expect(padZero(99)).toBe("99");
130
+ });
131
+ });
132
+
133
+ describe("getBadgeClass", () => {
134
+ it("returns intervention for intervention type", () => {
135
+ expect(getBadgeClass("foo.intervention")).toBe("intervention");
136
+ });
137
+
138
+ it("returns heartbeat for heartbeat type", () => {
139
+ expect(getBadgeClass("machine.heartbeat")).toBe("heartbeat");
140
+ });
141
+
142
+ it("returns recovery for recovery/checkpoint", () => {
143
+ expect(getBadgeClass("stage.recovery")).toBe("recovery");
144
+ expect(getBadgeClass("foo.checkpoint")).toBe("recovery");
145
+ });
146
+
147
+ it("returns remote for remote/distributed", () => {
148
+ expect(getBadgeClass("job.remote")).toBe("remote");
149
+ expect(getBadgeClass("distributed.task")).toBe("remote");
150
+ });
151
+
152
+ it("returns other specific classes", () => {
153
+ expect(getBadgeClass("poll")).toBe("poll");
154
+ expect(getBadgeClass("spawn")).toBe("spawn");
155
+ expect(getBadgeClass("started")).toBe("started");
156
+ expect(getBadgeClass("completed")).toBe("completed");
157
+ expect(getBadgeClass("reap")).toBe("completed");
158
+ expect(getBadgeClass("failed")).toBe("failed");
159
+ expect(getBadgeClass("stage")).toBe("stage");
160
+ expect(getBadgeClass("scale")).toBe("scale");
161
+ });
162
+
163
+ it("returns default for unknown type", () => {
164
+ expect(getBadgeClass("unknown")).toBe("default");
165
+ });
166
+ });
167
+
168
+ describe("getTypeShort", () => {
169
+ it("returns last segment of dotted type", () => {
170
+ expect(getTypeShort("foo.bar.baz")).toBe("baz");
171
+ expect(getTypeShort("machine.heartbeat")).toBe("heartbeat");
172
+ });
173
+
174
+ it("returns full string when no dots", () => {
175
+ expect(getTypeShort("simple")).toBe("simple");
176
+ });
177
+
178
+ it("returns unknown for null/undefined (String converts)", () => {
179
+ expect(getTypeShort("")).toBe("unknown");
180
+ });
181
+ });
182
+
183
+ describe("animateValue", () => {
184
+ let el: HTMLElement;
185
+
186
+ beforeEach(() => {
187
+ el = document.createElement("span");
188
+ vi.useFakeTimers({ toFake: ["requestAnimationFrame"] });
189
+ });
190
+
191
+ afterEach(() => {
192
+ vi.useRealTimers();
193
+ });
194
+
195
+ it("does nothing when el is null", () => {
196
+ animateValue(null, 0, 100, 1000);
197
+ vi.advanceTimersToNextFrame();
198
+ expect(el.textContent).toBe("");
199
+ });
200
+
201
+ it("sets final value immediately when start equals end", () => {
202
+ animateValue(el, 50, 50, 1000);
203
+ expect(el.textContent).toBe("50");
204
+ });
205
+
206
+ it("uses requestAnimationFrame when start differs from end", () => {
207
+ animateValue(el, 0, 100, 1000);
208
+ vi.advanceTimersToNextFrame();
209
+ expect(el.textContent).toBe("0");
210
+ vi.advanceTimersByTime(1000);
211
+ vi.advanceTimersToNextFrame();
212
+ expect(el.textContent).toBe("100");
213
+ });
214
+
215
+ it("appends suffix when provided", () => {
216
+ animateValue(el, 10, 10, 1000, "%");
217
+ expect(el.textContent).toBe("10%");
218
+ });
219
+ });
220
+
221
+ describe("timeAgo", () => {
222
+ const now = 1708200000000; // fixed timestamp
223
+
224
+ beforeEach(() => {
225
+ vi.useFakeTimers();
226
+ vi.setSystemTime(now);
227
+ });
228
+
229
+ afterEach(() => {
230
+ vi.useRealTimers();
231
+ });
232
+
233
+ it("returns seconds ago when < 60s", () => {
234
+ const date = new Date(now - 30 * 1000);
235
+ expect(timeAgo(date)).toBe("30s ago");
236
+ expect(timeAgo(new Date(now - 0))).toBe("0s ago");
237
+ expect(timeAgo(new Date(now - 59 * 1000))).toBe("59s ago");
238
+ });
239
+
240
+ it("returns minutes ago when 60s to < 60m", () => {
241
+ expect(timeAgo(new Date(now - 60 * 1000))).toBe("1m ago");
242
+ expect(timeAgo(new Date(now - 90 * 1000))).toBe("1m ago");
243
+ expect(timeAgo(new Date(now - 3599 * 1000))).toBe("59m ago");
244
+ });
245
+
246
+ it("returns hours ago when 60m to < 24h", () => {
247
+ expect(timeAgo(new Date(now - 3600 * 1000))).toBe("1h ago");
248
+ expect(timeAgo(new Date(now - 7200 * 1000))).toBe("2h ago");
249
+ expect(timeAgo(new Date(now - 23 * 3600 * 1000))).toBe("23h ago");
250
+ });
251
+
252
+ it("returns days ago when >= 24h", () => {
253
+ expect(timeAgo(new Date(now - 24 * 3600 * 1000))).toBe("1d ago");
254
+ expect(timeAgo(new Date(now - 48 * 3600 * 1000))).toBe("2d ago");
255
+ });
256
+ });
257
+
258
+ describe("formatMarkdown", () => {
259
+ it("returns empty string for null/undefined", () => {
260
+ expect(formatMarkdown(null)).toBe("");
261
+ expect(formatMarkdown(undefined)).toBe("");
262
+ });
263
+
264
+ it("converts headers to strong", () => {
265
+ expect(formatMarkdown("# Title")).toContain("<strong>Title</strong>");
266
+ expect(formatMarkdown("## Subtitle")).toContain(
267
+ "<strong>Subtitle</strong>",
268
+ );
269
+ expect(formatMarkdown("### Small")).toContain("<strong>Small</strong>");
270
+ });
271
+
272
+ it("converts code blocks to pre", () => {
273
+ const result = formatMarkdown("```\nconst x = 1;\n```");
274
+ expect(result).toContain('<pre class="artifact-code">');
275
+ expect(result).toContain("const x = 1;");
276
+ expect(result).toContain("</pre>");
277
+ });
278
+
279
+ it("converts inline code to code", () => {
280
+ expect(formatMarkdown("Use `foo()` here")).toContain(
281
+ "<code>foo()</code>",
282
+ );
283
+ });
284
+
285
+ it("converts list items", () => {
286
+ const result = formatMarkdown("- item one\n- item two");
287
+ expect(result).toContain("<li>item one</li>");
288
+ expect(result).toContain("<li>item two</li>");
289
+ });
290
+
291
+ it("converts newlines to br", () => {
292
+ expect(formatMarkdown("line1\nline2")).toContain("line1<br>line2");
293
+ });
294
+
295
+ it("escapes HTML in content", () => {
296
+ const result = formatMarkdown("<script>alert(1)</script>");
297
+ expect(result).toContain("&lt;script&gt;");
298
+ expect(result).not.toContain("<script>");
299
+ });
300
+ });
301
+ });
@@ -148,9 +148,49 @@ describe("Router", () => {
148
148
  router.switchTab("metrics");
149
149
  expect(mockView.render).toHaveBeenCalledWith(fakeState);
150
150
  });
151
+
152
+ it("clears team refresh timer when leaving team tab", () => {
153
+ const timer = setInterval(() => {}, 999999);
154
+ router.__setTeamRefreshTimerForTest(timer);
155
+
156
+ const clearSpy = vi.spyOn(global, "clearInterval");
157
+
158
+ store.set("activeTab", "team");
159
+ router.switchTab("overview");
160
+
161
+ expect(clearSpy).toHaveBeenCalledWith(timer);
162
+
163
+ router.__setTeamRefreshTimerForTest(null);
164
+ clearSpy.mockRestore();
165
+ });
151
166
  });
152
167
 
153
168
  describe("error boundaries", () => {
169
+ it("shows string errors when non-Error is thrown", () => {
170
+ const errorView = {
171
+ init: vi.fn(() => {
172
+ throw "string error";
173
+ }),
174
+ render: vi.fn(),
175
+ destroy: vi.fn(),
176
+ };
177
+ router.registerView("metrics", errorView);
178
+
179
+ const consoleSpy = vi
180
+ .spyOn(console, "error")
181
+ .mockImplementation(() => {});
182
+
183
+ store.set("activeTab", "overview");
184
+ router.switchTab("metrics");
185
+
186
+ const panel = document.getElementById("panel-metrics");
187
+ const errorBoundary = panel?.querySelector(".tab-error-boundary");
188
+ expect(errorBoundary).toBeTruthy();
189
+ expect(errorBoundary?.textContent).toContain("string error");
190
+
191
+ consoleSpy.mockRestore();
192
+ });
193
+
154
194
  it("catches init errors and shows error boundary", () => {
155
195
  const errorView = {
156
196
  init: vi.fn(() => {
@@ -262,5 +302,272 @@ describe("Router", () => {
262
302
  router.renderActiveView();
263
303
  expect(mockView.render).not.toHaveBeenCalled();
264
304
  });
305
+
306
+ it("does nothing when no view is registered for active tab", () => {
307
+ store.set("activeTab", "agents");
308
+ store.set("fleetState", { pipelines: [] });
309
+
310
+ expect(() => router.renderActiveView()).not.toThrow();
311
+ });
312
+
313
+ it("catches render errors and shows error boundary", () => {
314
+ const errorView = {
315
+ init: vi.fn(),
316
+ render: vi.fn(() => {
317
+ throw new Error("renderActiveView render failed!");
318
+ }),
319
+ destroy: vi.fn(),
320
+ };
321
+ router.registerView("overview", errorView);
322
+
323
+ const consoleSpy = vi
324
+ .spyOn(console, "error")
325
+ .mockImplementation(() => {});
326
+
327
+ store.set("fleetState", { pipelines: [] });
328
+ store.set("activeTab", "overview");
329
+
330
+ router.renderActiveView();
331
+
332
+ const panel = document.getElementById("panel-overview");
333
+ const errorBoundary = panel?.querySelector(".tab-error-boundary");
334
+ expect(errorBoundary).toBeTruthy();
335
+ expect(errorBoundary?.textContent).toContain(
336
+ "renderActiveView render failed!",
337
+ );
338
+
339
+ consoleSpy.mockRestore();
340
+ });
341
+ });
342
+
343
+ describe("error boundary retry", () => {
344
+ it("retry button reinitalizes view and re-renders on success", () => {
345
+ let initCalled = 0;
346
+ const errorView = {
347
+ init: vi.fn(() => {
348
+ initCalled++;
349
+ if (initCalled === 1) throw new Error("First init failed");
350
+ }),
351
+ render: vi.fn(),
352
+ destroy: vi.fn(),
353
+ };
354
+ router.registerView("metrics", errorView);
355
+
356
+ const consoleSpy = vi
357
+ .spyOn(console, "error")
358
+ .mockImplementation(() => {});
359
+
360
+ store.set("fleetState", { pipelines: [] });
361
+ store.set("activeTab", "overview");
362
+ router.switchTab("metrics");
363
+
364
+ const panel = document.getElementById("panel-metrics");
365
+ const retryBtn = panel?.querySelector(".error-boundary-retry");
366
+ expect(retryBtn).toBeTruthy();
367
+
368
+ retryBtn?.dispatchEvent(new Event("click", { bubbles: true }));
369
+
370
+ expect(errorView.init).toHaveBeenCalledTimes(2);
371
+ expect(errorView.render).toHaveBeenCalledWith({ pipelines: [] });
372
+ expect(panel?.querySelector(".tab-error-boundary")).toBeFalsy();
373
+
374
+ consoleSpy.mockRestore();
375
+ });
376
+
377
+ it("retry button does not call render when fleetState is null", () => {
378
+ const errorView = {
379
+ init: vi.fn(),
380
+ render: vi.fn(() => {
381
+ throw new Error("Render failed");
382
+ }),
383
+ destroy: vi.fn(),
384
+ };
385
+ router.registerView("metrics", errorView);
386
+
387
+ const consoleSpy = vi
388
+ .spyOn(console, "error")
389
+ .mockImplementation(() => {});
390
+
391
+ store.set("fleetState", { pipelines: [] });
392
+ store.set("activeTab", "overview");
393
+ router.switchTab("metrics");
394
+ expect(errorView.render).toHaveBeenCalledTimes(1);
395
+
396
+ store.set("fleetState", null);
397
+
398
+ const panel = document.getElementById("panel-metrics");
399
+ const retryBtn = panel?.querySelector(".error-boundary-retry");
400
+ retryBtn?.dispatchEvent(new Event("click", { bubbles: true }));
401
+
402
+ expect(errorView.init).toHaveBeenCalledTimes(2);
403
+ expect(errorView.render).toHaveBeenCalledTimes(1);
404
+
405
+ consoleSpy.mockRestore();
406
+ });
407
+
408
+ it("retry button shows error boundary again when retry fails", () => {
409
+ const errorView = {
410
+ init: vi.fn(() => {
411
+ throw new Error("Retry failed!");
412
+ }),
413
+ render: vi.fn(),
414
+ destroy: vi.fn(),
415
+ };
416
+ router.registerView("metrics", errorView);
417
+
418
+ const consoleSpy = vi
419
+ .spyOn(console, "error")
420
+ .mockImplementation(() => {});
421
+
422
+ store.set("fleetState", { pipelines: [] });
423
+ store.set("activeTab", "overview");
424
+ router.switchTab("metrics");
425
+
426
+ const panel = document.getElementById("panel-metrics");
427
+ const retryBtn = panel?.querySelector(".error-boundary-retry");
428
+ retryBtn?.dispatchEvent(new Event("click", { bubbles: true }));
429
+
430
+ const errorBoundary = panel?.querySelector(".tab-error-boundary");
431
+ expect(errorBoundary).toBeTruthy();
432
+ expect(errorBoundary?.textContent).toContain("Retry failed!");
433
+
434
+ consoleSpy.mockRestore();
435
+ });
436
+ });
437
+
438
+ describe("setupRouter", () => {
439
+ beforeEach(async () => {
440
+ vi.resetModules();
441
+ store = (await import("./state")).store;
442
+ router = await import("./router");
443
+ });
444
+
445
+ it("handles tab button clicks and switches tab", () => {
446
+ const mockView = {
447
+ init: vi.fn(),
448
+ render: vi.fn(),
449
+ destroy: vi.fn(),
450
+ };
451
+ router.registerView("pipelines", mockView);
452
+
453
+ router.setupRouter();
454
+
455
+ const pipelinesBtn = document.querySelector(
456
+ '[data-tab="pipelines"]',
457
+ ) as HTMLElement;
458
+ pipelinesBtn?.click();
459
+
460
+ expect(store.get("activeTab")).toBe("pipelines");
461
+ });
462
+
463
+ it("does not switch when tab button has no data-tab", () => {
464
+ document.body.innerHTML = `
465
+ <div class="tab-btn">No Tab</div>
466
+ <div class="tab-btn" data-tab="overview">Overview</div>
467
+ <div class="tab-panel" id="panel-overview"></div>
468
+ `;
469
+ store.set("activeTab", "overview");
470
+ router.registerView("overview", {
471
+ init: vi.fn(),
472
+ render: vi.fn(),
473
+ destroy: vi.fn(),
474
+ });
475
+
476
+ router.setupRouter();
477
+
478
+ const noTabBtn = document.querySelector(".tab-btn");
479
+ noTabBtn?.dispatchEvent(new Event("click", { bubbles: true }));
480
+
481
+ expect(store.get("activeTab")).toBe("overview");
482
+ });
483
+
484
+ it("switches to tab from valid initial hash", () => {
485
+ location.hash = "#team";
486
+
487
+ router.registerView("team", {
488
+ init: vi.fn(),
489
+ render: vi.fn(),
490
+ destroy: vi.fn(),
491
+ });
492
+ router.setupRouter();
493
+
494
+ expect(store.get("activeTab")).toBe("team");
495
+ expect(location.hash).toBe("#team");
496
+ });
497
+
498
+ it("initializes current view when hash is invalid", () => {
499
+ location.hash = "#invalid";
500
+ store.set("activeTab", "overview");
501
+
502
+ const mockView = {
503
+ init: vi.fn(),
504
+ render: vi.fn(),
505
+ destroy: vi.fn(),
506
+ };
507
+ router.registerView("overview", mockView);
508
+
509
+ router.setupRouter();
510
+
511
+ expect(mockView.init).toHaveBeenCalledTimes(1);
512
+ });
513
+
514
+ it("responds to hashchange events", () => {
515
+ router.registerView("overview", {
516
+ init: vi.fn(),
517
+ render: vi.fn(),
518
+ destroy: vi.fn(),
519
+ });
520
+ router.registerView("metrics", {
521
+ init: vi.fn(),
522
+ render: vi.fn(),
523
+ destroy: vi.fn(),
524
+ });
525
+
526
+ router.setupRouter();
527
+
528
+ store.set("activeTab", "overview");
529
+ location.hash = "#metrics";
530
+
531
+ window.dispatchEvent(new HashChangeEvent("hashchange"));
532
+
533
+ expect(store.get("activeTab")).toBe("metrics");
534
+ });
535
+
536
+ it("does not switch on hashchange when hash matches current tab", () => {
537
+ router.registerView("metrics", {
538
+ init: vi.fn(),
539
+ render: vi.fn(),
540
+ destroy: vi.fn(),
541
+ });
542
+
543
+ router.setupRouter();
544
+
545
+ store.set("activeTab", "metrics");
546
+ location.hash = "#metrics";
547
+
548
+ const switchSpy = vi.spyOn(router, "switchTab");
549
+ window.dispatchEvent(new HashChangeEvent("hashchange"));
550
+
551
+ expect(switchSpy).not.toHaveBeenCalled();
552
+ });
553
+
554
+ it("re-renders active view when fleetState changes", () => {
555
+ const mockView = {
556
+ init: vi.fn(),
557
+ render: vi.fn(),
558
+ destroy: vi.fn(),
559
+ };
560
+ router.registerView("overview", mockView);
561
+
562
+ store.set("activeTab", "overview");
563
+ router.setupRouter();
564
+
565
+ store.set("fleetState", { pipelines: [], machines: [] });
566
+
567
+ expect(mockView.render).toHaveBeenCalledWith({
568
+ pipelines: [],
569
+ machines: [],
570
+ });
571
+ });
265
572
  });
266
573
  });
@@ -23,6 +23,13 @@ const VALID_TABS: TabId[] = [
23
23
 
24
24
  let teamRefreshTimer: ReturnType<typeof setInterval> | null = null;
25
25
 
26
+ /** Test hook: set team refresh timer so switchTab can clear it when leaving team tab */
27
+ export function __setTeamRefreshTimerForTest(
28
+ timer: ReturnType<typeof setInterval> | null,
29
+ ): void {
30
+ teamRefreshTimer = timer;
31
+ }
32
+
26
33
  export function registerView(tabId: TabId, view: View): void {
27
34
  views.set(tabId, view);
28
35
  }