shadow-claw 1.52.0 → 1.52.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. package/.well-known/mcp/server-card.json +1 -1
  2. package/.well-known/mcp.json +1 -1
  3. package/README.md +9 -9
  4. package/dist/cli/build/build.js +1 -1
  5. package/dist/cli/{chunk-DYq-orHK.js → chunk-BgTv9w0K.js} +1 -1
  6. package/dist/cli/{chunk-C6jii2Fl.js → chunk-d5hIhj8t.js} +1 -1
  7. package/dist/cli/cli.js +1 -1
  8. package/dist/lib/components/index.js +1 -1
  9. package/dist/lib/components/shadow-claw-pages.js +1 -1
  10. package/dist/lib/index.js +1 -1
  11. package/dist/lib/{shadow-claw-pages-kA2qa-4B.js → shadow-claw-pages-CjHS50HN.js} +2 -2
  12. package/dist/lib/{shadow-claw-pages-kA2qa-4B.js.map → shadow-claw-pages-CjHS50HN.js.map} +1 -1
  13. package/dist/public/.well-known/mcp/server-card.json +1 -1
  14. package/dist/public/.well-known/mcp.json +1 -1
  15. package/dist/public/AGENTS.md +1 -1
  16. package/dist/public/README.md +9 -9
  17. package/dist/public/docs/README.md +1 -1
  18. package/dist/public/docs/architecture/orchestrator.md +2 -2
  19. package/dist/public/docs/architecture/overview.md +3 -3
  20. package/dist/public/docs/architecture/worker-protocol.md +29 -29
  21. package/dist/public/docs/example/article/index.html +1 -1
  22. package/dist/public/docs/example/article.html +1 -1
  23. package/dist/public/docs/publishing/index.html +1 -1
  24. package/dist/public/docs/publishing.html +1 -1
  25. package/dist/public/docs/skill-creator/index.html +1 -1
  26. package/dist/public/docs/skill-creator.html +1 -1
  27. package/dist/public/docs/subsystems/local-models.md +1 -1
  28. package/dist/public/docs/subsystems/providers.md +11 -4
  29. package/dist/public/e2e/README.md +2 -2
  30. package/dist/public/index.html +1 -1
  31. package/dist/public/index.js +1 -1
  32. package/dist/public/llms.txt +1 -1
  33. package/dist/public/main/index.html +1 -1
  34. package/dist/public/main/memory/index.html +1 -1
  35. package/dist/public/main/memory.html +1 -1
  36. package/dist/public/main.html +1 -1
  37. package/dist/public/service-worker.js +1 -1
  38. package/dist/public/{shadow-claw-Ba2ZFE9m.js → shadow-claw-T9oPpqxT.js} +1 -1
  39. package/dist/public/{shadow-claw-pages-Cy6T0G9y.js → shadow-claw-pages-IW_LNEL_.js} +1 -1
  40. package/package.json +1 -1
  41. package/src/cli/build/build.test.ts +95 -0
  42. package/src/cli/build/build.ts +5 -1
  43. package/src/cli/commands/agent.test.ts +134 -0
  44. package/src/cli/commands/backup.test.ts +85 -0
  45. package/src/cli/commands/clients.test.ts +32 -0
  46. package/src/cli/commands/mcp.test.ts +260 -1
  47. package/src/cli/commands/send-file.test.ts +73 -0
  48. package/src/cli/commands/send.test.ts +58 -0
  49. package/src/cli/commands/tasks.test.ts +78 -0
  50. package/src/cli/commands/webrtc-listen.test.ts +74 -0
  51. package/src/cli/tools/assert-version-bump.test.ts +30 -1
  52. package/src/cli/tools/file-search-replace.test.ts +33 -1
  53. package/src/cli/tools/patch-service-worker-trusted-types.test.ts +24 -1
  54. package/src/cli/tools/touch-nojekyll.test.ts +12 -0
  55. package/src/cli/tools/version.test.ts +109 -5
  56. package/src/cli/utils/agent-core.test.ts +40 -0
  57. package/src/cli/utils/control-client.test.ts +239 -2
  58. package/src/cli/utils/load-workspace-config.test.ts +63 -0
  59. package/src/cli/utils/local-models.test.ts +98 -0
  60. package/src/cli/utils/progress-bar.test.ts +41 -2
  61. package/src/cli/utils/resolve-cache-dir.test.ts +61 -0
  62. package/src/cli/utils/resolve-project-root.test.ts +17 -0
  63. package/src/cli/utils/stdin.test.ts +38 -0
  64. package/src/cli/utils/stdio.test.ts +51 -1
  65. package/src/cli/utils/suppress-warnings.test.ts +9 -0
  66. package/src/cli/utils/webrtc-control-client.test.ts +569 -0
  67. package/src/common/utils/frontmatter.test.ts +117 -0
  68. package/src/components/common/help/providers.test.ts +67 -0
  69. package/src/components/common/shadow-claw-provider-model-picker/shadow-claw-provider-model-picker.test.ts +121 -0
  70. package/src/components/settings/shadow-claw-accounts/shadow-claw-accounts.test.ts +131 -0
  71. package/src/components/settings/shadow-claw-git/shadow-claw-git.test.ts +130 -0
  72. package/src/components/settings/shadow-claw-integrations/connection-test-auth.test.ts +12 -0
  73. package/src/components/settings/shadow-claw-integrations/shadow-claw-integrations.test.ts +328 -0
  74. package/src/components/settings/shadow-claw-llm/shadow-claw-llm.test.ts +530 -8
  75. package/src/components/settings/shadow-claw-mcp-remote/shadow-claw-mcp-remote.test.ts +459 -0
  76. package/src/components/settings/shadow-claw-networking/shadow-claw-networking.test.ts +88 -0
  77. package/src/components/settings/shadow-claw-notifications/shadow-claw-notifications.test.ts +92 -0
  78. package/src/components/settings/shadow-claw-peerjs/shadow-claw-peerjs.test.ts +127 -0
  79. package/src/components/settings/shadow-claw-storage/shadow-claw-storage.test.ts +173 -0
  80. package/src/components/settings/shadow-claw-task-server/shadow-claw-task-server.test.ts +62 -0
  81. package/src/components/settings/shadow-claw-webvm/shadow-claw-webvm.test.ts +104 -0
  82. package/src/components/shadow-claw/shadow-claw.test.ts +112 -0
  83. package/src/components/shadow-claw-a2ui/shadow-claw-a2ui.test.ts +109 -0
  84. package/src/components/shadow-claw-conversations/shadow-claw-conversations.test.ts +361 -0
  85. package/src/components/shadow-claw-dialog/shadow-claw-dialog.test.ts +76 -0
  86. package/src/components/shadow-claw-file-viewer/shadow-claw-file-viewer.test.ts +307 -0
  87. package/src/components/shadow-claw-files/shadow-claw-files.test.ts +398 -1
  88. package/src/components/shadow-claw-pages/shadow-claw-pages.test.ts +85 -0
  89. package/src/components/shadow-claw-pages/shadow-claw-pages.ts +2 -1
  90. package/src/components/shadow-claw-pdf-viewer/shadow-claw-pdf-viewer.test.ts +229 -0
  91. package/src/components/shadow-claw-settings/shadow-claw-settings.test.ts +233 -0
  92. package/src/components/shadow-claw-tasks/shadow-claw-tasks.test.ts +223 -1
  93. package/src/components/shadow-claw-toast/shadow-claw-toast.test.ts +79 -0
  94. package/src/components/shadow-claw-tools/shadow-claw-tools.test.ts +229 -1
  95. package/src/server/logger.test.ts +57 -0
  96. package/src/server/peer.test.ts +47 -0
  97. package/src/server/routes/gemini.test.ts +244 -0
  98. package/src/server/server-peer.test.ts +245 -11
  99. package/src/server/services/llamafile-manager.test.ts +668 -0
  100. package/src/share-target/pending-shares.test.ts +90 -0
  101. package/src/shell/fs.test.ts +125 -0
  102. package/src/shell/shell.test.ts +8 -0
  103. package/src/subsystems/git/credentials.test.ts +260 -0
  104. package/src/subsystems/git/git.test.ts +64 -0
  105. package/src/subsystems/providers/executeNativeAiTask.test.ts +78 -0
  106. package/src/subsystems/providers/utils/parseLocalModelToolCall.test.ts +57 -0
  107. package/src/worker/tools/builtin-ai/builtin-ai.test.ts +88 -0
  108. package/src/worker/tools/email/email.test.ts +358 -0
  109. package/src/worker/tools/email/utils/resolveIntegrationEmailAuth.test.ts +120 -0
  110. package/src/worker/tools/email/utils/resolveUniqueAttachmentPath.test.ts +80 -0
  111. package/src/worker/tools/fetch-file/fetch-file.test.ts +111 -0
  112. package/src/worker/tools/fetch-url/fetch-url.test.ts +295 -0
