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.
- package/.well-known/mcp/server-card.json +1 -1
- package/.well-known/mcp.json +1 -1
- package/README.md +9 -9
- package/dist/cli/build/build.js +1 -1
- package/dist/cli/{chunk-DYq-orHK.js → chunk-BgTv9w0K.js} +1 -1
- package/dist/cli/{chunk-C6jii2Fl.js → chunk-d5hIhj8t.js} +1 -1
- package/dist/cli/cli.js +1 -1
- package/dist/lib/components/index.js +1 -1
- package/dist/lib/components/shadow-claw-pages.js +1 -1
- package/dist/lib/index.js +1 -1
- package/dist/lib/{shadow-claw-pages-kA2qa-4B.js → shadow-claw-pages-CjHS50HN.js} +2 -2
- package/dist/lib/{shadow-claw-pages-kA2qa-4B.js.map → shadow-claw-pages-CjHS50HN.js.map} +1 -1
- package/dist/public/.well-known/mcp/server-card.json +1 -1
- package/dist/public/.well-known/mcp.json +1 -1
- package/dist/public/AGENTS.md +1 -1
- package/dist/public/README.md +9 -9
- package/dist/public/docs/README.md +1 -1
- package/dist/public/docs/architecture/orchestrator.md +2 -2
- package/dist/public/docs/architecture/overview.md +3 -3
- package/dist/public/docs/architecture/worker-protocol.md +29 -29
- package/dist/public/docs/example/article/index.html +1 -1
- package/dist/public/docs/example/article.html +1 -1
- package/dist/public/docs/publishing/index.html +1 -1
- package/dist/public/docs/publishing.html +1 -1
- package/dist/public/docs/skill-creator/index.html +1 -1
- package/dist/public/docs/skill-creator.html +1 -1
- package/dist/public/docs/subsystems/local-models.md +1 -1
- package/dist/public/docs/subsystems/providers.md +11 -4
- package/dist/public/e2e/README.md +2 -2
- package/dist/public/index.html +1 -1
- package/dist/public/index.js +1 -1
- package/dist/public/llms.txt +1 -1
- package/dist/public/main/index.html +1 -1
- package/dist/public/main/memory/index.html +1 -1
- package/dist/public/main/memory.html +1 -1
- package/dist/public/main.html +1 -1
- package/dist/public/service-worker.js +1 -1
- package/dist/public/{shadow-claw-Ba2ZFE9m.js → shadow-claw-T9oPpqxT.js} +1 -1
- package/dist/public/{shadow-claw-pages-Cy6T0G9y.js → shadow-claw-pages-IW_LNEL_.js} +1 -1
- package/package.json +1 -1
- package/src/cli/build/build.test.ts +95 -0
- package/src/cli/build/build.ts +5 -1
- package/src/cli/commands/agent.test.ts +134 -0
- package/src/cli/commands/backup.test.ts +85 -0
- package/src/cli/commands/clients.test.ts +32 -0
- package/src/cli/commands/mcp.test.ts +260 -1
- package/src/cli/commands/send-file.test.ts +73 -0
- package/src/cli/commands/send.test.ts +58 -0
- package/src/cli/commands/tasks.test.ts +78 -0
- package/src/cli/commands/webrtc-listen.test.ts +74 -0
- package/src/cli/tools/assert-version-bump.test.ts +30 -1
- package/src/cli/tools/file-search-replace.test.ts +33 -1
- package/src/cli/tools/patch-service-worker-trusted-types.test.ts +24 -1
- package/src/cli/tools/touch-nojekyll.test.ts +12 -0
- package/src/cli/tools/version.test.ts +109 -5
- package/src/cli/utils/agent-core.test.ts +40 -0
- package/src/cli/utils/control-client.test.ts +239 -2
- package/src/cli/utils/load-workspace-config.test.ts +63 -0
- package/src/cli/utils/local-models.test.ts +98 -0
- package/src/cli/utils/progress-bar.test.ts +41 -2
- package/src/cli/utils/resolve-cache-dir.test.ts +61 -0
- package/src/cli/utils/resolve-project-root.test.ts +17 -0
- package/src/cli/utils/stdin.test.ts +38 -0
- package/src/cli/utils/stdio.test.ts +51 -1
- package/src/cli/utils/suppress-warnings.test.ts +9 -0
- package/src/cli/utils/webrtc-control-client.test.ts +569 -0
- package/src/common/utils/frontmatter.test.ts +117 -0
- package/src/components/common/help/providers.test.ts +67 -0
- package/src/components/common/shadow-claw-provider-model-picker/shadow-claw-provider-model-picker.test.ts +121 -0
- package/src/components/settings/shadow-claw-accounts/shadow-claw-accounts.test.ts +131 -0
- package/src/components/settings/shadow-claw-git/shadow-claw-git.test.ts +130 -0
- package/src/components/settings/shadow-claw-integrations/connection-test-auth.test.ts +12 -0
- package/src/components/settings/shadow-claw-integrations/shadow-claw-integrations.test.ts +328 -0
- package/src/components/settings/shadow-claw-llm/shadow-claw-llm.test.ts +530 -8
- package/src/components/settings/shadow-claw-mcp-remote/shadow-claw-mcp-remote.test.ts +459 -0
- package/src/components/settings/shadow-claw-networking/shadow-claw-networking.test.ts +88 -0
- package/src/components/settings/shadow-claw-notifications/shadow-claw-notifications.test.ts +92 -0
- package/src/components/settings/shadow-claw-peerjs/shadow-claw-peerjs.test.ts +127 -0
- package/src/components/settings/shadow-claw-storage/shadow-claw-storage.test.ts +173 -0
- package/src/components/settings/shadow-claw-task-server/shadow-claw-task-server.test.ts +62 -0
- package/src/components/settings/shadow-claw-webvm/shadow-claw-webvm.test.ts +104 -0
- package/src/components/shadow-claw/shadow-claw.test.ts +112 -0
- package/src/components/shadow-claw-a2ui/shadow-claw-a2ui.test.ts +109 -0
- package/src/components/shadow-claw-conversations/shadow-claw-conversations.test.ts +361 -0
- package/src/components/shadow-claw-dialog/shadow-claw-dialog.test.ts +76 -0
- package/src/components/shadow-claw-file-viewer/shadow-claw-file-viewer.test.ts +307 -0
- package/src/components/shadow-claw-files/shadow-claw-files.test.ts +398 -1
- package/src/components/shadow-claw-pages/shadow-claw-pages.test.ts +85 -0
- package/src/components/shadow-claw-pages/shadow-claw-pages.ts +2 -1
- package/src/components/shadow-claw-pdf-viewer/shadow-claw-pdf-viewer.test.ts +229 -0
- package/src/components/shadow-claw-settings/shadow-claw-settings.test.ts +233 -0
- package/src/components/shadow-claw-tasks/shadow-claw-tasks.test.ts +223 -1
- package/src/components/shadow-claw-toast/shadow-claw-toast.test.ts +79 -0
- package/src/components/shadow-claw-tools/shadow-claw-tools.test.ts +229 -1
- package/src/server/logger.test.ts +57 -0
- package/src/server/peer.test.ts +47 -0
- package/src/server/routes/gemini.test.ts +244 -0
- package/src/server/server-peer.test.ts +245 -11
- package/src/server/services/llamafile-manager.test.ts +668 -0
- package/src/share-target/pending-shares.test.ts +90 -0
- package/src/shell/fs.test.ts +125 -0
- package/src/shell/shell.test.ts +8 -0
- package/src/subsystems/git/credentials.test.ts +260 -0
- package/src/subsystems/git/git.test.ts +64 -0
- package/src/subsystems/providers/executeNativeAiTask.test.ts +78 -0
- package/src/subsystems/providers/utils/parseLocalModelToolCall.test.ts +57 -0
- package/src/worker/tools/builtin-ai/builtin-ai.test.ts +88 -0
- package/src/worker/tools/email/email.test.ts +358 -0
- package/src/worker/tools/email/utils/resolveIntegrationEmailAuth.test.ts +120 -0
- package/src/worker/tools/email/utils/resolveUniqueAttachmentPath.test.ts +80 -0
- package/src/worker/tools/fetch-file/fetch-file.test.ts +111 -0
- 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
|
});
|