@shogo-ai/worker 1.8.10 → 1.8.12
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/package.json +1 -1
- package/src/lib/__tests__/cloud-login-coverage.test.ts +210 -0
- package/src/lib/__tests__/git-cloner.test.ts +130 -6
- package/src/lib/__tests__/runtime-manager-coverage-gaps.test.ts +768 -0
- package/src/lib/__tests__/runtime-manager-wait-for-health.test.ts +66 -48
- package/src/lib/__tests__/tunnel-coverage.test.ts +1094 -0
- package/src/lib/runtime-manager.ts +112 -62
- package/src/lib/tunnel.ts +0 -4
package/package.json
CHANGED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Copyright (C) 2026 Shogo Technologies, Inc.
|
|
3
|
+
/**
|
|
4
|
+
* Coverage gaps for cloud-login.ts.
|
|
5
|
+
*
|
|
6
|
+
* Targets:
|
|
7
|
+
* L201-205 sigHandler body (SIGINT/SIGTERM aborts the poll loop)
|
|
8
|
+
* L239-244 poll fetch network error → .catch swallows and retries
|
|
9
|
+
* L287-289 sleep() onAbort handler (signal fires DURING sleep)
|
|
10
|
+
* L323-337 openInBrowser (default openBrowser path)
|
|
11
|
+
*/
|
|
12
|
+
import { describe, it, expect } from 'bun:test';
|
|
13
|
+
import { runCloudLogin, CloudLoginError } from '../cloud-login.ts';
|
|
14
|
+
|
|
15
|
+
function jsonResponse(body: unknown, status = 200) {
|
|
16
|
+
return new Response(JSON.stringify(body), {
|
|
17
|
+
status,
|
|
18
|
+
headers: { 'content-type': 'application/json' },
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const startBody = {
|
|
23
|
+
ok: true,
|
|
24
|
+
state: 'state-abc',
|
|
25
|
+
userCode: 'XXXXXX',
|
|
26
|
+
authUrl: 'https://cloud.example.com/auth/cli-link?state=state-abc',
|
|
27
|
+
expiresInMs: 60_000,
|
|
28
|
+
pollIntervalMs: 1,
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
describe('runCloudLogin — sigHandler (L201-205)', () => {
|
|
32
|
+
it('SIGINT during the poll loop unwinds via cancelled CloudLoginError', async () => {
|
|
33
|
+
// Issue SIGINT after start; the sigHandler should abort the local controller.
|
|
34
|
+
let pollCount = 0;
|
|
35
|
+
const fetchImpl = (async (input: RequestInfo | URL) => {
|
|
36
|
+
const url = typeof input === 'string' ? input : input.toString();
|
|
37
|
+
if (url.endsWith('/api/cli/login/start')) {
|
|
38
|
+
return jsonResponse(startBody);
|
|
39
|
+
}
|
|
40
|
+
pollCount += 1;
|
|
41
|
+
if (pollCount === 1) {
|
|
42
|
+
// Fire SIGINT in the next microtask while the loop is about to sleep
|
|
43
|
+
setImmediate(() => process.emit('SIGINT' as any));
|
|
44
|
+
return jsonResponse({ ok: true, status: 'pending' });
|
|
45
|
+
}
|
|
46
|
+
return jsonResponse({ ok: true, status: 'pending' });
|
|
47
|
+
}) as unknown as typeof fetch;
|
|
48
|
+
|
|
49
|
+
try {
|
|
50
|
+
await runCloudLogin({
|
|
51
|
+
cloudUrl: 'https://cloud.example.com',
|
|
52
|
+
deviceId: 'dev-abc',
|
|
53
|
+
openBrowser: false,
|
|
54
|
+
pollIntervalMs: 100,
|
|
55
|
+
installSignalHandlers: true,
|
|
56
|
+
log: () => {},
|
|
57
|
+
fetchImpl,
|
|
58
|
+
});
|
|
59
|
+
throw new Error('should have thrown');
|
|
60
|
+
} catch (err) {
|
|
61
|
+
expect(err).toBeInstanceOf(CloudLoginError);
|
|
62
|
+
expect((err as CloudLoginError).kind).toBe('cancelled');
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe('runCloudLogin — poll network error .catch (L239-244)', () => {
|
|
68
|
+
it('soft network error during poll is logged and the loop retries until approved', async () => {
|
|
69
|
+
let pollCount = 0;
|
|
70
|
+
const logs: string[] = [];
|
|
71
|
+
const fetchImpl = (async (input: RequestInfo | URL) => {
|
|
72
|
+
const url = typeof input === 'string' ? input : input.toString();
|
|
73
|
+
if (url.endsWith('/api/cli/login/start')) {
|
|
74
|
+
return jsonResponse(startBody);
|
|
75
|
+
}
|
|
76
|
+
pollCount += 1;
|
|
77
|
+
if (pollCount === 1) {
|
|
78
|
+
throw new Error('ECONNRESET on first poll');
|
|
79
|
+
}
|
|
80
|
+
return jsonResponse({
|
|
81
|
+
ok: true, status: 'approved', key: 'shogo_sk_recovered',
|
|
82
|
+
email: 'u@e.com', workspace: 'w', deviceId: 'd',
|
|
83
|
+
});
|
|
84
|
+
}) as unknown as typeof fetch;
|
|
85
|
+
|
|
86
|
+
const result = await runCloudLogin({
|
|
87
|
+
cloudUrl: 'https://cloud.example.com',
|
|
88
|
+
deviceId: 'd',
|
|
89
|
+
openBrowser: false,
|
|
90
|
+
pollIntervalMs: 1,
|
|
91
|
+
installSignalHandlers: false,
|
|
92
|
+
log: (s) => logs.push(s),
|
|
93
|
+
fetchImpl,
|
|
94
|
+
});
|
|
95
|
+
expect(result.key).toBe('shogo_sk_recovered');
|
|
96
|
+
expect(logs.some((l) => l.includes('poll error'))).toBe(true);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe('runCloudLogin — sleep onAbort (L287-289)', () => {
|
|
101
|
+
it('abort signal fires DURING the inter-poll sleep → cancelled CloudLoginError', async () => {
|
|
102
|
+
// Use a long pollIntervalMs so sleep is in flight when we abort.
|
|
103
|
+
const ac = new AbortController();
|
|
104
|
+
let pollCount = 0;
|
|
105
|
+
const fetchImpl = (async (input: RequestInfo | URL) => {
|
|
106
|
+
const url = typeof input === 'string' ? input : input.toString();
|
|
107
|
+
if (url.endsWith('/api/cli/login/start')) {
|
|
108
|
+
return jsonResponse({ ...startBody, pollIntervalMs: 5000 });
|
|
109
|
+
}
|
|
110
|
+
pollCount += 1;
|
|
111
|
+
if (pollCount === 1) {
|
|
112
|
+
// After this poll, sleep(5000) begins. Schedule abort 20ms later.
|
|
113
|
+
setTimeout(() => ac.abort(), 20);
|
|
114
|
+
return jsonResponse({ ok: true, status: 'pending' });
|
|
115
|
+
}
|
|
116
|
+
return jsonResponse({ ok: true, status: 'pending' });
|
|
117
|
+
}) as unknown as typeof fetch;
|
|
118
|
+
|
|
119
|
+
try {
|
|
120
|
+
await runCloudLogin({
|
|
121
|
+
cloudUrl: 'https://cloud.example.com',
|
|
122
|
+
deviceId: 'd',
|
|
123
|
+
openBrowser: false,
|
|
124
|
+
pollIntervalMs: 5000, // long sleep
|
|
125
|
+
installSignalHandlers: false,
|
|
126
|
+
log: () => {},
|
|
127
|
+
fetchImpl,
|
|
128
|
+
abortSignal: ac.signal,
|
|
129
|
+
});
|
|
130
|
+
throw new Error('should have thrown');
|
|
131
|
+
} catch (err) {
|
|
132
|
+
expect(err).toBeInstanceOf(CloudLoginError);
|
|
133
|
+
expect((err as CloudLoginError).kind).toBe('cancelled');
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
describe('runCloudLogin — openInBrowser default path (L323-337)', () => {
|
|
139
|
+
it('with openBrowser undefined: invokes openInBrowser (default path) without breaking the flow', async () => {
|
|
140
|
+
// Default openBrowser triggers spawn('xdg-open' | 'open' | 'cmd'). On a
|
|
141
|
+
// sandbox the spawn may fail or succeed; in BOTH cases the .catch on the
|
|
142
|
+
// call site swallows it. We're after coverage of L323-334 / L335-337.
|
|
143
|
+
let pollCount = 0;
|
|
144
|
+
const fetchImpl = (async (input: RequestInfo | URL) => {
|
|
145
|
+
const url = typeof input === 'string' ? input : input.toString();
|
|
146
|
+
if (url.endsWith('/api/cli/login/start')) {
|
|
147
|
+
return jsonResponse(startBody);
|
|
148
|
+
}
|
|
149
|
+
pollCount += 1;
|
|
150
|
+
return jsonResponse({
|
|
151
|
+
ok: true, status: 'approved', key: 'shogo_sk_via_browser',
|
|
152
|
+
email: 'u@e.com', workspace: 'w', deviceId: 'd',
|
|
153
|
+
});
|
|
154
|
+
}) as unknown as typeof fetch;
|
|
155
|
+
|
|
156
|
+
const result = await runCloudLogin({
|
|
157
|
+
cloudUrl: 'https://cloud.example.com',
|
|
158
|
+
deviceId: 'd',
|
|
159
|
+
// openBrowser omitted → defaults to true → openInBrowser runs
|
|
160
|
+
pollIntervalMs: 1,
|
|
161
|
+
installSignalHandlers: false,
|
|
162
|
+
log: () => {},
|
|
163
|
+
fetchImpl,
|
|
164
|
+
});
|
|
165
|
+
expect(result.key).toBe('shogo_sk_via_browser');
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('openInBrowser swallows synchronous spawn errors via the outer try/catch (L335-337)', async () => {
|
|
169
|
+
// Force spawn to throw synchronously by temporarily replacing it via
|
|
170
|
+
// process.binding-style override. The cleanest cross-runtime way: monkey-
|
|
171
|
+
// patch the resolved module's spawn. cloud-login imports `spawn` at the
|
|
172
|
+
// top, so we can't change it post-import. Instead, indirectly: pass a
|
|
173
|
+
// platform that resolves to a binary that almost certainly does NOT exist
|
|
174
|
+
// — and rely on the catch branch firing if spawn throws.
|
|
175
|
+
//
|
|
176
|
+
// In practice on the sandbox `spawn('xdg-open', ...)` resolves to ENOENT
|
|
177
|
+
// asynchronously via child.on('error'), not synchronously, so this test
|
|
178
|
+
// mainly documents the contract: a synchronous spawn failure is silently
|
|
179
|
+
// swallowed and the flow still completes.
|
|
180
|
+
let pollCount = 0;
|
|
181
|
+
const fetchImpl = (async (input: RequestInfo | URL) => {
|
|
182
|
+
const url = typeof input === 'string' ? input : input.toString();
|
|
183
|
+
if (url.endsWith('/api/cli/login/start')) {
|
|
184
|
+
return jsonResponse(startBody);
|
|
185
|
+
}
|
|
186
|
+
pollCount += 1;
|
|
187
|
+
return jsonResponse({
|
|
188
|
+
ok: true, status: 'approved', key: 'shogo_sk_browser_failed',
|
|
189
|
+
email: 'u@e.com', workspace: 'w', deviceId: 'd',
|
|
190
|
+
});
|
|
191
|
+
}) as unknown as typeof fetch;
|
|
192
|
+
|
|
193
|
+
// Custom openBrowser function that throws — runCloudLogin .catch()es it.
|
|
194
|
+
// This exercises L189-192 (custom openBrowser branch). For the actual
|
|
195
|
+
// spawn-throws branch in openInBrowser we'd need module mocking, which
|
|
196
|
+
// would conflict with the git-cloner.test.ts spawn mock running in the
|
|
197
|
+
// same Bun process. The default-openBrowser test above already exercises
|
|
198
|
+
// openInBrowser's spawn path with whatever the sandbox provides.
|
|
199
|
+
const result = await runCloudLogin({
|
|
200
|
+
cloudUrl: 'https://cloud.example.com',
|
|
201
|
+
deviceId: 'd',
|
|
202
|
+
openBrowser: () => { throw new Error('user opener boom'); },
|
|
203
|
+
pollIntervalMs: 1,
|
|
204
|
+
installSignalHandlers: false,
|
|
205
|
+
log: () => {},
|
|
206
|
+
fetchImpl,
|
|
207
|
+
});
|
|
208
|
+
expect(result.key).toBe('shogo_sk_browser_failed');
|
|
209
|
+
});
|
|
210
|
+
});
|
|
@@ -22,12 +22,14 @@ interface SpawnInvocation {
|
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
const spawnInvocations: SpawnInvocation[] = [];
|
|
25
|
-
type
|
|
26
|
-
|
|
27
|
-
) => { stdout?: string; stderr?: string; exitCode?: number; runError?: Error };
|
|
25
|
+
type SpawnResult = { stdout?: string; stderr?: string; exitCode?: number; runError?: Error; hang?: boolean };
|
|
26
|
+
type SpawnHandler = (inv: SpawnInvocation) => SpawnResult;
|
|
28
27
|
let spawnHandler: SpawnHandler = () => ({ exitCode: 0 });
|
|
29
28
|
|
|
30
|
-
|
|
29
|
+
let execFileHandler: (cmd: string, args: string[]) => { error?: Error; stdout?: string } =
|
|
30
|
+
() => ({ stdout: 'git version 2.40.0' });
|
|
31
|
+
|
|
32
|
+
function makeFakeChild(result: SpawnResult) {
|
|
31
33
|
const child = new EventEmitter() as any;
|
|
32
34
|
child.stdout = new EventEmitter();
|
|
33
35
|
child.stdout.setEncoding = () => {};
|
|
@@ -38,6 +40,7 @@ function makeFakeChild(result: { stdout?: string; stderr?: string; exitCode?: nu
|
|
|
38
40
|
child.signalCode = null;
|
|
39
41
|
// Emit chunks + close asynchronously so callers' on() handlers register first.
|
|
40
42
|
setTimeout(() => {
|
|
43
|
+
if (result.hang) return; // never emit close — used for timeout tests
|
|
41
44
|
if (result.runError) {
|
|
42
45
|
child.emit('error', result.runError);
|
|
43
46
|
return;
|
|
@@ -60,9 +63,12 @@ mock.module('node:child_process', () => ({
|
|
|
60
63
|
execFile: (cmd: string, args: string[], opts: any, cb: any) => {
|
|
61
64
|
const inv: SpawnInvocation = { cmd, args };
|
|
62
65
|
spawnInvocations.push(inv);
|
|
63
|
-
// probe for `git --version`
|
|
64
66
|
const callback = typeof opts === 'function' ? opts : cb;
|
|
65
|
-
|
|
67
|
+
const r = execFileHandler(cmd, args);
|
|
68
|
+
setTimeout(() => {
|
|
69
|
+
if (r.error) callback?.(r.error, '', '');
|
|
70
|
+
else callback?.(null, r.stdout ?? '', '');
|
|
71
|
+
}, 1);
|
|
66
72
|
},
|
|
67
73
|
}));
|
|
68
74
|
|
|
@@ -72,6 +78,7 @@ const { buildGitUrl, cloneProject, commitAndPush, gitIsAvailable, isGitRepo, git
|
|
|
72
78
|
beforeEach(() => {
|
|
73
79
|
spawnInvocations.length = 0;
|
|
74
80
|
spawnHandler = () => ({ exitCode: 0 });
|
|
81
|
+
execFileHandler = () => ({ stdout: 'git version 2.40.0' });
|
|
75
82
|
});
|
|
76
83
|
|
|
77
84
|
describe('buildGitUrl', () => {
|
|
@@ -256,3 +263,120 @@ describe('isGitRepo', () => {
|
|
|
256
263
|
}
|
|
257
264
|
});
|
|
258
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
|
+
|