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
|
@@ -76,4 +76,94 @@ describe("consumePendingShares", () => {
|
|
|
76
76
|
|
|
77
77
|
db.close();
|
|
78
78
|
});
|
|
79
|
+
|
|
80
|
+
it("returns empty array when db is null or does not have transaction function", async () => {
|
|
81
|
+
expect(await consumePendingShares(null as any)).toEqual([]);
|
|
82
|
+
expect(await consumePendingShares({} as any)).toEqual([]);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("handles NotFoundError DOMException by resolving with empty array", async () => {
|
|
86
|
+
const mockDb: any = {
|
|
87
|
+
transaction: () => {
|
|
88
|
+
throw new DOMException("Store not found", "NotFoundError");
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
const res = await consumePendingShares(mockDb);
|
|
92
|
+
expect(res).toEqual([]);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("rejects when transaction throws other error", async () => {
|
|
96
|
+
const mockDb: any = {
|
|
97
|
+
transaction: () => {
|
|
98
|
+
throw new Error("Disk corruption");
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
await expect(consumePendingShares(mockDb)).rejects.toThrow(
|
|
102
|
+
"Disk corruption",
|
|
103
|
+
);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("handles getAll error, clear error, and tx abort", async () => {
|
|
107
|
+
// 1. getAll error
|
|
108
|
+
const req1: any = {};
|
|
109
|
+
const mockDb1: any = {
|
|
110
|
+
transaction: () => ({
|
|
111
|
+
objectStore: () => ({
|
|
112
|
+
getAll: () => {
|
|
113
|
+
setTimeout(() => {
|
|
114
|
+
req1.error = new Error("Read error");
|
|
115
|
+
req1.onerror();
|
|
116
|
+
}, 0);
|
|
117
|
+
return req1;
|
|
118
|
+
},
|
|
119
|
+
}),
|
|
120
|
+
}),
|
|
121
|
+
};
|
|
122
|
+
await expect(consumePendingShares(mockDb1)).rejects.toThrow("Read error");
|
|
123
|
+
|
|
124
|
+
// 2. clear error
|
|
125
|
+
const req2: any = {};
|
|
126
|
+
const clearReq: any = {};
|
|
127
|
+
const mockDb2: any = {
|
|
128
|
+
transaction: () => ({
|
|
129
|
+
objectStore: () => ({
|
|
130
|
+
getAll: () => {
|
|
131
|
+
setTimeout(() => {
|
|
132
|
+
req2.result = [{ id: "x", createdAt: 1 }];
|
|
133
|
+
req2.onsuccess();
|
|
134
|
+
}, 0);
|
|
135
|
+
return req2;
|
|
136
|
+
},
|
|
137
|
+
clear: () => {
|
|
138
|
+
setTimeout(() => {
|
|
139
|
+
clearReq.error = new Error("Clear error");
|
|
140
|
+
clearReq.onerror();
|
|
141
|
+
}, 0);
|
|
142
|
+
return clearReq;
|
|
143
|
+
},
|
|
144
|
+
}),
|
|
145
|
+
}),
|
|
146
|
+
};
|
|
147
|
+
await expect(consumePendingShares(mockDb2)).rejects.toThrow("Clear error");
|
|
148
|
+
|
|
149
|
+
// 3. tx abort
|
|
150
|
+
const req3: any = {};
|
|
151
|
+
const tx3: any = {
|
|
152
|
+
objectStore: () => ({
|
|
153
|
+
getAll: () => {
|
|
154
|
+
setTimeout(() => {
|
|
155
|
+
tx3.error = new Error("Aborted error");
|
|
156
|
+
tx3.onabort();
|
|
157
|
+
}, 0);
|
|
158
|
+
return req3;
|
|
159
|
+
},
|
|
160
|
+
}),
|
|
161
|
+
};
|
|
162
|
+
const mockDb3: any = {
|
|
163
|
+
transaction: () => tx3,
|
|
164
|
+
};
|
|
165
|
+
await expect(consumePendingShares(mockDb3)).rejects.toThrow(
|
|
166
|
+
"Aborted error",
|
|
167
|
+
);
|
|
168
|
+
});
|
|
79
169
|
});
|
package/src/shell/fs.test.ts
CHANGED
|
@@ -25,6 +25,7 @@ jest.unstable_mockModule("../storage/createGroupDirectory.js", () => ({
|
|
|
25
25
|
}));
|
|
26
26
|
|
|
27
27
|
const { ShadowClawFileSystem, createFileSystem } = await import("./fs.js");
|
|
28
|
+
const { readGroupFileBytes } = await import("../storage/readGroupFileBytes.js");
|
|
28
29
|
const { writeGroupFile } = await import("../storage/writeGroupFile.js");
|
|
29
30
|
const { deleteGroupFile } = await import("../storage/deleteGroupFile.js");
|
|
30
31
|
const { createGroupDirectory } =
|
|
@@ -97,6 +98,53 @@ describe("ShadowClawFileSystem", () => {
|
|
|
97
98
|
});
|
|
98
99
|
});
|
|
99
100
|
|
|
101
|
+
describe("appendFile", () => {
|
|
102
|
+
it("persists appended content to group storage when inside /home/user", async () => {
|
|
103
|
+
const fs = new ShadowClawFileSystem(db, groupId, {});
|
|
104
|
+
await fs.appendFile("/home/user/app.log", "log line\n");
|
|
105
|
+
|
|
106
|
+
expect(writeGroupFile).toHaveBeenCalledWith(
|
|
107
|
+
db,
|
|
108
|
+
groupId,
|
|
109
|
+
"app.log",
|
|
110
|
+
expect.anything(),
|
|
111
|
+
);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("does not persist appended content when outside /home/user", async () => {
|
|
115
|
+
const fs = new ShadowClawFileSystem(db, groupId, {});
|
|
116
|
+
await fs.appendFile("/tmp/app.log", "log line\n");
|
|
117
|
+
|
|
118
|
+
expect(writeGroupFile).not.toHaveBeenCalled();
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
describe("cp", () => {
|
|
123
|
+
it("persists copied file to group storage when destination inside /home/user", async () => {
|
|
124
|
+
const fs = new ShadowClawFileSystem(db, groupId, {});
|
|
125
|
+
const statSpy = jest
|
|
126
|
+
.spyOn(InMemoryFs.prototype, "stat")
|
|
127
|
+
.mockResolvedValue({ isDirectory: () => false } as any);
|
|
128
|
+
|
|
129
|
+
await fs.cp("/tmp/file.txt", "/home/user/copied.txt");
|
|
130
|
+
|
|
131
|
+
expect(writeGroupFile).toHaveBeenCalledWith(
|
|
132
|
+
db,
|
|
133
|
+
groupId,
|
|
134
|
+
"copied.txt",
|
|
135
|
+
expect.anything(),
|
|
136
|
+
);
|
|
137
|
+
statSpy.mockRestore();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("does not persist copied file when destination outside /home/user", async () => {
|
|
141
|
+
const fs = new ShadowClawFileSystem(db, groupId, {});
|
|
142
|
+
await fs.cp("/home/user/file.txt", "/tmp/copied.txt");
|
|
143
|
+
|
|
144
|
+
expect(writeGroupFile).not.toHaveBeenCalled();
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
100
148
|
describe("mv", () => {
|
|
101
149
|
it("persists moved file under destination directory path", async () => {
|
|
102
150
|
const fs = new ShadowClawFileSystem(db, groupId, {});
|
|
@@ -195,5 +243,82 @@ describe("ShadowClawFileSystem", () => {
|
|
|
195
243
|
|
|
196
244
|
mkdirSyncSpy.mockRestore();
|
|
197
245
|
});
|
|
246
|
+
|
|
247
|
+
it("loads file contents when reading created file", async () => {
|
|
248
|
+
(readGroupFileBytes as unknown as jest.Mock).mockResolvedValue(
|
|
249
|
+
Uint8Array.from([1, 2, 3]) as any,
|
|
250
|
+
);
|
|
251
|
+
|
|
252
|
+
const listMock = listGroupFiles as unknown as jest.Mock;
|
|
253
|
+
listMock.mockImplementation(async (...args: any[]) => {
|
|
254
|
+
const dir = args[2] as string;
|
|
255
|
+
if (dir === ".") {
|
|
256
|
+
return ["hello.txt"];
|
|
257
|
+
}
|
|
258
|
+
return [];
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
const fs = await createFileSystem(db, groupId);
|
|
262
|
+
const fileFn = (fs as any).files["/home/user/hello.txt"];
|
|
263
|
+
expect(typeof fileFn).toBe("function");
|
|
264
|
+
|
|
265
|
+
const bytes = await fileFn();
|
|
266
|
+
expect(bytes).toEqual(Uint8Array.from([1, 2, 3]));
|
|
267
|
+
|
|
268
|
+
// Fallback on read error
|
|
269
|
+
(readGroupFileBytes as unknown as jest.Mock).mockRejectedValueOnce(
|
|
270
|
+
new Error("File read error"),
|
|
271
|
+
);
|
|
272
|
+
const str = await fileFn();
|
|
273
|
+
expect(str).toBe("");
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
describe("rm", () => {
|
|
278
|
+
it("deletes file from storage when inside /home/user", async () => {
|
|
279
|
+
const fs = new ShadowClawFileSystem(db, groupId, {});
|
|
280
|
+
|
|
281
|
+
await fs.rm("/home/user/test.txt");
|
|
282
|
+
|
|
283
|
+
expect(deleteGroupFile).toHaveBeenCalledWith(db, groupId, "test.txt");
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it("does not delete file from storage when outside /home/user", async () => {
|
|
287
|
+
const fs = new ShadowClawFileSystem(db, groupId, {});
|
|
288
|
+
|
|
289
|
+
await fs.rm("/tmp/test.txt");
|
|
290
|
+
|
|
291
|
+
expect(deleteGroupFile).not.toHaveBeenCalled();
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it("silently catches errors when deleteGroupFile throws", async () => {
|
|
295
|
+
const fs = new ShadowClawFileSystem(db, groupId, {});
|
|
296
|
+
(deleteGroupFile as unknown as jest.Mock).mockRejectedValueOnce(
|
|
297
|
+
new Error("Failed to delete"),
|
|
298
|
+
);
|
|
299
|
+
|
|
300
|
+
await expect(fs.rm("/home/user/test.txt")).resolves.not.toThrow();
|
|
301
|
+
});
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
describe("internal path helpers", () => {
|
|
305
|
+
it("handles non-function stat, root dir joining, and workspace prefix root", async () => {
|
|
306
|
+
const fs = new ShadowClawFileSystem(db, groupId, {});
|
|
307
|
+
expect((fs as any)._isDirectoryStat({ isDirectory: true })).toBe(true);
|
|
308
|
+
expect((fs as any)._isDirectoryStat({ isDirectory: false })).toBe(false);
|
|
309
|
+
expect((fs as any)._joinPath("/", "hello.txt")).toBe("/hello.txt");
|
|
310
|
+
expect((fs as any)._toDbPath("/home/user")).toBe("");
|
|
311
|
+
});
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
describe("mv edge cases", () => {
|
|
315
|
+
it("deletes source when moving to outside /home/user", async () => {
|
|
316
|
+
const fs = new ShadowClawFileSystem(db, groupId, {});
|
|
317
|
+
|
|
318
|
+
await fs.mv("/home/user/file.pdf", "/tmp/file.pdf");
|
|
319
|
+
|
|
320
|
+
expect(deleteGroupFile).toHaveBeenCalledWith(db, groupId, "file.pdf");
|
|
321
|
+
expect(writeGroupFile).not.toHaveBeenCalled();
|
|
322
|
+
});
|
|
198
323
|
});
|
|
199
324
|
});
|
package/src/shell/shell.test.ts
CHANGED
|
@@ -62,4 +62,12 @@ describe("executeShell", () => {
|
|
|
62
62
|
}),
|
|
63
63
|
);
|
|
64
64
|
});
|
|
65
|
+
|
|
66
|
+
it("handles errors during execution gracefully", async () => {
|
|
67
|
+
mockExec.mockRejectedValueOnce(new Error("Bash failed"));
|
|
68
|
+
const result = await executeShell({} as any, "bad command", "group1");
|
|
69
|
+
|
|
70
|
+
expect(result.exitCode).toBe(1);
|
|
71
|
+
expect(result.stderr).toBe("Bash failed");
|
|
72
|
+
});
|
|
65
73
|
});
|
|
@@ -233,5 +233,265 @@ describe("git credentials", () => {
|
|
|
233
233
|
expect(creds.token).toBeUndefined();
|
|
234
234
|
expect(creds.provider).toBe("generic");
|
|
235
235
|
});
|
|
236
|
+
|
|
237
|
+
it("matches account by authMode when multiple accounts match host", async () => {
|
|
238
|
+
storedConfig["git_accounts"] = [
|
|
239
|
+
{
|
|
240
|
+
id: "pat-acct",
|
|
241
|
+
hostPattern: "github.com",
|
|
242
|
+
token: "enc:pat-tok",
|
|
243
|
+
authMode: "token",
|
|
244
|
+
},
|
|
245
|
+
{
|
|
246
|
+
id: "oauth-acct",
|
|
247
|
+
hostPattern: "github.com",
|
|
248
|
+
token: "enc:oauth-tok",
|
|
249
|
+
authMode: "oauth",
|
|
250
|
+
},
|
|
251
|
+
];
|
|
252
|
+
|
|
253
|
+
const creds = await resolveGitCredentials(
|
|
254
|
+
mockDb,
|
|
255
|
+
"https://github.com/foo/bar",
|
|
256
|
+
{ authMode: "oauth" },
|
|
257
|
+
);
|
|
258
|
+
expect(creds.accountId).toBe("oauth-acct");
|
|
259
|
+
expect(creds.token).toBe("oauth-tok");
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it("matches legacy account without authMode when token mode requested", async () => {
|
|
263
|
+
storedConfig["git_accounts"] = [
|
|
264
|
+
{
|
|
265
|
+
id: "legacy-acct",
|
|
266
|
+
hostPattern: "github.com",
|
|
267
|
+
token: "enc:legacy-tok",
|
|
268
|
+
},
|
|
269
|
+
];
|
|
270
|
+
|
|
271
|
+
const creds = await resolveGitCredentials(
|
|
272
|
+
mockDb,
|
|
273
|
+
"https://github.com/foo/bar",
|
|
274
|
+
{ authMode: "token" },
|
|
275
|
+
);
|
|
276
|
+
expect(creds.accountId).toBe("legacy-acct");
|
|
277
|
+
expect(creds.token).toBe("legacy-tok");
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it("falls back to default account matching url hostPattern when direct host match fails", async () => {
|
|
281
|
+
storedConfig["git_default_account"] = "default-match";
|
|
282
|
+
storedConfig["git_accounts"] = [
|
|
283
|
+
{
|
|
284
|
+
id: "default-match",
|
|
285
|
+
hostPattern: "ghe.internal.corp",
|
|
286
|
+
token: "enc:internal-tok",
|
|
287
|
+
},
|
|
288
|
+
];
|
|
289
|
+
|
|
290
|
+
const creds = await resolveGitCredentials(
|
|
291
|
+
mockDb,
|
|
292
|
+
"https://ghe.internal.corp/org/repo",
|
|
293
|
+
);
|
|
294
|
+
expect(creds.accountId).toBe("default-match");
|
|
295
|
+
expect(creds.token).toBe("internal-tok");
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it("falls back to first account matching authMode when no url provided", async () => {
|
|
299
|
+
storedConfig["git_accounts"] = [
|
|
300
|
+
{
|
|
301
|
+
id: "acct-pat",
|
|
302
|
+
hostPattern: "github.com",
|
|
303
|
+
token: "enc:pat-tok",
|
|
304
|
+
authMode: "token",
|
|
305
|
+
},
|
|
306
|
+
{
|
|
307
|
+
id: "acct-oauth",
|
|
308
|
+
hostPattern: "github.com",
|
|
309
|
+
token: "enc:oauth-tok",
|
|
310
|
+
authMode: "oauth",
|
|
311
|
+
},
|
|
312
|
+
];
|
|
313
|
+
|
|
314
|
+
const creds = await resolveGitCredentials(mockDb, undefined, {
|
|
315
|
+
authMode: "oauth",
|
|
316
|
+
});
|
|
317
|
+
expect(creds.accountId).toBe("acct-oauth");
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it("falls back to first account when no url and no default account", async () => {
|
|
321
|
+
storedConfig["git_accounts"] = [
|
|
322
|
+
{
|
|
323
|
+
id: "first-acct",
|
|
324
|
+
hostPattern: "github.com",
|
|
325
|
+
token: "enc:first-tok",
|
|
326
|
+
},
|
|
327
|
+
];
|
|
328
|
+
|
|
329
|
+
const creds = await resolveGitCredentials(mockDb);
|
|
330
|
+
expect(creds.accountId).toBe("first-acct");
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
it("refreshes expired oauth token successfully", async () => {
|
|
334
|
+
const originalFetch = globalThis.fetch;
|
|
335
|
+
const mockFetch = jest.fn(async () => ({
|
|
336
|
+
ok: true,
|
|
337
|
+
json: async () => ({
|
|
338
|
+
accessToken: "new-access-token",
|
|
339
|
+
refreshToken: "new-refresh-token",
|
|
340
|
+
expiresIn: 3600,
|
|
341
|
+
scope: "repo read:org",
|
|
342
|
+
tokenType: "Bearer",
|
|
343
|
+
}),
|
|
344
|
+
})) as any;
|
|
345
|
+
globalThis.fetch = mockFetch;
|
|
346
|
+
|
|
347
|
+
try {
|
|
348
|
+
storedConfig["git_accounts"] = [
|
|
349
|
+
{
|
|
350
|
+
id: "oauth-refresh-acct",
|
|
351
|
+
hostPattern: "github.com",
|
|
352
|
+
provider: "github",
|
|
353
|
+
authMode: "oauth",
|
|
354
|
+
token: "enc:old-access",
|
|
355
|
+
refreshToken: "enc:old-refresh",
|
|
356
|
+
oauthProviderId: "github",
|
|
357
|
+
oauthClientId: "client-id-123",
|
|
358
|
+
oauthClientSecret: "enc:secret-123",
|
|
359
|
+
accessTokenExpiresAt: Date.now() - 1000,
|
|
360
|
+
},
|
|
361
|
+
];
|
|
362
|
+
|
|
363
|
+
const creds = await resolveGitCredentials(
|
|
364
|
+
mockDb,
|
|
365
|
+
"https://github.com/foo/bar",
|
|
366
|
+
);
|
|
367
|
+
expect(creds.token).toBe("new-access-token");
|
|
368
|
+
expect(mockFetch).toHaveBeenCalled();
|
|
369
|
+
const updated = storedConfig["git_accounts"][0];
|
|
370
|
+
expect(updated.token).toBe("enc:new-access-token");
|
|
371
|
+
expect(updated.refreshToken).toBe("enc:new-refresh-token");
|
|
372
|
+
expect(updated.scopes).toEqual(["repo", "read:org"]);
|
|
373
|
+
} finally {
|
|
374
|
+
globalThis.fetch = originalFetch;
|
|
375
|
+
}
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
it("marks refresh failure and transitions to reauthRequired after threshold", async () => {
|
|
379
|
+
const originalFetch = globalThis.fetch;
|
|
380
|
+
const mockFetch = jest.fn(async () => ({
|
|
381
|
+
ok: false,
|
|
382
|
+
status: 400,
|
|
383
|
+
})) as any;
|
|
384
|
+
globalThis.fetch = mockFetch;
|
|
385
|
+
|
|
386
|
+
try {
|
|
387
|
+
storedConfig["git_accounts"] = [
|
|
388
|
+
{
|
|
389
|
+
id: "failing-oauth-acct",
|
|
390
|
+
hostPattern: "github.com",
|
|
391
|
+
provider: "github",
|
|
392
|
+
authMode: "oauth",
|
|
393
|
+
token: "enc:token",
|
|
394
|
+
refreshToken: "enc:refresh",
|
|
395
|
+
oauthProviderId: "github",
|
|
396
|
+
oauthClientId: "client-id-123",
|
|
397
|
+
oauthRefreshFailureCount: 2,
|
|
398
|
+
accessTokenExpiresAt: Date.now() - 1000,
|
|
399
|
+
},
|
|
400
|
+
];
|
|
401
|
+
|
|
402
|
+
const creds = await resolveGitCredentials(
|
|
403
|
+
mockDb,
|
|
404
|
+
"https://github.com/foo/bar",
|
|
405
|
+
{ forceRefresh: true },
|
|
406
|
+
);
|
|
407
|
+
expect(creds.reauthRequired).toBe(true);
|
|
408
|
+
expect(storedConfig["git_accounts"][0].oauthReauthRequired).toBe(true);
|
|
409
|
+
} finally {
|
|
410
|
+
globalThis.fetch = originalFetch;
|
|
411
|
+
}
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
it("handles fetch exception during oauth refresh gracefully", async () => {
|
|
415
|
+
const originalFetch = globalThis.fetch;
|
|
416
|
+
globalThis.fetch = jest.fn(async () => {
|
|
417
|
+
throw new Error("Network offline");
|
|
418
|
+
}) as any;
|
|
419
|
+
|
|
420
|
+
try {
|
|
421
|
+
storedConfig["git_accounts"] = [
|
|
422
|
+
{
|
|
423
|
+
id: "network-fail-acct",
|
|
424
|
+
hostPattern: "github.com",
|
|
425
|
+
provider: "github",
|
|
426
|
+
authMode: "oauth",
|
|
427
|
+
token: "enc:token",
|
|
428
|
+
refreshToken: "enc:refresh",
|
|
429
|
+
oauthProviderId: "github",
|
|
430
|
+
oauthClientId: "client-id-123",
|
|
431
|
+
accessTokenExpiresAt: Date.now() - 1000,
|
|
432
|
+
},
|
|
433
|
+
];
|
|
434
|
+
|
|
435
|
+
const creds = await resolveGitCredentials(
|
|
436
|
+
mockDb,
|
|
437
|
+
"https://github.com/foo/bar",
|
|
438
|
+
);
|
|
439
|
+
expect(creds.token).toBe("token");
|
|
440
|
+
expect(storedConfig["git_accounts"][0].oauthRefreshFailureCount).toBe(
|
|
441
|
+
1,
|
|
442
|
+
);
|
|
443
|
+
} finally {
|
|
444
|
+
globalThis.fetch = originalFetch;
|
|
445
|
+
}
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
it("handles oauth response missing accessToken", async () => {
|
|
449
|
+
const originalFetch = globalThis.fetch;
|
|
450
|
+
globalThis.fetch = jest.fn(async () => ({
|
|
451
|
+
ok: true,
|
|
452
|
+
json: async () => ({}),
|
|
453
|
+
})) as any;
|
|
454
|
+
|
|
455
|
+
try {
|
|
456
|
+
storedConfig["git_accounts"] = [
|
|
457
|
+
{
|
|
458
|
+
id: "missing-token-acct",
|
|
459
|
+
hostPattern: "github.com",
|
|
460
|
+
provider: "github",
|
|
461
|
+
authMode: "oauth",
|
|
462
|
+
token: "enc:token",
|
|
463
|
+
refreshToken: "enc:refresh",
|
|
464
|
+
oauthProviderId: "github",
|
|
465
|
+
oauthClientId: "client-id-123",
|
|
466
|
+
accessTokenExpiresAt: Date.now() - 1000,
|
|
467
|
+
},
|
|
468
|
+
];
|
|
469
|
+
|
|
470
|
+
const creds = await resolveGitCredentials(
|
|
471
|
+
mockDb,
|
|
472
|
+
"https://github.com/foo/bar",
|
|
473
|
+
);
|
|
474
|
+
expect(creds.token).toBe("token");
|
|
475
|
+
expect(storedConfig["git_accounts"][0].oauthRefreshFailureCount).toBe(
|
|
476
|
+
1,
|
|
477
|
+
);
|
|
478
|
+
} finally {
|
|
479
|
+
globalThis.fetch = originalFetch;
|
|
480
|
+
}
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
it("resolves full legacy config with password and user metadata", async () => {
|
|
484
|
+
storedConfig["git_token"] = "enc:pat";
|
|
485
|
+
storedConfig["git_password"] = "enc:secret-pass";
|
|
486
|
+
storedConfig["git_username"] = "myuser";
|
|
487
|
+
storedConfig["git_author_name"] = "Full Name";
|
|
488
|
+
storedConfig["git_author_email"] = "me@example.com";
|
|
489
|
+
|
|
490
|
+
const creds = await resolveGitCredentials(mockDb);
|
|
491
|
+
expect(creds.password).toBe("secret-pass");
|
|
492
|
+
expect(creds.username).toBe("myuser");
|
|
493
|
+
expect(creds.authorName).toBe("Full Name");
|
|
494
|
+
expect(creds.authorEmail).toBe("me@example.com");
|
|
495
|
+
});
|
|
236
496
|
});
|
|
237
497
|
});
|
|
@@ -572,6 +572,15 @@ describe("git", () => {
|
|
|
572
572
|
|
|
573
573
|
expect(result).toBe("No branches found.");
|
|
574
574
|
});
|
|
575
|
+
|
|
576
|
+
it("handles detached HEAD when currentBranch throws", async () => {
|
|
577
|
+
mockGit.listBranches.mockResolvedValue(["main", "feature"]);
|
|
578
|
+
mockGit.currentBranch.mockRejectedValue(new Error("HEAD is detached"));
|
|
579
|
+
|
|
580
|
+
const result = await gitListBranches({ repo: "my-repo" });
|
|
581
|
+
|
|
582
|
+
expect(result).toBe(" main\n feature");
|
|
583
|
+
});
|
|
575
584
|
});
|
|
576
585
|
|
|
577
586
|
describe("gitCurrentBranch", () => {
|
|
@@ -705,6 +714,22 @@ describe("git", () => {
|
|
|
705
714
|
}),
|
|
706
715
|
);
|
|
707
716
|
});
|
|
717
|
+
|
|
718
|
+
it("returns result details when push is not ok", async () => {
|
|
719
|
+
mockGit.currentBranch.mockResolvedValue("main");
|
|
720
|
+
mockGit.push.mockResolvedValue({
|
|
721
|
+
ok: false,
|
|
722
|
+
refs: { "refs/heads/main": { ok: false, error: "non-fast-forward" } },
|
|
723
|
+
});
|
|
724
|
+
|
|
725
|
+
const result = await gitPush({
|
|
726
|
+
token: "fake",
|
|
727
|
+
repo: "my-repo",
|
|
728
|
+
});
|
|
729
|
+
|
|
730
|
+
expect(result).toContain("Push result:");
|
|
731
|
+
expect(result).toContain("non-fast-forward");
|
|
732
|
+
});
|
|
708
733
|
});
|
|
709
734
|
|
|
710
735
|
describe("gitListRepos", () => {
|
|
@@ -732,6 +757,14 @@ describe("git", () => {
|
|
|
732
757
|
|
|
733
758
|
expect(result).toBe("No repos cloned.");
|
|
734
759
|
});
|
|
760
|
+
|
|
761
|
+
it("returns 'No repos cloned.' when readdir returns empty array", async () => {
|
|
762
|
+
mockPfs.readdir.mockResolvedValue([]);
|
|
763
|
+
|
|
764
|
+
const result = await gitListRepos();
|
|
765
|
+
|
|
766
|
+
expect(result).toBe("No repos cloned.");
|
|
767
|
+
});
|
|
735
768
|
});
|
|
736
769
|
|
|
737
770
|
describe("getProxyUrl", () => {
|
|
@@ -930,6 +963,37 @@ describe("git", () => {
|
|
|
930
963
|
expect.objectContaining({ filepath: "new-file.txt" }),
|
|
931
964
|
);
|
|
932
965
|
});
|
|
966
|
+
|
|
967
|
+
it("reports no changes when staging with '.' and clean status matrix", async () => {
|
|
968
|
+
mockGit.statusMatrix.mockResolvedValue([["file.txt", 1, 1, 1]]);
|
|
969
|
+
|
|
970
|
+
const result = await gitAdd({ repo: "my-repo", filepath: "." });
|
|
971
|
+
|
|
972
|
+
expect(result).toBe("No changes to add in my-repo");
|
|
973
|
+
});
|
|
974
|
+
|
|
975
|
+
it("stages multiple files when filepath is an array", async () => {
|
|
976
|
+
const result = await gitAdd({
|
|
977
|
+
repo: "my-repo",
|
|
978
|
+
filepath: ["./a.txt", "b.txt"],
|
|
979
|
+
});
|
|
980
|
+
|
|
981
|
+
expect(result).toContain("a.txt, b.txt");
|
|
982
|
+
expect(mockGit.add).toHaveBeenCalledWith(
|
|
983
|
+
expect.objectContaining({ filepath: "a.txt" }),
|
|
984
|
+
);
|
|
985
|
+
expect(mockGit.add).toHaveBeenCalledWith(
|
|
986
|
+
expect.objectContaining({ filepath: "b.txt" }),
|
|
987
|
+
);
|
|
988
|
+
});
|
|
989
|
+
|
|
990
|
+
it("defaults to '.' when filepath is an empty array", async () => {
|
|
991
|
+
mockGit.statusMatrix.mockResolvedValue([["file.txt", 1, 1, 1]]);
|
|
992
|
+
|
|
993
|
+
const result = await gitAdd({ repo: "my-repo", filepath: [] });
|
|
994
|
+
|
|
995
|
+
expect(result).toBe("No changes to add in my-repo");
|
|
996
|
+
});
|
|
933
997
|
});
|
|
934
998
|
|
|
935
999
|
describe("makeDirHandleFs", () => {
|
|
@@ -489,5 +489,83 @@ describe("executeNativeAiTask", () => {
|
|
|
489
489
|
setHeadlessMode(false);
|
|
490
490
|
}
|
|
491
491
|
});
|
|
492
|
+
|
|
493
|
+
it("handles detect-language via fetch with JSON and fallback", async () => {
|
|
494
|
+
mockGetProvider.mockReturnValue({
|
|
495
|
+
id: "custom",
|
|
496
|
+
name: "Custom",
|
|
497
|
+
baseUrl: "http://localhost:8080/v1/chat/completions",
|
|
498
|
+
format: "openai",
|
|
499
|
+
defaultModel: "model-1",
|
|
500
|
+
requiresApiKey: false,
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
mockFormatRequest.mockReturnValue({
|
|
504
|
+
messages: [{ role: "user", content: "Detect" }],
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
mockParseResponse.mockReturnValue({
|
|
508
|
+
content: [{ type: "text", text: "not-json" }],
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
globalThis.fetch = jest.fn<any>().mockResolvedValue({
|
|
512
|
+
ok: true,
|
|
513
|
+
json: () => Promise.resolve({}),
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
const resFallback = await executeNativeAiTask({
|
|
517
|
+
taskType: "detect-language",
|
|
518
|
+
input: { text: "Hello" },
|
|
519
|
+
providerId: "custom",
|
|
520
|
+
});
|
|
521
|
+
expect(resFallback).toEqual([
|
|
522
|
+
{ detectedLanguage: "en", confidence: 0.8 },
|
|
523
|
+
]);
|
|
524
|
+
|
|
525
|
+
// Now with valid JSON
|
|
526
|
+
mockParseResponse.mockReturnValue({
|
|
527
|
+
content: [
|
|
528
|
+
{
|
|
529
|
+
type: "text",
|
|
530
|
+
text: JSON.stringify([
|
|
531
|
+
{ detectedLanguage: "es", confidence: 0.99 },
|
|
532
|
+
]),
|
|
533
|
+
},
|
|
534
|
+
],
|
|
535
|
+
});
|
|
536
|
+
const resJson = await executeNativeAiTask({
|
|
537
|
+
taskType: "detect-language",
|
|
538
|
+
input: { text: "Hola" },
|
|
539
|
+
providerId: "custom",
|
|
540
|
+
});
|
|
541
|
+
expect(resJson).toEqual([{ detectedLanguage: "es", confidence: 0.99 }]);
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
it("throws error when provider fetch fails with non-ok status", async () => {
|
|
545
|
+
mockGetProvider.mockReturnValue({
|
|
546
|
+
id: "custom",
|
|
547
|
+
name: "Custom",
|
|
548
|
+
baseUrl: "http://localhost:8080/v1/chat/completions",
|
|
549
|
+
format: "openai",
|
|
550
|
+
defaultModel: "model-1",
|
|
551
|
+
requiresApiKey: false,
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
mockFormatRequest.mockReturnValue({ messages: [] });
|
|
555
|
+
|
|
556
|
+
globalThis.fetch = jest.fn<any>().mockResolvedValue({
|
|
557
|
+
ok: false,
|
|
558
|
+
status: 500,
|
|
559
|
+
text: () => Promise.resolve("Internal Error"),
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
await expect(
|
|
563
|
+
executeNativeAiTask({
|
|
564
|
+
taskType: "summarize",
|
|
565
|
+
input: { text: "Hello" },
|
|
566
|
+
providerId: "custom",
|
|
567
|
+
}),
|
|
568
|
+
).rejects.toThrow("Active provider task API error 500: Internal Error");
|
|
569
|
+
});
|
|
492
570
|
});
|
|
493
571
|
});
|