shipwright-cli 2.3.1 → 3.0.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.
- package/README.md +95 -28
- package/completions/_shipwright +1 -1
- package/completions/shipwright.bash +3 -8
- package/completions/shipwright.fish +1 -1
- package/config/defaults.json +111 -0
- package/config/event-schema.json +81 -0
- package/config/policy.json +155 -2
- package/config/policy.schema.json +162 -1
- package/dashboard/coverage/coverage-summary.json +14 -0
- package/dashboard/public/index.html +1 -1
- package/dashboard/server.ts +306 -17
- package/dashboard/src/components/charts/bar.test.ts +79 -0
- package/dashboard/src/components/charts/donut.test.ts +68 -0
- package/dashboard/src/components/charts/pipeline-rail.test.ts +117 -0
- package/dashboard/src/components/charts/sparkline.test.ts +125 -0
- package/dashboard/src/core/api.test.ts +309 -0
- package/dashboard/src/core/helpers.test.ts +301 -0
- package/dashboard/src/core/router.test.ts +307 -0
- package/dashboard/src/core/router.ts +7 -0
- package/dashboard/src/core/sse.test.ts +144 -0
- package/dashboard/src/views/metrics.test.ts +186 -0
- package/dashboard/src/views/overview.test.ts +173 -0
- package/dashboard/src/views/pipelines.test.ts +183 -0
- package/dashboard/src/views/team.test.ts +253 -0
- package/dashboard/vitest.config.ts +14 -5
- package/docs/TIPS.md +1 -1
- package/docs/patterns/README.md +1 -1
- package/package.json +15 -5
- package/scripts/adapters/docker-deploy.sh +1 -1
- package/scripts/adapters/tmux-adapter.sh +11 -1
- package/scripts/adapters/wezterm-adapter.sh +1 -1
- package/scripts/check-version-consistency.sh +1 -1
- package/scripts/lib/architecture.sh +126 -0
- package/scripts/lib/bootstrap.sh +75 -0
- package/scripts/lib/compat.sh +89 -6
- package/scripts/lib/config.sh +91 -0
- package/scripts/lib/daemon-adaptive.sh +3 -3
- package/scripts/lib/daemon-dispatch.sh +39 -16
- package/scripts/lib/daemon-health.sh +1 -1
- package/scripts/lib/daemon-patrol.sh +24 -12
- package/scripts/lib/daemon-poll.sh +37 -25
- package/scripts/lib/daemon-state.sh +115 -23
- package/scripts/lib/daemon-triage.sh +30 -8
- package/scripts/lib/fleet-failover.sh +63 -0
- package/scripts/lib/helpers.sh +30 -6
- package/scripts/lib/pipeline-detection.sh +2 -2
- package/scripts/lib/pipeline-github.sh +9 -9
- package/scripts/lib/pipeline-intelligence.sh +85 -35
- package/scripts/lib/pipeline-quality-checks.sh +16 -16
- package/scripts/lib/pipeline-quality.sh +1 -1
- package/scripts/lib/pipeline-stages.sh +242 -28
- package/scripts/lib/pipeline-state.sh +40 -4
- package/scripts/lib/test-helpers.sh +247 -0
- package/scripts/postinstall.mjs +3 -11
- package/scripts/sw +10 -4
- package/scripts/sw-activity.sh +1 -11
- package/scripts/sw-adaptive.sh +109 -85
- package/scripts/sw-adversarial.sh +4 -14
- package/scripts/sw-architecture-enforcer.sh +1 -11
- package/scripts/sw-auth.sh +8 -17
- package/scripts/sw-autonomous.sh +111 -49
- package/scripts/sw-changelog.sh +1 -11
- package/scripts/sw-checkpoint.sh +144 -20
- package/scripts/sw-ci.sh +2 -12
- package/scripts/sw-cleanup.sh +13 -17
- package/scripts/sw-code-review.sh +16 -36
- package/scripts/sw-connect.sh +5 -12
- package/scripts/sw-context.sh +9 -26
- package/scripts/sw-cost.sh +6 -16
- package/scripts/sw-daemon.sh +75 -70
- package/scripts/sw-dashboard.sh +57 -17
- package/scripts/sw-db.sh +506 -15
- package/scripts/sw-decompose.sh +1 -11
- package/scripts/sw-deps.sh +15 -25
- package/scripts/sw-developer-simulation.sh +1 -11
- package/scripts/sw-discovery.sh +112 -30
- package/scripts/sw-doc-fleet.sh +7 -17
- package/scripts/sw-docs-agent.sh +6 -16
- package/scripts/sw-docs.sh +4 -12
- package/scripts/sw-doctor.sh +134 -43
- package/scripts/sw-dora.sh +11 -19
- package/scripts/sw-durable.sh +35 -52
- package/scripts/sw-e2e-orchestrator.sh +11 -27
- package/scripts/sw-eventbus.sh +115 -115
- package/scripts/sw-evidence.sh +748 -0
- package/scripts/sw-feedback.sh +3 -13
- package/scripts/sw-fix.sh +2 -20
- package/scripts/sw-fleet-discover.sh +1 -11
- package/scripts/sw-fleet-viz.sh +10 -18
- package/scripts/sw-fleet.sh +13 -17
- package/scripts/sw-github-app.sh +6 -16
- package/scripts/sw-github-checks.sh +1 -11
- package/scripts/sw-github-deploy.sh +1 -11
- package/scripts/sw-github-graphql.sh +2 -12
- package/scripts/sw-guild.sh +1 -11
- package/scripts/sw-heartbeat.sh +49 -12
- package/scripts/sw-hygiene.sh +45 -43
- package/scripts/sw-incident.sh +284 -67
- package/scripts/sw-init.sh +35 -37
- package/scripts/sw-instrument.sh +1 -11
- package/scripts/sw-intelligence.sh +362 -51
- package/scripts/sw-jira.sh +5 -14
- package/scripts/sw-launchd.sh +2 -12
- package/scripts/sw-linear.sh +8 -17
- package/scripts/sw-logs.sh +4 -12
- package/scripts/sw-loop.sh +641 -90
- package/scripts/sw-memory.sh +243 -17
- package/scripts/sw-mission-control.sh +2 -12
- package/scripts/sw-model-router.sh +73 -34
- package/scripts/sw-otel.sh +11 -21
- package/scripts/sw-oversight.sh +1 -11
- package/scripts/sw-patrol-meta.sh +5 -11
- package/scripts/sw-pipeline-composer.sh +7 -17
- package/scripts/sw-pipeline-vitals.sh +1 -11
- package/scripts/sw-pipeline.sh +478 -122
- package/scripts/sw-pm.sh +2 -12
- package/scripts/sw-pr-lifecycle.sh +203 -29
- package/scripts/sw-predictive.sh +16 -22
- package/scripts/sw-prep.sh +6 -16
- package/scripts/sw-ps.sh +1 -11
- package/scripts/sw-public-dashboard.sh +2 -12
- package/scripts/sw-quality.sh +77 -10
- package/scripts/sw-reaper.sh +1 -11
- package/scripts/sw-recruit.sh +15 -25
- package/scripts/sw-regression.sh +11 -21
- package/scripts/sw-release-manager.sh +19 -28
- package/scripts/sw-release.sh +8 -16
- package/scripts/sw-remote.sh +1 -11
- package/scripts/sw-replay.sh +48 -44
- package/scripts/sw-retro.sh +70 -92
- package/scripts/sw-review-rerun.sh +220 -0
- package/scripts/sw-scale.sh +109 -32
- package/scripts/sw-security-audit.sh +12 -22
- package/scripts/sw-self-optimize.sh +239 -23
- package/scripts/sw-session.sh +3 -13
- package/scripts/sw-setup.sh +8 -18
- package/scripts/sw-standup.sh +5 -15
- package/scripts/sw-status.sh +32 -23
- package/scripts/sw-strategic.sh +129 -13
- package/scripts/sw-stream.sh +1 -11
- package/scripts/sw-swarm.sh +76 -36
- package/scripts/sw-team-stages.sh +10 -20
- package/scripts/sw-templates.sh +4 -14
- package/scripts/sw-testgen.sh +3 -13
- package/scripts/sw-tmux-pipeline.sh +1 -19
- package/scripts/sw-tmux-role-color.sh +0 -10
- package/scripts/sw-tmux-status.sh +3 -11
- package/scripts/sw-tmux.sh +2 -20
- package/scripts/sw-trace.sh +1 -19
- package/scripts/sw-tracker-github.sh +0 -10
- package/scripts/sw-tracker-jira.sh +1 -11
- package/scripts/sw-tracker-linear.sh +1 -11
- package/scripts/sw-tracker.sh +7 -24
- package/scripts/sw-triage.sh +24 -34
- package/scripts/sw-upgrade.sh +5 -23
- package/scripts/sw-ux.sh +1 -19
- package/scripts/sw-webhook.sh +18 -32
- package/scripts/sw-widgets.sh +3 -21
- package/scripts/sw-worktree.sh +11 -27
- package/scripts/update-homebrew-sha.sh +67 -0
- package/templates/pipelines/tdd.json +72 -0
- package/scripts/sw-pipeline.sh.mock +0 -7
|
@@ -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
|
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { SSEClient } from "./sse";
|
|
3
|
+
|
|
4
|
+
const EventSourceOpen = 1;
|
|
5
|
+
const EventSourceClosed = 2;
|
|
6
|
+
|
|
7
|
+
describe("SSEClient", () => {
|
|
8
|
+
let mockEventSource: {
|
|
9
|
+
close: ReturnType<typeof vi.fn>;
|
|
10
|
+
readyState: number;
|
|
11
|
+
onmessage: ((e: { data: string }) => void) | null;
|
|
12
|
+
onerror: (() => void) | null;
|
|
13
|
+
};
|
|
14
|
+
let EventSourceConstructor: ReturnType<typeof vi.fn>;
|
|
15
|
+
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
mockEventSource = {
|
|
18
|
+
close: vi.fn(),
|
|
19
|
+
readyState: EventSourceClosed,
|
|
20
|
+
onmessage: null,
|
|
21
|
+
onerror: null,
|
|
22
|
+
};
|
|
23
|
+
EventSourceConstructor = vi.fn(function (this: unknown) {
|
|
24
|
+
return mockEventSource;
|
|
25
|
+
}) as ReturnType<typeof vi.fn> & { OPEN: number };
|
|
26
|
+
EventSourceConstructor.OPEN = EventSourceOpen;
|
|
27
|
+
EventSourceConstructor.CLOSED = EventSourceClosed;
|
|
28
|
+
EventSourceConstructor.CONNECTING = 0;
|
|
29
|
+
vi.stubGlobal("EventSource", EventSourceConstructor);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
afterEach(() => {
|
|
33
|
+
vi.unstubAllGlobals();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe("constructor", () => {
|
|
37
|
+
it("stores url and callbacks", () => {
|
|
38
|
+
const onMessage = vi.fn();
|
|
39
|
+
const onError = vi.fn();
|
|
40
|
+
const client = new SSEClient(
|
|
41
|
+
"https://example.com/events",
|
|
42
|
+
onMessage,
|
|
43
|
+
onError,
|
|
44
|
+
);
|
|
45
|
+
expect(client).toBeDefined();
|
|
46
|
+
// Verify connect uses stored values
|
|
47
|
+
client.connect();
|
|
48
|
+
expect(EventSourceConstructor).toHaveBeenCalledWith(
|
|
49
|
+
"https://example.com/events",
|
|
50
|
+
);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe("connect", () => {
|
|
55
|
+
it("creates EventSource with url", () => {
|
|
56
|
+
const client = new SSEClient("/api/logs/42/stream", vi.fn());
|
|
57
|
+
client.connect();
|
|
58
|
+
expect(EventSourceConstructor).toHaveBeenCalledWith(
|
|
59
|
+
"/api/logs/42/stream",
|
|
60
|
+
);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("closes existing connection before reconnecting", () => {
|
|
64
|
+
const client = new SSEClient("/api/stream", vi.fn());
|
|
65
|
+
client.connect();
|
|
66
|
+
const firstES = EventSourceConstructor.mock.results[0].value;
|
|
67
|
+
client.connect();
|
|
68
|
+
expect(firstES.close).toHaveBeenCalled();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("registers onmessage handler that forwards to callback", () => {
|
|
72
|
+
const onMessage = vi.fn();
|
|
73
|
+
const client = new SSEClient("/api/stream", onMessage);
|
|
74
|
+
client.connect();
|
|
75
|
+
|
|
76
|
+
expect(mockEventSource.onmessage).toBeDefined();
|
|
77
|
+
mockEventSource.onmessage!({ data: "hello world" });
|
|
78
|
+
expect(onMessage).toHaveBeenCalledWith("hello world");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("registers onerror handler when onError provided", () => {
|
|
82
|
+
const onError = vi.fn();
|
|
83
|
+
const client = new SSEClient("/api/stream", vi.fn(), onError);
|
|
84
|
+
client.connect();
|
|
85
|
+
|
|
86
|
+
expect(mockEventSource.onerror).toBeDefined();
|
|
87
|
+
mockEventSource.onerror!();
|
|
88
|
+
expect(onError).toHaveBeenCalled();
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe("close", () => {
|
|
93
|
+
it("closes EventSource and sets to null", () => {
|
|
94
|
+
const client = new SSEClient("/api/stream", vi.fn());
|
|
95
|
+
client.connect();
|
|
96
|
+
client.close();
|
|
97
|
+
expect(mockEventSource.close).toHaveBeenCalled();
|
|
98
|
+
// eventSource is private, but isConnected should reflect closed state
|
|
99
|
+
mockEventSource.readyState = EventSourceClosed;
|
|
100
|
+
expect(client.isConnected()).toBe(false);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("is safe to call when not connected", () => {
|
|
104
|
+
const client = new SSEClient("/api/stream", vi.fn());
|
|
105
|
+
expect(() => client.close()).not.toThrow();
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
describe("isConnected", () => {
|
|
110
|
+
it("returns false when not connected", () => {
|
|
111
|
+
const client = new SSEClient("/api/stream", vi.fn());
|
|
112
|
+
expect(client.isConnected()).toBe(false);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("returns false when EventSource is closed", () => {
|
|
116
|
+
const client = new SSEClient("/api/stream", vi.fn());
|
|
117
|
+
client.connect();
|
|
118
|
+
mockEventSource.readyState = EventSourceClosed;
|
|
119
|
+
expect(client.isConnected()).toBe(false);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("returns true when EventSource is OPEN", () => {
|
|
123
|
+
const client = new SSEClient("/api/stream", vi.fn());
|
|
124
|
+
client.connect();
|
|
125
|
+
mockEventSource.readyState = EventSourceOpen;
|
|
126
|
+
expect(client.isConnected()).toBe(true);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
describe("message handling", () => {
|
|
131
|
+
it("receives multiple messages", () => {
|
|
132
|
+
const onMessage = vi.fn();
|
|
133
|
+
const client = new SSEClient("/api/stream", onMessage);
|
|
134
|
+
client.connect();
|
|
135
|
+
|
|
136
|
+
mockEventSource.onmessage!({ data: "msg1" });
|
|
137
|
+
mockEventSource.onmessage!({ data: "msg2" });
|
|
138
|
+
|
|
139
|
+
expect(onMessage).toHaveBeenCalledTimes(2);
|
|
140
|
+
expect(onMessage).toHaveBeenNthCalledWith(1, "msg1");
|
|
141
|
+
expect(onMessage).toHaveBeenNthCalledWith(2, "msg2");
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
});
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { store } from "../core/state";
|
|
3
|
+
import type { FleetState, MetricsData } from "../types/api";
|
|
4
|
+
|
|
5
|
+
vi.mock("../core/api", () => ({
|
|
6
|
+
fetchMetricsHistory: vi.fn().mockResolvedValue({
|
|
7
|
+
success_rate: 0.95,
|
|
8
|
+
avg_duration_s: 600,
|
|
9
|
+
throughput_per_hour: 2.5,
|
|
10
|
+
total_completed: 100,
|
|
11
|
+
total_failed: 5,
|
|
12
|
+
stage_durations: {},
|
|
13
|
+
daily_counts: [],
|
|
14
|
+
}),
|
|
15
|
+
fetchCostBreakdown: vi.fn().mockResolvedValue({}),
|
|
16
|
+
fetchCostTrend: vi.fn().mockResolvedValue({ points: [] }),
|
|
17
|
+
fetchDoraTrend: vi.fn().mockResolvedValue({}),
|
|
18
|
+
fetchStagePerformance: vi.fn().mockResolvedValue({ stages: [] }),
|
|
19
|
+
fetchBottlenecks: vi.fn().mockResolvedValue({ bottlenecks: [] }),
|
|
20
|
+
fetchThroughputTrend: vi.fn().mockResolvedValue({ points: [] }),
|
|
21
|
+
fetchCapacity: vi.fn().mockResolvedValue({ rate: 1, queue_clear_hours: 2 }),
|
|
22
|
+
}));
|
|
23
|
+
|
|
24
|
+
function createMetricsDOM(): void {
|
|
25
|
+
const ids = [
|
|
26
|
+
"metric-donut-wrap",
|
|
27
|
+
"metric-avg-duration",
|
|
28
|
+
"metric-throughput",
|
|
29
|
+
"metric-total-completed",
|
|
30
|
+
"metric-total-failed",
|
|
31
|
+
"stage-breakdown",
|
|
32
|
+
"daily-chart",
|
|
33
|
+
"dora-grades-container",
|
|
34
|
+
];
|
|
35
|
+
for (const id of ids) {
|
|
36
|
+
const el = document.createElement("div");
|
|
37
|
+
el.id = id;
|
|
38
|
+
document.body.appendChild(el);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function cleanupMetricsDOM(): void {
|
|
43
|
+
const ids = [
|
|
44
|
+
"metric-donut-wrap",
|
|
45
|
+
"metric-avg-duration",
|
|
46
|
+
"metric-throughput",
|
|
47
|
+
"metric-total-completed",
|
|
48
|
+
"metric-total-failed",
|
|
49
|
+
"stage-breakdown",
|
|
50
|
+
"daily-chart",
|
|
51
|
+
"dora-grades-container",
|
|
52
|
+
];
|
|
53
|
+
ids.forEach((id) => document.getElementById(id)?.remove());
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function emptyFleetState(): FleetState {
|
|
57
|
+
return {
|
|
58
|
+
timestamp: new Date().toISOString(),
|
|
59
|
+
daemon: {
|
|
60
|
+
running: false,
|
|
61
|
+
pid: null,
|
|
62
|
+
uptime_s: 0,
|
|
63
|
+
maxParallel: 0,
|
|
64
|
+
pollInterval: 5,
|
|
65
|
+
},
|
|
66
|
+
pipelines: [],
|
|
67
|
+
queue: [],
|
|
68
|
+
events: [],
|
|
69
|
+
scale: {},
|
|
70
|
+
metrics: {},
|
|
71
|
+
agents: [],
|
|
72
|
+
machines: [],
|
|
73
|
+
cost: { today_spent: 0, daily_budget: 0, pct_used: 0 },
|
|
74
|
+
dora: {} as any,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
describe("MetricsView", () => {
|
|
79
|
+
beforeEach(() => {
|
|
80
|
+
store.set("firstRender", false);
|
|
81
|
+
store.set("metricsCache", null);
|
|
82
|
+
createMetricsDOM();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
afterEach(() => {
|
|
86
|
+
cleanupMetricsDOM();
|
|
87
|
+
vi.clearAllMocks();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("renders metric cards with cached data", async () => {
|
|
91
|
+
const { metricsView } = await import("./metrics");
|
|
92
|
+
const metricsData: MetricsData = {
|
|
93
|
+
success_rate: 0.92,
|
|
94
|
+
avg_duration_s: 420,
|
|
95
|
+
throughput_per_hour: 3.5,
|
|
96
|
+
total_completed: 50,
|
|
97
|
+
total_failed: 4,
|
|
98
|
+
stage_durations: { plan: 60, code: 300, review: 120 },
|
|
99
|
+
daily_counts: [{ date: "2025-02-17", completed: 5, failed: 1 }],
|
|
100
|
+
dora_grades: {} as any,
|
|
101
|
+
};
|
|
102
|
+
store.set("metricsCache", metricsData);
|
|
103
|
+
const data = emptyFleetState();
|
|
104
|
+
metricsView.render(data);
|
|
105
|
+
const avgEl = document.getElementById("metric-avg-duration");
|
|
106
|
+
const tpEl = document.getElementById("metric-throughput");
|
|
107
|
+
const tcEl = document.getElementById("metric-total-completed");
|
|
108
|
+
expect(avgEl?.textContent).toBeTruthy();
|
|
109
|
+
expect(tpEl?.textContent).toBe("3.50");
|
|
110
|
+
expect(tcEl?.textContent).toContain("50");
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("handles missing data gracefully", async () => {
|
|
114
|
+
const { metricsView } = await import("./metrics");
|
|
115
|
+
const emptyMetrics: MetricsData = {
|
|
116
|
+
success_rate: 0,
|
|
117
|
+
avg_duration_s: 0,
|
|
118
|
+
throughput_per_hour: 0,
|
|
119
|
+
total_completed: 0,
|
|
120
|
+
total_failed: 0,
|
|
121
|
+
stage_durations: {},
|
|
122
|
+
daily_counts: [],
|
|
123
|
+
dora_grades: {} as any,
|
|
124
|
+
};
|
|
125
|
+
store.set("metricsCache", emptyMetrics);
|
|
126
|
+
const data = emptyFleetState();
|
|
127
|
+
expect(() => metricsView.render(data)).not.toThrow();
|
|
128
|
+
const donutEl = document.getElementById("metric-donut-wrap");
|
|
129
|
+
expect(donutEl?.innerHTML).toBeTruthy();
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("formats numbers correctly for totals", async () => {
|
|
133
|
+
const { metricsView } = await import("./metrics");
|
|
134
|
+
const metricsData: MetricsData = {
|
|
135
|
+
success_rate: 1,
|
|
136
|
+
avg_duration_s: 0,
|
|
137
|
+
throughput_per_hour: 0,
|
|
138
|
+
total_completed: 1234,
|
|
139
|
+
total_failed: 10,
|
|
140
|
+
stage_durations: {},
|
|
141
|
+
daily_counts: [],
|
|
142
|
+
dora_grades: {} as any,
|
|
143
|
+
};
|
|
144
|
+
store.set("metricsCache", metricsData);
|
|
145
|
+
const data = emptyFleetState();
|
|
146
|
+
metricsView.render(data);
|
|
147
|
+
const tcEl = document.getElementById("metric-total-completed");
|
|
148
|
+
const failedEl = document.getElementById("metric-total-failed");
|
|
149
|
+
expect(tcEl?.textContent).toContain("1,234");
|
|
150
|
+
expect(failedEl?.textContent).toContain("10");
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("renders stage breakdown when stage_durations provided", async () => {
|
|
154
|
+
const { metricsView } = await import("./metrics");
|
|
155
|
+
const metricsData: MetricsData = {
|
|
156
|
+
success_rate: 0,
|
|
157
|
+
avg_duration_s: 0,
|
|
158
|
+
throughput_per_hour: 0,
|
|
159
|
+
total_completed: 0,
|
|
160
|
+
total_failed: 0,
|
|
161
|
+
stage_durations: { plan: 120, code: 400 },
|
|
162
|
+
daily_counts: [],
|
|
163
|
+
dora_grades: {} as any,
|
|
164
|
+
};
|
|
165
|
+
store.set("metricsCache", metricsData);
|
|
166
|
+
const data = emptyFleetState();
|
|
167
|
+
metricsView.render(data);
|
|
168
|
+
const breakdownEl = document.getElementById("stage-breakdown");
|
|
169
|
+
expect(breakdownEl?.innerHTML).toContain("plan");
|
|
170
|
+
expect(breakdownEl?.innerHTML).toContain("code");
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("shows empty state when no metrics cache", async () => {
|
|
174
|
+
const { metricsView } = await import("./metrics");
|
|
175
|
+
store.set("metricsCache", null);
|
|
176
|
+
const data = emptyFleetState();
|
|
177
|
+
metricsView.render(data);
|
|
178
|
+
expect(() => metricsView.render(data)).not.toThrow();
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("init and destroy do not throw", async () => {
|
|
182
|
+
const { metricsView } = await import("./metrics");
|
|
183
|
+
expect(() => metricsView.init()).not.toThrow();
|
|
184
|
+
expect(() => metricsView.destroy()).not.toThrow();
|
|
185
|
+
});
|
|
186
|
+
});
|