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.
- package/README.md +3 -3
- package/dashboard/public/index.html +1 -1
- package/dashboard/src/core/api.test.ts +362 -0
- package/dashboard/src/core/router.test.ts +266 -0
- package/dashboard/src/core/state.test.ts +235 -0
- package/dashboard/src/core/ws.test.ts +216 -0
- package/dashboard/src/design/icons.test.ts +105 -0
- package/dashboard/src/design/tokens.test.ts +204 -0
- package/dashboard/tsconfig.json +1 -1
- package/dashboard/vitest.config.ts +27 -0
- package/package.json +10 -3
- package/scripts/lib/pipeline-stages.sh +59 -0
- package/scripts/sw +1 -1
- package/scripts/sw-activity.sh +1 -1
- package/scripts/sw-adaptive.sh +1 -1
- package/scripts/sw-adversarial.sh +1 -1
- package/scripts/sw-architecture-enforcer.sh +1 -1
- package/scripts/sw-auth.sh +1 -1
- package/scripts/sw-autonomous.sh +230 -13
- package/scripts/sw-changelog.sh +1 -1
- package/scripts/sw-checkpoint.sh +1 -1
- package/scripts/sw-ci.sh +1 -1
- package/scripts/sw-cleanup.sh +1 -1
- package/scripts/sw-code-review.sh +1 -1
- package/scripts/sw-connect.sh +1 -1
- package/scripts/sw-context.sh +1 -1
- package/scripts/sw-cost.sh +1 -1
- package/scripts/sw-daemon.sh +1 -1
- package/scripts/sw-dashboard.sh +1 -1
- package/scripts/sw-db.sh +1 -1
- package/scripts/sw-decompose.sh +1 -1
- package/scripts/sw-deps.sh +1 -1
- package/scripts/sw-developer-simulation.sh +1 -1
- package/scripts/sw-discovery.sh +1 -1
- package/scripts/sw-doc-fleet.sh +1 -1
- package/scripts/sw-docs-agent.sh +1 -1
- package/scripts/sw-docs.sh +1 -1
- package/scripts/sw-doctor.sh +1 -1
- package/scripts/sw-dora.sh +1 -1
- package/scripts/sw-durable.sh +1 -1
- package/scripts/sw-e2e-orchestrator.sh +1 -1
- package/scripts/sw-eventbus.sh +1 -1
- package/scripts/sw-feedback.sh +1 -1
- package/scripts/sw-fix.sh +1 -1
- package/scripts/sw-fleet-discover.sh +1 -1
- package/scripts/sw-fleet-viz.sh +1 -1
- package/scripts/sw-fleet.sh +1 -1
- package/scripts/sw-github-app.sh +1 -1
- package/scripts/sw-github-checks.sh +1 -1
- package/scripts/sw-github-deploy.sh +1 -1
- package/scripts/sw-github-graphql.sh +1 -1
- package/scripts/sw-guild.sh +1 -1
- package/scripts/sw-heartbeat.sh +1 -1
- package/scripts/sw-hygiene.sh +1 -1
- package/scripts/sw-incident.sh +1 -1
- package/scripts/sw-init.sh +1 -1
- package/scripts/sw-instrument.sh +1 -1
- package/scripts/sw-intelligence.sh +1 -1
- package/scripts/sw-jira.sh +1 -1
- package/scripts/sw-launchd.sh +1 -1
- package/scripts/sw-linear.sh +1 -1
- package/scripts/sw-logs.sh +1 -1
- package/scripts/sw-loop.sh +1 -1
- package/scripts/sw-memory.sh +1 -1
- package/scripts/sw-mission-control.sh +1 -1
- package/scripts/sw-model-router.sh +1 -1
- package/scripts/sw-otel.sh +1 -1
- package/scripts/sw-oversight.sh +1 -1
- package/scripts/sw-pipeline-composer.sh +1 -1
- package/scripts/sw-pipeline-vitals.sh +1 -1
- package/scripts/sw-pipeline.sh +1 -1
- package/scripts/sw-pm.sh +1 -1
- package/scripts/sw-pr-lifecycle.sh +1 -1
- package/scripts/sw-predictive.sh +1 -1
- package/scripts/sw-prep.sh +1 -1
- package/scripts/sw-ps.sh +1 -1
- package/scripts/sw-public-dashboard.sh +1 -1
- package/scripts/sw-quality.sh +1 -1
- package/scripts/sw-reaper.sh +1 -1
- package/scripts/sw-regression.sh +1 -1
- package/scripts/sw-release-manager.sh +1 -1
- package/scripts/sw-release.sh +1 -1
- package/scripts/sw-remote.sh +1 -1
- package/scripts/sw-replay.sh +1 -1
- package/scripts/sw-retro.sh +4 -1
- package/scripts/sw-scale.sh +1 -1
- package/scripts/sw-security-audit.sh +1 -1
- package/scripts/sw-self-optimize.sh +99 -1
- package/scripts/sw-session.sh +1 -1
- package/scripts/sw-setup.sh +1 -1
- package/scripts/sw-standup.sh +1 -1
- package/scripts/sw-status.sh +1 -1
- package/scripts/sw-strategic.sh +1 -1
- package/scripts/sw-stream.sh +1 -1
- package/scripts/sw-swarm.sh +1 -1
- package/scripts/sw-team-stages.sh +1 -1
- package/scripts/sw-templates.sh +1 -1
- package/scripts/sw-testgen.sh +1 -1
- package/scripts/sw-tmux-pipeline.sh +1 -1
- package/scripts/sw-tmux.sh +1 -1
- package/scripts/sw-trace.sh +1 -1
- package/scripts/sw-tracker.sh +1 -1
- package/scripts/sw-triage.sh +198 -11
- package/scripts/sw-upgrade.sh +1 -1
- package/scripts/sw-ux.sh +1 -1
- package/scripts/sw-webhook.sh +1 -1
- package/scripts/sw-widgets.sh +1 -1
- package/scripts/sw-worktree.sh +1 -1
|
@@ -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,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
|
+
});
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { icon, iconNames } from "./icons";
|
|
3
|
+
import type { IconName } from "./icons";
|
|
4
|
+
|
|
5
|
+
describe("Icons", () => {
|
|
6
|
+
describe("iconNames", () => {
|
|
7
|
+
it("exports a non-empty list of icon names", () => {
|
|
8
|
+
expect(iconNames.length).toBeGreaterThan(0);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it("includes essential navigation icons", () => {
|
|
12
|
+
const essential = [
|
|
13
|
+
"anchor",
|
|
14
|
+
"layout-dashboard",
|
|
15
|
+
"users",
|
|
16
|
+
"activity",
|
|
17
|
+
"server",
|
|
18
|
+
];
|
|
19
|
+
for (const name of essential) {
|
|
20
|
+
expect(iconNames).toContain(name);
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("includes status icons", () => {
|
|
25
|
+
const status = [
|
|
26
|
+
"circle-check",
|
|
27
|
+
"circle-x",
|
|
28
|
+
"circle-alert",
|
|
29
|
+
"circle-pause",
|
|
30
|
+
];
|
|
31
|
+
for (const name of status) {
|
|
32
|
+
expect(iconNames).toContain(name);
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("includes action icons", () => {
|
|
37
|
+
const actions = ["play", "pause", "send", "plus", "x", "copy"];
|
|
38
|
+
for (const name of actions) {
|
|
39
|
+
expect(iconNames).toContain(name);
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe("icon()", () => {
|
|
45
|
+
it("returns an SVG string for a valid icon", () => {
|
|
46
|
+
const svg = icon("anchor");
|
|
47
|
+
expect(svg).toContain("<svg");
|
|
48
|
+
expect(svg).toContain("</svg>");
|
|
49
|
+
expect(svg).toContain('xmlns="http://www.w3.org/2000/svg"');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("returns empty string for unknown icon", () => {
|
|
53
|
+
const svg = icon("nonexistent-icon-name" as IconName);
|
|
54
|
+
expect(svg).toBe("");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("uses default size of 16", () => {
|
|
58
|
+
const svg = icon("anchor");
|
|
59
|
+
expect(svg).toContain('width="16"');
|
|
60
|
+
expect(svg).toContain('height="16"');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("respects custom size", () => {
|
|
64
|
+
const svg = icon("anchor", 24);
|
|
65
|
+
expect(svg).toContain('width="24"');
|
|
66
|
+
expect(svg).toContain('height="24"');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("applies color attribute when provided", () => {
|
|
70
|
+
const svg = icon("anchor", 16, "#ff0000");
|
|
71
|
+
expect(svg).toContain('color="#ff0000"');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("omits color attribute when not provided", () => {
|
|
75
|
+
const svg = icon("anchor");
|
|
76
|
+
expect(svg).not.toContain("color=");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("always includes stroke attributes", () => {
|
|
80
|
+
const svg = icon("anchor");
|
|
81
|
+
expect(svg).toContain('stroke="currentColor"');
|
|
82
|
+
expect(svg).toContain('stroke-width="2"');
|
|
83
|
+
expect(svg).toContain('stroke-linecap="round"');
|
|
84
|
+
expect(svg).toContain('stroke-linejoin="round"');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("includes fill=none", () => {
|
|
88
|
+
const svg = icon("anchor");
|
|
89
|
+
expect(svg).toContain('fill="none"');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("uses 24x24 viewBox", () => {
|
|
93
|
+
const svg = icon("anchor");
|
|
94
|
+
expect(svg).toContain('viewBox="0 0 24 24"');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("renders every registered icon without error", () => {
|
|
98
|
+
for (const name of iconNames) {
|
|
99
|
+
const svg = icon(name);
|
|
100
|
+
expect(svg).toContain("<svg");
|
|
101
|
+
expect(svg).toContain("</svg>");
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
});
|