@hoox-sh/hoox-tui 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/README.md +50 -31
  2. package/dist/main.js +4238 -1711
  3. package/package.json +2 -2
  4. package/src/app.tsx +45 -30
  5. package/src/cli-bridge-test-double.ts +48 -1
  6. package/src/components/layout/sidebar.tsx +27 -11
  7. package/src/components/layout/statusbar.test.tsx +19 -5
  8. package/src/components/layout/statusbar.tsx +34 -18
  9. package/src/components/shared/crash-screen.tsx +24 -2
  10. package/src/components/shared/error-boundary.tsx +56 -33
  11. package/src/components/ui/connection-toasts.test.ts +58 -1
  12. package/src/components/views/config-editor.test.tsx +4 -2
  13. package/src/components/views/config-editor.tsx +31 -3
  14. package/src/components/views/dashboard/alerts-panel.tsx +36 -8
  15. package/src/components/views/dashboard/auto-repair-helpers.test.ts +23 -0
  16. package/src/components/views/dashboard.test.tsx +2 -1
  17. package/src/components/views/dashboard.tsx +126 -41
  18. package/src/components/views/db-query.test.tsx +7 -0
  19. package/src/components/views/db-query.tsx +5 -4
  20. package/src/components/views/edge-topology.tsx +3 -2
  21. package/src/components/views/kv-viewer.test.tsx +28 -0
  22. package/src/components/views/kv-viewer.tsx +4 -3
  23. package/src/components/views/logs-viewer.tsx +46 -19
  24. package/src/components/views/queue-depth.test.tsx +39 -0
  25. package/src/components/views/queue-depth.tsx +3 -2
  26. package/src/components/views/secrets-viewer.test.tsx +2 -2
  27. package/src/components/views/secrets-viewer.tsx +4 -3
  28. package/src/components/views/service-manager.tsx +147 -53
  29. package/src/components/views/settings.tsx +50 -10
  30. package/src/components/views/setup-wizard.test.tsx +13 -0
  31. package/src/components/views/setup-wizard.tsx +53 -22
  32. package/src/components/views/trade-monitor.tsx +20 -13
  33. package/src/components/views/worker-detail.tsx +27 -12
  34. package/src/components/views/worker-settings.test.tsx +5 -6
  35. package/src/components/views/worker-settings.tsx +13 -11
  36. package/src/components/views/workers-overview.tsx +27 -6
  37. package/src/hooks/hooks.test.ts +303 -9
  38. package/src/hooks/index.ts +7 -1
  39. package/src/hooks/shell-overlay.ts +41 -0
  40. package/src/hooks/use-polling.ts +91 -35
  41. package/src/hooks/use-service-data.ts +21 -4
  42. package/src/main.tsx +15 -0
  43. package/src/services/cli-bridge/index.ts +28 -22
  44. package/src/services/cli-bridge/standalone.ts +29 -2
  45. package/src/services/cli-bridge.test.ts +848 -1
  46. package/src/services/clipboard.test.ts +217 -3
  47. package/src/services/dashboard-settings-loader.test.ts +50 -0
  48. package/src/services/tui-connection.test.ts +21 -0
  49. package/src/stores/service-store.test.ts +89 -3
  50. package/src/stores/ui-store.test.ts +23 -0
  51. package/src/test-setup.ts +11 -2
  52. package/src/types.ts +1 -0
  53. package/src/view-registry.test.ts +3 -1
  54. package/src/view-registry.tsx +14 -1
@@ -16,13 +16,29 @@
16
16
  * - Mixed-case variants are handled case-insensitively
17
17
  * - String literals and comments are stripped before keyword check
18
18
  */
19
- import { describe, it, expect } from "bun:test";
19
+ import { describe, it, expect, beforeEach, afterEach } from "bun:test";
20
20
  import {
21
21
  validateReadOnlySql,
22
22
  tryParseJsonLoose,
23
23
  sanitizeCliArgsForLog,
24
+ parseDbQueryResult,
25
+ deriveQueueDepthStatus,
26
+ parseQueueDepths,
27
+ parseSecretsList,
28
+ inferSecretType,
29
+ parseKvList,
30
+ parseKvManifest,
31
+ stripAnsi,
32
+ parseKillSwitchStatus,
33
+ parsePyneHealth,
34
+ parseAgentHealth,
35
+ extractNamespaceId,
36
+ agentChatStream,
37
+ AI_MODEL_OPTIONS,
24
38
  type SqlValidationResult,
25
39
  } from "./cli-bridge";
40
+ import { realCliBridge } from "../cli-bridge-test-double";
41
+ import type { CliResult } from "../types";
26
42
 
27
43
  // ─── Helper ───────────────────────────────────────────────────────────────────
28
44
 
@@ -374,6 +390,32 @@ describe("tryParseJsonLoose", () => {
374
390
  expect(tryParseJsonLoose("")).toBeNull();
375
391
  expect(tryParseJsonLoose("not json at all")).toBeNull();
376
392
  });
