@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.
- package/README.md +50 -31
- package/dist/main.js +4238 -1711
- package/package.json +2 -2
- package/src/app.tsx +45 -30
- package/src/cli-bridge-test-double.ts +48 -1
- package/src/components/layout/sidebar.tsx +27 -11
- package/src/components/layout/statusbar.test.tsx +19 -5
- package/src/components/layout/statusbar.tsx +34 -18
- package/src/components/shared/crash-screen.tsx +24 -2
- package/src/components/shared/error-boundary.tsx +56 -33
- package/src/components/ui/connection-toasts.test.ts +58 -1
- package/src/components/views/config-editor.test.tsx +4 -2
- package/src/components/views/config-editor.tsx +31 -3
- package/src/components/views/dashboard/alerts-panel.tsx +36 -8
- package/src/components/views/dashboard/auto-repair-helpers.test.ts +23 -0
- package/src/components/views/dashboard.test.tsx +2 -1
- package/src/components/views/dashboard.tsx +126 -41
- package/src/components/views/db-query.test.tsx +7 -0
- package/src/components/views/db-query.tsx +5 -4
- package/src/components/views/edge-topology.tsx +3 -2
- package/src/components/views/kv-viewer.test.tsx +28 -0
- package/src/components/views/kv-viewer.tsx +4 -3
- package/src/components/views/logs-viewer.tsx +46 -19
- package/src/components/views/queue-depth.test.tsx +39 -0
- package/src/components/views/queue-depth.tsx +3 -2
- package/src/components/views/secrets-viewer.test.tsx +2 -2
- package/src/components/views/secrets-viewer.tsx +4 -3
- package/src/components/views/service-manager.tsx +147 -53
- package/src/components/views/settings.tsx +50 -10
- package/src/components/views/setup-wizard.test.tsx +13 -0
- package/src/components/views/setup-wizard.tsx +53 -22
- package/src/components/views/trade-monitor.tsx +20 -13
- package/src/components/views/worker-detail.tsx +27 -12
- package/src/components/views/worker-settings.test.tsx +5 -6
- package/src/components/views/worker-settings.tsx +13 -11
- package/src/components/views/workers-overview.tsx +27 -6
- package/src/hooks/hooks.test.ts +303 -9
- package/src/hooks/index.ts +7 -1
- package/src/hooks/shell-overlay.ts +41 -0
- package/src/hooks/use-polling.ts +91 -35
- package/src/hooks/use-service-data.ts +21 -4
- package/src/main.tsx +15 -0
- package/src/services/cli-bridge/index.ts +28 -22
- package/src/services/cli-bridge/standalone.ts +29 -2
- package/src/services/cli-bridge.test.ts +848 -1
- package/src/services/clipboard.test.ts +217 -3
- package/src/services/dashboard-settings-loader.test.ts +50 -0
- package/src/services/tui-connection.test.ts +21 -0
- package/src/stores/service-store.test.ts +89 -3
- package/src/stores/ui-store.test.ts +23 -0
- package/src/test-setup.ts +11 -2
- package/src/types.ts +1 -0
- package/src/view-registry.test.ts +3 -1
- 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
|
});
|