shipwright-cli 2.2.2 → 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 (151) hide show
  1. package/README.md +12 -11
  2. package/dashboard/public/index.html +224 -8
  3. package/dashboard/public/styles.css +1078 -4
  4. package/dashboard/server.ts +1100 -15
  5. package/dashboard/src/canvas/interactions.ts +74 -0
  6. package/dashboard/src/canvas/layout.ts +85 -0
  7. package/dashboard/src/canvas/overlays.ts +117 -0
  8. package/dashboard/src/canvas/particles.ts +105 -0
  9. package/dashboard/src/canvas/renderer.ts +191 -0
  10. package/dashboard/src/components/charts/bar.ts +54 -0
  11. package/dashboard/src/components/charts/donut.ts +25 -0
  12. package/dashboard/src/components/charts/pipeline-rail.ts +105 -0
  13. package/dashboard/src/components/charts/sparkline.ts +82 -0
  14. package/dashboard/src/components/header.ts +616 -0
  15. package/dashboard/src/components/modal.ts +413 -0
  16. package/dashboard/src/components/terminal.ts +144 -0
  17. package/dashboard/src/core/api.test.ts +362 -0
  18. package/dashboard/src/core/api.ts +381 -0
  19. package/dashboard/src/core/helpers.ts +118 -0
  20. package/dashboard/src/core/router.test.ts +266 -0
  21. package/dashboard/src/core/router.ts +190 -0
  22. package/dashboard/src/core/sse.ts +38 -0
  23. package/dashboard/src/core/state.test.ts +235 -0
  24. package/dashboard/src/core/state.ts +150 -0
  25. package/dashboard/src/core/ws.test.ts +216 -0
  26. package/dashboard/src/core/ws.ts +143 -0
  27. package/dashboard/src/design/icons.test.ts +105 -0
  28. package/dashboard/src/design/icons.ts +131 -0
  29. package/dashboard/src/design/tokens.test.ts +204 -0
  30. package/dashboard/src/design/tokens.ts +160 -0
  31. package/dashboard/src/main.ts +68 -0
  32. package/dashboard/src/types/api.ts +337 -0
  33. package/dashboard/src/views/activity.ts +185 -0
  34. package/dashboard/src/views/agent-cockpit.ts +236 -0
  35. package/dashboard/src/views/agents.ts +72 -0
  36. package/dashboard/src/views/fleet-map.ts +299 -0
  37. package/dashboard/src/views/insights.ts +298 -0
  38. package/dashboard/src/views/machines.ts +162 -0
  39. package/dashboard/src/views/metrics.ts +420 -0
  40. package/dashboard/src/views/overview.ts +409 -0
  41. package/dashboard/src/views/pipeline-theater.ts +219 -0
  42. package/dashboard/src/views/pipelines.ts +595 -0
  43. package/dashboard/src/views/team.ts +362 -0
  44. package/dashboard/src/views/timeline.ts +389 -0
  45. package/dashboard/tsconfig.json +21 -0
  46. package/dashboard/vitest.config.ts +27 -0
  47. package/docs/AGI-WHATS-NEXT.md +15 -15
  48. package/package.json +16 -2
  49. package/scripts/lib/helpers.sh +30 -0
  50. package/scripts/lib/pipeline-quality-checks.sh +1 -1
  51. package/scripts/lib/pipeline-stages.sh +59 -0
  52. package/scripts/sw +86 -167
  53. package/scripts/sw-activity.sh +1 -1
  54. package/scripts/sw-adaptive.sh +1 -1
  55. package/scripts/sw-adversarial.sh +1 -1
  56. package/scripts/sw-architecture-enforcer.sh +1 -1
  57. package/scripts/sw-auth.sh +14 -6
  58. package/scripts/sw-autonomous.sh +230 -13
  59. package/scripts/sw-changelog.sh +2 -2
  60. package/scripts/sw-checkpoint.sh +1 -1
  61. package/scripts/sw-ci.sh +1 -1
  62. package/scripts/sw-cleanup.sh +1 -1
  63. package/scripts/sw-code-review.sh +1 -1
  64. package/scripts/sw-connect.sh +1 -1
  65. package/scripts/sw-context.sh +1 -1
  66. package/scripts/sw-cost.sh +1 -1
  67. package/scripts/sw-daemon.sh +2 -2
  68. package/scripts/sw-dashboard.sh +1 -1
  69. package/scripts/sw-db.sh +1 -1
  70. package/scripts/sw-decompose.sh +1 -1
  71. package/scripts/sw-deps.sh +1 -1
  72. package/scripts/sw-developer-simulation.sh +1 -1
  73. package/scripts/sw-discovery.sh +1 -1
  74. package/scripts/sw-doc-fleet.sh +1 -1
  75. package/scripts/sw-docs-agent.sh +1 -1
  76. package/scripts/sw-docs.sh +1 -1
  77. package/scripts/sw-doctor.sh +8 -1
  78. package/scripts/sw-dora.sh +1 -1
  79. package/scripts/sw-durable.sh +1 -1
  80. package/scripts/sw-e2e-orchestrator.sh +1 -1
  81. package/scripts/sw-eventbus.sh +1 -1
  82. package/scripts/sw-feedback.sh +1 -1
  83. package/scripts/sw-fix.sh +6 -5
  84. package/scripts/sw-fleet-discover.sh +1 -1
  85. package/scripts/sw-fleet-viz.sh +1 -1
  86. package/scripts/sw-fleet.sh +1 -1
  87. package/scripts/sw-github-app.sh +5 -2
  88. package/scripts/sw-github-checks.sh +1 -1
  89. package/scripts/sw-github-deploy.sh +1 -1
  90. package/scripts/sw-github-graphql.sh +1 -1
  91. package/scripts/sw-guild.sh +1 -1
  92. package/scripts/sw-heartbeat.sh +1 -1
  93. package/scripts/sw-hygiene.sh +1 -1
  94. package/scripts/sw-incident.sh +1 -1
  95. package/scripts/sw-init.sh +112 -9
  96. package/scripts/sw-instrument.sh +6 -1
  97. package/scripts/sw-intelligence.sh +5 -1
  98. package/scripts/sw-jira.sh +1 -1
  99. package/scripts/sw-launchd.sh +1 -1
  100. package/scripts/sw-linear.sh +20 -9
  101. package/scripts/sw-logs.sh +1 -1
  102. package/scripts/sw-loop.sh +2 -1
  103. package/scripts/sw-memory.sh +10 -1
  104. package/scripts/sw-mission-control.sh +1 -1
  105. package/scripts/sw-model-router.sh +4 -1
  106. package/scripts/sw-otel.sh +1 -1
  107. package/scripts/sw-oversight.sh +1 -1
  108. package/scripts/sw-pipeline-composer.sh +3 -1
  109. package/scripts/sw-pipeline-vitals.sh +4 -6
  110. package/scripts/sw-pipeline.sh +4 -1
  111. package/scripts/sw-pm.sh +5 -2
  112. package/scripts/sw-pr-lifecycle.sh +1 -1
  113. package/scripts/sw-predictive.sh +4 -1
  114. package/scripts/sw-prep.sh +3 -2
  115. package/scripts/sw-ps.sh +1 -1
  116. package/scripts/sw-public-dashboard.sh +10 -4
  117. package/scripts/sw-quality.sh +1 -1
  118. package/scripts/sw-reaper.sh +1 -1
  119. package/scripts/sw-recruit.sh +16 -0
  120. package/scripts/sw-regression.sh +2 -1
  121. package/scripts/sw-release-manager.sh +1 -1
  122. package/scripts/sw-release.sh +7 -5
  123. package/scripts/sw-remote.sh +1 -1
  124. package/scripts/sw-replay.sh +1 -1
  125. package/scripts/sw-retro.sh +4 -1
  126. package/scripts/sw-scale.sh +4 -1
  127. package/scripts/sw-security-audit.sh +1 -1
  128. package/scripts/sw-self-optimize.sh +113 -1
  129. package/scripts/sw-session.sh +1 -1
  130. package/scripts/sw-setup.sh +1 -1
  131. package/scripts/sw-standup.sh +2 -1
  132. package/scripts/sw-status.sh +1 -1
  133. package/scripts/sw-strategic.sh +2 -1
  134. package/scripts/sw-stream.sh +1 -1
  135. package/scripts/sw-swarm.sh +6 -1
  136. package/scripts/sw-team-stages.sh +1 -1
  137. package/scripts/sw-templates.sh +1 -1
  138. package/scripts/sw-testgen.sh +3 -2
  139. package/scripts/sw-tmux-pipeline.sh +2 -1
  140. package/scripts/sw-tmux.sh +1 -1
  141. package/scripts/sw-trace.sh +1 -1
  142. package/scripts/sw-tracker-jira.sh +1 -0
  143. package/scripts/sw-tracker-linear.sh +1 -0
  144. package/scripts/sw-tracker.sh +1 -1
  145. package/scripts/sw-triage.sh +198 -11
  146. package/scripts/sw-upgrade.sh +1 -1
  147. package/scripts/sw-ux.sh +1 -1
  148. package/scripts/sw-webhook.sh +1 -1
  149. package/scripts/sw-widgets.sh +2 -2
  150. package/scripts/sw-worktree.sh +1 -1
  151. package/dashboard/public/app.js +0 -4422