393
+
394
+ it("extracts balanced JSON embedded in trailing noise", () => {
395
+ const mixed = 'prefix { "a": 1 } trailing text';
396
+ expect(tryParseJsonLoose(mixed)).toEqual({ a: 1 });
397
+ });
398
+
399
+ it("extracts JSON array from mixed text", () => {
400
+ expect(tryParseJsonLoose('noise [1, {"x": true}] more')).toEqual([
401
+ 1,
402
+ { x: true },
403
+ ]);
404
+ });
405
+
406
+ it("handles escaped quotes inside strings during extract", () => {
407
+ const raw = 'log\n{"msg":"say \\"hi\\""}\n';
408
+ expect(tryParseJsonLoose(raw)).toEqual({ msg: 'say "hi"' });
409
+ });
410
+
411
+ it("returns null when braces never close", () => {
412
+ expect(tryParseJsonLoose("{ not closed")).toBeNull();
413
+ });
414
+
415
+ it("prefers earlier of object vs array start", () => {
416
+ expect(tryParseJsonLoose('x [1] y {"a":2}')).toEqual([1]);
417
+ expect(tryParseJsonLoose('x {"a":2} y [1]')).toEqual({ a: 2 });
418
+ });
377
419
  });
378
420
 
379
421
  describe("sanitizeCliArgsForLog", () => {
@@ -403,4 +445,809 @@ describe("sanitizeCliArgsForLog", () => {
403
445
  expect(sanitized[1]).toContain("[redacted]");
404
446
  expect(sanitized.join(" ")).not.toContain("abcdefghijklmnop");
405
447
  });
448
+
449
+ it("redacts put and login next-arg forms", () => {
450
+ expect(sanitizeCliArgsForLog(["secrets", "put", "s3cr3t"])).toEqual([
451
+ "secrets",
452
+ "put",
453
+ "[redacted]",
454
+ ]);
455
+ expect(sanitizeCliArgsForLog(["auth", "login", "tok-xyz"])).toEqual([
456
+ "auth",
457
+ "login",
458
+ "[redacted]",
459
+ ]);
460
+ });
461
+
462
+ it("redacts create-token value at i+2 when present", () => {
463
+ expect(
464
+ sanitizeCliArgsForLog(["auth", "create-token", "name", "raw-token"])
465
+ ).toEqual(["auth", "create-token", "name", "[redacted]"]);
466
+ });
467
+
468
+ it("leaves set without value alone", () => {
469
+ expect(sanitizeCliArgsForLog(["kv", "set", "KEY_ONLY"])).toEqual([
470
+ "kv",
471
+ "set",
472
+ "KEY_ONLY",
473
+ ]);
474
+ });
475
+ });
476
+
477
+ // ─── Pure CLI stdout parsers ──────────────────────────────────────────────────
478
+
479
+ describe("parseDbQueryResult", () => {
480
+ it("returns empty shape for blank/invalid input", () => {
481
+ expect(parseDbQueryResult("")).toEqual({
482
+ columns: [],
483
+ rows: [],
484
+ rowCount: 0,
485
+ executionTimeMs: null,
486
+ meta: null,
487
+ });
488
+ expect(parseDbQueryResult("not json")).toEqual({
489
+ columns: [],
490
+ rows: [],
491
+ rowCount: 0,
492
+ executionTimeMs: null,
493
+ meta: null,
494
+ });
495
+ expect(parseDbQueryResult("[]")).toEqual({
496
+ columns: [],
497
+ rows: [],
498
+ rowCount: 0,
499
+ executionTimeMs: null,
500
+ meta: null,
501
+ });
502
+ });
503
+
504
+ it("parses wrangler envelope with rows and duration", () => {
505
+ const stdout = JSON.stringify([
506
+ {
507
+ results: [{ id: 1, name: "a" }, { id: 2, name: "b" }, null, [1]],
508
+ success: true,
509
+ meta: { duration: 0.125, rows_read: 2 },
510
+ },
511
+ ]);
512
+ const result = parseDbQueryResult(stdout);
513
+ expect(result.columns).toEqual(["id", "name"]);
514
+ expect(result.rowCount).toBe(2);
515
+ expect(result.rows).toEqual([
516
+ { id: 1, name: "a" },
517
+ { id: 2, name: "b" },
518
+ ]);
519
+ expect(result.executionTimeMs).toBe(125);
520
+ expect(result.meta).toEqual({ duration: 0.125, rows_read: 2 });
521
+ });
522
+
523
+ it("handles missing results and non-object envelope", () => {
524
+ expect(
525
+ parseDbQueryResult(JSON.stringify([{ success: true }]))
526
+ ).toMatchObject({ rows: [], columns: [] });
527
+ expect(parseDbQueryResult(JSON.stringify([null]))).toMatchObject({
528
+ rows: [],
529
+ });
530
+ expect(parseDbQueryResult(JSON.stringify(["x"]))).toMatchObject({
531
+ rows: [],
532
+ });
533
+ });
534
+
535
+ it("nulls absurd durations over 24h", () => {
536
+ const stdout = JSON.stringify([
537
+ {
538
+ results: [{ x: 1 }],
539
+ meta: { duration: 100_000 }, // seconds → 100M ms
540
+ },
541
+ ]);
542
+ expect(parseDbQueryResult(stdout).executionTimeMs).toBeNull();
543
+ });
544
+ });
545
+
546
+ describe("deriveQueueDepthStatus", () => {
547
+ it("maps thresholds and paused flag", () => {
548
+ expect(deriveQueueDepthStatus(0, true)).toBe("paused");
549
+ expect(deriveQueueDepthStatus(50, false)).toBe("healthy");
550
+ expect(deriveQueueDepthStatus(100, false)).toBe("backlogged");
551
+ expect(deriveQueueDepthStatus(500, false)).toBe("backlogged");
552
+ expect(deriveQueueDepthStatus(501, false)).toBe("critical");
553
+ });
554
+ });
555
+
556
+ describe("parseQueueDepths", () => {
557
+ it("returns empty for blank/invalid", () => {
558
+ expect(parseQueueDepths("")).toEqual([]);
559
+ expect(parseQueueDepths("{}")).toEqual([]);
560
+ expect(parseQueueDepths('{"queues":"nope"}')).toEqual([]);
561
+ });
562
+
563
+ it("normalizes queue records with heuristic depth", () => {
564
+ const stdout = JSON.stringify({
565
+ queues: [
566
+ {
567
+ queue_name: "trades",
568
+ producers_total_count: 2,
569
+ consumers_total_count: 1,
570
+ settings: { delivery_paused: false },
571
+ },
572
+ {
573
+ queue_id: "fallback-id",
574
+ producers_total_count: 0,
575
+ settings: { delivery_paused: true },
576
+ },
577
+ null,
578
+ { queue_name: "" },
579
+ { producers_total_count: 1 },
580
+ ],
581
+ });
582
+ const depths = parseQueueDepths(stdout);
583
+ expect(depths).toHaveLength(2);
584
+ expect(depths[0]).toMatchObject({
585
+ queueName: "trades",
586
+ depth: 200,
587
+ producers: 2,
588
+ consumers: 1,
589
+ paused: false,
590
+ status: "backlogged",
591
+ });
592
+ expect(depths[1]).toMatchObject({
593
+ queueName: "fallback-id",
594
+ depth: 1000,
595
+ paused: true,
596
+ status: "paused",
597
+ });
598
+ expect(typeof depths[0]!.timestamp).toBe("string");
599
+ });
600
+ });
601
+
602
+ describe("inferSecretType", () => {
603
+ it("infers from naming conventions", () => {
604
+ expect(inferSecretType("OPENAI_API_KEY")).toBe("api_key");
605
+ expect(inferSecretType("access_token")).toBe("token");
606
+ expect(inferSecretType("db_password")).toBe("password");
607
+ expect(inferSecretType("DB_PASSWD")).toBe("password");
608
+ expect(inferSecretType("client_secret")).toBe("secret");
609
+ expect(inferSecretType("RANDOM_THING")).toBe("unknown");
610
+ });
611
+ });
612
+
613
+ describe("parseSecretsList", () => {
614
+ it("returns empty for blank/null JSON", () => {
615
+ expect(parseSecretsList("")).toEqual([]);
616
+ expect(parseSecretsList("not json")).toEqual([]);
617
+ });
618
+
619
+ it("parses single-worker envelope", () => {
620
+ const out = parseSecretsList(
621
+ JSON.stringify({
622
+ worker: "trade-worker",
623
+ secrets: ["API_KEY", "auth_token", 42, null],
624
+ })
625
+ );
626
+ expect(out).toEqual([
627
+ { name: "API_KEY", type: "api_key", source: "config" },
628
+ { name: "auth_token", type: "token", source: "config" },
629
+ ]);
630
+ });
631
+
632
+ it("returns empty when single-worker secrets is not an array", () => {
633
+ expect(
634
+ parseSecretsList(JSON.stringify({ worker: "w", secrets: "nope" }))
635
+ ).toEqual([]);
636
+ });
637
+
638
+ it("parses all-workers envelope", () => {
639
+ const out = parseSecretsList(
640
+ JSON.stringify({
641
+ a: ["KEY_A"],
642
+ b: ["PASSWORD_B", 1],
643
+ c: "skip",
644
+ })
645
+ );
646
+ expect(out.map((s) => s.name)).toEqual(["KEY_A", "PASSWORD_B"]);
647
+ expect(out[1]!.type).toBe("password");
648
+ });
649
+
650
+ it("returns empty for array root", () => {
651
+ expect(parseSecretsList("[1,2]")).toEqual([]);
652
+ });
653
+ });
654
+
655
+ describe("parseKvManifest + parseKvList", () => {
656
+ it("parseKvManifest builds typed map", () => {
657
+ const map = parseKvManifest(
658
+ JSON.stringify({
659
+ namespace: "CONFIG_KV",
660
+ keys: [
661
+ { key: "FLAG", type: "boolean", secret: false },
662
+ { key: "COUNT", type: "number", secret: false },
663
+ { key: "TOKEN", type: "string", secret: true },
664
+ { key: "ODD", type: "weird", secret: false },
665
+ { key: "", type: "string" },
666
+ null,
667
+ { type: "string" },
668
+ ],
669
+ })
670
+ );
671
+ expect(map.get("FLAG")).toEqual({ type: "boolean", secret: false });
672
+ expect(map.get("COUNT")).toEqual({ type: "number", secret: false });
673
+ expect(map.get("TOKEN")).toEqual({ type: "string", secret: true });
674
+ expect(map.get("ODD")).toEqual({ type: "string", secret: false });
675
+ expect(map.has("")).toBe(false);
676
+ });
677
+
678
+ it("parseKvManifest returns empty on garbage", () => {
679
+ expect(parseKvManifest("")).toEqual(new Map());
680
+ expect(parseKvManifest("not json")).toEqual(new Map());
681
+ expect(parseKvManifest("{}")).toEqual(new Map());
682
+ });
683
+
684
+ it("parseKvList merges array form with manifest", () => {
685
+ const manifest = new Map([
686
+ ["SECRET_KEY", { type: "string" as const, secret: true }],
687
+ ["PLAIN", { type: "number" as const, secret: false }],
688
+ ]);
689
+ const keys = parseKvList(
690
+ JSON.stringify([
691
+ {
692
+ name: "SECRET_KEY",
693
+ metadata: { value_size: 12, last_modified: "2026-01-01" },
694
+ },
695
+ { name: "PLAIN" },
696
+ { name: "UNKNOWN" },
697
+ null,
698
+ { name: "" },
699
+ {},
700
+ ]),
701
+ manifest
702
+ );
703
+ expect(keys).toEqual([
704
+ {
705
+ name: "SECRET_KEY",
706
+ valueSize: 12,
707
+ lastModified: "2026-01-01",
708
+ isSecret: true,
709
+ manifestType: "string",
710
+ },
711
+ {
712
+ name: "PLAIN",
713
+ valueSize: null,
714
+ lastModified: null,
715
+ isSecret: false,
716
+ manifestType: "number",
717
+ },
718
+ {
719
+ name: "UNKNOWN",
720
+ valueSize: null,
721
+ lastModified: null,
722
+ isSecret: false,
723
+ manifestType: null,
724
+ },
725
+ ]);
726
+ });
727
+
728
+ it("parseKvList accepts keys envelope form", () => {
729
+ const keys = parseKvList(
730
+ JSON.stringify({ keys: [{ name: "A" }] }),
731
+ new Map()
732
+ );
733
+ expect(keys).toHaveLength(1);
734
+ expect(keys[0]!.name).toBe("A");
735
+ });
736
+
737
+ it("parseKvList returns empty for bad shapes", () => {
738
+ expect(parseKvList("", new Map())).toEqual([]);
739
+ expect(parseKvList("nope", new Map())).toEqual([]);
740
+ expect(parseKvList('{"other":[]}', new Map())).toEqual([]);
741
+ });
742
+ });
743
+
744
+ describe("stripAnsi + parseKillSwitchStatus", () => {
745
+ it("strips ANSI color codes", () => {
746
+ expect(stripAnsi("\x1b[31mred\x1b[0m")).toBe("red");
747
+ });
748
+
749
+ it("engage/release ignore stdout text", () => {
750
+ expect(parseKillSwitchStatus("engage", "whatever").engaged).toBe(true);
751
+ expect(parseKillSwitchStatus("release", "whatever").engaged).toBe(false);
752
+ });
753
+
754
+ it("show detects on/halted and off/active phrasing", () => {
755
+ expect(
756
+ parseKillSwitchStatus("show", "Kill switch is ON — trading halted")
757
+ .engaged
758
+ ).toBe(true);
759
+ expect(
760
+ parseKillSwitchStatus("show", "Kill switch is off — system active")
761
+ .engaged
762
+ ).toBe(false);
763
+ });
764
+
765
+ it("show falls back to value: marker", () => {
766
+ const on = parseKillSwitchStatus("show", "status value: true");
767
+ expect(on.engaged).toBe(true);
768
+ expect(on.rawValue).toBe("true");
769
+ const off = parseKillSwitchStatus("show", "status value: false");
770
+ expect(off.engaged).toBe(false);
771
+ });
772
+
773
+ it("show unknown output is fail-open released", () => {
774
+ const unknown = parseKillSwitchStatus("show", "???");
775
+ expect(unknown.engaged).toBe(false);
776
+ expect(unknown.rawValue).toBeNull();
777
+ });
778
+ });
779
+
780
+ describe("parseAgentHealth + parsePyneHealth", () => {
781
+ it("parseAgentHealth skips bad providers", () => {
782
+ expect(parseAgentHealth("")).toMatchObject({ providers: [] });
783
+ expect(parseAgentHealth("nope")).toMatchObject({ providers: [] });
784
+ const result = parseAgentHealth(
785
+ JSON.stringify({
786
+ providers: [
787
+ {
788
+ name: "openai",
789
+ model: "gpt-4o",
790
+ status: "online",
791
+ latencyMs: 40,
792
+ dailyRequests: 10,
793
+ },
794
+ { name: "bad", status: "weird", latencyMs: -1 },
795
+ null,
796
+ { model: "no-name" },
797
+ ],
798
+ })
799
+ );
800
+ expect(result.providers).toHaveLength(2);
801
+ expect(result.providers[0]).toMatchObject({
802
+ name: "openai",
803
+ status: "online",
804
+ latencyMs: 40,
805
+ dailyRequests: 10,
806
+ });
807
+ expect(result.providers[1]).toMatchObject({
808
+ name: "bad",
809
+ status: "offline",
810
+ latencyMs: null,
811
+ model: "unknown",
812
+ });
813
+ });
814
+
815
+ it("parsePyneHealth handles empty/invalid/valid", () => {
816
+ expect(parsePyneHealth("").status).toBe("down");
817
+ expect(parsePyneHealth("[]").status).toBe("down");
818
+ const ok = parsePyneHealth(
819
+ JSON.stringify({
820
+ worker: "pyne-worker",
821
+ url: "http://localhost:8787",
822
+ status: "healthy",
823
+ httpStatus: 200,
824
+ latencyMs: 12,
825
+ })
826
+ );
827
+ expect(ok).toMatchObject({
828
+ status: "healthy",
829
+ httpStatus: 200,
830
+ latencyMs: 12,
831
+ url: "http://localhost:8787",
832
+ });
833
+ expect(parsePyneHealth(JSON.stringify({ status: "nope" })).status).toBe(
834
+ "down"
835
+ );
836
+ });
837
+ });
838
+
839
+ describe("extractNamespaceId", () => {
840
+ it("returns null until CLI exposes namespace id", () => {
841
+ expect(extractNamespaceId('{"namespace":"CONFIG_KV"}')).toBeNull();
842
+ });
843
+ });
844
+
845
+ // ─── agentChatStream (standalone SSE) ─────────────────────────────────────────
846
+
847
+ describe("AI_MODEL_OPTIONS", () => {
848
+ it("includes workers-ai and external providers", () => {
849
+ expect(AI_MODEL_OPTIONS.length).toBeGreaterThanOrEqual(3);
850
+ expect(AI_MODEL_OPTIONS.some((m) => m.id === "workers-ai")).toBe(true);
851
+ expect(AI_MODEL_OPTIONS.every((m) => m.id && m.label && m.provider)).toBe(
852
+ true
853
+ );
854
+ });
855
+ });
856
+
857
+ describe("agentChatStream", () => {
858
+ const originalFetch = globalThis.fetch;
859
+
860
+ afterEach(() => {
861
+ globalThis.fetch = originalFetch;
862
+ });
863
+
864
+ function sseBody(chunks: string[]): ReadableStream<Uint8Array> {
865
+ const encoder = new TextEncoder();
866
+ let i = 0;
867
+ return new ReadableStream({
868
+ pull(controller) {
869
+ if (i >= chunks.length) {
870
+ controller.close();
871
+ return;
872
+ }
873
+ controller.enqueue(encoder.encode(chunks[i]!));
874
+ i++;
875
+ },
876
+ });
877
+ }
878
+
879
+ it("streams tokens and completes on [DONE]", async () => {
880
+ const tokens: string[] = [];
881
+ const statuses: string[] = [];
882
+ globalThis.fetch = (async () =>
883
+ new Response(
884
+ sseBody([
885
+ 'data: {"content":"Hello"}\n',
886
+ 'data: {"content":" world"}\n',
887
+ "data: [DONE]\n",
888
+ ]),
889
+ {
890
+ status: 200,
891
+ headers: { "Content-Type": "text/event-stream" },
892
+ }
893
+ )) as unknown as typeof fetch;
894
+
895
+ const { finished } = agentChatStream(
896
+ {
897
+ messages: [{ role: "user", content: "hi", timestamp: "t1" }],
898
+ model: "workers-ai",
899
+ },
900
+ "http://localhost:8787/",
901
+ "tok",
902
+ (t) => tokens.push(t),
903
+ (s) => statuses.push(s)
904
+ );
905
+ await finished;
906
+ expect(tokens.join("")).toBe("Hello world");
907
+ expect(statuses).toContain("connected");
908
+ });
909
+
910
+ it("skips malformed SSE data lines", async () => {
911
+ const tokens: string[] = [];
912
+ globalThis.fetch = (async () =>
913
+ new Response(
914
+ sseBody([
915
+ "data: not-json\n",
916
+ 'data: {"content":"ok"}\n',
917
+ "data: [DONE]\n",
918
+ ]),
919
+ { status: 200 }
920
+ )) as unknown as typeof fetch;
921
+
922
+ const { finished } = agentChatStream(
923
+ {
924
+ messages: [{ role: "user", content: "x", timestamp: "t1" }],
925
+ model: "openai:gpt-4o-mini",
926
+ temperature: 0.2,
927
+ maxTokens: 10,
928
+ },
929
+ "http://api.test",
930
+ "",
931
+ (t) => tokens.push(t)
932
+ );
933
+ await finished;
934
+ expect(tokens).toEqual(["ok"]);
935
+ });
936
+
937
+ it("reconnects on HTTP failure then disconnects", async () => {
938
+ let calls = 0;
939
+ const statuses: string[] = [];
940
+ globalThis.fetch = (async () => {
941
+ calls++;
942
+ return new Response("fail", { status: 503 });
943
+ }) as unknown as typeof fetch;
944
+
945
+ const { finished } = agentChatStream(
946
+ { messages: [{ role: "user", content: "x", timestamp: "t1" }] },
947
+ "http://localhost:9",
948
+ "t",
949
+ undefined,
950
+ (s) => statuses.push(s)
951
+ );
952
+ await finished;
953
+ expect(calls).toBeGreaterThanOrEqual(2);
954
+ expect(statuses).toContain("reconnecting");
955
+ expect(statuses).toContain("disconnected");
956
+ }, 30_000);
957
+
958
+ it("aborts while fetch is pending", async () => {
959
+ globalThis.fetch = ((_url: RequestInfo | URL, init?: RequestInit) =>
960
+ new Promise<Response>((_resolve, reject) => {
961
+ const signal = init?.signal;
962
+ if (signal?.aborted) {
963
+ reject(new DOMException("Aborted", "AbortError"));
964
+ return;
965
+ }
966
+ signal?.addEventListener(
967
+ "abort",
968
+ () => reject(new DOMException("Aborted", "AbortError")),
969
+ { once: true }
970
+ );
971
+ })) as unknown as typeof fetch;
972
+
973
+ const { abort, finished } = agentChatStream(
974
+ { messages: [{ role: "user", content: "x", timestamp: "t1" }] },
975
+ "http://localhost:9",
976
+ "t"
977
+ );
978
+ // Abort on next microtask so fetch has registered the listener
979
+ await Promise.resolve();
980
+ abort.abort();
981
+ await finished;
982
+ expect(abort.signal.aborted).toBe(true);
983
+ });
984
+ });
985
+
986
+ // ─── CliBridge methods with stubbed exec (real class) ─────────────────────────
987
+
988
+ function okExec(stdout: string, data: unknown = null): CliResult<unknown> {
989
+ return {
990
+ success: true,
991
+ exitCode: 0,
992
+ stdout,
993
+ stderr: "",
994
+ data,
995
+ duration: 1,
996
+ command: "hoox test",
997
+ errorType: null,
998
+ };
999
+ }
1000
+
1001
+ function failExec(stderr = "fail"): CliResult<unknown> {
1002
+ return {
1003
+ success: false,
1004
+ exitCode: 1,
1005
+ stdout: "",
1006
+ stderr,
1007
+ data: null,
1008
+ duration: 1,
1009
+ command: "hoox test",
1010
+ errorType: "non-zero-exit",
1011
+ };
1012
+ }
1013
+
1014
+ describe("CliBridge methods (exec stubbed)", () => {
1015
+ const bridge = realCliBridge!;
1016
+ let originalExec: typeof bridge.exec;
1017
+
1018
+ beforeEach(() => {
1019
+ expect(bridge).toBeTruthy();
1020
+ originalExec = bridge.exec.bind(bridge);
1021
+ });
1022
+
1023
+ afterEach(() => {
1024
+ bridge.exec = originalExec;
1025
+ });
1026
+
1027
+ it("dbQuery rejects non-readonly SQL without spawning", async () => {
1028
+ const calls: unknown[] = [];
1029
+ bridge.exec = (async (...a: unknown[]) => {
1030
+ calls.push(a);
1031
+ return okExec("[]");
1032
+ }) as typeof bridge.exec;
1033
+
1034
+ const result = (await bridge.dbQuery("DROP TABLE x")) as CliResult<null>;
1035
+ expect(result.success).toBe(false);
1036
+ expect(result.stderr).toContain("Read-only validation failed");
1037
+ expect(calls).toHaveLength(0);
1038
+ });
1039
+
1040
+ it("dbQuery parses success stdout", async () => {
1041
+ bridge.exec = (async () =>
1042
+ okExec(
1043
+ JSON.stringify([{ results: [{ id: 1 }], meta: { duration: 0.01 } }])
1044
+ )) as typeof bridge.exec;
1045
+ const result = (await bridge.dbQuery("SELECT 1")) as CliResult<{
1046
+ rowCount: number;
1047
+ }>;
1048
+ expect(result.success).toBe(true);
1049
+ expect(result.data?.rowCount).toBe(1);
1050
+ });
1051
+
1052
+ it("monitorQueueDepth / monitorKillSwitch parse stdout", async () => {
1053
+ bridge.exec = (async () =>
1054
+ okExec(
1055
+ JSON.stringify({
1056
+ queues: [
1057
+ {
1058
+ queue_name: "q1",
1059
+ producers_total_count: 1,
1060
+ consumers_total_count: 0,
1061
+ settings: { delivery_paused: false },
1062
+ },
1063
+ ],
1064
+ })
1065
+ )) as typeof bridge.exec;
1066
+ const qd = (await bridge.monitorQueueDepth()) as CliResult<unknown[]>;
1067
+ expect(qd.success).toBe(true);
1068
+ expect(Array.isArray(qd.data)).toBe(true);
1069
+ expect((qd.data as unknown[]).length).toBe(1);
1070
+
1071
+ bridge.exec = (async () =>
1072
+ okExec("Kill switch is ON — trading halted")) as typeof bridge.exec;
1073
+ const ks = (await bridge.monitorKillSwitch("show")) as CliResult<{
1074
+ engaged: boolean;
1075
+ }>;
1076
+ expect(ks.data?.engaged).toBe(true);
1077
+
1078
+ bridge.exec = (async () => failExec()) as typeof bridge.exec;
1079
+ expect(
1080
+ ((await bridge.monitorKillSwitch("engage")) as CliResult<null>).data
1081
+ ).toBeNull();
1082
+ expect(
1083
+ ((await bridge.monitorQueueDepth()) as CliResult<null>).data
1084
+ ).toBeNull();
1085
+ });
1086
+
1087
+ it("configKvList merges list + manifest", async () => {
1088
+ bridge.exec = (async (args: unknown) => {
1089
+ const a = args as string[];
1090
+ if (a.includes("manifest")) {
1091
+ return okExec(
1092
+ JSON.stringify({
1093
+ keys: [{ key: "FLAG", type: "boolean", secret: false }],
1094
+ })
1095
+ );
1096
+ }
1097
+ return okExec(JSON.stringify([{ name: "FLAG" }, { name: "OTHER" }]));
1098
+ }) as typeof bridge.exec;
1099
+
1100
+ const result = (await bridge.configKvList()) as CliResult<{
1101
+ keys: { name: string }[];
1102
+ }>;
1103
+ expect(result.success).toBe(true);
1104
+ expect(result.data?.keys.map((k) => k.name)).toEqual(["FLAG", "OTHER"]);
1105
+ });
1106
+
1107
+ it("configKvList surfaces list failure", async () => {
1108
+ bridge.exec = (async (args: unknown) => {
1109
+ const a = args as string[];
1110
+ if (a.includes("list") && !a.includes("manifest")) {
1111
+ return failExec("list boom");
1112
+ }
1113
+ return okExec("{}");
1114
+ }) as typeof bridge.exec;
1115
+ const result = (await bridge.configKvList()) as CliResult<null>;
1116
+ expect(result.success).toBe(false);
1117
+ expect(result.data).toBeNull();
1118
+ });
1119
+
1120
+ it("configKvGet treats not found as success null", async () => {
1121
+ bridge.exec = (async () => failExec("key not found")) as typeof bridge.exec;
1122
+ const result = (await bridge.configKvGet("missing")) as CliResult<
1123
+ string | null
1124
+ >;
1125
+ expect(result.success).toBe(true);
1126
+ expect(result.data).toBeNull();
1127
+ });
1128
+
1129
+ it("configKvGet returns trimmed value", async () => {
1130
+ bridge.exec = (async () => okExec("hello\n")) as typeof bridge.exec;
1131
+ const result = (await bridge.configKvGet("k")) as CliResult<string | null>;
1132
+ expect(result.data).toBe("hello");
1133
+ });
1134
+
1135
+ it("configSecretsList / agentHealth / pyneHealth parse or null", async () => {
1136
+ bridge.exec = (async () =>
1137
+ okExec(
1138
+ JSON.stringify({ worker: "w", secrets: ["API_KEY"] })
1139
+ )) as typeof bridge.exec;
1140
+ const secrets = (await bridge.configSecretsList()) as CliResult<{
1141
+ secrets: { name: string }[];
1142
+ }>;
1143
+ expect(secrets.data?.secrets[0]?.name).toBe("API_KEY");
1144
+
1145
+ bridge.exec = (async () =>
1146
+ okExec(
1147
+ JSON.stringify({
1148
+ providers: [
1149
+ {
1150
+ name: "openai",
1151
+ model: "gpt",
1152
+ status: "online",
1153
+ latencyMs: 1,
1154
+ dailyRequests: 0,
1155
+ },
1156
+ ],
1157
+ })
1158
+ )) as typeof bridge.exec;
1159
+ const health = (await bridge.agentHealthCheck()) as CliResult<{
1160
+ providers: unknown[];
1161
+ }>;
1162
+ expect(health.data?.providers).toHaveLength(1);
1163
+
1164
+ bridge.exec = (async () =>
1165
+ okExec(
1166
+ JSON.stringify({
1167
+ worker: "pyne-worker",
1168
+ url: "http://x",
1169
+ status: "healthy",
1170
+ httpStatus: 200,
1171
+ latencyMs: 3,
1172
+ })
1173
+ )) as typeof bridge.exec;
1174
+ const pyne = (await bridge.pyneHealthCheck()) as CliResult<{
1175
+ status: string;
1176
+ }>;
1177
+ expect(pyne.data?.status).toBe("healthy");
1178
+
1179
+ bridge.exec = (async () => failExec()) as typeof bridge.exec;
1180
+ expect(
1181
+ ((await bridge.configSecretsList()) as CliResult<null>).data
1182
+ ).toBeNull();
1183
+ expect(
1184
+ ((await bridge.agentHealthCheck()) as CliResult<null>).data
1185
+ ).toBeNull();
1186
+ });
1187
+
1188
+ it("thin wrappers forward tags via exec args", async () => {
1189
+ const seen: string[][] = [];
1190
+ bridge.exec = (async (args: unknown) => {
1191
+ seen.push(args as string[]);
1192
+ return okExec("{}");
1193
+ }) as typeof bridge.exec;
1194
+
1195
+ await bridge.deployAll();
1196
+ await bridge.deployWorker("trade-worker");
1197
+ await bridge.checkHealth();
1198
+ await bridge.checkHealthFix();
1199
+ await bridge.checkFix();
1200
+ await bridge.workerLogs("hoox");
1201
+ await bridge.configShow();
1202
+ await bridge.configValidate();
1203
+ await bridge.monitorStatus();
1204
+ await bridge.rebuild();
1205
+ await bridge.repairWorker("d1-worker");
1206
+ await bridge.checkSetup();
1207
+ await bridge.configKvSet("k", "v");
1208
+
1209
+ expect(seen.some((a) => a[0] === "deploy" && a[1] === "all")).toBe(true);
1210
+ expect(seen.some((a) => a[0] === "deploy" && a[2] === "trade-worker")).toBe(
1211
+ true
1212
+ );
1213
+ expect(seen.some((a) => a[0] === "check" && a[1] === "health")).toBe(true);
1214
+ expect(seen.some((a) => a.includes("--fix"))).toBe(true);
1215
+ expect(seen.some((a) => a[0] === "check" && a[1] === "fix")).toBe(true);
1216
+ expect(seen.some((a) => a[0] === "logs")).toBe(true);
1217
+ expect(seen.some((a) => a[0] === "config" && a[1] === "show")).toBe(true);
1218
+ expect(seen.some((a) => a.includes("validate"))).toBe(true);
1219
+ expect(seen.some((a) => a[0] === "repair")).toBe(true);
1220
+ expect(seen.some((a) => a[0] === "check" && a[1] === "setup")).toBe(true);
1221
+ expect(
1222
+ seen.some((a) => a[0] === "config" && a[1] === "kv" && a[2] === "set")
1223
+ ).toBe(true);
1224
+ });
1225
+
1226
+ it("onError sink receives validation failures; sink throw is swallowed", async () => {
1227
+ const events: unknown[] = [];
1228
+ const unsub = bridge.onError((d) => {
1229
+ events.push(d);
1230
+ throw new Error("sink boom");
1231
+ });
1232
+ // dbQuery validation path calls notifyError without going through exec
1233
+ await bridge.dbQuery("DELETE FROM users");
1234
+ expect(events.length).toBe(1);
1235
+ unsub();
1236
+
1237
+ // After unsubscribe, further failures should not append
1238
+ const before = events.length;
1239
+ await bridge.dbQuery("DROP TABLE x");
1240
+ expect(events.length).toBe(before);
1241
+ });
1242
+
1243
+ it("invalidateCache clears binary path cache", () => {
1244
+ bridge.invalidateCache();
1245
+ // no throw
1246
+ expect(typeof bridge.resolveBinary).toBe("function");
1247
+ });
1248
+
1249
+ it("abort and dispose are safe with no active commands", () => {
1250
+ bridge.abort("missing-tag");
1251
+ bridge.dispose();
1252
+ });
406
1253
  });