@shogo-ai/worker 1.0.10
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/LICENSE +21 -0
- package/README.md +432 -0
- package/bin/shogo.mjs +38 -0
- package/dist/cli.mjs +64 -0
- package/package.json +62 -0
- package/src/cli.ts +199 -0
- package/src/commands/agent.ts +123 -0
- package/src/commands/config.ts +29 -0
- package/src/commands/doctor.ts +128 -0
- package/src/commands/login.ts +126 -0
- package/src/commands/logs.ts +24 -0
- package/src/commands/project-checkout.ts +179 -0
- package/src/commands/project-pull.ts +133 -0
- package/src/commands/project-push.ts +89 -0
- package/src/commands/runtime.ts +104 -0
- package/src/commands/start.ts +290 -0
- package/src/commands/status.ts +22 -0
- package/src/commands/stop.ts +13 -0
- package/src/lib/__tests__/agent-launch.test.ts +92 -0
- package/src/lib/__tests__/cloud-login-coverage.test.ts +210 -0
- package/src/lib/__tests__/cloud-login.test.ts +218 -0
- package/src/lib/__tests__/cloud-sync-watcher.test.ts +214 -0
- package/src/lib/__tests__/config.test.ts +141 -0
- package/src/lib/__tests__/db-doctor.test.ts +204 -0
- package/src/lib/__tests__/git-cloner.test.ts +382 -0
- package/src/lib/__tests__/runtime-manager-auto-pull.test.ts +275 -0
- package/src/lib/__tests__/runtime-manager-cloud-sync-gate.test.ts +119 -0
- package/src/lib/__tests__/runtime-manager-coverage-gaps.test.ts +768 -0
- package/src/lib/__tests__/runtime-manager-describe-rejection.test.ts +42 -0
- package/src/lib/__tests__/runtime-manager-git-pull.test.ts +207 -0
- package/src/lib/__tests__/runtime-manager-idle-eviction.test.ts +196 -0
- package/src/lib/__tests__/runtime-manager-restart-cap.test.ts +310 -0
- package/src/lib/__tests__/runtime-manager-tree-sitter-env.test.ts +189 -0
- package/src/lib/__tests__/runtime-manager-wait-for-health.test.ts +269 -0
- package/src/lib/__tests__/runtime-resolver.test.ts +112 -0
- package/src/lib/__tests__/tunnel-coverage.test.ts +1094 -0
- package/src/lib/__tests__/tunnel-structured-502.test.ts +101 -0
- package/src/lib/__tests__/worker-commands-greenfield.test.ts +463 -0
- package/src/lib/__tests__/worker-libs-greenfield.test.ts +355 -0
- package/src/lib/__tests__/worker-transport-greenfield.test.ts +176 -0
- package/src/lib/api-discovery.ts +36 -0
- package/src/lib/cloud-login.ts +320 -0
- package/src/lib/cloud-sync-watcher.ts +322 -0
- package/src/lib/config.ts +68 -0
- package/src/lib/db-doctor.ts +355 -0
- package/src/lib/device-id.ts +27 -0
- package/src/lib/git-cloner.ts +354 -0
- package/src/lib/paths.ts +53 -0
- package/src/lib/preflight.ts +158 -0
- package/src/lib/process-manager.ts +123 -0
- package/src/lib/resource-limits.ts +340 -0
- package/src/lib/runtime-install.ts +371 -0
- package/src/lib/runtime-manager.ts +1873 -0
- package/src/lib/runtime-resolver.ts +136 -0
- package/src/lib/transport.ts +202 -0
- package/src/lib/tunnel.ts +710 -0
- package/src/lib/user-agent.ts +28 -0
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Copyright (C) 2026 Shogo Technologies, Inc.
|
|
3
|
+
/**
|
|
4
|
+
* Tests for {@link cloneProject}, {@link commitAndPush}, and the
|
|
5
|
+
* support helpers in `lib/git-cloner.ts`.
|
|
6
|
+
*
|
|
7
|
+
* Strategy:
|
|
8
|
+
* - Mock `node:child_process` so we never actually shell out.
|
|
9
|
+
* - Capture the argv each call would have run with and assert the
|
|
10
|
+
* bearer header lives in `-c http.extraHeader=…` rather than the
|
|
11
|
+
* URL or environment.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { describe, it, expect, mock, beforeEach } from 'bun:test';
|
|
15
|
+
import { EventEmitter } from 'node:events';
|
|
16
|
+
|
|
17
|
+
interface SpawnInvocation {
|
|
18
|
+
cmd: string;
|
|
19
|
+
args: string[];
|
|
20
|
+
cwd?: string;
|
|
21
|
+
env?: NodeJS.ProcessEnv;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const spawnInvocations: SpawnInvocation[] = [];
|
|
25
|
+
type SpawnResult = { stdout?: string; stderr?: string; exitCode?: number; runError?: Error; hang?: boolean };
|
|
26
|
+
type SpawnHandler = (inv: SpawnInvocation) => SpawnResult;
|
|
27
|
+
let spawnHandler: SpawnHandler = () => ({ exitCode: 0 });
|
|
28
|
+
|
|
29
|
+
let execFileHandler: (cmd: string, args: string[]) => { error?: Error; stdout?: string } =
|
|
30
|
+
() => ({ stdout: 'git version 2.40.0' });
|
|
31
|
+
|
|
32
|
+
function makeFakeChild(result: SpawnResult) {
|
|
33
|
+
const child = new EventEmitter() as any;
|
|
34
|
+
child.stdout = new EventEmitter();
|
|
35
|
+
child.stdout.setEncoding = () => {};
|
|
36
|
+
child.stderr = new EventEmitter();
|
|
37
|
+
child.stderr.setEncoding = () => {};
|
|
38
|
+
child.kill = () => {};
|
|
39
|
+
child.exitCode = null;
|
|
40
|
+
child.signalCode = null;
|
|
41
|
+
// Emit chunks + close asynchronously so callers' on() handlers register first.
|
|
42
|
+
setTimeout(() => {
|
|
43
|
+
if (result.hang) return; // never emit close — used for timeout tests
|
|
44
|
+
if (result.runError) {
|
|
45
|
+
child.emit('error', result.runError);
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
if (result.stdout) child.stdout.emit('data', result.stdout);
|
|
49
|
+
if (result.stderr) child.stderr.emit('data', result.stderr);
|
|
50
|
+
child.exitCode = result.exitCode ?? 0;
|
|
51
|
+
child.emit('close', result.exitCode ?? 0);
|
|
52
|
+
}, 5);
|
|
53
|
+
return child;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
mock.module('node:child_process', () => ({
|
|
57
|
+
spawn: (cmd: string, args: string[], opts?: any) => {
|
|
58
|
+
const inv: SpawnInvocation = { cmd, args, cwd: opts?.cwd, env: opts?.env };
|
|
59
|
+
spawnInvocations.push(inv);
|
|
60
|
+
const result = spawnHandler(inv);
|
|
61
|
+
return makeFakeChild(result);
|
|
62
|
+
},
|
|
63
|
+
execFile: (cmd: string, args: string[], opts: any, cb: any) => {
|
|
64
|
+
const inv: SpawnInvocation = { cmd, args };
|
|
65
|
+
spawnInvocations.push(inv);
|
|
66
|
+
const callback = typeof opts === 'function' ? opts : cb;
|
|
67
|
+
const r = execFileHandler(cmd, args);
|
|
68
|
+
setTimeout(() => {
|
|
69
|
+
if (r.error) callback?.(r.error, '', '');
|
|
70
|
+
else callback?.(null, r.stdout ?? '', '');
|
|
71
|
+
}, 1);
|
|
72
|
+
},
|
|
73
|
+
}));
|
|
74
|
+
|
|
75
|
+
const { buildGitUrl, cloneProject, commitAndPush, gitIsAvailable, isGitRepo, gitFetchAndReset } =
|
|
76
|
+
await import('../git-cloner.ts');
|
|
77
|
+
|
|
78
|
+
beforeEach(() => {
|
|
79
|
+
spawnInvocations.length = 0;
|
|
80
|
+
spawnHandler = () => ({ exitCode: 0 });
|
|
81
|
+
execFileHandler = () => ({ stdout: 'git version 2.40.0' });
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe('buildGitUrl', () => {
|
|
85
|
+
it('joins cloud url and project id with a trailing /git', () => {
|
|
86
|
+
expect(buildGitUrl('https://api.shogo.ai', 'p_abc')).toBe('https://api.shogo.ai/api/projects/p_abc/git');
|
|
87
|
+
});
|
|
88
|
+
it('strips trailing slashes from the cloud url', () => {
|
|
89
|
+
expect(buildGitUrl('https://api.shogo.ai///', 'p_abc')).toBe('https://api.shogo.ai/api/projects/p_abc/git');
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe('gitIsAvailable', () => {
|
|
94
|
+
it('returns true when git --version succeeds', async () => {
|
|
95
|
+
const ok = await gitIsAvailable(true);
|
|
96
|
+
expect(ok).toBe(true);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe('cloneProject', () => {
|
|
101
|
+
it('uses http.extraHeader for the bearer token (never in argv URL)', async () => {
|
|
102
|
+
spawnHandler = (inv) => {
|
|
103
|
+
if (inv.args[0] === 'rev-parse') return { stdout: 'abc123\n' };
|
|
104
|
+
return { exitCode: 0 };
|
|
105
|
+
};
|
|
106
|
+
const res = await cloneProject({
|
|
107
|
+
apiUrl: 'https://api.shogo.ai',
|
|
108
|
+
apiKey: 'shogo_sk_secret',
|
|
109
|
+
projectId: 'p_demo',
|
|
110
|
+
localDir: '/tmp/test-clone',
|
|
111
|
+
});
|
|
112
|
+
expect(res.commitSha).toBe('abc123');
|
|
113
|
+
|
|
114
|
+
const cloneInv = spawnInvocations.find((i) => i.args.includes('clone'));
|
|
115
|
+
expect(cloneInv).toBeDefined();
|
|
116
|
+
expect(cloneInv!.args).toContain('-c');
|
|
117
|
+
expect(cloneInv!.args.some((a) => a.startsWith('http.extraHeader=Authorization: Bearer'))).toBe(true);
|
|
118
|
+
// URL should NOT carry the secret.
|
|
119
|
+
const urlArg = cloneInv!.args.find((a) => a.startsWith('https://'))!;
|
|
120
|
+
expect(urlArg.includes('shogo_sk_secret')).toBe(false);
|
|
121
|
+
expect(urlArg).toBe('https://api.shogo.ai/api/projects/p_demo/git');
|
|
122
|
+
// Shallow by default.
|
|
123
|
+
expect(cloneInv!.args.includes('--depth=1')).toBe(true);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('throws when the target dir already has a .git', async () => {
|
|
127
|
+
// existsSync uses real fs; create a tmp dir with a .git.
|
|
128
|
+
const { mkdtempSync, mkdirSync, rmSync } = await import('node:fs');
|
|
129
|
+
const { tmpdir } = await import('node:os');
|
|
130
|
+
const { join } = await import('node:path');
|
|
131
|
+
const dir = mkdtempSync(join(tmpdir(), 'git-cloner-'));
|
|
132
|
+
try {
|
|
133
|
+
mkdirSync(join(dir, '.git'));
|
|
134
|
+
await expect(
|
|
135
|
+
cloneProject({
|
|
136
|
+
apiUrl: 'https://api.shogo.ai',
|
|
137
|
+
apiKey: 'shogo_sk_secret',
|
|
138
|
+
projectId: 'p_demo',
|
|
139
|
+
localDir: dir,
|
|
140
|
+
}),
|
|
141
|
+
).rejects.toThrow(/\.git already exists/);
|
|
142
|
+
} finally {
|
|
143
|
+
rmSync(dir, { recursive: true, force: true });
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('shallow=false omits --depth=1', async () => {
|
|
148
|
+
spawnHandler = (inv) => {
|
|
149
|
+
if (inv.args[0] === 'rev-parse') return { stdout: 'abc123\n' };
|
|
150
|
+
return { exitCode: 0 };
|
|
151
|
+
};
|
|
152
|
+
await cloneProject({
|
|
153
|
+
apiUrl: 'https://api.shogo.ai',
|
|
154
|
+
apiKey: 'k',
|
|
155
|
+
projectId: 'p',
|
|
156
|
+
localDir: '/tmp/never',
|
|
157
|
+
shallow: false,
|
|
158
|
+
});
|
|
159
|
+
const cloneInv = spawnInvocations.find((i) => i.args.includes('clone'))!;
|
|
160
|
+
expect(cloneInv.args.includes('--depth=1')).toBe(false);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('rejects when git exits non-zero', async () => {
|
|
164
|
+
spawnHandler = () => ({ exitCode: 128, stderr: 'fatal: repository not found' });
|
|
165
|
+
await expect(
|
|
166
|
+
cloneProject({
|
|
167
|
+
apiUrl: 'https://api.shogo.ai',
|
|
168
|
+
apiKey: 'k',
|
|
169
|
+
projectId: 'p',
|
|
170
|
+
localDir: '/tmp/never',
|
|
171
|
+
}),
|
|
172
|
+
).rejects.toThrow(/exited with code 128/);
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
describe('commitAndPush', () => {
|
|
177
|
+
it('reports committed=false when there is nothing to commit', async () => {
|
|
178
|
+
spawnHandler = (inv) => {
|
|
179
|
+
// `git diff --cached --quiet` exits 0 when nothing is staged.
|
|
180
|
+
if (inv.args[0] === 'diff') return { exitCode: 0 };
|
|
181
|
+
return { exitCode: 0 };
|
|
182
|
+
};
|
|
183
|
+
const res = await commitAndPush({
|
|
184
|
+
apiUrl: 'https://api.shogo.ai',
|
|
185
|
+
apiKey: 'shogo_sk_test',
|
|
186
|
+
projectId: 'p_clean',
|
|
187
|
+
localDir: '/tmp/never',
|
|
188
|
+
message: 'no-op',
|
|
189
|
+
});
|
|
190
|
+
expect(res.committed).toBe(false);
|
|
191
|
+
expect(res.commitSha).toBeUndefined();
|
|
192
|
+
// We should not have pushed.
|
|
193
|
+
expect(spawnInvocations.some((i) => i.args[0] === 'push')).toBe(false);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('commits + pushes when there are staged changes', async () => {
|
|
197
|
+
spawnHandler = (inv) => {
|
|
198
|
+
if (inv.args[0] === 'diff') {
|
|
199
|
+
// Non-zero exit = "diff present" = something to commit.
|
|
200
|
+
return { exitCode: 1 };
|
|
201
|
+
}
|
|
202
|
+
if (inv.args[0] === 'rev-parse') return { stdout: 'feedface\n' };
|
|
203
|
+
return { exitCode: 0 };
|
|
204
|
+
};
|
|
205
|
+
const res = await commitAndPush({
|
|
206
|
+
apiUrl: 'https://api.shogo.ai',
|
|
207
|
+
apiKey: 'shogo_sk_test',
|
|
208
|
+
projectId: 'p_dirty',
|
|
209
|
+
localDir: '/tmp/never',
|
|
210
|
+
message: 'auto: 2026-01-01T00:00:00Z',
|
|
211
|
+
});
|
|
212
|
+
expect(res.committed).toBe(true);
|
|
213
|
+
expect(res.commitSha).toBe('feedface');
|
|
214
|
+
const pushInv = spawnInvocations.find((i) => i.args.includes('push'))!;
|
|
215
|
+
expect(pushInv).toBeDefined();
|
|
216
|
+
expect(pushInv.args.some((a) => a.startsWith('http.extraHeader='))).toBe(true);
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
describe('gitFetchAndReset', () => {
|
|
221
|
+
it('passes bearer via -c http.extraHeader on both fetch and reset', async () => {
|
|
222
|
+
spawnHandler = (inv) => {
|
|
223
|
+
if (inv.args[0] === 'rev-parse') return { stdout: 'cafebabe\n' };
|
|
224
|
+
return { exitCode: 0 };
|
|
225
|
+
};
|
|
226
|
+
const res = await gitFetchAndReset({
|
|
227
|
+
apiUrl: 'https://api.shogo.ai',
|
|
228
|
+
apiKey: 'shogo_sk_test',
|
|
229
|
+
projectId: 'p_demo',
|
|
230
|
+
localDir: '/tmp/never',
|
|
231
|
+
});
|
|
232
|
+
expect(res.commitSha).toBe('cafebabe');
|
|
233
|
+
const fetchInv = spawnInvocations.find((i) => i.args.includes('fetch'))!;
|
|
234
|
+
expect(fetchInv.args.some((a) => a.startsWith('http.extraHeader='))).toBe(true);
|
|
235
|
+
// reset does NOT need the bearer.
|
|
236
|
+
const resetInv = spawnInvocations.find((i) => i.args.includes('reset'))!;
|
|
237
|
+
expect(resetInv.args.some((a) => a.startsWith('http.extraHeader='))).toBe(false);
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
describe('isGitRepo', () => {
|
|
242
|
+
it('returns true when .git exists in the dir', async () => {
|
|
243
|
+
const { mkdtempSync, mkdirSync, rmSync } = await import('node:fs');
|
|
244
|
+
const { tmpdir } = await import('node:os');
|
|
245
|
+
const { join } = await import('node:path');
|
|
246
|
+
const dir = mkdtempSync(join(tmpdir(), 'is-git-'));
|
|
247
|
+
try {
|
|
248
|
+
mkdirSync(join(dir, '.git'));
|
|
249
|
+
expect(isGitRepo(dir)).toBe(true);
|
|
250
|
+
} finally {
|
|
251
|
+
rmSync(dir, { recursive: true, force: true });
|
|
252
|
+
}
|
|
253
|
+
});
|
|
254
|
+
it('returns false when .git is missing', async () => {
|
|
255
|
+
const { mkdtempSync, rmSync } = await import('node:fs');
|
|
256
|
+
const { tmpdir } = await import('node:os');
|
|
257
|
+
const { join } = await import('node:path');
|
|
258
|
+
const dir = mkdtempSync(join(tmpdir(), 'is-git-'));
|
|
259
|
+
try {
|
|
260
|
+
expect(isGitRepo(dir)).toBe(false);
|
|
261
|
+
} finally {
|
|
262
|
+
rmSync(dir, { recursive: true, force: true });
|
|
263
|
+
}
|
|
264
|
+
});
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
// ─── Additional coverage tests ──────────────────────────────────────────────
|
|
268
|
+
|
|
269
|
+
describe('gitIsAvailable (failure path)', () => {
|
|
270
|
+
it('returns false when git --version fails (L46)', async () => {
|
|
271
|
+
execFileHandler = () => ({ error: new Error('git not found in PATH') });
|
|
272
|
+
const ok = await gitIsAvailable(true); // force=true bypasses cache
|
|
273
|
+
expect(ok).toBe(false);
|
|
274
|
+
// Restore the cache to true for subsequent tests (idempotent re-probe)
|
|
275
|
+
execFileHandler = () => ({ stdout: 'git version 2.40.0' });
|
|
276
|
+
await gitIsAvailable(true);
|
|
277
|
+
});
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
describe('runGit (timeout + error paths)', () => {
|
|
281
|
+
it('rejects when the timer fires before the child closes (L127-128)', async () => {
|
|
282
|
+
const { runGit } = await import('../git-cloner.ts');
|
|
283
|
+
spawnHandler = () => ({ hang: true });
|
|
284
|
+
await expect(
|
|
285
|
+
runGit(['fetch'], { timeoutMs: 30 }),
|
|
286
|
+
).rejects.toThrow(/timed out after 30ms/);
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it('rejects when the child emits an error event (L132-133)', async () => {
|
|
290
|
+
const { runGit } = await import('../git-cloner.ts');
|
|
291
|
+
spawnHandler = () => ({ runError: new Error('spawn ENOENT') });
|
|
292
|
+
await expect(
|
|
293
|
+
runGit(['status'], { timeoutMs: 5000 }),
|
|
294
|
+
).rejects.toThrow(/spawn ENOENT/);
|
|
295
|
+
});
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
describe('gitFetchUnshallow', () => {
|
|
299
|
+
it('runs git fetch --unshallow when .git/shallow exists (L270-276)', async () => {
|
|
300
|
+
const { gitFetchUnshallow } = await import('../git-cloner.ts');
|
|
301
|
+
const { mkdtempSync, mkdirSync, writeFileSync, rmSync } = await import('node:fs');
|
|
302
|
+
const { tmpdir } = await import('node:os');
|
|
303
|
+
const { join } = await import('node:path');
|
|
304
|
+
const dir = mkdtempSync(join(tmpdir(), 'unshallow-'));
|
|
305
|
+
try {
|
|
306
|
+
mkdirSync(join(dir, '.git'));
|
|
307
|
+
writeFileSync(join(dir, '.git', 'shallow'), 'deadbeef\n');
|
|
308
|
+
await gitFetchUnshallow({
|
|
309
|
+
apiUrl: 'https://api.shogo.ai',
|
|
310
|
+
apiKey: 'shogo_sk_test',
|
|
311
|
+
projectId: 'p_demo',
|
|
312
|
+
localDir: dir,
|
|
313
|
+
});
|
|
314
|
+
const fetchInv = spawnInvocations.find((i) => i.args.includes('--unshallow'));
|
|
315
|
+
expect(fetchInv).toBeDefined();
|
|
316
|
+
expect(fetchInv!.args.some((a) => a.startsWith('http.extraHeader='))).toBe(true);
|
|
317
|
+
} finally {
|
|
318
|
+
rmSync(dir, { recursive: true, force: true });
|
|
319
|
+
}
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
it('no-ops when .git/shallow is missing (repo already complete)', async () => {
|
|
323
|
+
const { gitFetchUnshallow } = await import('../git-cloner.ts');
|
|
324
|
+
const { mkdtempSync, mkdirSync, rmSync } = await import('node:fs');
|
|
325
|
+
const { tmpdir } = await import('node:os');
|
|
326
|
+
const { join } = await import('node:path');
|
|
327
|
+
const dir = mkdtempSync(join(tmpdir(), 'unshallow-'));
|
|
328
|
+
try {
|
|
329
|
+
mkdirSync(join(dir, '.git'));
|
|
330
|
+
await gitFetchUnshallow({
|
|
331
|
+
apiUrl: 'https://api.shogo.ai',
|
|
332
|
+
apiKey: 'shogo_sk_test',
|
|
333
|
+
projectId: 'p_demo',
|
|
334
|
+
localDir: dir,
|
|
335
|
+
});
|
|
336
|
+
expect(spawnInvocations.length).toBe(0);
|
|
337
|
+
} finally {
|
|
338
|
+
rmSync(dir, { recursive: true, force: true });
|
|
339
|
+
}
|
|
340
|
+
});
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
describe('commitAndPush (author env vars)', () => {
|
|
344
|
+
it('sets GIT_AUTHOR_EMAIL + GIT_COMMITTER_EMAIL when authorEmail is provided (L315-316)', async () => {
|
|
345
|
+
spawnHandler = (inv) => {
|
|
346
|
+
if (inv.args[0] === 'diff') return { exitCode: 1 };
|
|
347
|
+
if (inv.args[0] === 'rev-parse') return { stdout: 'shaaaaaa\n' };
|
|
348
|
+
return { exitCode: 0 };
|
|
349
|
+
};
|
|
350
|
+
await commitAndPush({
|
|
351
|
+
apiUrl: 'https://api.shogo.ai',
|
|
352
|
+
apiKey: 'k',
|
|
353
|
+
projectId: 'p',
|
|
354
|
+
localDir: '/tmp/never',
|
|
355
|
+
message: 'feat: x',
|
|
356
|
+
authorEmail: 'dev@shogo.ai',
|
|
357
|
+
});
|
|
358
|
+
const addInv = spawnInvocations.find((i) => i.args[0] === 'add')!;
|
|
359
|
+
expect(addInv.env?.GIT_AUTHOR_EMAIL).toBe('dev@shogo.ai');
|
|
360
|
+
expect(addInv.env?.GIT_COMMITTER_EMAIL).toBe('dev@shogo.ai');
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
it('sets GIT_AUTHOR_NAME + GIT_COMMITTER_NAME when authorName is provided (L319-320)', async () => {
|
|
364
|
+
spawnHandler = (inv) => {
|
|
365
|
+
if (inv.args[0] === 'diff') return { exitCode: 1 };
|
|
366
|
+
if (inv.args[0] === 'rev-parse') return { stdout: 'shaaaaaa\n' };
|
|
367
|
+
return { exitCode: 0 };
|
|
368
|
+
};
|
|
369
|
+
await commitAndPush({
|
|
370
|
+
apiUrl: 'https://api.shogo.ai',
|
|
371
|
+
apiKey: 'k',
|
|
372
|
+
projectId: 'p',
|
|
373
|
+
localDir: '/tmp/never',
|
|
374
|
+
message: 'feat: x',
|
|
375
|
+
authorName: 'Shogo Bot',
|
|
376
|
+
});
|
|
377
|
+
const addInv = spawnInvocations.find((i) => i.args[0] === 'add')!;
|
|
378
|
+
expect(addInv.env?.GIT_AUTHOR_NAME).toBe('Shogo Bot');
|
|
379
|
+
expect(addInv.env?.GIT_COMMITTER_NAME).toBe('Shogo Bot');
|
|
380
|
+
});
|
|
381
|
+
});
|
|
382
|
+
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Copyright (C) 2026 Shogo Technologies, Inc.
|
|
3
|
+
/**
|
|
4
|
+
* Tests for the `WorkerRuntimeManager` auto-pull hook (Chunk E of the
|
|
5
|
+
* project-pull feature).
|
|
6
|
+
*
|
|
7
|
+
* We exercise the path via the public `ensurePulled(projectId, config)`
|
|
8
|
+
* entry point so we don't have to spawn an actual agent-runtime binary.
|
|
9
|
+
* The internal CloudFileTransport is exercised end-to-end against a fake
|
|
10
|
+
* `fetch` that scripts the manifest + presign + download responses.
|
|
11
|
+
*
|
|
12
|
+
* Coverage:
|
|
13
|
+
* - autoPull disabled → no clone, projectDir not injected
|
|
14
|
+
* - autoPull enabled + empty target dir → clone runs, projectDir set
|
|
15
|
+
* - autoPull enabled + populated target dir → no clone (skip)
|
|
16
|
+
* - autoPull enabled + cloud failure → soft-fail, projectDir still set
|
|
17
|
+
* - second call for same projectId is short-circuited (idempotent)
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test';
|
|
21
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync, existsSync, readFileSync } from 'node:fs';
|
|
22
|
+
import { tmpdir } from 'node:os';
|
|
23
|
+
import { join } from 'node:path';
|
|
24
|
+
|
|
25
|
+
// We need to inject a fake fetch into the global so the SDK's
|
|
26
|
+
// CloudFileTransport picks it up.
|
|
27
|
+
let scriptedFetch: typeof fetch | null = null;
|
|
28
|
+
const originalFetch = globalThis.fetch;
|
|
29
|
+
beforeEach(() => {
|
|
30
|
+
globalThis.fetch = ((input: any, init?: RequestInit) => {
|
|
31
|
+
if (!scriptedFetch) throw new Error('no scripted fetch set');
|
|
32
|
+
return scriptedFetch(input, init);
|
|
33
|
+
}) as any;
|
|
34
|
+
});
|
|
35
|
+
afterEach(() => {
|
|
36
|
+
globalThis.fetch = originalFetch;
|
|
37
|
+
scriptedFetch = null;
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
function scriptManifest(files: Array<{ path: string; size: number; content: string }>) {
|
|
41
|
+
return (async (input: any, init?: RequestInit) => {
|
|
42
|
+
const url = typeof input === 'string' ? input : input.url;
|
|
43
|
+
if (url.includes('/workspace/manifest')) {
|
|
44
|
+
return new Response(
|
|
45
|
+
JSON.stringify({
|
|
46
|
+
ok: true,
|
|
47
|
+
projectId: 'p',
|
|
48
|
+
source: 's3',
|
|
49
|
+
generatedAt: '',
|
|
50
|
+
files: files.map((f) => ({ path: f.path, size: f.size, lastModified: null, etag: null })),
|
|
51
|
+
}),
|
|
52
|
+
{ status: 200, headers: { 'content-type': 'application/json' } },
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
if (url.includes('/s3/presign')) {
|
|
56
|
+
const body = JSON.parse((init?.body as string) || '{}') as { files: Array<{ path: string; action: string }> };
|
|
57
|
+
return new Response(
|
|
58
|
+
JSON.stringify({
|
|
59
|
+
ok: true,
|
|
60
|
+
urls: body.files.map((f) => ({ path: f.path, action: f.action, url: `https://dl.test/${f.path}` })),
|
|
61
|
+
}),
|
|
62
|
+
{ status: 200 },
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
if (url.startsWith('https://dl.test/')) {
|
|
66
|
+
const path = url.slice('https://dl.test/'.length);
|
|
67
|
+
const file = files.find((f) => f.path === path);
|
|
68
|
+
if (!file) return new Response('not found', { status: 404 });
|
|
69
|
+
return new Response(file.content, { status: 200 });
|
|
70
|
+
}
|
|
71
|
+
return new Response('unhandled', { status: 500 });
|
|
72
|
+
}) as unknown as typeof fetch;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
describe('WorkerRuntimeManager auto-pull', () => {
|
|
76
|
+
let dir: string;
|
|
77
|
+
beforeEach(() => {
|
|
78
|
+
dir = mkdtempSync(join(tmpdir(), 'wrm-auto-pull-'));
|
|
79
|
+
});
|
|
80
|
+
afterEach(() => {
|
|
81
|
+
try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('throws a loud, multi-line error when auto-pull is disabled and no workspace was pre-pulled', async () => {
|
|
85
|
+
const { WorkerRuntimeManager } = await import('../runtime-manager.ts');
|
|
86
|
+
scriptedFetch = scriptManifest([{ path: 'a.ts', size: 1, content: 'A' }]);
|
|
87
|
+
|
|
88
|
+
const mgr = new WorkerRuntimeManager({
|
|
89
|
+
autoPull: { enabled: false, projectsDir: dir },
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
let caught: Error | null = null;
|
|
93
|
+
try {
|
|
94
|
+
await mgr.ensurePulled('proj-1', {
|
|
95
|
+
cloudUrl: 'https://api.test',
|
|
96
|
+
apiKey: 'shogo_sk_x',
|
|
97
|
+
});
|
|
98
|
+
} catch (err: any) {
|
|
99
|
+
caught = err;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
expect(caught).not.toBeNull();
|
|
103
|
+
const msg = caught!.message;
|
|
104
|
+
// Multi-line so a future debugger reading the worker's stderr can
|
|
105
|
+
// see the full menu of fixes without log archaeology.
|
|
106
|
+
expect(msg.split('\n').length).toBeGreaterThan(5);
|
|
107
|
+
expect(msg).toContain('auto-pull was disabled');
|
|
108
|
+
expect(msg).toContain('--no-auto-pull');
|
|
109
|
+
expect(msg).toContain('shogo project pull');
|
|
110
|
+
expect(msg).toContain('--projects-dir');
|
|
111
|
+
expect(msg).toContain('SHOGO_PROJECTS_DIR');
|
|
112
|
+
expect(msg).toContain(join(dir, 'proj-1'));
|
|
113
|
+
expect(msg).toContain('https://shogo.ai/docs/self-hosted-worker');
|
|
114
|
+
|
|
115
|
+
// The ask was loud-failure, not silent-mkdir: the dir must NOT have
|
|
116
|
+
// been touched (so a follow-up `shogo project pull` lands cleanly).
|
|
117
|
+
expect(existsSync(join(dir, 'proj-1'))).toBe(false);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('honours a pre-pulled workspace when auto-pull is disabled', async () => {
|
|
121
|
+
const { WorkerRuntimeManager } = await import('../runtime-manager.ts');
|
|
122
|
+
// No scripted fetch — the manager must NOT try to clone.
|
|
123
|
+
scriptedFetch = (async () => {
|
|
124
|
+
throw new Error('auto-pull is disabled, no fetch should fire');
|
|
125
|
+
}) as unknown as typeof fetch;
|
|
126
|
+
|
|
127
|
+
const target = join(dir, 'proj-prepulled');
|
|
128
|
+
mkdirSync(target, { recursive: true });
|
|
129
|
+
writeFileSync(join(target, 'AGENTS.md'), 'pre-pulled by `shogo project pull`');
|
|
130
|
+
|
|
131
|
+
const mgr = new WorkerRuntimeManager({
|
|
132
|
+
autoPull: { enabled: false, projectsDir: dir },
|
|
133
|
+
});
|
|
134
|
+
const result = await mgr.ensurePulled('proj-prepulled', {
|
|
135
|
+
cloudUrl: 'https://api.test',
|
|
136
|
+
apiKey: 'shogo_sk_x',
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
expect(result.projectDir).toBe(target);
|
|
140
|
+
expect(readFileSync(join(target, 'AGENTS.md'), 'utf-8')).toBe(
|
|
141
|
+
'pre-pulled by `shogo project pull`',
|
|
142
|
+
);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('throws when no autoPull config and no caller-provided projectDir', async () => {
|
|
146
|
+
const { WorkerRuntimeManager } = await import('../runtime-manager.ts');
|
|
147
|
+
|
|
148
|
+
const mgr = new WorkerRuntimeManager({});
|
|
149
|
+
|
|
150
|
+
let caught: Error | null = null;
|
|
151
|
+
try {
|
|
152
|
+
await mgr.ensurePulled('proj-x', {
|
|
153
|
+
cloudUrl: 'https://api.test',
|
|
154
|
+
apiKey: 'shogo_sk_x',
|
|
155
|
+
});
|
|
156
|
+
} catch (err: any) {
|
|
157
|
+
caught = err;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
expect(caught).not.toBeNull();
|
|
161
|
+
expect(caught!.message).toContain('without an `autoPull` config');
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('honours an existing caller-provided projectDir without touching autoPull', async () => {
|
|
165
|
+
const { WorkerRuntimeManager } = await import('../runtime-manager.ts');
|
|
166
|
+
scriptedFetch = (async () => {
|
|
167
|
+
throw new Error('caller-provided projectDir — no fetch should fire');
|
|
168
|
+
}) as unknown as typeof fetch;
|
|
169
|
+
|
|
170
|
+
const target = join(dir, 'desktop-managed');
|
|
171
|
+
mkdirSync(target, { recursive: true });
|
|
172
|
+
|
|
173
|
+
const mgr = new WorkerRuntimeManager({
|
|
174
|
+
// autoPull intentionally omitted — desktop adapter case.
|
|
175
|
+
});
|
|
176
|
+
const result = await mgr.ensurePulled('proj-desktop', {
|
|
177
|
+
cloudUrl: 'https://api.test',
|
|
178
|
+
apiKey: 'shogo_sk_x',
|
|
179
|
+
projectDir: target,
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
expect(result.projectDir).toBe(target);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('clones into <projectsDir>/<projectId> when the dir is empty', async () => {
|
|
186
|
+
const { WorkerRuntimeManager } = await import('../runtime-manager.ts');
|
|
187
|
+
scriptedFetch = scriptManifest([
|
|
188
|
+
{ path: 'config.json', size: 2, content: '{}' },
|
|
189
|
+
{ path: 'AGENTS.md', size: 5, content: 'AGENT' },
|
|
190
|
+
]);
|
|
191
|
+
|
|
192
|
+
const mgr = new WorkerRuntimeManager({
|
|
193
|
+
autoPull: { enabled: true, projectsDir: dir, watch: false, useGit: false },
|
|
194
|
+
});
|
|
195
|
+
const result = await mgr.ensurePulled('proj-2', {
|
|
196
|
+
cloudUrl: 'https://api.test',
|
|
197
|
+
apiKey: 'shogo_sk_x',
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
expect(result.projectDir).toBe(join(dir, 'proj-2'));
|
|
201
|
+
expect(existsSync(join(dir, 'proj-2', 'config.json'))).toBe(true);
|
|
202
|
+
expect(readFileSync(join(dir, 'proj-2', 'config.json'), 'utf-8')).toBe('{}');
|
|
203
|
+
expect(readFileSync(join(dir, 'proj-2', 'AGENTS.md'), 'utf-8')).toBe('AGENT');
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it('skips clone when the target directory already has files', async () => {
|
|
207
|
+
const { WorkerRuntimeManager } = await import('../runtime-manager.ts');
|
|
208
|
+
const target = join(dir, 'proj-3');
|
|
209
|
+
mkdirSync(target, { recursive: true });
|
|
210
|
+
writeFileSync(join(target, 'config.json'), '{"existing": true}');
|
|
211
|
+
|
|
212
|
+
// Set the manifest to return a different file — if clone runs, it would
|
|
213
|
+
// overwrite. Use atomic-replace semantics from downloadAll() so we'd
|
|
214
|
+
// see the existing file blown away. We assert it's preserved.
|
|
215
|
+
scriptedFetch = scriptManifest([{ path: 'fresh.md', size: 5, content: 'NEW' }]);
|
|
216
|
+
|
|
217
|
+
const mgr = new WorkerRuntimeManager({
|
|
218
|
+
autoPull: { enabled: true, projectsDir: dir, watch: false, useGit: false },
|
|
219
|
+
});
|
|
220
|
+
const result = await mgr.ensurePulled('proj-3', {
|
|
221
|
+
cloudUrl: 'https://api.test',
|
|
222
|
+
apiKey: 'shogo_sk_x',
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
expect(result.projectDir).toBe(target);
|
|
226
|
+
expect(existsSync(join(target, 'fresh.md'))).toBe(false);
|
|
227
|
+
expect(readFileSync(join(target, 'config.json'), 'utf-8')).toBe('{"existing": true}');
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it('soft-fails when the cloud is unreachable, still sets projectDir', async () => {
|
|
231
|
+
const { WorkerRuntimeManager } = await import('../runtime-manager.ts');
|
|
232
|
+
// Manifest returns 500 — autoPull should swallow and continue.
|
|
233
|
+
scriptedFetch = (async () => new Response('boom', { status: 500 })) as unknown as typeof fetch;
|
|
234
|
+
|
|
235
|
+
const warnings: string[] = [];
|
|
236
|
+
const logger = {
|
|
237
|
+
log: () => {},
|
|
238
|
+
warn: (...args: any[]) => warnings.push(args.join(' ')),
|
|
239
|
+
error: () => {},
|
|
240
|
+
};
|
|
241
|
+
const mgr = new WorkerRuntimeManager({
|
|
242
|
+
autoPull: { enabled: true, projectsDir: dir, watch: false, logger, useGit: false },
|
|
243
|
+
});
|
|
244
|
+
const result = await mgr.ensurePulled('proj-4', {
|
|
245
|
+
cloudUrl: 'https://api.test',
|
|
246
|
+
apiKey: 'shogo_sk_x',
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
expect(result.projectDir).toBe(join(dir, 'proj-4'));
|
|
250
|
+
expect(warnings.some((w) => w.includes('auto-pull: failed for proj-4'))).toBe(true);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it('is idempotent — second call short-circuits the manifest fetch', async () => {
|
|
254
|
+
const { WorkerRuntimeManager } = await import('../runtime-manager.ts');
|
|
255
|
+
let manifestRequests = 0;
|
|
256
|
+
scriptedFetch = (async (input: any, init?: RequestInit) => {
|
|
257
|
+
const url = typeof input === 'string' ? input : input.url;
|
|
258
|
+
if (url.includes('/workspace/manifest')) {
|
|
259
|
+
manifestRequests++;
|
|
260
|
+
return new Response(JSON.stringify({ ok: true, files: [], source: 's3', generatedAt: '' }), { status: 200 });
|
|
261
|
+
}
|
|
262
|
+
return new Response('', { status: 200 });
|
|
263
|
+
}) as unknown as typeof fetch;
|
|
264
|
+
|
|
265
|
+
const mgr = new WorkerRuntimeManager({
|
|
266
|
+
autoPull: { enabled: true, projectsDir: dir, watch: false, useGit: false },
|
|
267
|
+
});
|
|
268
|
+
const config = { cloudUrl: 'https://api.test', apiKey: 'shogo_sk_x' };
|
|
269
|
+
await mgr.ensurePulled('proj-5', config);
|
|
270
|
+
await mgr.ensurePulled('proj-5', config);
|
|
271
|
+
await mgr.ensurePulled('proj-5', config);
|
|
272
|
+
|
|
273
|
+
expect(manifestRequests).toBe(1);
|
|
274
|
+
});
|
|
275
|
+
});
|