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
|
@@ -334,6 +334,315 @@ describe("API Client", () => {
|
|
|
334
334
|
});
|
|
335
335
|
});
|
|
336
336
|
|
|
337
|
+
describe("machine health check and join tokens", () => {
|
|
338
|
+
it("machineHealthCheck calls POST", async () => {
|
|
339
|
+
mockFetch.mockReturnValueOnce(jsonResponse({ machine: {} }));
|
|
340
|
+
await api.machineHealthCheck("node-1");
|
|
341
|
+
expect(mockFetch).toHaveBeenCalledWith(
|
|
342
|
+
"/api/machines/node-1/health-check",
|
|
343
|
+
expect.objectContaining({ method: "POST" }),
|
|
344
|
+
);
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
it("fetchJoinTokens calls GET /api/join-token", async () => {
|
|
348
|
+
mockFetch.mockReturnValueOnce(jsonResponse({ tokens: [] }));
|
|
349
|
+
await api.fetchJoinTokens();
|
|
350
|
+
expect(mockFetch).toHaveBeenCalledWith("/api/join-token", undefined);
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
it("generateJoinToken calls POST /api/join-token", async () => {
|
|
354
|
+
mockFetch.mockReturnValueOnce(jsonResponse({ join_cmd: "sw join ..." }));
|
|
355
|
+
await api.generateJoinToken({ label: "test", max_workers: 4 });
|
|
356
|
+
expect(mockFetch).toHaveBeenCalledWith(
|
|
357
|
+
"/api/join-token",
|
|
358
|
+
expect.objectContaining({ method: "POST" }),
|
|
359
|
+
);
|
|
360
|
+
});
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
describe("costs", () => {
|
|
364
|
+
it("fetchCostBreakdown defaults to 7 day period", async () => {
|
|
365
|
+
mockFetch.mockReturnValueOnce(jsonResponse({}));
|
|
366
|
+
await api.fetchCostBreakdown();
|
|
367
|
+
expect(mockFetch).toHaveBeenCalledWith(
|
|
368
|
+
"/api/costs/breakdown?period=7",
|
|
369
|
+
undefined,
|
|
370
|
+
);
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
it("fetchCostTrend defaults to 30 day period", async () => {
|
|
374
|
+
mockFetch.mockReturnValueOnce(jsonResponse({ points: [] }));
|
|
375
|
+
await api.fetchCostTrend();
|
|
376
|
+
expect(mockFetch).toHaveBeenCalledWith(
|
|
377
|
+
"/api/costs/trend?period=30",
|
|
378
|
+
undefined,
|
|
379
|
+
);
|
|
380
|
+
});
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
describe("daemon", () => {
|
|
384
|
+
it("fetchDaemonConfig calls GET /api/daemon/config", async () => {
|
|
385
|
+
mockFetch.mockReturnValueOnce(jsonResponse({}));
|
|
386
|
+
await api.fetchDaemonConfig();
|
|
387
|
+
expect(mockFetch).toHaveBeenCalledWith("/api/daemon/config", undefined);
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
it("daemonControl calls POST /api/daemon/:action", async () => {
|
|
391
|
+
mockFetch.mockReturnValueOnce(jsonResponse({ ok: true }));
|
|
392
|
+
await api.daemonControl("pause");
|
|
393
|
+
expect(mockFetch).toHaveBeenCalledWith(
|
|
394
|
+
"/api/daemon/pause",
|
|
395
|
+
expect.objectContaining({ method: "POST" }),
|
|
396
|
+
);
|
|
397
|
+
});
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
describe("alerts and artifacts", () => {
|
|
401
|
+
it("fetchAlerts calls GET /api/alerts", async () => {
|
|
402
|
+
mockFetch.mockReturnValueOnce(jsonResponse({ alerts: [] }));
|
|
403
|
+
await api.fetchAlerts();
|
|
404
|
+
expect(mockFetch).toHaveBeenCalledWith("/api/alerts", undefined);
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
it("fetchArtifact calls correct endpoint", async () => {
|
|
408
|
+
mockFetch.mockReturnValueOnce(jsonResponse({ content: "..." }));
|
|
409
|
+
await api.fetchArtifact(42, "plan");
|
|
410
|
+
expect(mockFetch).toHaveBeenCalledWith(
|
|
411
|
+
"/api/artifacts/42/plan",
|
|
412
|
+
undefined,
|
|
413
|
+
);
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
it("fetchGitHubStatus calls GET", async () => {
|
|
417
|
+
mockFetch.mockReturnValueOnce(jsonResponse({}));
|
|
418
|
+
await api.fetchGitHubStatus(42);
|
|
419
|
+
expect(mockFetch).toHaveBeenCalledWith("/api/github/42", undefined);
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
it("fetchLogs calls GET", async () => {
|
|
423
|
+
mockFetch.mockReturnValueOnce(jsonResponse({ content: "log" }));
|
|
424
|
+
await api.fetchLogs(42);
|
|
425
|
+
expect(mockFetch).toHaveBeenCalledWith("/api/logs/42", undefined);
|
|
426
|
+
});
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
describe("metrics detail", () => {
|
|
430
|
+
it("fetchStagePerformance defaults to 7 days", async () => {
|
|
431
|
+
mockFetch.mockReturnValueOnce(jsonResponse({ stages: [] }));
|
|
432
|
+
await api.fetchStagePerformance();
|
|
433
|
+
expect(mockFetch).toHaveBeenCalledWith(
|
|
434
|
+
"/api/metrics/stage-performance?period=7",
|
|
435
|
+
undefined,
|
|
436
|
+
);
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
it("fetchBottlenecks calls GET", async () => {
|
|
440
|
+
mockFetch.mockReturnValueOnce(jsonResponse({ bottlenecks: [] }));
|
|
441
|
+
await api.fetchBottlenecks();
|
|
442
|
+
expect(mockFetch).toHaveBeenCalledWith(
|
|
443
|
+
"/api/metrics/bottlenecks",
|
|
444
|
+
undefined,
|
|
445
|
+
);
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
it("fetchThroughputTrend defaults to 30 days", async () => {
|
|
449
|
+
mockFetch.mockReturnValueOnce(jsonResponse({ points: [] }));
|
|
450
|
+
await api.fetchThroughputTrend();
|
|
451
|
+
expect(mockFetch).toHaveBeenCalledWith(
|
|
452
|
+
"/api/metrics/throughput-trend?period=30",
|
|
453
|
+
undefined,
|
|
454
|
+
);
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
it("fetchCapacity calls GET", async () => {
|
|
458
|
+
mockFetch.mockReturnValueOnce(
|
|
459
|
+
jsonResponse({ rate: 2, queue_clear_hours: 1 }),
|
|
460
|
+
);
|
|
461
|
+
await api.fetchCapacity();
|
|
462
|
+
expect(mockFetch).toHaveBeenCalledWith(
|
|
463
|
+
"/api/metrics/capacity",
|
|
464
|
+
undefined,
|
|
465
|
+
);
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
it("fetchDoraTrend defaults to 30 days", async () => {
|
|
469
|
+
mockFetch.mockReturnValueOnce(jsonResponse({}));
|
|
470
|
+
await api.fetchDoraTrend();
|
|
471
|
+
expect(mockFetch).toHaveBeenCalledWith(
|
|
472
|
+
"/api/metrics/dora-trend?period=30",
|
|
473
|
+
undefined,
|
|
474
|
+
);
|
|
475
|
+
});
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
describe("team endpoints", () => {
|
|
479
|
+
it("fetchTeam calls GET /api/team", async () => {
|
|
480
|
+
mockFetch.mockReturnValueOnce(jsonResponse({}));
|
|
481
|
+
await api.fetchTeam();
|
|
482
|
+
expect(mockFetch).toHaveBeenCalledWith("/api/team", undefined);
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
it("fetchTeamActivity returns events array", async () => {
|
|
486
|
+
mockFetch.mockReturnValueOnce(jsonResponse({ events: [{ id: 1 }] }));
|
|
487
|
+
const result = await api.fetchTeamActivity();
|
|
488
|
+
expect(result).toEqual([{ id: 1 }]);
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
it("fetchTeamActivity returns empty array on error", async () => {
|
|
492
|
+
mockFetch.mockReturnValueOnce(errorResponse(500));
|
|
493
|
+
const result = await api.fetchTeamActivity();
|
|
494
|
+
expect(result).toEqual([]);
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
it("createTeamInvite calls POST /api/team/invite", async () => {
|
|
498
|
+
mockFetch.mockReturnValueOnce(
|
|
499
|
+
jsonResponse({ token: "abc", url: "...", expires_at: "..." }),
|
|
500
|
+
);
|
|
501
|
+
await api.createTeamInvite({ expires_hours: 24 });
|
|
502
|
+
expect(mockFetch).toHaveBeenCalledWith(
|
|
503
|
+
"/api/team/invite",
|
|
504
|
+
expect.objectContaining({ method: "POST" }),
|
|
505
|
+
);
|
|
506
|
+
});
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
describe("pipeline test results and learnings", () => {
|
|
510
|
+
it("fetchPipelineTestResults calls GET", async () => {
|
|
511
|
+
mockFetch.mockReturnValueOnce(jsonResponse({}));
|
|
512
|
+
await api.fetchPipelineTestResults(42);
|
|
513
|
+
expect(mockFetch).toHaveBeenCalledWith(
|
|
514
|
+
"/api/pipeline/42/test-results",
|
|
515
|
+
undefined,
|
|
516
|
+
);
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
it("fetchGlobalLearnings calls GET", async () => {
|
|
520
|
+
mockFetch.mockReturnValueOnce(jsonResponse({ learnings: [] }));
|
|
521
|
+
await api.fetchGlobalLearnings();
|
|
522
|
+
expect(mockFetch).toHaveBeenCalledWith("/api/memory/global", undefined);
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
it("fetchPatrol returns findings on success", async () => {
|
|
526
|
+
mockFetch.mockReturnValueOnce(jsonResponse({ findings: [{ id: 1 }] }));
|
|
527
|
+
const result = await api.fetchPatrol();
|
|
528
|
+
expect(result).toEqual({ findings: [{ id: 1 }] });
|
|
529
|
+
});
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
describe("integration and DB endpoints", () => {
|
|
533
|
+
it("fetchLinearStatus calls GET", async () => {
|
|
534
|
+
mockFetch.mockReturnValueOnce(jsonResponse({}));
|
|
535
|
+
await api.fetchLinearStatus();
|
|
536
|
+
expect(mockFetch).toHaveBeenCalledWith("/api/linear/status", undefined);
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
it("fetchDbEvents with defaults", async () => {
|
|
540
|
+
mockFetch.mockReturnValueOnce(jsonResponse({ events: [], source: "db" }));
|
|
541
|
+
await api.fetchDbEvents();
|
|
542
|
+
expect(mockFetch).toHaveBeenCalledWith(
|
|
543
|
+
"/api/db/events?since=0&limit=200",
|
|
544
|
+
undefined,
|
|
545
|
+
);
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
it("fetchDbJobs without status filter", async () => {
|
|
549
|
+
mockFetch.mockReturnValueOnce(jsonResponse({ jobs: [], source: "db" }));
|
|
550
|
+
await api.fetchDbJobs();
|
|
551
|
+
expect(mockFetch).toHaveBeenCalledWith("/api/db/jobs", undefined);
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
it("fetchDbJobs with status filter", async () => {
|
|
555
|
+
mockFetch.mockReturnValueOnce(jsonResponse({ jobs: [], source: "db" }));
|
|
556
|
+
await api.fetchDbJobs("active");
|
|
557
|
+
expect(mockFetch).toHaveBeenCalledWith(
|
|
558
|
+
"/api/db/jobs?status=active",
|
|
559
|
+
undefined,
|
|
560
|
+
);
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
it("fetchDbCostsToday calls GET", async () => {
|
|
564
|
+
mockFetch.mockReturnValueOnce(jsonResponse({}));
|
|
565
|
+
await api.fetchDbCostsToday();
|
|
566
|
+
expect(mockFetch).toHaveBeenCalledWith("/api/db/costs/today", undefined);
|
|
567
|
+
});
|
|
568
|
+
|
|
569
|
+
it("fetchDbHeartbeats calls GET", async () => {
|
|
570
|
+
mockFetch.mockReturnValueOnce(
|
|
571
|
+
jsonResponse({ heartbeats: [], source: "db" }),
|
|
572
|
+
);
|
|
573
|
+
await api.fetchDbHeartbeats();
|
|
574
|
+
expect(mockFetch).toHaveBeenCalledWith("/api/db/heartbeats", undefined);
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
it("fetchDbHealth calls GET", async () => {
|
|
578
|
+
mockFetch.mockReturnValueOnce(jsonResponse({}));
|
|
579
|
+
await api.fetchDbHealth();
|
|
580
|
+
expect(mockFetch).toHaveBeenCalledWith("/api/db/health", undefined);
|
|
581
|
+
});
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
describe("audit, quality gates, approvals, notifications", () => {
|
|
585
|
+
it("fetchAuditLog calls GET", async () => {
|
|
586
|
+
mockFetch.mockReturnValueOnce(jsonResponse({ entries: [] }));
|
|
587
|
+
await api.fetchAuditLog();
|
|
588
|
+
expect(mockFetch).toHaveBeenCalledWith("/api/audit-log", undefined);
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
it("fetchQualityGates calls GET", async () => {
|
|
592
|
+
mockFetch.mockReturnValueOnce(jsonResponse({ enabled: true, rules: [] }));
|
|
593
|
+
await api.fetchQualityGates();
|
|
594
|
+
expect(mockFetch).toHaveBeenCalledWith("/api/quality-gates", undefined);
|
|
595
|
+
});
|
|
596
|
+
|
|
597
|
+
it("fetchPipelineQuality calls GET", async () => {
|
|
598
|
+
mockFetch.mockReturnValueOnce(
|
|
599
|
+
jsonResponse({ quality: {}, gates: {}, results: [] }),
|
|
600
|
+
);
|
|
601
|
+
await api.fetchPipelineQuality(42);
|
|
602
|
+
expect(mockFetch).toHaveBeenCalledWith(
|
|
603
|
+
"/api/pipeline/42/quality",
|
|
604
|
+
undefined,
|
|
605
|
+
);
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
it("fetchApprovalGates calls GET", async () => {
|
|
609
|
+
mockFetch.mockReturnValueOnce(
|
|
610
|
+
jsonResponse({ enabled: true, stages: [], pending: [] }),
|
|
611
|
+
);
|
|
612
|
+
await api.fetchApprovalGates();
|
|
613
|
+
expect(mockFetch).toHaveBeenCalledWith("/api/approval-gates", undefined);
|
|
614
|
+
});
|
|
615
|
+
|
|
616
|
+
it("updateApprovalGates calls POST", async () => {
|
|
617
|
+
mockFetch.mockReturnValueOnce(jsonResponse({ ok: true }));
|
|
618
|
+
await api.updateApprovalGates({ enabled: true, stages: ["review"] });
|
|
619
|
+
expect(mockFetch).toHaveBeenCalledWith(
|
|
620
|
+
"/api/approval-gates",
|
|
621
|
+
expect.objectContaining({ method: "POST" }),
|
|
622
|
+
);
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
it("fetchNotificationConfig calls GET", async () => {
|
|
626
|
+
mockFetch.mockReturnValueOnce(
|
|
627
|
+
jsonResponse({ enabled: true, webhooks: [] }),
|
|
628
|
+
);
|
|
629
|
+
await api.fetchNotificationConfig();
|
|
630
|
+
expect(mockFetch).toHaveBeenCalledWith(
|
|
631
|
+
"/api/notifications/config",
|
|
632
|
+
undefined,
|
|
633
|
+
);
|
|
634
|
+
});
|
|
635
|
+
|
|
636
|
+
it("testNotification calls POST", async () => {
|
|
637
|
+
mockFetch.mockReturnValueOnce(jsonResponse({ ok: true }));
|
|
638
|
+
await api.testNotification();
|
|
639
|
+
expect(mockFetch).toHaveBeenCalledWith(
|
|
640
|
+
"/api/notifications/test",
|
|
641
|
+
expect.objectContaining({ method: "POST" }),
|
|
642
|
+
);
|
|
643
|
+
});
|
|
644
|
+
});
|
|
645
|
+
|
|
337
646
|
describe("error handling", () => {
|
|
338
647
|
it("throws on non-ok response", async () => {
|
|
339
648
|
mockFetch.mockReturnValueOnce(errorResponse(404, { error: "Not found" }));
|
|
@@ -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("<script>");
|
|
76
|
+
expect(escapeHtml(">")).toBe(">");
|
|
77
|
+
expect(escapeHtml("&")).toBe("&");
|
|
78
|
+
expect(escapeHtml('"')).toBe(""");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("escapes combined characters", () => {
|
|
82
|
+
expect(escapeHtml('<img src="x">')).toBe("<img src="x">");
|
|
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("<script>");
|
|
298
|
+
expect(result).not.toContain("<script>");
|
|
299
|
+
});
|
|
300
|
+
});
|
|
301
|
+
});
|