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
|
@@ -361,4 +361,99 @@ describe("build without and with pages", () => {
|
|
|
361
361
|
await rm(tempConsumerRoot, { recursive: true, force: true });
|
|
362
362
|
}
|
|
363
363
|
});
|
|
364
|
+
|
|
365
|
+
it("builds an external consumer with copyAllAssets, pagesOrigin, scripts, and service worker", async () => {
|
|
366
|
+
const tempConsumerRoot = await mkdtemp(
|
|
367
|
+
path.join(os.tmpdir(), "shadow-claw-consumer-prod-"),
|
|
368
|
+
);
|
|
369
|
+
const logSpy = jest.spyOn(console, "log").mockImplementation(() => {});
|
|
370
|
+
try {
|
|
371
|
+
await mkdir(path.join(tempConsumerRoot, "assets/icons"), {
|
|
372
|
+
recursive: true,
|
|
373
|
+
});
|
|
374
|
+
await mkdir(path.join(tempConsumerRoot, "assets/fonts"), {
|
|
375
|
+
recursive: true,
|
|
376
|
+
});
|
|
377
|
+
await writeFile(
|
|
378
|
+
path.join(tempConsumerRoot, "assets/icons/icon.png"),
|
|
379
|
+
"icon",
|
|
380
|
+
);
|
|
381
|
+
await writeFile(
|
|
382
|
+
path.join(tempConsumerRoot, "assets/fonts/font.woff2"),
|
|
383
|
+
"font",
|
|
384
|
+
);
|
|
385
|
+
await mkdir(path.join(tempConsumerRoot, ".agents/scripts"), {
|
|
386
|
+
recursive: true,
|
|
387
|
+
});
|
|
388
|
+
await writeFile(
|
|
389
|
+
path.join(tempConsumerRoot, ".agents/scripts/run.sh"),
|
|
390
|
+
"echo hi",
|
|
391
|
+
);
|
|
392
|
+
await writeFile(
|
|
393
|
+
path.join(tempConsumerRoot, "manifest.json"),
|
|
394
|
+
JSON.stringify({ start_url: "/initial" }),
|
|
395
|
+
);
|
|
396
|
+
|
|
397
|
+
// Create a dummy service-worker.js in tempProjectRoot/dist/public if missing
|
|
398
|
+
const swPath = path.join(
|
|
399
|
+
tempProjectRoot,
|
|
400
|
+
"dist/public/service-worker.js",
|
|
401
|
+
);
|
|
402
|
+
if (!fs.existsSync(swPath)) {
|
|
403
|
+
await writeFile(swPath, "// sw dummy\nimportScripts();");
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
await runConsumerBuild({
|
|
407
|
+
contentRoot: tempConsumerRoot,
|
|
408
|
+
toolchainRoot: tempProjectRoot,
|
|
409
|
+
isProduction: true,
|
|
410
|
+
copyAllAssets: true,
|
|
411
|
+
pagesOrigin: "https://consumer.example.com",
|
|
412
|
+
quiet: false,
|
|
413
|
+
stdio: "pipe",
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
const distDir = path.join(tempConsumerRoot, "dist/public");
|
|
417
|
+
expect(fs.existsSync(path.join(distDir, ".nojekyll"))).toBe(true);
|
|
418
|
+
expect(fs.existsSync(path.join(distDir, ".agents/scripts/run.sh"))).toBe(
|
|
419
|
+
true,
|
|
420
|
+
);
|
|
421
|
+
|
|
422
|
+
const manifest = JSON.parse(
|
|
423
|
+
await readFile(path.join(distDir, "manifest.json"), "utf8"),
|
|
424
|
+
);
|
|
425
|
+
expect(manifest.start_url).toBe("https://consumer.example.com");
|
|
426
|
+
} finally {
|
|
427
|
+
logSpy.mockRestore();
|
|
428
|
+
await rm(tempConsumerRoot, { recursive: true, force: true });
|
|
429
|
+
}
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
it("builds in-repo with isProduction, copyAllAssets, and prerenderMainMemory false", async () => {
|
|
433
|
+
const logSpy = jest.spyOn(console, "log").mockImplementation(() => {});
|
|
434
|
+
try {
|
|
435
|
+
await runConsumerBuild({
|
|
436
|
+
contentRoot: projectRoot,
|
|
437
|
+
toolchainRoot: projectRoot,
|
|
438
|
+
isProduction: true,
|
|
439
|
+
copyAllAssets: true,
|
|
440
|
+
prerenderMainMemory: false,
|
|
441
|
+
pagesOrigin: "https://shadow-claw.example.com/",
|
|
442
|
+
basePath: "/shadow-claw/",
|
|
443
|
+
quiet: false,
|
|
444
|
+
stdio: "pipe",
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
const indexHtml = await readFile(
|
|
448
|
+
path.join(projectRoot, "dist/public/index.html"),
|
|
449
|
+
"utf8",
|
|
450
|
+
);
|
|
451
|
+
expect(indexHtml).toContain('<base href="/shadow-claw/"');
|
|
452
|
+
expect(
|
|
453
|
+
fs.existsSync(path.join(projectRoot, "dist/public/.nojekyll")),
|
|
454
|
+
).toBe(true);
|
|
455
|
+
} finally {
|
|
456
|
+
logSpy.mockRestore();
|
|
457
|
+
}
|
|
458
|
+
});
|
|
364
459
|
});
|
package/src/cli/build/build.ts
CHANGED
|
@@ -341,7 +341,11 @@ export async function runBuild(options: RunBuildOptions = {}): Promise<void> {
|
|
|
341
341
|
src.includes("/assets/cache/") ||
|
|
342
342
|
src.includes("\\assets\\cache\\") ||
|
|
343
343
|
src.includes("/.cache/") ||
|
|
344
|
-
src.includes("\\.cache\\")
|
|
344
|
+
src.includes("\\.cache\\") ||
|
|
345
|
+
src.includes("/cache/") ||
|
|
346
|
+
src.includes("\\cache\\") ||
|
|
347
|
+
src.endsWith("/cache") ||
|
|
348
|
+
src.endsWith("\\cache");
|
|
345
349
|
return !isCache;
|
|
346
350
|
},
|
|
347
351
|
});
|
|
@@ -3267,4 +3267,138 @@ Fetch today's weather.
|
|
|
3267
3267
|
expect(calledOpts.model).toBe("test-model");
|
|
3268
3268
|
expect(calledOpts.agent).toBe(true);
|
|
3269
3269
|
});
|
|
3270
|
+
|
|
3271
|
+
it("prints usage when unknown subcommand passed to runAgentCommand", async () => {
|
|
3272
|
+
const { runAgentCommand } = await import("./agent.js");
|
|
3273
|
+
const logSpy = jest.spyOn(console, "log").mockImplementation(() => {});
|
|
3274
|
+
|
|
3275
|
+
await runAgentCommand("unknown-command", [], {});
|
|
3276
|
+
|
|
3277
|
+
expect(logSpy).toHaveBeenCalledWith(
|
|
3278
|
+
expect.stringContaining("Usage: shadow-claw agent"),
|
|
3279
|
+
);
|
|
3280
|
+
logSpy.mockRestore();
|
|
3281
|
+
});
|
|
3282
|
+
|
|
3283
|
+
it("handles skill subcommand without name", async () => {
|
|
3284
|
+
const { runAgentCommand } = await import("./agent.js");
|
|
3285
|
+
const errSpy = jest.spyOn(console, "error").mockImplementation(() => {});
|
|
3286
|
+
|
|
3287
|
+
await runAgentCommand("skill", [], {});
|
|
3288
|
+
|
|
3289
|
+
expect(errSpy).toHaveBeenCalledWith(
|
|
3290
|
+
expect.stringContaining("Skill name required"),
|
|
3291
|
+
);
|
|
3292
|
+
expect(process.exitCode).toBe(1);
|
|
3293
|
+
errSpy.mockRestore();
|
|
3294
|
+
});
|
|
3295
|
+
|
|
3296
|
+
it("executes default listenFn dynamically importing webrtc-listen", async () => {
|
|
3297
|
+
const { runAgentCommand } = await import("./agent.js");
|
|
3298
|
+
const { CliWebRtcListener } =
|
|
3299
|
+
await import("../utils/webrtc-control-client.js");
|
|
3300
|
+
jest
|
|
3301
|
+
.spyOn(CliWebRtcListener.prototype, "start")
|
|
3302
|
+
.mockRejectedValueOnce(new Error("Listen stop"));
|
|
3303
|
+
const errSpy = jest.spyOn(console, "error").mockImplementation(() => {});
|
|
3304
|
+
|
|
3305
|
+
await runAgentCommand("listen", [], { workspace: tmpDir });
|
|
3306
|
+
|
|
3307
|
+
expect(errSpy).toHaveBeenCalledWith(
|
|
3308
|
+
expect.stringContaining("Error starting WebRTC listener: Listen stop"),
|
|
3309
|
+
);
|
|
3310
|
+
errSpy.mockRestore();
|
|
3311
|
+
});
|
|
3312
|
+
|
|
3313
|
+
it("dispatches init, skills, tools, tool, clear, and models via runAgentCommand", async () => {
|
|
3314
|
+
const { runAgentCommand } = await import("./agent.js");
|
|
3315
|
+
const logSpy = jest.spyOn(console, "log").mockImplementation(() => {});
|
|
3316
|
+
const errSpy = jest.spyOn(console, "error").mockImplementation(() => {});
|
|
3317
|
+
|
|
3318
|
+
// init
|
|
3319
|
+
const initRes = await runAgentCommand("init", [], { workspace: tmpDir });
|
|
3320
|
+
expect(initRes.success).toBe(true);
|
|
3321
|
+
|
|
3322
|
+
// skills (with a skill present)
|
|
3323
|
+
const dummySkillDir = path.join(tmpDir, ".agents/skills/dummy");
|
|
3324
|
+
await mkdir(dummySkillDir, { recursive: true });
|
|
3325
|
+
await writeFile(
|
|
3326
|
+
path.join(dummySkillDir, "SKILL.md"),
|
|
3327
|
+
"---\nname: dummy\ndescription: Test skill description\n---\nBody",
|
|
3328
|
+
"utf8",
|
|
3329
|
+
);
|
|
3330
|
+
const skillsRes = await runAgentCommand("skills", [], {
|
|
3331
|
+
workspace: tmpDir,
|
|
3332
|
+
});
|
|
3333
|
+
expect(skillsRes.skills.length).toBeGreaterThan(0);
|
|
3334
|
+
|
|
3335
|
+
// tools (list)
|
|
3336
|
+
const toolsRes = await runAgentCommand("tools", [], { workspace: tmpDir });
|
|
3337
|
+
expect(toolsRes.tools).toBeDefined();
|
|
3338
|
+
|
|
3339
|
+
// tool (missing name)
|
|
3340
|
+
await runAgentCommand("tool", [], { workspace: tmpDir });
|
|
3341
|
+
expect(errSpy).toHaveBeenCalledWith(
|
|
3342
|
+
expect.stringContaining("Tool name required"),
|
|
3343
|
+
);
|
|
3344
|
+
|
|
3345
|
+
// clear
|
|
3346
|
+
const clearRes = await runAgentCommand("clear", [], { workspace: tmpDir });
|
|
3347
|
+
expect(clearRes.success).toBe(true);
|
|
3348
|
+
|
|
3349
|
+
// model list
|
|
3350
|
+
const modelRes = await runAgentCommand("models", ["list"], {
|
|
3351
|
+
workspace: tmpDir,
|
|
3352
|
+
quiet: true,
|
|
3353
|
+
});
|
|
3354
|
+
expect(modelRes.success).toBe(true);
|
|
3355
|
+
|
|
3356
|
+
// tools with toolName
|
|
3357
|
+
const toolCallRes = await runAgentCommand(
|
|
3358
|
+
"tools",
|
|
3359
|
+
["bash", '{"command":"echo hi"}'],
|
|
3360
|
+
{ workspace: tmpDir, quiet: true },
|
|
3361
|
+
);
|
|
3362
|
+
expect(toolCallRes).toBeDefined();
|
|
3363
|
+
|
|
3364
|
+
// skill with skillName
|
|
3365
|
+
await expect(
|
|
3366
|
+
runAgentCommand("skill", ["non-existent-skill"], { workspace: tmpDir }),
|
|
3367
|
+
).rejects.toThrow("not found in workspace");
|
|
3368
|
+
|
|
3369
|
+
// run with prompt
|
|
3370
|
+
const { getAgentCore } = await import("../utils/agent-core.js");
|
|
3371
|
+
const core = await getAgentCore();
|
|
3372
|
+
const runRes = await runAgentCommand("run", ["Hello agent"], {
|
|
3373
|
+
workspace: tmpDir,
|
|
3374
|
+
provider: "openrouter",
|
|
3375
|
+
apiKey: "sk-test",
|
|
3376
|
+
invokeHandler: async (_db, payload) => {
|
|
3377
|
+
core.post({
|
|
3378
|
+
type: "response",
|
|
3379
|
+
payload: { groupId: payload.groupId, text: "Ready." },
|
|
3380
|
+
});
|
|
3381
|
+
},
|
|
3382
|
+
quiet: true,
|
|
3383
|
+
});
|
|
3384
|
+
expect(runRes.success).toBe(true);
|
|
3385
|
+
|
|
3386
|
+
// run with empty prompt
|
|
3387
|
+
const runEmptyRes = await runAgentCommand("run", [], {
|
|
3388
|
+
workspace: tmpDir,
|
|
3389
|
+
provider: "openrouter",
|
|
3390
|
+
apiKey: "sk-test",
|
|
3391
|
+
invokeHandler: async (_db, payload) => {
|
|
3392
|
+
core.post({
|
|
3393
|
+
type: "response",
|
|
3394
|
+
payload: { groupId: payload.groupId, text: "Ready." },
|
|
3395
|
+
});
|
|
3396
|
+
},
|
|
3397
|
+
quiet: true,
|
|
3398
|
+
});
|
|
3399
|
+
expect(runEmptyRes.success).toBe(false);
|
|
3400
|
+
|
|
3401
|
+
logSpy.mockRestore();
|
|
3402
|
+
errSpy.mockRestore();
|
|
3403
|
+
});
|
|
3270
3404
|
});
|
|
@@ -22,6 +22,7 @@ describe("runBackupCommand", () => {
|
|
|
22
22
|
afterEach(() => {
|
|
23
23
|
logSpy.mockRestore();
|
|
24
24
|
errorSpy.mockRestore();
|
|
25
|
+
jest.restoreAllMocks();
|
|
25
26
|
process.exitCode = 0;
|
|
26
27
|
});
|
|
27
28
|
|
|
@@ -64,4 +65,88 @@ describe("runBackupCommand", () => {
|
|
|
64
65
|
);
|
|
65
66
|
expect(process.exitCode).toBe(1);
|
|
66
67
|
});
|
|
68
|
+
|
|
69
|
+
it("deletes backup when backupId is provided", async () => {
|
|
70
|
+
const deleteSpy = jest
|
|
71
|
+
.spyOn(CliControlClient.prototype, "deleteBackup")
|
|
72
|
+
.mockResolvedValueOnce(undefined as any);
|
|
73
|
+
|
|
74
|
+
await runBackupCommand("delete", {
|
|
75
|
+
backupId: "bk-123",
|
|
76
|
+
client: "client-1",
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
expect(deleteSpy).toHaveBeenCalledWith("bk-123", "client-1");
|
|
80
|
+
expect(logSpy).toHaveBeenCalledWith("Backup bk-123 deleted.");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("triggers backup with provided client successfully", async () => {
|
|
84
|
+
const sendSpy = jest
|
|
85
|
+
.spyOn(CliControlClient.prototype, "sendCommand")
|
|
86
|
+
.mockResolvedValueOnce({ success: true, data: { fileCount: 10 } });
|
|
87
|
+
|
|
88
|
+
await runBackupCommand("trigger", {
|
|
89
|
+
client: "client-target",
|
|
90
|
+
group: "grp-1",
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
expect(sendSpy).toHaveBeenCalledWith("client-target", "trigger-backup", {
|
|
94
|
+
groupId: "grp-1",
|
|
95
|
+
token: expect.anything(),
|
|
96
|
+
});
|
|
97
|
+
expect(logSpy).toHaveBeenCalledWith("Backup completed successfully.");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("triggers backup falling back to first client from listClients", async () => {
|
|
101
|
+
jest
|
|
102
|
+
.spyOn(CliControlClient.prototype, "listClients")
|
|
103
|
+
.mockResolvedValueOnce([{ clientId: "auto-client" }]);
|
|
104
|
+
const sendSpy = jest
|
|
105
|
+
.spyOn(CliControlClient.prototype, "sendCommand")
|
|
106
|
+
.mockResolvedValueOnce({ success: true, data: {} });
|
|
107
|
+
|
|
108
|
+
await runBackupCommand("trigger", {});
|
|
109
|
+
|
|
110
|
+
expect(sendSpy).toHaveBeenCalledWith("auto-client", "trigger-backup", {
|
|
111
|
+
groupId: undefined,
|
|
112
|
+
token: expect.anything(),
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("errors when triggering backup and no clients are connected", async () => {
|
|
117
|
+
jest
|
|
118
|
+
.spyOn(CliControlClient.prototype, "listClients")
|
|
119
|
+
.mockResolvedValueOnce([]);
|
|
120
|
+
|
|
121
|
+
await runBackupCommand("trigger", {});
|
|
122
|
+
|
|
123
|
+
expect(errorSpy).toHaveBeenCalledWith(
|
|
124
|
+
"Error: No clients are connected to back up.",
|
|
125
|
+
);
|
|
126
|
+
expect(process.exitCode).toBe(1);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("handles backup failure response", async () => {
|
|
130
|
+
jest
|
|
131
|
+
.spyOn(CliControlClient.prototype, "sendCommand")
|
|
132
|
+
.mockResolvedValueOnce({ success: false, error: "Disk full" });
|
|
133
|
+
|
|
134
|
+
await runBackupCommand("trigger", { client: "client-fail" });
|
|
135
|
+
|
|
136
|
+
expect(errorSpy).toHaveBeenCalledWith("Backup failed: Disk full");
|
|
137
|
+
expect(process.exitCode).toBe(1);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("handles exception thrown during execution", async () => {
|
|
141
|
+
jest
|
|
142
|
+
.spyOn(CliControlClient.prototype, "listBackups")
|
|
143
|
+
.mockRejectedValueOnce(new Error("Network timeout"));
|
|
144
|
+
|
|
145
|
+
await runBackupCommand("list", {});
|
|
146
|
+
|
|
147
|
+
expect(errorSpy).toHaveBeenCalledWith(
|
|
148
|
+
"Error executing backup command: Network timeout",
|
|
149
|
+
);
|
|
150
|
+
expect(process.exitCode).toBe(1);
|
|
151
|
+
});
|
|
67
152
|
});
|
|
@@ -57,4 +57,36 @@ describe("runClientsCommand", () => {
|
|
|
57
57
|
"No clients currently registered or connected.",
|
|
58
58
|
);
|
|
59
59
|
});
|
|
60
|
+
|
|
61
|
+
it("formats client without peerId, empty capabilities, and older lastSeen", async () => {
|
|
62
|
+
jest
|
|
63
|
+
.spyOn(CliControlClient.prototype, "listClients")
|
|
64
|
+
.mockResolvedValueOnce([
|
|
65
|
+
{
|
|
66
|
+
clientId: "c-old",
|
|
67
|
+
deviceLabel: "Headless Agent",
|
|
68
|
+
capabilities: [],
|
|
69
|
+
version: "2.0.0",
|
|
70
|
+
lastSeen: Date.now() - 125000,
|
|
71
|
+
},
|
|
72
|
+
]);
|
|
73
|
+
|
|
74
|
+
await runClientsCommand({});
|
|
75
|
+
|
|
76
|
+
expect(logSpy).toHaveBeenCalledWith(" Capabilities: none");
|
|
77
|
+
expect(logSpy).toHaveBeenCalledWith(" Last seen: 2m ago\n");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("handles exception during fetch", async () => {
|
|
81
|
+
jest
|
|
82
|
+
.spyOn(CliControlClient.prototype, "listClients")
|
|
83
|
+
.mockRejectedValueOnce(new Error("Connection refused"));
|
|
84
|
+
|
|
85
|
+
await runClientsCommand({});
|
|
86
|
+
|
|
87
|
+
expect(errorSpy).toHaveBeenCalledWith(
|
|
88
|
+
"Error fetching clients: Connection refused",
|
|
89
|
+
);
|
|
90
|
+
expect(process.exitCode).toBe(1);
|
|
91
|
+
});
|
|
60
92
|
});
|
|
@@ -11,7 +11,9 @@ import {
|
|
|
11
11
|
beforeEach,
|
|
12
12
|
afterEach,
|
|
13
13
|
} from "@jest/globals";
|
|
14
|
-
import
|
|
14
|
+
import http from "node:http";
|
|
15
|
+
import readline from "node:readline";
|
|
16
|
+
import { createCliMcpEngine, runMcpCommand } from "./mcp.js";
|
|
15
17
|
|
|
16
18
|
const pkg = JSON.parse(
|
|
17
19
|
fs.readFileSync(
|
|
@@ -1086,5 +1088,262 @@ describe("CLI MCP Engine (createCliMcpEngine)", () => {
|
|
|
1086
1088
|
expect(toolNames).not.toContain("shadowclaw_local_write_file");
|
|
1087
1089
|
expect(toolNames).not.toContain("shadowclaw_local_bash");
|
|
1088
1090
|
});
|
|
1091
|
+
|
|
1092
|
+
it("supports setting and getting activeClientId", () => {
|
|
1093
|
+
const engine = createCliMcpEngine({});
|
|
1094
|
+
expect(engine.getActiveClientId()).toBe("");
|
|
1095
|
+
engine.setActiveClientId?.("client-custom-1");
|
|
1096
|
+
expect(engine.getActiveClientId()).toBe("client-custom-1");
|
|
1097
|
+
});
|
|
1098
|
+
|
|
1099
|
+
it("handles tasks/get method", async () => {
|
|
1100
|
+
const engine = createCliMcpEngine({});
|
|
1101
|
+
const res = await engine.handleMessage({
|
|
1102
|
+
jsonrpc: "2.0",
|
|
1103
|
+
id: 50,
|
|
1104
|
+
method: "tasks/get",
|
|
1105
|
+
params: { taskId: "task-test-99" },
|
|
1106
|
+
});
|
|
1107
|
+
expect(res.result.task.taskId).toBe("task-test-99");
|
|
1108
|
+
expect(res.result.task.status).toBe("completed");
|
|
1109
|
+
});
|
|
1110
|
+
|
|
1111
|
+
it("handles unknown method returning error or null for notification", async () => {
|
|
1112
|
+
const engine = createCliMcpEngine({});
|
|
1113
|
+
const errRes = await engine.handleMessage({
|
|
1114
|
+
jsonrpc: "2.0",
|
|
1115
|
+
id: 51,
|
|
1116
|
+
method: "unknown/method",
|
|
1117
|
+
});
|
|
1118
|
+
expect(errRes.error.code).toBe(-32601);
|
|
1119
|
+
|
|
1120
|
+
const notifRes = await engine.handleMessage({
|
|
1121
|
+
jsonrpc: "2.0",
|
|
1122
|
+
method: "unknown/notification",
|
|
1123
|
+
});
|
|
1124
|
+
expect(notifRes).toBeNull();
|
|
1125
|
+
});
|
|
1126
|
+
|
|
1127
|
+
it("handles tools/call when no client is available", async () => {
|
|
1128
|
+
const engine = createCliMcpEngine({
|
|
1129
|
+
client: { listClients: async () => [] },
|
|
1130
|
+
});
|
|
1131
|
+
const res = await engine.handleMessage({
|
|
1132
|
+
jsonrpc: "2.0",
|
|
1133
|
+
id: 52,
|
|
1134
|
+
method: "tools/call",
|
|
1135
|
+
params: {
|
|
1136
|
+
name: "remote_tool_1",
|
|
1137
|
+
arguments: {},
|
|
1138
|
+
},
|
|
1139
|
+
});
|
|
1140
|
+
expect(res.result.isError).toBe(true);
|
|
1141
|
+
expect(res.result.content[0].text).toContain(
|
|
1142
|
+
"No connected client available",
|
|
1143
|
+
);
|
|
1144
|
+
});
|
|
1145
|
+
|
|
1146
|
+
it("closes sqlite database when engine.close is invoked after headless context creation", async () => {
|
|
1147
|
+
const tmpDir = await mkdtemp(path.join(tmpdir(), "sc-mcp-close-test-"));
|
|
1148
|
+
const engine = createCliMcpEngine({
|
|
1149
|
+
workspace: tmpDir,
|
|
1150
|
+
localTools: true,
|
|
1151
|
+
});
|
|
1152
|
+
|
|
1153
|
+
// Invoke local tool to initialize headlessContextPromise
|
|
1154
|
+
await engine.handleMessage({
|
|
1155
|
+
jsonrpc: "2.0",
|
|
1156
|
+
id: 53,
|
|
1157
|
+
method: "tools/call",
|
|
1158
|
+
params: {
|
|
1159
|
+
name: "shadowclaw_local_list_files",
|
|
1160
|
+
arguments: {},
|
|
1161
|
+
},
|
|
1162
|
+
});
|
|
1163
|
+
|
|
1164
|
+
// Call close
|
|
1165
|
+
await engine.close?.();
|
|
1166
|
+
await rm(tmpDir, { recursive: true, force: true });
|
|
1167
|
+
});
|
|
1168
|
+
});
|
|
1169
|
+
|
|
1170
|
+
describe("runMcpCommand", () => {
|
|
1171
|
+
let tmpWorkspace: string;
|
|
1172
|
+
|
|
1173
|
+
beforeEach(async () => {
|
|
1174
|
+
tmpWorkspace = await mkdtemp(path.join(tmpdir(), "sc-mcp-cmd-test-"));
|
|
1175
|
+
});
|
|
1176
|
+
|
|
1177
|
+
afterEach(async () => {
|
|
1178
|
+
try {
|
|
1179
|
+
await rm(tmpWorkspace, { recursive: true, force: true });
|
|
1180
|
+
} catch (_) {}
|
|
1181
|
+
});
|
|
1182
|
+
|
|
1183
|
+
it("handles HTTP transport requests including 404, parse error, success, notification, and 500", async () => {
|
|
1184
|
+
let requestHandler: any;
|
|
1185
|
+
const mockServer = {
|
|
1186
|
+
listen: jest.fn((_port: any, _host: any, cb: any) => cb?.()),
|
|
1187
|
+
close: jest.fn(),
|
|
1188
|
+
};
|
|
1189
|
+
jest.spyOn(http, "createServer").mockImplementation((handler: any) => {
|
|
1190
|
+
requestHandler = handler;
|
|
1191
|
+
return mockServer as any;
|
|
1192
|
+
});
|
|
1193
|
+
|
|
1194
|
+
await runMcpCommand({
|
|
1195
|
+
mcpTransport: "http",
|
|
1196
|
+
port: 9999,
|
|
1197
|
+
host: "127.0.0.1",
|
|
1198
|
+
workspace: tmpWorkspace,
|
|
1199
|
+
});
|
|
1200
|
+
|
|
1201
|
+
expect(http.createServer).toHaveBeenCalled();
|
|
1202
|
+
expect(mockServer.listen).toHaveBeenCalledWith(
|
|
1203
|
+
9999,
|
|
1204
|
+
"127.0.0.1",
|
|
1205
|
+
expect.any(Function),
|
|
1206
|
+
);
|
|
1207
|
+
|
|
1208
|
+
// 1. Non-POST or wrong URL -> 404
|
|
1209
|
+
const res404 = {
|
|
1210
|
+
writeHead: jest.fn(),
|
|
1211
|
+
end: jest.fn(),
|
|
1212
|
+
};
|
|
1213
|
+
await requestHandler({ method: "GET", url: "/mcp" }, res404);
|
|
1214
|
+
expect(res404.writeHead).toHaveBeenCalledWith(404, expect.any(Object));
|
|
1215
|
+
|
|
1216
|
+
// Helper to simulate request events
|
|
1217
|
+
const invokeHandler = (reqMeta: any, rawData: string) => {
|
|
1218
|
+
return new Promise<any>((resolve) => {
|
|
1219
|
+
const res = {
|
|
1220
|
+
writeHead: jest.fn(),
|
|
1221
|
+
end: jest.fn(() => resolve(res)),
|
|
1222
|
+
};
|
|
1223
|
+
const handlers: Record<string, Function> = {};
|
|
1224
|
+
const req: any = {
|
|
1225
|
+
method: "POST",
|
|
1226
|
+
url: "/mcp",
|
|
1227
|
+
headers: {},
|
|
1228
|
+
...reqMeta,
|
|
1229
|
+
on: (event: string, cb: any) => {
|
|
1230
|
+
handlers[event] = cb;
|
|
1231
|
+
},
|
|
1232
|
+
};
|
|
1233
|
+
Promise.resolve(requestHandler(req, res)).then(() => {
|
|
1234
|
+
handlers["data"]?.(rawData);
|
|
1235
|
+
handlers["end"]?.();
|
|
1236
|
+
});
|
|
1237
|
+
});
|
|
1238
|
+
};
|
|
1239
|
+
|
|
1240
|
+
// 2. Parse error -> 400
|
|
1241
|
+
const res400 = await invokeHandler({}, "not-valid-json");
|
|
1242
|
+
expect(res400.writeHead).toHaveBeenCalledWith(400, expect.any(Object));
|
|
1243
|
+
|
|
1244
|
+
// 3. Valid JSON-RPC method -> 200
|
|
1245
|
+
const validReq = JSON.stringify({
|
|
1246
|
+
jsonrpc: "2.0",
|
|
1247
|
+
id: 1,
|
|
1248
|
+
method: "tools/list",
|
|
1249
|
+
});
|
|
1250
|
+
const res200 = await invokeHandler(
|
|
1251
|
+
{
|
|
1252
|
+
headers: {
|
|
1253
|
+
"mcp-protocol-version": "2026-07-28",
|
|
1254
|
+
"mcp-method": "tools/list",
|
|
1255
|
+
"mcp-name": "test-client",
|
|
1256
|
+
},
|
|
1257
|
+
},
|
|
1258
|
+
validReq,
|
|
1259
|
+
);
|
|
1260
|
+
expect(res200.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
|
|
1261
|
+
|
|
1262
|
+
// 4. Notification (reply === null) -> 202
|
|
1263
|
+
const notifReq = JSON.stringify({
|
|
1264
|
+
jsonrpc: "2.0",
|
|
1265
|
+
method: "notifications/initialized",
|
|
1266
|
+
});
|
|
1267
|
+
const res202 = await invokeHandler({}, notifReq);
|
|
1268
|
+
expect(res202.writeHead).toHaveBeenCalledWith(202);
|
|
1269
|
+
|
|
1270
|
+
// 5. Error during handling -> 500
|
|
1271
|
+
const throwingHeaders = {
|
|
1272
|
+
get ["mcp-protocol-version"]() {
|
|
1273
|
+
throw new Error("Boom headers");
|
|
1274
|
+
},
|
|
1275
|
+
};
|
|
1276
|
+
const res500 = await invokeHandler(
|
|
1277
|
+
{ headers: throwingHeaders },
|
|
1278
|
+
validReq,
|
|
1279
|
+
);
|
|
1280
|
+
expect(res500.writeHead).toHaveBeenCalledWith(500, expect.any(Object));
|
|
1281
|
+
|
|
1282
|
+
// Restore
|
|
1283
|
+
jest.spyOn(http, "createServer").mockRestore();
|
|
1284
|
+
});
|
|
1285
|
+
|
|
1286
|
+
it("handles STDIO transport lines including parse error, valid request, notification, and close", async () => {
|
|
1287
|
+
let lineHandler: any;
|
|
1288
|
+
let closeHandler: any;
|
|
1289
|
+
const mockRl = {
|
|
1290
|
+
on: jest.fn((event: string, cb: any) => {
|
|
1291
|
+
if (event === "line") lineHandler = cb;
|
|
1292
|
+
if (event === "close") closeHandler = cb;
|
|
1293
|
+
return mockRl;
|
|
1294
|
+
}),
|
|
1295
|
+
};
|
|
1296
|
+
jest.spyOn(readline, "createInterface").mockReturnValue(mockRl as any);
|
|
1297
|
+
const writeSpy = jest
|
|
1298
|
+
.spyOn(process.stdout, "write")
|
|
1299
|
+
.mockImplementation(() => true);
|
|
1300
|
+
|
|
1301
|
+
await runMcpCommand({
|
|
1302
|
+
mcpTransport: "stdio",
|
|
1303
|
+
workspace: tmpWorkspace,
|
|
1304
|
+
});
|
|
1305
|
+
|
|
1306
|
+
expect(readline.createInterface).toHaveBeenCalled();
|
|
1307
|
+
|
|
1308
|
+
// Empty line ignored
|
|
1309
|
+
await lineHandler(" ");
|
|
1310
|
+
expect(writeSpy).not.toHaveBeenCalled();
|
|
1311
|
+
|
|
1312
|
+
// Parse error
|
|
1313
|
+
await lineHandler("bad-json");
|
|
1314
|
+
expect(writeSpy).toHaveBeenCalledWith(expect.stringContaining("-32700"));
|
|
1315
|
+
writeSpy.mockClear();
|
|
1316
|
+
|
|
1317
|
+
// Valid tools/list
|
|
1318
|
+
await lineHandler(
|
|
1319
|
+
JSON.stringify({ jsonrpc: "2.0", id: 2, method: "tools/list" }),
|
|
1320
|
+
);
|
|
1321
|
+
expect(writeSpy).toHaveBeenCalledWith(
|
|
1322
|
+
expect.stringContaining('"result"'),
|
|
1323
|
+
);
|
|
1324
|
+
writeSpy.mockClear();
|
|
1325
|
+
|
|
1326
|
+
// Exception during write triggering catch block (lines 1613-1620)
|
|
1327
|
+
writeSpy.mockImplementationOnce(() => {
|
|
1328
|
+
throw new Error("Stdout write failure");
|
|
1329
|
+
});
|
|
1330
|
+
await lineHandler(
|
|
1331
|
+
JSON.stringify({ jsonrpc: "2.0", id: 3, method: "tools/list" }),
|
|
1332
|
+
);
|
|
1333
|
+
expect(writeSpy).toHaveBeenCalledWith(expect.stringContaining("-32603"));
|
|
1334
|
+
writeSpy.mockClear();
|
|
1335
|
+
|
|
1336
|
+
// Notification
|
|
1337
|
+
await lineHandler(
|
|
1338
|
+
JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" }),
|
|
1339
|
+
);
|
|
1340
|
+
expect(writeSpy).not.toHaveBeenCalled();
|
|
1341
|
+
|
|
1342
|
+
// Close handler
|
|
1343
|
+
await closeHandler();
|
|
1344
|
+
|
|
1345
|
+
writeSpy.mockRestore();
|
|
1346
|
+
jest.spyOn(readline, "createInterface").mockRestore();
|
|
1347
|
+
});
|
|
1089
1348
|
});
|
|
1090
1349
|
});
|