@@ -0,0 +1,235 @@
1
+ import { describe, it, expect, beforeEach, vi } from "vitest";
2
+
3
+ // Re-create store for each test to avoid state leakage
4
+ function createStore() {
5
+ // Reset module cache to get fresh store
6
+ const mod = require("./state");
7
+ return mod.store;
8
+ }
9
+
10
+ // Since the module exports a singleton, we'll test the Store class behavior
11
+ // by importing and using the exported store, resetting between tests
12
+ import { store } from "./state";
13
+ import type { AppState } from "./state";
14
+
15
+ describe("Store", () => {
16
+ beforeEach(() => {
17
+ // Reset to default state
18
+ store.update({
19
+ connected: false,
20
+ connectedAt: null,
21
+ fleetState: null,
22
+ activeTab: "overview",
23
+ selectedPipelineIssue: null,
24
+ pipelineDetail: null,
25
+ pipelineFilter: "all",
26
+ activityFilter: "all",
27
+ activityIssueFilter: "",
28
+ activityEvents: [],
29
+ activityOffset: 0,
30
+ activityHasMore: false,
31
+ metricsCache: null,
32
+ insightsCache: null,
33
+ machinesCache: null,
34
+ joinTokensCache: null,
35
+ costBreakdownCache: null,
36
+ alertsCache: null,
37
+ alertDismissed: false,
38
+ teamCache: null,
39
+ teamActivityCache: null,
40
+ daemonConfig: null,
41
+ currentUser: null,
42
+ selectedIssues: {},
43
+ firstRender: true,
44
+ });
45
+ });
46
+
47
+ describe("get/set", () => {
48
+ it("returns the initial value for a key", () => {
49
+ expect(store.get("connected")).toBe(false);
50
+ expect(store.get("activeTab")).toBe("overview");
51
+ expect(store.get("firstRender")).toBe(true);
52
+ });
53
+
54
+ it("sets and retrieves a value", () => {
55
+ store.set("connected", true);
56
+ expect(store.get("connected")).toBe(true);
57
+ });
58
+
59
+ it("sets activeTab correctly", () => {
60
+ store.set("activeTab", "pipelines");
61
+ expect(store.get("activeTab")).toBe("pipelines");
62
+ });
63
+
64
+ it("handles null values", () => {
65
+ store.set("fleetState", null);
66
+ expect(store.get("fleetState")).toBeNull();
67
+ });
68
+
69
+ it("handles numeric values", () => {
70
+ store.set("selectedPipelineIssue", 42);
71
+ expect(store.get("selectedPipelineIssue")).toBe(42);
72
+ });
73
+ });
74
+
75
+ describe("getState", () => {
76
+ it("returns a snapshot of the full state", () => {
77
+ const state = store.getState();
78
+ expect(state.connected).toBe(false);
79
+ expect(state.activeTab).toBe("overview");
80
+ expect(state).toHaveProperty("fleetState");
81
+ });
82
+ });
83
+
84
+ describe("update", () => {
85
+ it("updates multiple keys at once", () => {
86
+ store.update({
87
+ connected: true,
88
+ connectedAt: 1234567890,
89
+ activeTab: "metrics",
90
+ });
91
+ expect(store.get("connected")).toBe(true);
92
+ expect(store.get("connectedAt")).toBe(1234567890);
93
+ expect(store.get("activeTab")).toBe("metrics");
94
+ });
95
+
96
+ it("does not trigger listeners when values haven't changed", () => {
97
+ const listener = vi.fn();
98
+ store.subscribe("connected", listener);
99
+ store.update({ connected: false }); // already false
100
+ expect(listener).not.toHaveBeenCalled();
101
+ });
102
+
103
+ it("triggers listeners for each changed key", () => {
104
+ const connectedListener = vi.fn();
105
+ const tabListener = vi.fn();
106
+ store.subscribe("connected", connectedListener);
107
+ store.subscribe("activeTab", tabListener);
108
+
109
+ store.update({ connected: true, activeTab: "insights" });
110
+
111
+ expect(connectedListener).toHaveBeenCalledWith(true, false);
112
+ expect(tabListener).toHaveBeenCalledWith("insights", "overview");
113
+ });
114
+ });
115
+
116
+ describe("subscribe", () => {
117
+ it("calls listener when the subscribed key changes", () => {
118
+ const listener = vi.fn();
119
+ store.subscribe("connected", listener);
120
+ store.set("connected", true);
121
+ expect(listener).toHaveBeenCalledWith(true, false);
122
+ });
123
+
124
+ it("does not call listener for unchanged value (same reference)", () => {
125
+ const listener = vi.fn();
126
+ store.subscribe("connected", listener);
127
+ store.set("connected", false); // already false
128
+ expect(listener).not.toHaveBeenCalled();
129
+ });
130
+
131
+ it("returns an unsubscribe function", () => {
132
+ const listener = vi.fn();
133
+ const unsub = store.subscribe("connected", listener);
134
+
135
+ store.set("connected", true);
136
+ expect(listener).toHaveBeenCalledTimes(1);
137
+
138
+ unsub();
139
+ store.set("connected", false);
140
+ expect(listener).toHaveBeenCalledTimes(1); // not called again
141
+ });
142
+
143
+ it("supports multiple listeners on the same key", () => {
144
+ const listener1 = vi.fn();
145
+ const listener2 = vi.fn();
146
+ store.subscribe("activeTab", listener1);
147
+ store.subscribe("activeTab", listener2);
148
+
149
+ store.set("activeTab", "team");
150
+
151
+ expect(listener1).toHaveBeenCalledWith("team", "overview");
152
+ expect(listener2).toHaveBeenCalledWith("team", "overview");
153
+ });
154
+
155
+ it("supports listeners on different keys independently", () => {
156
+ const connListener = vi.fn();
157
+ const tabListener = vi.fn();
158
+ store.subscribe("connected", connListener);
159
+ store.subscribe("activeTab", tabListener);
160
+
161
+ store.set("connected", true);
162
+ expect(connListener).toHaveBeenCalledTimes(1);
163
+ expect(tabListener).not.toHaveBeenCalled();
164
+ });
165
+ });
166
+
167
+ describe("onAny", () => {
168
+ it("fires on any state change", () => {
169
+ const listener = vi.fn();
170
+ store.onAny(listener);
171
+
172
+ store.set("connected", true);
173
+ expect(listener).toHaveBeenCalledTimes(1);
174
+
175
+ store.set("activeTab", "metrics");
176
+ expect(listener).toHaveBeenCalledTimes(2);
177
+ });
178
+
179
+ it("returns an unsubscribe function", () => {
180
+ const listener = vi.fn();
181
+ const unsub = store.onAny(listener);
182
+
183
+ store.set("connected", true);
184
+ expect(listener).toHaveBeenCalledTimes(1);
185
+
186
+ unsub();
187
+ store.set("activeTab", "metrics");
188
+ expect(listener).toHaveBeenCalledTimes(1);
189
+ });
190
+
191
+ it("receives the full state object", () => {
192
+ const listener = vi.fn();
193
+ store.onAny(listener);
194
+ store.set("connected", true);
195
+
196
+ const receivedState = listener.mock.calls[0][0] as AppState;
197
+ expect(receivedState.connected).toBe(true);
198
+ expect(receivedState.activeTab).toBe("overview");
199
+ });
200
+ });
201
+
202
+ describe("edge cases", () => {
203
+ it("handles rapid updates without losing data", () => {
204
+ for (let i = 0; i < 100; i++) {
205
+ store.set("activityOffset", i);
206
+ }
207
+ expect(store.get("activityOffset")).toBe(99);
208
+ });
209
+
210
+ it("handles object values (reference equality check)", () => {
211
+ const events = [{ type: "test" }];
212
+ const listener = vi.fn();
213
+ store.subscribe("activityEvents", listener);
214
+
215
+ store.set("activityEvents", events);
216
+ expect(listener).toHaveBeenCalledTimes(1);
217
+
218
+ // Same reference, should not fire
219
+ store.set("activityEvents", events);
220
+ expect(listener).toHaveBeenCalledTimes(1);
221
+
222
+ // New reference with same content, WILL fire (reference check)
223
+ store.set("activityEvents", [...events]);
224
+ expect(listener).toHaveBeenCalledTimes(2);
225
+ });
226
+
227
+ it("handles selectedIssues record updates", () => {
228
+ store.set("selectedIssues", { "123": true, "456": true });
229
+ expect(store.get("selectedIssues")).toEqual({
230
+ "123": true,
231
+ "456": true,
232
+ });
233
+ });
234
+ });
235
+ });
@@ -0,0 +1,150 @@
1
+ // Global state store with typed subscriptions
2
+
3
+ import type {
4
+ FleetState,
5
+ TabId,
6
+ PipelineDetail,
7
+ InsightsData,
8
+ MetricsData,
9
+ MachineInfo,
10
+ JoinToken,
11
+ DaemonConfig,
12
+ AlertInfo,
13
+ TeamData,
14
+ TeamActivityEvent,
15
+ UserInfo,
16
+ } from "../types/api";
17
+
18
+ export interface AppState {
19
+ connected: boolean;
20
+ connectedAt: number | null;
21
+ fleetState: FleetState | null;
22
+ activeTab: TabId;
23
+ selectedPipelineIssue: number | null;
24
+ pipelineDetail: PipelineDetail | null;
25
+ pipelineFilter: string;
26
+ activityFilter: string;
27
+ activityIssueFilter: string;
28
+ activityEvents: Array<Record<string, unknown>>;
29
+ activityOffset: number;
30
+ activityHasMore: boolean;
31
+ metricsCache: MetricsData | null;
32
+ insightsCache: InsightsData | null;
33
+ machinesCache: MachineInfo[] | null;
34
+ joinTokensCache: JoinToken[] | null;
35
+ costBreakdownCache: Record<string, unknown> | null;
36
+ alertsCache: AlertInfo[] | null;
37
+ alertDismissed: boolean;
38
+ teamCache: TeamData | null;
39
+ teamActivityCache: TeamActivityEvent[] | null;
40
+ daemonConfig: DaemonConfig | null;
41
+ currentUser: UserInfo | null;
42
+ selectedIssues: Record<string, boolean>;
43
+ firstRender: boolean;
44
+ }
45
+
46
+ const initialState: AppState = {
47
+ connected: false,
48
+ connectedAt: null,
49
+ fleetState: null,
50
+ activeTab: "overview",
51
+ selectedPipelineIssue: null,
52
+ pipelineDetail: null,
53
+ pipelineFilter: "all",
54
+ activityFilter: "all",
55
+ activityIssueFilter: "",
56
+ activityEvents: [],
57
+ activityOffset: 0,
58
+ activityHasMore: false,
59
+ metricsCache: null,
60
+ insightsCache: null,
61
+ machinesCache: null,
62
+ joinTokensCache: null,
63
+ costBreakdownCache: null,
64
+ alertsCache: null,
65
+ alertDismissed: false,
66
+ teamCache: null,
67
+ teamActivityCache: null,
68
+ daemonConfig: null,
69
+ currentUser: null,
70
+ selectedIssues: {},
71
+ firstRender: true,
72
+ };
73
+
74
+ type Listener<K extends keyof AppState> = (
75
+ value: AppState[K],
76
+ prev: AppState[K],
77
+ ) => void;
78
+ type AnyListener = (state: AppState) => void;
79
+
80
+ class Store {
81
+ private state: AppState;
82
+ private listeners: Map<keyof AppState, Set<Listener<any>>> = new Map();
83
+ private globalListeners: Set<AnyListener> = new Set();
84
+
85
+ constructor() {
86
+ this.state = { ...initialState };
87
+ }
88
+
89
+ get<K extends keyof AppState>(key: K): AppState[K] {
90
+ return this.state[key];
91
+ }
92
+
93
+ getState(): Readonly<AppState> {
94
+ return this.state;
95
+ }
96
+
97
+ set<K extends keyof AppState>(key: K, value: AppState[K]): void {
98
+ const prev = this.state[key];
99
+ if (prev === value) return;
100
+ this.state = { ...this.state, [key]: value };
101
+ const keyListeners = this.listeners.get(key);
102
+ if (keyListeners) {
103
+ keyListeners.forEach((fn) => fn(value, prev));
104
+ }
105
+ this.globalListeners.forEach((fn) => fn(this.state));
106
+ }
107
+
108
+ update(partial: Partial<AppState>): void {
109
+ const keys = Object.keys(partial) as Array<keyof AppState>;
110
+ let changed = false;
111
+ const prevState = this.state;
112
+ const nextState = { ...this.state };
113
+ for (const key of keys) {
114
+ if (nextState[key] !== partial[key]) {
115
+ (nextState as any)[key] = partial[key];
116
+ changed = true;
117
+ }
118
+ }
119
+ if (!changed) return;
120
+ this.state = nextState;
121
+ for (const key of keys) {
122
+ if (prevState[key] !== this.state[key]) {
123
+ const keyListeners = this.listeners.get(key);
124
+ if (keyListeners) {
125
+ keyListeners.forEach((fn) => fn(this.state[key], prevState[key]));
126
+ }
127
+ }
128
+ }
129
+ this.globalListeners.forEach((fn) => fn(this.state));
130
+ }
131
+
132
+ subscribe<K extends keyof AppState>(key: K, fn: Listener<K>): () => void {
133
+ if (!this.listeners.has(key)) {
134
+ this.listeners.set(key, new Set());
135
+ }
136
+ this.listeners.get(key)!.add(fn);
137
+ return () => {
138
+ this.listeners.get(key)?.delete(fn);
139
+ };
140
+ }
141
+
142
+ onAny(fn: AnyListener): () => void {
143
+ this.globalListeners.add(fn);
144
+ return () => {
145
+ this.globalListeners.delete(fn);
146
+ };
147
+ }
148
+ }
149
+
150
+ export const store = new Store();
@@ -0,0 +1,216 @@
1
+ import { describe, it, expect, beforeEach, vi, afterEach } from "vitest";
2
+
3
+ describe("WebSocket module", () => {
4
+ let ws: typeof import("./ws");
5
+ let store: typeof import("./state");
6
+
7
+ // Mock WebSocket
8
+ class MockWebSocket {
9
+ url: string;
10
+ onopen: (() => void) | null = null;
11
+ onclose: (() => void) | null = null;
12
+ onerror: (() => void) | null = null;
13
+ onmessage: ((e: { data: string }) => void) | null = null;
14
+
15
+ constructor(url: string) {
16
+ this.url = url;
17
+ MockWebSocket.instances.push(this);
18
+ }
19
+
20
+ close() {}
21
+
22
+ static instances: MockWebSocket[] = [];
23
+ static reset() {
24
+ MockWebSocket.instances = [];
25
+ }
26
+ }
27
+
28
+ beforeEach(async () => {
29
+ vi.resetModules();
30
+ vi.useFakeTimers();
31
+
32
+ // Set up DOM elements the ws module expects
33
+ document.body.innerHTML = `
34
+ <div id="connection-dot" class="connection-dot offline"></div>
35
+ <span id="connection-text">OFFLINE</span>
36
+ <div id="stale-data-banner" style="display:none"></div>
37
+ <span id="stale-data-age"></span>
38
+ <div class="main"></div>
39
+ `;
40
+
41
+ // Mock WebSocket
42
+ MockWebSocket.reset();
43
+ (global as any).WebSocket = MockWebSocket;
44
+
45
+ // Mock location
46
+ Object.defineProperty(window, "location", {
47
+ value: {
48
+ protocol: "http:",
49
+ host: "localhost:18767",
50
+ hash: "",
51
+ },
52
+ writable: true,
53
+ });
54
+
55
+ // Import fresh
56
+ store = await import("./state");
57
+ ws = await import("./ws");
58
+ });
59
+
60
+ afterEach(() => {
61
+ vi.useRealTimers();
62
+ vi.restoreAllMocks();
63
+ });
64
+
65
+ describe("connect", () => {
66
+ it("creates a WebSocket with the correct URL", () => {
67
+ ws.connect();
68
+ expect(MockWebSocket.instances).toHaveLength(1);
69
+ expect(MockWebSocket.instances[0].url).toBe("ws://localhost:18767/ws");
70
+ });
71
+
72
+ it("stores the WebSocket reference", () => {
73
+ ws.connect();
74
+ expect(ws.getWebSocket()).toBeTruthy();
75
+ });
76
+ });
77
+
78
+ describe("onopen", () => {
79
+ it("sets connected state to true", () => {
80
+ ws.connect();
81
+ const instance = MockWebSocket.instances[0];
82
+ instance.onopen?.();
83
+
84
+ expect(store.store.get("connected")).toBe(true);
85
+ expect(store.store.get("connectedAt")).toBeGreaterThan(0);
86
+ });
87
+
88
+ it("updates connection status to LIVE", () => {
89
+ ws.connect();
90
+ MockWebSocket.instances[0].onopen?.();
91
+
92
+ const dot = document.getElementById("connection-dot");
93
+ expect(dot?.className).toContain("live");
94
+ });
95
+ });
96
+
97
+ describe("onclose", () => {
98
+ it("sets connected state to false", () => {
99
+ ws.connect();
100
+ const instance = MockWebSocket.instances[0];
101
+ instance.onopen?.();
102
+ instance.onclose?.();
103
+
104
+ expect(store.store.get("connected")).toBe(false);
105
+ expect(store.store.get("connectedAt")).toBeNull();
106
+ });
107
+
108
+ it("updates connection status to OFFLINE", () => {
109
+ ws.connect();
110
+ const instance = MockWebSocket.instances[0];
111
+ instance.onclose?.();
112
+
113
+ const dot = document.getElementById("connection-dot");
114
+ expect(dot?.className).toContain("offline");
115
+ });
116
+
117
+ it("schedules reconnection", () => {
118
+ ws.connect();
119
+ const instance = MockWebSocket.instances[0];
120
+ instance.onclose?.();
121
+
122
+ // Should schedule a reconnect
123
+ vi.advanceTimersByTime(1500);
124
+ // A new WebSocket should have been created
125
+ expect(MockWebSocket.instances.length).toBeGreaterThanOrEqual(2);
126
+ });
127
+
128
+ it("uses exponential backoff for reconnection", () => {
129
+ ws.connect();
130
+
131
+ // First close - 1s delay
132
+ MockWebSocket.instances[0].onclose?.();
133
+ vi.advanceTimersByTime(1100);
134
+ expect(MockWebSocket.instances.length).toBe(2);
135
+
136
+ // Second close - 2s delay
137
+ MockWebSocket.instances[1].onclose?.();
138
+ vi.advanceTimersByTime(1100);
139
+ expect(MockWebSocket.instances.length).toBe(2); // not yet
140
+ vi.advanceTimersByTime(1100);
141
+ expect(MockWebSocket.instances.length).toBe(3); // now
142
+ });
143
+ });
144
+
145
+ describe("onmessage", () => {
146
+ it("parses JSON and updates fleet state", () => {
147
+ ws.connect();
148
+ const instance = MockWebSocket.instances[0];
149
+ instance.onopen?.();
150
+
151
+ const mockState = {
152
+ pipelines: [{ issue: 42, status: "building" }],
153
+ machines: [],
154
+ };
155
+ instance.onmessage?.({ data: JSON.stringify(mockState) });
156
+
157
+ expect(store.store.get("fleetState")).toEqual(mockState);
158
+ expect(store.store.get("firstRender")).toBe(false);
159
+ });
160
+
161
+ it("handles malformed JSON gracefully", () => {
162
+ ws.connect();
163
+ const instance = MockWebSocket.instances[0];
164
+ instance.onopen?.();
165
+
166
+ const consoleSpy = vi
167
+ .spyOn(console, "error")
168
+ .mockImplementation(() => {});
169
+ instance.onmessage?.({ data: "not json{" });
170
+
171
+ // Should not crash, fleet state should remain null
172
+ expect(store.store.get("fleetState")).toBeNull();
173
+ consoleSpy.mockRestore();
174
+ });
175
+ });
176
+
177
+ describe("stale data detection", () => {
178
+ it("shows stale banner after 30s without data", () => {
179
+ ws.connect();
180
+ const instance = MockWebSocket.instances[0];
181
+ instance.onopen?.();
182
+
183
+ // Send one message to set lastDataTime
184
+ instance.onmessage?.({ data: JSON.stringify({ pipelines: [] }) });
185
+
186
+ // Advance time by 35 seconds
187
+ vi.advanceTimersByTime(35000);
188
+
189
+ const banner = document.getElementById("stale-data-banner");
190
+ expect(banner?.style.display).not.toBe("none");
191
+ });
192
+ });
193
+
194
+ describe("offline banner", () => {
195
+ it("shows offline banner on disconnect", () => {
196
+ ws.connect();
197
+ MockWebSocket.instances[0].onclose?.();
198
+
199
+ const banner = document.getElementById("offline-banner");
200
+ expect(banner).toBeTruthy();
201
+ expect(banner?.style.display).not.toBe("none");
202
+ });
203
+
204
+ it("hides offline banner on reconnect", () => {
205
+ ws.connect();
206
+ MockWebSocket.instances[0].onclose?.();
207
+
208
+ // Reconnect
209
+ vi.advanceTimersByTime(1500);
210
+ MockWebSocket.instances[1].onopen?.();
211
+
212
+ const banner = document.getElementById("offline-banner");
213
+ expect(banner?.style.display).toBe("none");
214
+ });
215
+ });
216
+ });