@@ -44,4 +44,13 @@ describe("suppressExperimentalWarnings", () => {
44
44
  expect(captured!.warning).toBe("Some other warning");
45
45
  expect(captured!.args[0]).toBe("UserWarning");
46
46
  });
47
+
48
+ it("is idempotent when called multiple times or when emitWarning is absent", () => {
49
+ suppressExperimentalWarnings();
50
+ // Second call should return early
51
+ expect(() => suppressExperimentalWarnings()).not.toThrow();
52
+
53
+ process.emitWarning = undefined as any;
54
+ expect(() => suppressExperimentalWarnings()).not.toThrow();
55
+ });
47
56
  });
@@ -21,6 +21,9 @@ import {
21
21
  writeIpcFile,
22
22
  clearIpcFile,
23
23
  CliWebRtcListener,
24
+ resolveListenerIpc,
25
+ sendCommandViaIpc,
26
+ getIpcSocketPath,
24
27
  } from "./webrtc-control-client.js";
25
28
 
26
29
  describe("src/cli/utils/webrtc-control-client.ts", () => {
@@ -423,4 +426,570 @@ describe("CliWebRtcListener", () => {
423
426
  120000,
424
427
  );
425
428
  });
429
+
430
+ it("CliWebRtcControlClient.listClients returns empty array when no IPC or IPC error", async () => {
431
+ const client = new CliWebRtcControlClient({ cacheDir: tempDir });
432
+ const empty = await client.listClients();
433
+ expect(empty).toEqual([]);
434
+
435
+ // With invalid IPC socket
436
+ writeIpcFile("/tmp/non-existent-sock-" + Date.now(), tempDir);
437
+ const emptyWithIpc = await client.listClients();
438
+ expect(emptyWithIpc).toEqual([]);
439
+ clearIpcFile(tempDir);
440
+ });
441
+
442
+ it("CliWebRtcControlClient.sendFile throws when file does not exist or is directory", async () => {
443
+ const client = new CliWebRtcControlClient({ cacheDir: tempDir });
444
+ await expect(client.sendFile("peer-1", "/not/found.txt")).rejects.toThrow(
445
+ "File not found",
446
+ );
447
+ await expect(client.sendFile("peer-1", tempDir)).rejects.toThrow(
448
+ "Cannot send directory",
449
+ );
450
+ });
451
+
452
+ it("CliWebRtcListener._setupConnection handles open, agent cards, invalid JSON, close, and error", async () => {
453
+ const listener = new CliWebRtcListener({
454
+ cacheDir: tempDir,
455
+ trustedPeerIds: ["trusted-peer", "prefix-*"],
456
+ });
457
+
458
+ expect(listener._isTrusted("trusted-peer")).toBe(true);
459
+ expect(listener._isTrusted("prefix-abc")).toBe(true);
460
+ expect(listener._isTrusted("untrusted-peer")).toBe(false);
461
+
462
+ const handlers: Record<string, Function> = {};
463
+ const mockConn = {
464
+ on: jest.fn((event: string, handler: Function) => {
465
+ handlers[event] = handler;
466
+ }),
467
+ send: jest.fn(),
468
+ close: jest.fn(),
469
+ };
470
+
471
+ listener._setupConnection(mockConn, "trusted-peer");
472
+
473
+ // open event
474
+ handlers["open"]();
475
+ expect(mockConn.send).toHaveBeenCalledWith(
476
+ expect.objectContaining({ method: "GetAgentCard" }),
477
+ );
478
+
479
+ // data: invalid string
480
+ await handlers["data"]("invalid-json");
481
+
482
+ // data: card result
483
+ await handlers["data"]({ result: { name: "Remote Agent" } });
484
+
485
+ // data: GetAgentCard request
486
+ await handlers["data"]({ method: "GetAgentCard", id: "card-req-1" });
487
+ expect(mockConn.send).toHaveBeenCalledWith(
488
+ expect.objectContaining({
489
+ id: "card-req-1",
490
+ result: expect.objectContaining({ name: "ShadowClaw CLI" }),
491
+ }),
492
+ );
493
+
494
+ // error event
495
+ handlers["error"](new Error("conn err"));
496
+
497
+ // close event
498
+ handlers["close"]();
499
+ });
500
+
501
+ it("CliWebRtcListener._handleCommand handles errors and unknown actions", async () => {
502
+ const listener = new CliWebRtcListener({
503
+ cacheDir: tempDir,
504
+ handlers: {
505
+ "failing-action": async () => {
506
+ throw new Error("Action failure");
507
+ },
508
+ },
509
+ });
510
+
511
+ const mockConn = { send: jest.fn() };
512
+
513
+ // Failing action
514
+ await listener._handleCommand(mockConn, "peer-1", {
515
+ payload: { commandId: "cmd-err", action: "failing-action", args: {} },
516
+ });
517
+ expect(mockConn.send).toHaveBeenCalledWith(
518
+ expect.objectContaining({
519
+ type: "command:result",
520
+ payload: expect.objectContaining({
521
+ commandId: "cmd-err",
522
+ success: false,
523
+ error: "Action failure",
524
+ }),
525
+ }),
526
+ );
527
+
528
+ // Unknown action
529
+ await listener._handleCommand(mockConn, "peer-1", {
530
+ payload: { commandId: "cmd-unk", action: "unknown-action", args: {} },
531
+ });
532
+ expect(mockConn.send).toHaveBeenCalledWith(
533
+ expect.objectContaining({
534
+ type: "command:result",
535
+ payload: expect.objectContaining({
536
+ commandId: "cmd-unk",
537
+ success: false,
538
+ error: "Unknown action: unknown-action",
539
+ }),
540
+ }),
541
+ );
542
+ });
543
+ });
544
+
545
+ describe("resolveListenerIpc and sendCommandViaIpc", () => {
546
+ let tempDir: string;
547
+ let http: typeof import("node:http");
548
+
549
+ beforeEach(async () => {
550
+ http = await import("node:http");
551
+ tempDir = path.join(
552
+ os.tmpdir(),
553
+ "test-webrtc-ipc-func-" +
554
+ Date.now() +
555
+ "-" +
556
+ Math.random().toString(36).slice(2),
557
+ );
558
+ fs.mkdirSync(tempDir, { recursive: true });
559
+ });
560
+
561
+ afterEach(() => {
562
+ try {
563
+ if (fs.existsSync(tempDir)) {
564
+ fs.rmSync(tempDir, { recursive: true, force: true });
565
+ }
566
+ } catch (_) {}
567
+ });
568
+
569
+ it("resolveListenerIpc returns null when socket does not exist", async () => {
570
+ const res = await resolveListenerIpc(tempDir);
571
+ expect(res).toBeNull();
572
+ });
573
+
574
+ it("resolveListenerIpc returns socketPath when /ping returns 200, and null when /ping errors", async () => {
575
+ const socketPath = getIpcSocketPath(tempDir);
576
+ let pingStatus = 200;
577
+ const server = http.createServer((req, res) => {
578
+ if (req.url === "/ping") {
579
+ res.writeHead(pingStatus);
580
+ res.end(JSON.stringify({ ok: true }));
581
+ } else {
582
+ res.writeHead(404);
583
+ res.end();
584
+ }
585
+ });
586
+
587
+ await new Promise((resolve) => server.listen(socketPath, resolve as any));
588
+ try {
589
+ const resOk = await resolveListenerIpc(tempDir);
590
+ expect(resOk).toEqual({ socketPath });
591
+
592
+ pingStatus = 500;
593
+ const resFail = await resolveListenerIpc(tempDir);
594
+ expect(resFail).toBeNull();
595
+ } finally {
596
+ await new Promise((resolve) => server.close(resolve as any));
597
+ }
598
+ });
599
+
600
+ it("sendCommandViaIpc sends command to IPC socket and handles success, failure, and bad JSON", async () => {
601
+ const socketPath = getIpcSocketPath(tempDir);
602
+ let responseStatus = 200;
603
+ let responseBody: string = JSON.stringify({ ok: true, data: "pong" });
604
+
605
+ const server = http.createServer((req, res) => {
606
+ if (req.method === "POST" && req.url === "/command") {
607
+ let body = "";
608
+ req.on("data", (c) => (body += c));
609
+ req.on("end", () => {
610
+ res.writeHead(responseStatus, { "Content-Type": "application/json" });
611
+ res.end(responseBody);
612
+ });
613
+ }
614
+ });
615
+
616
+ await new Promise((resolve) => server.listen(socketPath, resolve as any));
617
+ try {
618
+ // 1. Success
619
+ const res = await sendCommandViaIpc(
620
+ { socketPath },
621
+ "target-peer-1",
622
+ "ping",
623
+ { val: 1 },
624
+ 5000,
625
+ );
626
+ expect(res).toEqual({ ok: true, data: "pong" });
627
+
628
+ // 2. HTTP error with error message
629
+ responseStatus = 400;
630
+ responseBody = JSON.stringify({ error: "Invalid target peer" });
631
+ await expect(
632
+ sendCommandViaIpc(
633
+ { socketPath },
634
+ "target-peer-1",
635
+ "bad-action",
636
+ {},
637
+ 5000,
638
+ ),
639
+ ).rejects.toThrow("Invalid target peer");
640
+
641
+ // 3. Bad JSON
642
+ responseStatus = 200;
643
+ responseBody = "not valid json";
644
+ await expect(
645
+ sendCommandViaIpc({ socketPath }, "target-peer-1", "ping", {}, 5000),
646
+ ).rejects.toThrow("Bad IPC response");
647
+ } finally {
648
+ await new Promise((resolve) => server.close(resolve as any));
649
+ }
650
+ });
651
+ });
652
+
653
+ describe("CliWebRtcControlClient sendCommand and sendFile", () => {
654
+ let tempDir: string;
655
+
656
+ beforeEach(() => {
657
+ tempDir = path.join(
658
+ os.tmpdir(),
659
+ "test-webrtc-client-cmd-" +
660
+ Date.now() +
661
+ "-" +
662
+ Math.random().toString(36).slice(2),
663
+ );
664
+ fs.mkdirSync(tempDir, { recursive: true });
665
+ });
666
+
667
+ afterEach(() => {
668
+ try {
669
+ if (fs.existsSync(tempDir)) {
670
+ fs.rmSync(tempDir, { recursive: true, force: true });
671
+ }
672
+ } catch (_) {}
673
+ });
674
+
675
+ it("sendCommand uses IPC when listener socket is active", async () => {
676
+ const http = await import("node:http");
677
+ const socketPath = getIpcSocketPath(tempDir);
678
+ const server = http.createServer((req, res) => {
679
+ if (req.url === "/ping") {
680
+ res.writeHead(200);
681
+ res.end("ok");
682
+ return;
683
+ }
684
+ if (req.method === "POST" && req.url === "/command") {
685
+ res.writeHead(200, { "Content-Type": "application/json" });
686
+ res.end(JSON.stringify({ success: true, result: "ipc-done" }));
687
+ return;
688
+ }
689
+ });
690
+
691
+ await new Promise((resolve) => server.listen(socketPath, resolve as any));
692
+ try {
693
+ const client = new CliWebRtcControlClient({ cacheDir: tempDir });
694
+ const res = await client.sendCommand("target-1", "action-1", { a: 1 });
695
+ expect(res).toEqual({ success: true, result: "ipc-done" });
696
+ } finally {
697
+ await new Promise((resolve) => server.close(resolve as any));
698
+ }
699
+ });
700
+
701
+ it("sendCommand connects via WebRTC peer when IPC is inactive", async () => {
702
+ const client = new CliWebRtcControlClient({ cacheDir: tempDir });
703
+ const handlers: Record<string, Function> = {};
704
+ const mockConn = {
705
+ on: jest.fn((evt: string, fn: Function) => {
706
+ handlers[evt] = fn;
707
+ }),
708
+ send: jest.fn(),
709
+ close: jest.fn(),
710
+ };
711
+ const mockPeer = {
712
+ connect: jest.fn().mockReturnValue(mockConn),
713
+ destroy: jest.fn(),
714
+ };
715
+ (client as any)._getPeer = jest.fn().mockResolvedValue(mockPeer);
716
+
717
+ const promise = client.sendCommand("peer-x", "run", { arg: 123 });
718
+ for (let i = 0; i < 20 && !handlers["open"]; i++) {
719
+ await new Promise((r) => setTimeout(r, 10));
720
+ }
721
+ // Simulate open
722
+ handlers["open"]();
723
+ expect(mockConn.send).toHaveBeenCalledWith(
724
+ expect.objectContaining({
725
+ type: "command:execute",
726
+ payload: expect.objectContaining({ action: "run" }),
727
+ }),
728
+ );
729
+
730
+ // Simulate GetAgentCard message received
731
+ handlers["data"]({ method: "GetAgentCard", id: "card-msg-1" });
732
+ expect(mockConn.send).toHaveBeenCalledWith(
733
+ expect.objectContaining({
734
+ id: "card-msg-1",
735
+ result: expect.objectContaining({ name: "ShadowClaw CLI" }),
736
+ }),
737
+ );
738
+
739
+ // Simulate result data
740
+ handlers["data"]({
741
+ type: "command:result",
742
+ payload: { success: true, output: "finished" },
743
+ });
744
+
745
+ const res = await promise;
746
+ expect(res).toEqual({ success: true, output: "finished" });
747
+ expect(mockConn.close).toHaveBeenCalled();
748
+ });
749
+
750
+ it("sendCommand rejects when connection receives error or error payload", async () => {
751
+ const client = new CliWebRtcControlClient({ cacheDir: tempDir });
752
+ let handlers: Record<string, Function> = {};
753
+ const mockConn = {
754
+ on: jest.fn((evt: string, fn: Function) => {
755
+ handlers[evt] = fn;
756
+ }),
757
+ send: jest.fn(),
758
+ close: jest.fn(),
759
+ };
760
+ const mockPeer = {
761
+ connect: jest.fn().mockReturnValue(mockConn),
762
+ destroy: jest.fn(),
763
+ };
764
+ (client as any)._getPeer = jest.fn().mockResolvedValue(mockPeer);
765
+
766
+ // 1. Error payload
767
+ const p1 = client.sendCommand("peer-x", "fail-action", {});
768
+ for (let i = 0; i < 20 && !handlers["open"]; i++) {
769
+ await new Promise((r) => setTimeout(r, 10));
770
+ }
771
+ handlers["open"]();
772
+ handlers["data"]({
773
+ type: "command:result",
774
+ payload: { error: "Remote execution failed" },
775
+ });
776
+ await expect(p1).rejects.toThrow("Remote execution failed");
777
+
778
+ // 2. Connection error
779
+ handlers = {};
780
+ const p2 = client.sendCommand("peer-x", "err-action", {});
781
+ for (let i = 0; i < 20 && !handlers["error"]; i++) {
782
+ await new Promise((r) => setTimeout(r, 10));
783
+ }
784
+ handlers["error"](new Error("Peer disconnected abruptly"));
785
+ await expect(p2).rejects.toThrow(
786
+ "WebRTC connection error to peer 'peer-x': Peer disconnected abruptly",
787
+ );
788
+ });
789
+
790
+ it("sendFile validates file existence and directory before sending", async () => {
791
+ const client = new CliWebRtcControlClient({ cacheDir: tempDir });
792
+
793
+ // Non-existent file
794
+ await expect(
795
+ client.sendFile("peer-x", "/non/existent/file.bin"),
796
+ ).rejects.toThrow("File not found");
797
+
798
+ // Directory
799
+ await expect(client.sendFile("peer-x", tempDir)).rejects.toThrow(
800
+ "Cannot send directory",
801
+ );
802
+
803
+ // Valid file
804
+ const filePath = path.join(tempDir, "sample.txt");
805
+ fs.writeFileSync(filePath, "test content", "utf8");
806
+ client.sendCommand = jest.fn().mockResolvedValue({ success: true });
807
+
808
+ const res = await client.sendFile("peer-x", filePath, {
809
+ prompt: "Analyze this",
810
+ });
811
+ expect(res).toEqual({ success: true });
812
+ expect(client.sendCommand).toHaveBeenCalledWith(
813
+ "peer-x",
814
+ "send-file",
815
+ expect.objectContaining({
816
+ fileName: "sample.txt",
817
+ prompt: "Analyze this",
818
+ }),
819
+ 120000,
820
+ );
821
+
822
+ // client.close destroys peer if present
823
+ client.peer = { destroy: jest.fn() } as any;
824
+ client.close();
825
+ expect(client.peer).toBeNull();
826
+ });
827
+ });
828
+
829
+ describe("CliWebRtcListener lifecycle and RPC handling", () => {
830
+ let tempDir: string;
831
+
832
+ beforeEach(() => {
833
+ tempDir = path.join(
834
+ os.tmpdir(),
835
+ "test-webrtc-listener-adv-" +
836
+ Date.now() +
837
+ "-" +
838
+ Math.random().toString(36).slice(2),
839
+ );
840
+ });
841
+
842
+ afterEach(() => {
843
+ try {
844
+ if (fs.existsSync(tempDir)) {
845
+ fs.rmSync(tempDir, { recursive: true, force: true });
846
+ }
847
+ } catch (_) {}
848
+ });
849
+
850
+ it("_scheduleReconnect handles running and non-running states", () => {
851
+ const listener = new CliWebRtcListener({ cacheDir: tempDir });
852
+ (listener as any)._running = false;
853
+ (listener as any)._scheduleReconnect();
854
+ expect((listener as any)._reconnectTimer).toBeNull();
855
+
856
+ (listener as any)._running = true;
857
+ (listener as any)._scheduleReconnect();
858
+ expect((listener as any)._reconnectTimer).not.toBeNull();
859
+ expect((listener as any)._reconnectAttempts).toBe(1);
860
+
861
+ // Calling again while timer exists does not reschedule
862
+ (listener as any)._scheduleReconnect();
863
+ expect((listener as any)._reconnectAttempts).toBe(1);
864
+
865
+ // cleanup
866
+ listener.close();
867
+ expect((listener as any)._reconnectTimer).toBeNull();
868
+ expect((listener as any)._running).toBe(false);
869
+ });
870
+
871
+ it("_setupConnection handles SendMessage A2A RPC with send-message and prompt handlers", async () => {
872
+ const listener = new CliWebRtcListener({
873
+ cacheDir: tempDir,
874
+ handlers: {
875
+ "send-message": async (args: any) => {
876
+ return { reply: `Echo: ${args.text}` };
877
+ },
878
+ },
879
+ });
880
+
881
+ const handlers: Record<string, Function> = {};
882
+ const mockConn = {
883
+ on: jest.fn((evt: string, fn: Function) => {
884
+ handlers[evt] = fn;
885
+ }),
886
+ send: jest.fn(),
887
+ close: jest.fn(),
888
+ };
889
+
890
+ listener._setupConnection(mockConn, "remote-a2a-peer");
891
+
892
+ // SendMessage request
893
+ await handlers["data"]({
894
+ jsonrpc: "2.0",
895
+ id: "a2a-msg-1",
896
+ method: "SendMessage",
897
+ params: {
898
+ message: {
899
+ parts: [{ text: "Hello A2A" }],
900
+ },
901
+ contextId: "ctx-1",
902
+ },
903
+ });
904
+
905
+ expect(mockConn.send).toHaveBeenCalledWith(
906
+ expect.objectContaining({
907
+ id: "a2a-msg-1",
908
+ result: expect.objectContaining({
909
+ role: "ROLE_AGENT",
910
+ parts: [{ text: "Echo: Hello A2A" }],
911
+ }),
912
+ }),
913
+ );
914
+
915
+ // SendMessage failure handling
916
+ listener.handlers["send-message"] = async () => {
917
+ throw new Error("RPC handling crashed");
918
+ };
919
+
920
+ await handlers["data"]({
921
+ jsonrpc: "2.0",
922
+ id: "a2a-msg-2",
923
+ method: "SendMessage",
924
+ params: {
925
+ message: {
926
+ parts: [{ text: "Break it" }],
927
+ },
928
+ },
929
+ });
930
+
931
+ expect(mockConn.send).toHaveBeenCalledWith(
932
+ expect.objectContaining({
933
+ id: "a2a-msg-2",
934
+ error: expect.objectContaining({
935
+ code: -32603,
936
+ message: "RPC handling crashed",
937
+ }),
938
+ }),
939
+ );
940
+ });
941
+
942
+ it("_setupConnection handles command:execute messages", async () => {
943
+ const listener = new CliWebRtcListener({
944
+ cacheDir: tempDir,
945
+ handlers: {
946
+ hello: async () => ({ msg: "world" }),
947
+ },
948
+ });
949
+ const handlers: Record<string, Function> = {};
950
+ const mockConn = {
951
+ on: jest.fn((evt: string, fn: Function) => {
952
+ handlers[evt] = fn;
953
+ }),
954
+ send: jest.fn(),
955
+ close: jest.fn(),
956
+ };
957
+ listener._setupConnection(mockConn, "remote-cmd-peer");
958
+ await handlers["data"]({
959
+ type: "command:execute",
960
+ payload: { commandId: "cmd-1", action: "hello", args: {} },
961
+ });
962
+ expect(mockConn.send).toHaveBeenCalledWith(
963
+ expect.objectContaining({
964
+ type: "command:result",
965
+ payload: expect.objectContaining({
966
+ success: true,
967
+ data: { msg: "world" },
968
+ }),
969
+ }),
970
+ );
971
+ });
972
+
973
+ it("close() closes active connections, clears state and destroys peer", () => {
974
+ const listener = new CliWebRtcListener({ cacheDir: tempDir });
975
+ const mockConn1 = { close: jest.fn() };
976
+ const mockConn2 = { close: jest.fn() };
977
+ const mockPeer = { destroy: jest.fn() };
978
+
979
+ (listener as any)._connections.set("p1", mockConn1);
980
+ (listener as any)._connections.set("p2", mockConn2);
981
+ (listener as any)._peerCards.set("p1", { name: "Card 1" });
982
+ (listener as any)._peer = mockPeer;
983
+ (listener as any)._running = true;
984
+
985
+ listener.close();
986
+
987
+ expect(mockConn1.close).toHaveBeenCalled();
988
+ expect(mockConn2.close).toHaveBeenCalled();
989
+ expect(mockPeer.destroy).toHaveBeenCalled();
990
+ expect((listener as any)._connections.size).toBe(0);
991
+ expect((listener as any)._peerCards.size).toBe(0);
992
+ expect((listener as any)._peer).toBeNull();
993
+ expect((listener as any)._running).toBe(false);
994
+ });
426
995
  });