@shogo-ai/worker 1.9.7 → 1.9.8

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shogo-ai/worker",
3
- "version": "1.9.7",
3
+ "version": "1.9.8",
4
4
  "description": "Shogo Cloud Agent Worker — run Shogo agents on your own machine (laptop, devbox, CI).",
5
5
  "license": "MIT",
6
6
  "author": "Shogo Technologies, Inc.",
@@ -0,0 +1,463 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Copyright (C) 2026 Shogo Technologies, Inc.
3
+ /**
4
+ * Greenfield sweep for three never-loaded shogo-worker files:
5
+ * lib/runtime-install.ts, commands/project-checkout.ts, commands/project-push.ts
6
+ * Shared mocks: node:fs / node:child_process / node:crypto / node:os /
7
+ * node:stream / node:stream/promises / ../config / ../paths / ../git-cloner /
8
+ * @shogo-ai/sdk/cloud-file-transport, plus a routable global fetch.
9
+ */
10
+ import { describe, test, expect, beforeEach, afterEach, mock, afterAll } from 'bun:test'
11
+ import { EventEmitter } from 'node:events'
12
+
13
+ // ── routable global fetch ─────────────────────────────────────────────────────
14
+ type FetchHandler = (url: string, init?: any) => any
15
+ let fetchHandler: FetchHandler = () => { throw new Error('fetch not configured') }
16
+ const origFetch = globalThis.fetch
17
+ beforeEach(() => {
18
+ fetchHandler = () => { throw new Error('fetch not configured') }
19
+ ;(globalThis as any).fetch = (url: any, init?: any) => Promise.resolve(fetchHandler(String(url), init))
20
+ })
21
+ afterEach(() => { (globalThis as any).fetch = origFetch })
22
+
23
+ // ── controllable node:fs ──────────────────────────────────────────────────────
24
+ let existsPredicate: (p: string) => boolean = () => false
25
+ let readFileImpl: (p: string) => any = () => ''
26
+ const fsWrites: Array<{ path: string; data: string }> = []
27
+ const fsRenames: Array<{ from: string; to: string }> = []
28
+ const fsRemoves: string[] = []
29
+ const fsMkdirs: string[] = []
30
+ mock.module('node:fs', () => ({
31
+ existsSync: (p: string) => existsPredicate(String(p)),
32
+ readFileSync: (p: string) => readFileImpl(String(p)),
33
+ writeFileSync: (p: string, data: any) => { fsWrites.push({ path: String(p), data: String(data) }) },
34
+ mkdirSync: (p: string) => { fsMkdirs.push(String(p)) },
35
+ renameSync: (a: string, b: string) => { fsRenames.push({ from: String(a), to: String(b) }) },
36
+ rmSync: (p: string) => { fsRemoves.push(String(p)) },
37
+ chmodSync: () => {},
38
+ createWriteStream: () => ({ on: () => {}, end: () => {}, write: () => true }),
39
+ }))
40
+
41
+ // ── node:crypto (deterministic digest) ────────────────────────────────────────
42
+ let computedDigest = 'a'.repeat(64)
43
+ mock.module('node:crypto', () => ({
44
+ createHash: () => ({ update: () => ({ digest: () => computedDigest }) }),
45
+ }))
46
+
47
+ // ── node:os / node:stream / node:stream/promises ──────────────────────────────
48
+ mock.module('node:os', () => ({ tmpdir: () => '/tmp' }))
49
+ mock.module('node:stream', () => ({ Readable: { fromWeb: () => ({}) } }))
50
+ let pipelineThrows = false
51
+ mock.module('node:stream/promises', () => ({
52
+ pipeline: async () => { if (pipelineThrows) throw new Error('pipeline boom') },
53
+ }))
54
+
55
+ // ── fake child_process.spawn (tar) ────────────────────────────────────────────
56
+ let tarExitCode = 0
57
+ let tarEmitsError = false
58
+ class FakeTar extends EventEmitter {
59
+ stderr = new EventEmitter()
60
+ constructor() {
61
+ super()
62
+ queueMicrotask(() => {
63
+ if (tarEmitsError) { this.emit('error', new Error('spawn failed')); return }
64
+ if (tarExitCode !== 0) this.stderr.emit('data', Buffer.from('tar failure'))
65
+ this.emit('exit', tarExitCode)
66
+ })
67
+ }
68
+ }
69
+ mock.module('node:child_process', () => ({
70
+ spawn: () => new FakeTar(),
71
+ }))
72
+
73
+ // ── ../config ─────────────────────────────────────────────────────────────────
74
+ mock.module('../config.ts', () => ({
75
+ resolveConfig: (o: any = {}) => ({
76
+ apiKey: o.apiKey ?? 'key-123',
77
+ cloudUrl: o.cloudUrl ?? 'https://cloud.shogo.dev',
78
+ projectsDir: '/projects',
79
+ }),
80
+ }))
81
+
82
+ // ── ../paths (serves both runtime-install ./paths and commands ../lib/paths) ────
83
+ mock.module('../paths.ts', () => ({
84
+ RUNTIME_BIN: '/runtime/agent-runtime',
85
+ RUNTIME_DIR: '/runtime',
86
+ RUNTIME_VERSION_FILE: '/runtime/version.json',
87
+ ensureRuntimeDir: () => {},
88
+ projectDirFor: (id: string) => `/projects/${id}`,
89
+ }))
90
+
91
+ // ── ../git-cloner ─────────────────────────────────────────────────────────────
92
+ let isGitRepoVal = true
93
+ let runGitImpl: (args: string[]) => any = async () => ({ stdout: '', stderr: '' })
94
+ const gitCalls: { fetchReset: any[]; unshallow: any[] } = { fetchReset: [], unshallow: [] }
95
+ let fetchResetImpl: (o: any) => any = async () => ({ commitSha: 'abcdef1234567890' })
96
+ let unshallowImpl: (o: any) => any = async () => {}
97
+ mock.module('../git-cloner.ts', () => ({
98
+ isGitRepo: () => isGitRepoVal,
99
+ runGit: (args: string[], opts: any) => runGitImpl(args),
100
+ gitFetchAndReset: (o: any) => { gitCalls.fetchReset.push(o); return fetchResetImpl(o) },
101
+ gitFetchUnshallow: (o: any) => { gitCalls.unshallow.push(o); return unshallowImpl(o) },
102
+ }))
103
+
104
+ // ── @shogo-ai/sdk/cloud-file-transport ────────────────────────────────────────
105
+ let uploadAllImpl: (o: any) => any = async () => ({ uploaded: 3, deleted: 0, errors: [] })
106
+ let lastTransportOpts: any = null
107
+ mock.module('@shogo-ai/sdk/cloud-file-transport', () => ({
108
+ CloudFileTransport: class {
109
+ opts: any
110
+ constructor(o: any) { this.opts = o; lastTransportOpts = o }
111
+ uploadAll(args: any) { return uploadAllImpl({ ...args, ctor: this.opts }) }
112
+ },
113
+ }))
114
+
115
+ import {
116
+ detectTarget,
117
+ readInstalledVersion,
118
+ resolveLatestVersion,
119
+ installRuntime,
120
+ getRuntimePaths,
121
+ } from '../runtime-install'
122
+ import { runProjectCheckout } from '../../commands/project-checkout'
123
+ import { runProjectPush } from '../../commands/project-push'
124
+
125
+ const silentLogger = { log: () => {}, warn: () => {}, error: () => {} }
126
+
127
+ beforeEach(() => {
128
+ existsPredicate = () => false
129
+ readFileImpl = () => ''
130
+ fsWrites.length = 0; fsRenames.length = 0; fsRemoves.length = 0; fsMkdirs.length = 0
131
+ computedDigest = 'a'.repeat(64)
132
+ pipelineThrows = false
133
+ tarExitCode = 0; tarEmitsError = false
134
+ isGitRepoVal = true
135
+ runGitImpl = async () => ({ stdout: '', stderr: '' })
136
+ fetchResetImpl = async () => ({ commitSha: 'abcdef1234567890' })
137
+ unshallowImpl = async () => {}
138
+ gitCalls.fetchReset.length = 0; gitCalls.unshallow.length = 0
139
+ uploadAllImpl = async () => ({ uploaded: 3, deleted: 0, errors: [] })
140
+ lastTransportOpts = null
141
+ })
142
+
143
+ // ════════════════════════════════════════════════════════════════════════════
144
+ // runtime-install.ts
145
+ // ════════════════════════════════════════════════════════════════════════════
146
+ describe('runtime-install: detectTarget', () => {
147
+ const origPlatform = process.platform
148
+ const origArch = process.arch
149
+ function setEnv(platform: string, arch: string) {
150
+ Object.defineProperty(process, 'platform', { value: platform, configurable: true })
151
+ Object.defineProperty(process, 'arch', { value: arch, configurable: true })
152
+ }
153
+ afterEach(() => {
154
+ Object.defineProperty(process, 'platform', { value: origPlatform, configurable: true })
155
+ Object.defineProperty(process, 'arch', { value: origArch, configurable: true })
156
+ })
157
+
158
+ test('darwin/arm64', () => { setEnv('darwin', 'arm64'); expect(detectTarget()).toBe('darwin-arm64') })
159
+ test('linux/x64', () => { setEnv('linux', 'x64'); expect(detectTarget()).toBe('linux-x64') })
160
+ test('win32/x64', () => { setEnv('win32', 'x64'); expect(detectTarget()).toBe('windows-x64') })
161
+ test('unsupported platform throws', () => { setEnv('freebsd', 'x64'); expect(() => detectTarget()).toThrow(/Unsupported platform/) })
162
+ test('unsupported arch throws', () => { setEnv('linux', 'mips'); expect(() => detectTarget()).toThrow(/Unsupported arch/) })
163
+ })
164
+
165
+ describe('runtime-install: readInstalledVersion', () => {
166
+ test('returns null when version file absent', () => {
167
+ existsPredicate = () => false
168
+ expect(readInstalledVersion()).toBeNull()
169
+ })
170
+ test('parses installed version json', () => {
171
+ existsPredicate = () => true
172
+ readFileImpl = () => JSON.stringify({ version: '1.2.3', target: 'linux-x64' })
173
+ expect(readInstalledVersion()?.version).toBe('1.2.3')
174
+ })
175
+ test('returns null on malformed json', () => {
176
+ existsPredicate = () => true
177
+ readFileImpl = () => '{not-json'
178
+ expect(readInstalledVersion()).toBeNull()
179
+ })
180
+ })
181
+
182
+ describe('runtime-install: resolveLatestVersion', () => {
183
+ const ghBase = 'https://github.com/shogo-ai/shogo/releases/download'
184
+
185
+ test('non-github baseUrl throws', async () => {
186
+ await expect(resolveLatestVersion('stable', 'https://cdn.example.com/rt')).rejects.toThrow(/non-GitHub baseUrl/)
187
+ })
188
+ test('stable reads releases/latest tag_name', async () => {
189
+ fetchHandler = () => ({ ok: true, json: async () => ({ tag_name: 'v2.5.1' }) })
190
+ expect(await resolveLatestVersion('stable', ghBase)).toBe('2.5.1')
191
+ })
192
+ test('stable throws on API error', async () => {
193
+ fetchHandler = () => ({ ok: false, status: 503 })
194
+ await expect(resolveLatestVersion('stable', ghBase)).rejects.toThrow(/GitHub API 503/)
195
+ })
196
+ test('stable throws when tag_name missing', async () => {
197
+ fetchHandler = () => ({ ok: true, json: async () => ({}) })
198
+ await expect(resolveLatestVersion('stable', ghBase)).rejects.toThrow(/did not return tag_name/)
199
+ })
200
+ test('beta picks newest matching prerelease', async () => {
201
+ fetchHandler = () => ({ ok: true, json: async () => ([
202
+ { tag_name: 'v3.0.0', prerelease: false },
203
+ { tag_name: 'v3.1.0-beta.2', prerelease: true },
204
+ ]) })
205
+ expect(await resolveLatestVersion('beta', ghBase)).toBe('3.1.0-beta.2')
206
+ })
207
+ test('nightly matches -nightly tags', async () => {
208
+ fetchHandler = () => ({ ok: true, json: async () => ([
209
+ { tag_name: 'v4.0.0-nightly.7', prerelease: true },
210
+ ]) })
211
+ expect(await resolveLatestVersion('nightly', ghBase)).toBe('4.0.0-nightly.7')
212
+ })
213
+ test('prerelease list API error throws', async () => {
214
+ fetchHandler = () => ({ ok: false, status: 500 })
215
+ await expect(resolveLatestVersion('beta', ghBase)).rejects.toThrow(/GitHub API 500/)
216
+ })
217
+ test('no matching prerelease throws', async () => {
218
+ fetchHandler = () => ({ ok: true, json: async () => ([{ tag_name: 'v1.0.0', prerelease: false }]) })
219
+ await expect(resolveLatestVersion('beta', ghBase)).rejects.toThrow(/No beta runtime release/)
220
+ })
221
+ test('malformed tag throws via tagToVersion', async () => {
222
+ fetchHandler = () => ({ ok: true, json: async () => ({ tag_name: 'release-99' }) })
223
+ await expect(resolveLatestVersion('stable', ghBase)).rejects.toThrow(/Unexpected app tag/)
224
+ })
225
+ })
226
+
227
+ describe('runtime-install: installRuntime', () => {
228
+ const ghBase = 'https://github.com/shogo-ai/shogo/releases/download'
229
+
230
+ function wireDownload(sha = 'a'.repeat(64)) {
231
+ fetchHandler = (url: string) => {
232
+ if (url.endsWith('.sha256')) return { ok: true, text: async () => `${sha} shogo-agent-runtime.tar.gz` }
233
+ return { ok: true, body: {} }
234
+ }
235
+ }
236
+
237
+ test('short-circuits when same version already installed', async () => {
238
+ existsPredicate = () => true
239
+ readFileImpl = () => JSON.stringify({ version: '1.0.0', target: 'linux-x64', source: 'src-url', sha256: 'dd' })
240
+ const res = await installRuntime({ version: '1.0.0', target: 'linux-x64', logger: silentLogger })
241
+ expect(res.version).toBe('1.0.0')
242
+ expect(res.source).toBe('src-url')
243
+ expect(gitCalls.fetchReset.length).toBe(0)
244
+ })
245
+
246
+ test('full install happy path verifies sha + writes version record', async () => {
247
+ // version file absent → no short circuit; staging bin present after extract
248
+ existsPredicate = (p) => p.endsWith('agent-runtime') || p.endsWith('.next') === false && p.includes('extract')
249
+ existsPredicate = (p) => p.includes('extract') && p.endsWith('agent-runtime')
250
+ computedDigest = 'b'.repeat(64)
251
+ wireDownload('b'.repeat(64))
252
+ const res = await installRuntime({ version: '2.0.0', target: 'linux-x64', logger: silentLogger })
253
+ expect(res.version).toBe('2.0.0')
254
+ expect(res.sha256).toBe('b'.repeat(64))
255
+ expect(fsWrites.some((w) => w.path.endsWith('version.json'))).toBe(true)
256
+ })
257
+
258
+ test('sha mismatch throws', async () => {
259
+ existsPredicate = (p) => p.includes('extract') && p.endsWith('agent-runtime')
260
+ computedDigest = 'c'.repeat(64)
261
+ wireDownload('d'.repeat(64))
262
+ await expect(installRuntime({ version: '2.0.0', target: 'linux-x64', logger: silentLogger }))
263
+ .rejects.toThrow(/SHA-256 mismatch/)
264
+ })
265
+
266
+ test('missing agent-runtime in tarball throws', async () => {
267
+ existsPredicate = () => false
268
+ computedDigest = 'e'.repeat(64)
269
+ wireDownload('e'.repeat(64))
270
+ await expect(installRuntime({ version: '2.0.0', target: 'linux-x64', logger: silentLogger }))
271
+ .rejects.toThrow(/did not contain .\/agent-runtime/)
272
+ })
273
+
274
+ test('download HTTP error throws', async () => {
275
+ fetchHandler = () => ({ ok: false, status: 404 })
276
+ await expect(installRuntime({ version: '2.0.0', target: 'linux-x64', logger: silentLogger }))
277
+ .rejects.toThrow(/Download failed: HTTP 404/)
278
+ })
279
+
280
+ test('tar exit non-zero throws', async () => {
281
+ existsPredicate = (p) => p.includes('extract') && p.endsWith('agent-runtime')
282
+ tarExitCode = 1
283
+ computedDigest = 'f'.repeat(64)
284
+ wireDownload('f'.repeat(64))
285
+ await expect(installRuntime({ version: '2.0.0', target: 'linux-x64', logger: silentLogger }))
286
+ .rejects.toThrow(/tar exited 1/)
287
+ })
288
+
289
+ test('resolves latest version when none provided', async () => {
290
+ existsPredicate = (p) => p.includes('extract') && p.endsWith('agent-runtime')
291
+ computedDigest = '0'.repeat(64)
292
+ fetchHandler = (url: string) => {
293
+ if (url.includes('releases/latest')) return { ok: true, json: async () => ({ tag_name: 'v9.9.9' }) }
294
+ if (url.endsWith('.sha256')) return { ok: true, text: async () => `${'0'.repeat(64)} x` }
295
+ return { ok: true, body: {} }
296
+ }
297
+ const res = await installRuntime({ target: 'linux-x64', baseUrl: ghBase, logger: silentLogger })
298
+ expect(res.version).toBe('9.9.9')
299
+ })
300
+ })
301
+
302
+ describe('runtime-install: getRuntimePaths', () => {
303
+ test('returns the three runtime paths', () => {
304
+ const p = getRuntimePaths()
305
+ expect(p.runtimeBin).toBe('/runtime/agent-runtime')
306
+ expect(p.versionFile).toBe('/runtime/version.json')
307
+ })
308
+ })
309
+
310
+ // ════════════════════════════════════════════════════════════════════════════
311
+ // commands/project-checkout.ts
312
+ // ════════════════════════════════════════════════════════════════════════════
313
+ describe('project-checkout', () => {
314
+ test('throws without projectId', async () => {
315
+ await expect(runProjectCheckout('', {})).rejects.toThrow(/projectId is required/)
316
+ })
317
+ test('throws when local dir missing', async () => {
318
+ existsPredicate = () => false
319
+ await expect(runProjectCheckout('p1', {})).rejects.toThrow(/does not exist/)
320
+ })
321
+ test('throws when not a git repo', async () => {
322
+ existsPredicate = () => true
323
+ isGitRepoVal = false
324
+ await expect(runProjectCheckout('p1', {})).rejects.toThrow(/not a git repo/)
325
+ })
326
+ test('no --at fast-forwards to remote HEAD', async () => {
327
+ existsPredicate = () => true
328
+ await runProjectCheckout('p1', {})
329
+ expect(gitCalls.fetchReset.length).toBe(1)
330
+ expect(gitCalls.fetchReset[0].branch).toBeUndefined()
331
+ })
332
+ test('--unshallow runs unshallow before reset', async () => {
333
+ existsPredicate = () => true
334
+ await runProjectCheckout('p1', { unshallow: true })
335
+ expect(gitCalls.unshallow.length).toBe(1)
336
+ })
337
+ test('--at as resolvable SHA fetches that commit', async () => {
338
+ existsPredicate = () => true
339
+ runGitImpl = async (args) => {
340
+ if (args[0] === 'rev-parse') return { stdout: 'deadbeefdeadbeef\n', stderr: '' }
341
+ return { stdout: '', stderr: '' }
342
+ }
343
+ await runProjectCheckout('p1', { at: 'deadbeef' })
344
+ expect(gitCalls.fetchReset[0].branch).toBe('deadbeefdeadbeef')
345
+ })
346
+ test('--at falls back to checkpoint-name lookup', async () => {
347
+ existsPredicate = () => true
348
+ runGitImpl = async (args) => {
349
+ if (args[0] === 'rev-parse') throw new Error('not a sha')
350
+ return { stdout: '', stderr: '' }
351
+ }
352
+ fetchHandler = () => ({ ok: true, json: async () => ({
353
+ checkpoints: [{ id: 'c1', commitSha: 'cafebabecafebabe', name: 'My Save', commitMessage: 'x', createdAt: 'now' }],
354
+ hasMore: false,
355
+ }) })
356
+ await runProjectCheckout('p1', { at: 'My Save' })
357
+ expect(gitCalls.fetchReset[0].branch).toBe('cafebabecafebabe')
358
+ })
359
+ test('--at fetch failure triggers unshallow retry', async () => {
360
+ existsPredicate = () => true
361
+ runGitImpl = async (args) => (args[0] === 'rev-parse' ? { stdout: 'aa11bb22\n', stderr: '' } : { stdout: '', stderr: '' })
362
+ let calls = 0
363
+ fetchResetImpl = async () => { calls++; if (calls === 1) throw new Error('outside shallow window'); return { commitSha: 'aa11bb22' } }
364
+ await runProjectCheckout('p1', { at: 'aa11bb22' })
365
+ expect(gitCalls.unshallow.length).toBe(1)
366
+ })
367
+ test('--at fetch failure with --unshallow set throws', async () => {
368
+ existsPredicate = () => true
369
+ runGitImpl = async (args) => (args[0] === 'rev-parse' ? { stdout: 'aa11bb22\n', stderr: '' } : { stdout: '', stderr: '' })
370
+ fetchResetImpl = async () => { throw new Error('nope') }
371
+ await expect(runProjectCheckout('p1', { at: 'aa11bb22', unshallow: true })).rejects.toThrow(/Cannot reach commit/)
372
+ })
373
+ })
374
+
375
+ describe('project-checkout: resolveCheckpointByName branches', () => {
376
+ test('checkpoint list HTTP error throws', async () => {
377
+ existsPredicate = () => true
378
+ runGitImpl = async () => { throw new Error('not a sha') }
379
+ fetchHandler = () => ({ ok: false, status: 403 })
380
+ await expect(runProjectCheckout('p1', { at: 'foo' })).rejects.toThrow(/Failed to list checkpoints: HTTP 403/)
381
+ })
382
+ test('no matching checkpoint throws', async () => {
383
+ existsPredicate = () => true
384
+ runGitImpl = async () => { throw new Error('not a sha') }
385
+ fetchHandler = () => ({ ok: true, json: async () => ({ checkpoints: [], hasMore: false }) })
386
+ await expect(runProjectCheckout('p1', { at: 'foo' })).rejects.toThrow(/No checkpoint matches/)
387
+ })
388
+ test('matches by commitSha prefix', async () => {
389
+ existsPredicate = () => true
390
+ runGitImpl = async (args) => { if (args[0] === 'rev-parse') throw new Error('not a sha'); return { stdout: '', stderr: '' } }
391
+ fetchHandler = () => ({ ok: true, json: async () => ({
392
+ checkpoints: [{ id: 'c', commitSha: 'feed00001111', name: null, commitMessage: null, createdAt: 'n' }],
393
+ hasMore: false,
394
+ }) })
395
+ await runProjectCheckout('p1', { at: 'feed0000' })
396
+ expect(gitCalls.fetchReset[0].branch).toBe('feed00001111')
397
+ })
398
+ test('matches by commitMessage substring', async () => {
399
+ existsPredicate = () => true
400
+ runGitImpl = async (args) => { if (args[0] === 'rev-parse') throw new Error('not a sha'); return { stdout: '', stderr: '' } }
401
+ fetchHandler = () => ({ ok: true, json: async () => ({
402
+ checkpoints: [{ id: 'c', commitSha: '99887766aabb', name: null, commitMessage: 'Fix the LOGIN bug', createdAt: 'n' }],
403
+ hasMore: false,
404
+ }) })
405
+ await runProjectCheckout('p1', { at: 'login' })
406
+ expect(gitCalls.fetchReset[0].branch).toBe('99887766aabb')
407
+ })
408
+ })
409
+
410
+ // ════════════════════════════════════════════════════════════════════════════
411
+ // commands/project-push.ts
412
+ // ════════════════════════════════════════════════════════════════════════════
413
+ describe('project-push', () => {
414
+ test('throws without projectId', async () => {
415
+ await expect(runProjectPush('', {})).rejects.toThrow(/projectId is required/)
416
+ })
417
+ test('throws when source dir missing', async () => {
418
+ existsPredicate = () => false
419
+ await expect(runProjectPush('p1', {})).rejects.toThrow(/Source directory does not exist/)
420
+ })
421
+ test('happy path uploads and prints success summary', async () => {
422
+ existsPredicate = () => true
423
+ uploadAllImpl = async () => ({ uploaded: 5, deleted: 0, errors: [] })
424
+ await runProjectPush('p1', {})
425
+ expect(lastTransportOpts.projectId).toBe('p1')
426
+ })
427
+ test('deleteRemote flag forwarded to uploadAll', async () => {
428
+ existsPredicate = () => true
429
+ let seen: any = null
430
+ uploadAllImpl = async (o) => { seen = o; return { uploaded: 1, deleted: 2, errors: [] } }
431
+ await runProjectPush('p1', { deleteRemote: true })
432
+ expect(seen.deleteRemote).toBe(true)
433
+ })
434
+ test('include csv parsed into array', async () => {
435
+ existsPredicate = () => true
436
+ await runProjectPush('p1', { include: 'src, dist ,, README.md' })
437
+ expect(lastTransportOpts.include).toEqual(['src', 'dist', 'README.md'])
438
+ })
439
+ test('error summary prints first 5 + overflow note', async () => {
440
+ existsPredicate = () => true
441
+ const errors = Array.from({ length: 7 }, (_, i) => ({ path: `f${i}`, message: 'boom' }))
442
+ uploadAllImpl = async () => ({ uploaded: 0, deleted: 0, errors })
443
+ await runProjectPush('p1', {})
444
+ expect(lastTransportOpts.projectId).toBe('p1')
445
+ })
446
+ test('onProgress callback handles upload/delete/other + byte formats', async () => {
447
+ existsPredicate = () => true
448
+ uploadAllImpl = async (o) => {
449
+ const cb = o.ctor.onProgress
450
+ cb({ kind: 'upload', index: 0, total: 3, path: 'a', bytes: 512 }) // <1KB
451
+ cb({ kind: 'delete', index: 1, total: 3, path: 'b', bytes: 2048 }) // KB
452
+ cb({ kind: 'scan', index: 2, total: 0, path: 'c', bytes: 5 * 1024 * 1024 }) // MB, total 0
453
+ cb({ kind: 'upload', index: 2, total: 3, path: 'd', bytes: null }) // no bytes
454
+ return { uploaded: 1, deleted: 1, errors: [] }
455
+ }
456
+ await runProjectPush('p1', {})
457
+ expect(lastTransportOpts.projectId).toBe('p1')
458
+ })
459
+ })
460
+
461
+ afterAll(() => {
462
+ mock.restore()
463
+ })
@@ -0,0 +1,355 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Copyright (C) 2026 Shogo Technologies, Inc.
3
+ /**
4
+ * Greenfield sweep for three never-loaded shogo-worker libs:
5
+ * api-discovery.ts, process-manager.ts, preflight.ts
6
+ * Shared mocks: node:fs (controllable), node:child_process (fake spawn),
7
+ * ./paths, ./transport.
8
+ */
9
+ import { describe, test, expect, beforeEach, afterEach, mock, afterAll } from 'bun:test'
10
+ import { EventEmitter } from 'node:events'
11
+
12
+ // ── controllable node:fs ──────────────────────────────────────────────────────
13
+ let existsMap: Record<string, boolean> = {}
14
+ let existsPredicate: ((p: string) => boolean) | null = null
15
+ let pidFileContent: string | null = null
16
+ const fsWrites: Array<{ path: string; data: string }> = []
17
+ const fsUnlinks: string[] = []
18
+ let unlinkThrows = false
19
+
20
+ mock.module('node:fs', () => ({
21
+ existsSync: (p: string) => {
22
+ const s = String(p)
23
+ if (existsPredicate) return existsPredicate(s)
24
+ if (s in existsMap) return existsMap[s]
25
+ // PID file existence keyed off pidFileContent
26
+ if (s.endsWith('worker.pid')) return pidFileContent !== null
27
+ return false
28
+ },
29
+ readFileSync: (p: string) => {
30
+ if (String(p).endsWith('worker.pid')) return pidFileContent ?? ''
31
+ return ''
32
+ },
33
+ writeFileSync: (p: string, data: string) => { fsWrites.push({ path: String(p), data: String(data) }) },
34
+ unlinkSync: (p: string) => { if (unlinkThrows) throw new Error('ENOENT'); fsUnlinks.push(String(p)) },
35
+ openSync: () => 7,
36
+ }))
37
+
38
+ // ── fake child_process.spawn ──────────────────────────────────────────────────
39
+ let spawnPid: number | undefined = 4242
40
+ let lastSpawn: { cmd: string; args: string[]; opts: any; child: any } | null = null
41
+ class FakeChild extends EventEmitter {
42
+ pid: number | undefined
43
+ killed: string | null = null
44
+ unrefed = false
45
+ constructor(pid: number | undefined) { super(); this.pid = pid }
46
+ kill(sig: string) { this.killed = sig }
47
+ unref() { this.unrefed = true }
48
+ }
49
+ mock.module('node:child_process', () => ({
50
+ spawn: (cmd: string, args: string[], opts: any) => {
51
+ const child = new FakeChild(spawnPid)
52
+ lastSpawn = { cmd, args, opts, child }
53
+ return child
54
+ },
55
+ }))
56
+
57
+ // ── ./paths ───────────────────────────────────────────────────────────────────
58
+ let ensureHomeCalls = 0
59
+ mock.module('../paths.ts', () => ({
60
+ PID_FILE: '/home/.shogo/worker.pid',
61
+ WORKER_LOG: '/home/.shogo/logs/worker.log',
62
+ WORKER_ERR: '/home/.shogo/logs/worker.err.log',
63
+ ensureHome: () => { ensureHomeCalls++ },
64
+ }))
65
+
66
+ // ── ./transport (for preflight) ────────────────────────────────────────────────
67
+ // Delegate to the REAL transport by default so sibling files that import the
68
+ // genuine module (e.g. worker-transport-greenfield.test.ts) are not shadowed by
69
+ // this file's process-global mock. Individual tests below override the impls.
70
+ const _realTransport = require('../transport')
71
+ const _defaultAllowlist = _realTransport.deriveAllowlist
72
+ const _defaultProbeProxy = _realTransport.probeProxy
73
+ let allowlistImpl: (cloudUrl: string) => any[] = _defaultAllowlist
74
+ let probeProxyImpl: (...a: any[]) => Promise<{ ok: boolean; detail?: string }> = _defaultProbeProxy
75
+ mock.module('../transport.ts', () => ({
76
+ ..._realTransport,
77
+ deriveAllowlist: (u: string) => allowlistImpl(u),
78
+ probeProxy: (...a: any[]) => probeProxyImpl(...a),
79
+ }))
80
+
81
+ import { findApiEntry } from '../api-discovery'
82
+ import { readPid, isRunning, clearPid, spawnWorker, installShutdownHooks, stopWorker } from '../process-manager'
83
+ import { makeChecks, runPreflight, type Check } from '../preflight'
84
+
85
+ beforeEach(() => {
86
+ existsMap = {}; pidFileContent = null; unlinkThrows = false
87
+ fsWrites.length = 0; fsUnlinks.length = 0
88
+ spawnPid = 4242; lastSpawn = null; ensureHomeCalls = 0
89
+ existsPredicate = null
90
+ })
91
+
92
+ function mockExists(fn: (p: string) => boolean) { existsPredicate = fn }
93
+
94
+ // ════════════════════════════════════════════════════════════════════════════
95
+ describe('api-discovery.findApiEntry', () => {
96
+ test('prefers bundled dist entry (node runner) when present', () => {
97
+ mockExists((p) => p.endsWith('entry.js'))
98
+ const r = findApiEntry()
99
+ expect(r.mode).toBe('bundled')
100
+ expect(r.runner).toBe('node')
101
+ expect(r.entry).toContain('entry.js')
102
+ })
103
+ test('falls back to monorepo entry (bun runner)', () => {
104
+ mockExists((p) => p.endsWith('entry.ts'))
105
+ const r = findApiEntry()
106
+ expect(r.mode).toBe('monorepo')
107
+ expect(r.runner).toBe('bun')
108
+ expect(r.entry).toContain('entry.ts')
109
+ })
110
+ test('throws when neither entry exists', () => {
111
+ mockExists(() => false)
112
+ expect(() => findApiEntry()).toThrow('Cannot locate apps/api entry')
113
+ })
114
+ })
115
+
116
+ // ════════════════════════════════════════════════════════════════════════════
117
+ describe('process-manager', () => {
118
+ test('readPid returns null when no pid file, parses int when present', () => {
119
+ pidFileContent = null
120
+ expect(readPid()).toBe(null)
121
+ pidFileContent = ' 12345 \n'
122
+ expect(readPid()).toBe(12345)
123
+ pidFileContent = 'not-a-number'
124
+ expect(readPid()).toBe(null)
125
+ })
126
+
127
+ test('isRunning reflects process.kill(pid,0)', () => {
128
+ const realKill = process.kill
129
+ ;(process as any).kill = (_pid: number, _sig: number) => true
130
+ expect(isRunning(999)).toBe(true)
131
+ ;(process as any).kill = () => { throw new Error('ESRCH') }
132
+ expect(isRunning(999)).toBe(false)
133
+ process.kill = realKill
134
+ })
135
+
136
+ test('clearPid swallows unlink errors', () => {
137
+ unlinkThrows = true
138
+ expect(() => clearPid()).not.toThrow()
139
+ unlinkThrows = false
140
+ clearPid()
141
+ expect(fsUnlinks).toContain('/home/.shogo/worker.pid')
142
+ })
143
+
144
+ test('spawnWorker spawns, writes pid file, returns pid', () => {
145
+ const realKill = process.kill
146
+ ;(process as any).kill = () => { throw new Error('ESRCH') } // no existing process
147
+ pidFileContent = null
148
+ const { pid, child } = spawnWorker({ entry: '/e.ts', runner: 'bun', env: {}, cwd: '/w' })
149
+ expect(pid).toBe(4242)
150
+ expect(ensureHomeCalls).toBe(1)
151
+ expect(lastSpawn!.cmd).toBe('bun')
152
+ expect(fsWrites.some((w) => w.path.endsWith('worker.pid') && w.data === '4242')).toBe(true)
153
+ expect((child as any).unrefed).toBe(false)
154
+ process.kill = realKill
155
+ })
156
+
157
+ test('spawnWorker detached unrefs the child + inheritStdio path', () => {
158
+ const realKill = process.kill
159
+ ;(process as any).kill = () => { throw new Error('ESRCH') }
160
+ pidFileContent = null
161
+ const { child } = spawnWorker({ entry: '/e', runner: 'node', env: {}, cwd: '/w', detach: true, inheritStdio: true })
162
+ expect((child as any).unrefed).toBe(true)
163
+ expect(lastSpawn!.opts.detached).toBe(true)
164
+ process.kill = realKill
165
+ })
166
+
167
+ test('spawnWorker throws when a live worker already holds the pid file', () => {
168
+ const realKill = process.kill
169
+ pidFileContent = '777'
170
+ ;(process as any).kill = () => true // existing pid is alive
171
+ expect(() => spawnWorker({ entry: '/e', runner: 'bun', env: {}, cwd: '/w' })).toThrow('already running')
172
+ process.kill = realKill
173
+ })
174
+
175
+ test('spawnWorker clears a stale pid file then spawns', () => {
176
+ const realKill = process.kill
177
+ pidFileContent = '888'
178
+ let calls = 0
179
+ ;(process as any).kill = () => { calls++; throw new Error('ESRCH') } // stale
180
+ const { pid } = spawnWorker({ entry: '/e', runner: 'bun', env: {}, cwd: '/w' })
181
+ expect(pid).toBe(4242)
182
+ expect(fsUnlinks).toContain('/home/.shogo/worker.pid') // stale cleared
183
+ process.kill = realKill
184
+ })
185
+
186
+ test('spawnWorker throws when spawn yields no pid', () => {
187
+ const realKill = process.kill
188
+ ;(process as any).kill = () => { throw new Error('ESRCH') }
189
+ spawnPid = undefined
190
+ pidFileContent = null
191
+ expect(() => spawnWorker({ entry: '/e', runner: 'bun', env: {}, cwd: '/w' })).toThrow('Failed to spawn')
192
+ process.kill = realKill
193
+ })
194
+
195
+ test('stopWorker: null when no pid; clears stale; kills live', () => {
196
+ const realKill = process.kill
197
+ pidFileContent = null
198
+ expect(stopWorker().killedPid).toBe(null)
199
+ // stale
200
+ pidFileContent = '321'
201
+ ;(process as any).kill = (_p: number, sig: any) => { if (sig === 0) throw new Error('ESRCH'); return true }
202
+ expect(stopWorker().killedPid).toBe(null)
203
+ expect(fsUnlinks).toContain('/home/.shogo/worker.pid')
204
+ // live
205
+ pidFileContent = '654'
206
+ ;(process as any).kill = () => true
207
+ expect(stopWorker('SIGKILL').killedPid).toBe(654)
208
+ process.kill = realKill
209
+ })
210
+
211
+ test('installShutdownHooks forwards signal to child + clears pid; child exit triggers process.exit', () => {
212
+ const realKill = process.kill
213
+ const realExit = process.exit
214
+ const onceHandlers: Record<string, Function> = {}
215
+ const realOnce = process.once
216
+ ;(process as any).once = (evt: string, fn: Function) => { onceHandlers[evt] = fn; return process }
217
+ let exitCode: number | undefined
218
+ ;(process as any).exit = (c?: number) => { exitCode = c; throw new Error('__exit__') }
219
+
220
+ const child = new FakeChild(111)
221
+ installShutdownHooks(child as any)
222
+ // trigger SIGINT
223
+ onceHandlers['SIGINT']?.()
224
+ expect(child.killed).toBe('SIGINT')
225
+ expect(fsUnlinks).toContain('/home/.shogo/worker.pid')
226
+ // second invocation is a no-op (shutdownStarted guard)
227
+ onceHandlers['SIGTERM']?.()
228
+ // child 'exit' with a code → process.exit(code)
229
+ expect(() => child.emit('exit', 3, null)).toThrow('__exit__')
230
+ expect(exitCode).toBe(3)
231
+
232
+ process.once = realOnce; process.exit = realExit; process.kill = realKill
233
+ })
234
+
235
+ test('installShutdownHooks exit handler with signal maps to 128+n', () => {
236
+ const realExit = process.exit
237
+ const onceHandlers: Record<string, Function> = {}
238
+ const realOnce = process.once
239
+ ;(process as any).once = (evt: string, fn: Function) => { onceHandlers[evt] = fn; return process }
240
+ let exitCode: number | undefined
241
+ ;(process as any).exit = (c?: number) => { exitCode = c; throw new Error('__exit__') }
242
+ const child = new FakeChild(222)
243
+ installShutdownHooks(child as any)
244
+ expect(() => child.emit('exit', null, 'SIGTERM')).toThrow('__exit__')
245
+ expect(exitCode).toBe(128 + 15)
246
+ // 'exit' process handler clears pid when no shutdown started
247
+ onceHandlers['exit']?.()
248
+ process.once = realOnce; process.exit = realExit
249
+ })
250
+ })
251
+
252
+ // ════════════════════════════════════════════════════════════════════════════
253
+ describe('preflight', () => {
254
+ const baseOpts = { cloudUrl: 'https://api.shogo.dev', apiKey: 'K', workerDir: '/worker' }
255
+ const ORIGINAL_FETCH = globalThis.fetch
256
+ afterEach(() => { globalThis.fetch = ORIGINAL_FETCH })
257
+
258
+ test('makeChecks: node-version + worker-dir + allowlist + api-key checks; proxy added when set', async () => {
259
+ existsMap = { '/worker': true }
260
+ const checks = makeChecks(baseOpts)
261
+ const names = checks.map((c) => c.name)
262
+ expect(names[0]).toContain('Runtime')
263
+ expect(names.some((n) => n.includes('Worker directory'))).toBe(true)
264
+ expect(names.some((n) => n.includes('Reach api.shogo.dev'))).toBe(true)
265
+ expect(names.some((n) => n.includes('API key valid'))).toBe(true)
266
+ expect(names.some((n) => n.includes('Proxy'))).toBe(false)
267
+
268
+ const withProxy = makeChecks({ ...baseOpts, proxy: { url: 'http://proxy:3128' } as any })
269
+ expect(withProxy.some((c) => c.name.includes('Proxy reachable (proxy:3128)'))).toBe(true)
270
+ })
271
+
272
+ test('node-version check passes on >=20, worker-dir reflects existsSync', async () => {
273
+ existsMap = { '/worker': true }
274
+ const checks = makeChecks(baseOpts)
275
+ const node = await checks[0].run()
276
+ expect(node.ok).toBe(true) // test runner is node>=20
277
+ const dirOk = await checks[1].run()
278
+ expect(dirOk.ok).toBe(true)
279
+ existsMap = { '/worker': false }
280
+ const dirBad = await makeChecks(baseOpts)[1].run()
281
+ expect(dirBad.ok).toBe(false)
282
+ expect(dirBad.detail).toContain('does not exist')
283
+ })
284
+
285
+ test('allowlist health probe: ok, no-response, and abort/error paths', async () => {
286
+ existsMap = { '/worker': true }
287
+ // ok
288
+ globalThis.fetch = (async () => ({ status: 200 })) as any
289
+ let checks = makeChecks(baseOpts)
290
+ const reach = checks.find((c) => c.name.includes('Reach api.shogo.dev'))!
291
+ expect(await reach.run()).toMatchObject({ ok: true, detail: 'HTTP 200' })
292
+ // no response (fetch resolves null via .catch)
293
+ globalThis.fetch = (async () => { throw new Error('conn refused') }) as any
294
+ checks = makeChecks(baseOpts)
295
+ const reach2 = checks.find((c) => c.name.includes('Reach api.shogo.dev'))!
296
+ expect((await reach2.run()).ok).toBe(false)
297
+ })
298
+
299
+ test('api-key check: 401 rejected, ok, and thrown error', async () => {
300
+ existsMap = { '/worker': true }
301
+ globalThis.fetch = (async () => ({ status: 401, ok: false })) as any
302
+ let key = makeChecks(baseOpts).find((c) => c.name.includes('API key'))!
303
+ expect(await key.run()).toMatchObject({ ok: false })
304
+ globalThis.fetch = (async () => ({ status: 200, ok: true })) as any
305
+ key = makeChecks(baseOpts).find((c) => c.name.includes('API key'))!
306
+ expect(await key.run()).toMatchObject({ ok: true })
307
+ globalThis.fetch = (async () => ({ status: 500, ok: false })) as any
308
+ key = makeChecks(baseOpts).find((c) => c.name.includes('API key'))!
309
+ expect((await key.run()).ok).toBe(false)
310
+ globalThis.fetch = (async () => { throw new Error('network') }) as any
311
+ key = makeChecks(baseOpts).find((c) => c.name.includes('API key'))!
312
+ expect(await key.run()).toMatchObject({ ok: false, detail: 'network' })
313
+ })
314
+
315
+ test('proxy check delegates to probeProxy', async () => {
316
+ existsMap = { '/worker': true }
317
+ probeProxyImpl = async () => ({ ok: false, detail: 'proxy down' })
318
+ const checks = makeChecks({ ...baseOpts, proxy: { url: 'http://p:1' } as any })
319
+ const proxy = checks.find((c) => c.name.includes('Proxy'))!
320
+ expect(await proxy.run()).toMatchObject({ ok: false, detail: 'proxy down' })
321
+ probeProxyImpl = _defaultProbeProxy
322
+ })
323
+
324
+ test('makeChecks safeHost falls back to raw on bad proxy URL', () => {
325
+ const checks = makeChecks({ ...baseOpts, proxy: { url: 'not a url' } as any })
326
+ expect(checks.some((c) => c.name.includes('not a url'))).toBe(true)
327
+ })
328
+
329
+ test('runPreflight: all-pass returns true', async () => {
330
+ const checks: Check[] = [
331
+ { name: 'a', criticality: 'fatal', run: async () => ({ ok: true, detail: 'd' }) },
332
+ { name: 'b', criticality: 'graceful', run: async () => ({ ok: true }) },
333
+ ]
334
+ expect(await runPreflight(checks)).toBe(true)
335
+ })
336
+
337
+ test('runPreflight: graceful failure still returns true', async () => {
338
+ const checks: Check[] = [
339
+ { name: 'a', criticality: 'fatal', run: async () => ({ ok: true }) },
340
+ { name: 'b', criticality: 'graceful', run: async () => ({ ok: false, detail: 'optional down' }) },
341
+ ]
342
+ expect(await runPreflight(checks)).toBe(true)
343
+ })
344
+
345
+ test('runPreflight: fatal failure returns false', async () => {
346
+ const checks: Check[] = [
347
+ { name: 'a', criticality: 'fatal', run: async () => ({ ok: false, detail: 'boom' }) },
348
+ ]
349
+ expect(await runPreflight(checks)).toBe(false)
350
+ })
351
+ })
352
+
353
+ afterAll(() => {
354
+ mock.restore()
355
+ })
@@ -0,0 +1,176 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Copyright (C) 2026 Shogo Technologies, Inc.
3
+ /**
4
+ * Greenfield coverage for lib/transport.ts — corporate proxy resolution,
5
+ * env injection, CONNECT-based reachability probe, and allowlist derivation.
6
+ * node:http is mocked with a scriptable fake CONNECT request emitter.
7
+ */
8
+ import { describe, test, expect, beforeEach, afterEach, afterAll, mock } from 'bun:test'
9
+ import { EventEmitter } from 'node:events'
10
+
11
+ // ── scriptable node:http request mock ─────────────────────────────────────────
12
+ type ConnectScript =
13
+ | { kind: 'connect'; statusCode: number }
14
+ | { kind: 'timeout' }
15
+ | { kind: 'error'; code?: string; message?: string }
16
+ | { kind: 'throw'; message: string }
17
+ let connectScript: ConnectScript = { kind: 'connect', statusCode: 200 }
18
+ let lastRequestOpts: any = null
19
+ const _realHttp = require('node:http')
20
+
21
+ class FakeClientRequest extends EventEmitter {
22
+ destroyed = false
23
+ destroy() { this.destroyed = true }
24
+ end() {
25
+ queueMicrotask(() => {
26
+ if (connectScript.kind === 'connect') {
27
+ this.emit('connect', { statusCode: connectScript.statusCode }, {}, Buffer.from(''))
28
+ } else if (connectScript.kind === 'timeout') {
29
+ this.emit('timeout')
30
+ } else if (connectScript.kind === 'error') {
31
+ const e: NodeJS.ErrnoException = new Error(connectScript.message ?? 'socket error')
32
+ if (connectScript.code) e.code = connectScript.code
33
+ this.emit('error', e)
34
+ }
35
+ })
36
+ }
37
+ }
38
+ mock.module('node:http', () => ({
39
+ ..._realHttp,
40
+ request: (opts: any) => {
41
+ lastRequestOpts = opts
42
+ if (connectScript.kind === 'throw') throw new Error(connectScript.message)
43
+ return new FakeClientRequest()
44
+ },
45
+ }))
46
+
47
+ import { resolveProxy, applyProxyToEnv, probeProxy, deriveAllowlist } from '../transport'
48
+
49
+ beforeEach(() => {
50
+ connectScript = { kind: 'connect', statusCode: 200 }
51
+ lastRequestOpts = null
52
+ })
53
+ afterAll(() => {
54
+ mock.module('node:http', () => _realHttp)
55
+ })
56
+
57
+ // ════════════════════════════════════════════════════════════════════════════
58
+ describe('resolveProxy', () => {
59
+ test('flag wins over every env var', () => {
60
+ const r = resolveProxy('proxy.corp:8080', { HTTPS_PROXY: 'http://env:1' })
61
+ expect(r).toEqual({ url: 'http://proxy.corp:8080', source: 'flag' })
62
+ })
63
+ test('already-schemed flag is preserved', () => {
64
+ expect(resolveProxy('https://p.corp:443')?.url).toBe('https://p.corp:443')
65
+ })
66
+ test('env precedence: HTTPS_PROXY > https_proxy > HTTP_PROXY > http_proxy', () => {
67
+ expect(resolveProxy(undefined, { HTTPS_PROXY: 'a:1', https_proxy: 'b:2' })?.source).toBe('HTTPS_PROXY')
68
+ expect(resolveProxy(undefined, { https_proxy: 'b:2', HTTP_PROXY: 'c:3' })?.source).toBe('https_proxy')
69
+ expect(resolveProxy(undefined, { HTTP_PROXY: 'c:3', http_proxy: 'd:4' })?.source).toBe('HTTP_PROXY')
70
+ expect(resolveProxy(undefined, { http_proxy: 'd:4' })?.source).toBe('http_proxy')
71
+ })
72
+ test('normalizes bare host:port to http://', () => {
73
+ expect(resolveProxy(undefined, { HTTPS_PROXY: 'host:3128' })?.url).toBe('http://host:3128')
74
+ })
75
+ test('whitespace-only and empty values are ignored', () => {
76
+ expect(resolveProxy(' ', { HTTPS_PROXY: ' ', http_proxy: 'real:9' })?.source).toBe('http_proxy')
77
+ })
78
+ test('returns null when nothing is set', () => {
79
+ expect(resolveProxy(undefined, {})).toBeNull()
80
+ })
81
+ })
82
+
83
+ describe('applyProxyToEnv', () => {
84
+ test('returns env unchanged when proxy is null', () => {
85
+ const env = { FOO: 'bar' }
86
+ expect(applyProxyToEnv(env, null)).toBe(env)
87
+ })
88
+ test('injects all four variants when unset', () => {
89
+ const out = applyProxyToEnv({ PATH: '/x' }, { url: 'http://p:8080', source: 'flag' })
90
+ expect(out.HTTPS_PROXY).toBe('http://p:8080')
91
+ expect(out.https_proxy).toBe('http://p:8080')
92
+ expect(out.HTTP_PROXY).toBe('http://p:8080')
93
+ expect(out.http_proxy).toBe('http://p:8080')
94
+ expect(out.PATH).toBe('/x')
95
+ })
96
+ test('does not overwrite pre-existing proxy vars', () => {
97
+ const out = applyProxyToEnv({ HTTPS_PROXY: 'http://keep:1' }, { url: 'http://new:2', source: 'flag' })
98
+ expect(out.HTTPS_PROXY).toBe('http://keep:1')
99
+ expect(out.http_proxy).toBe('http://new:2')
100
+ })
101
+ })
102
+
103
+ describe('probeProxy', () => {
104
+ const proxy = { url: 'http://proxy.corp:3128', source: 'flag' as const }
105
+
106
+ test('CONNECT 200 → ok with detail', async () => {
107
+ connectScript = { kind: 'connect', statusCode: 200 }
108
+ const r = await probeProxy(proxy, 'api.shogo.ai')
109
+ expect(r.ok).toBe(true)
110
+ expect(r.detail).toContain('200')
111
+ expect(lastRequestOpts.method).toBe('CONNECT')
112
+ expect(lastRequestOpts.path).toBe('api.shogo.ai:443')
113
+ })
114
+ test('407 → not ok, surfaces auth hint with source', async () => {
115
+ connectScript = { kind: 'connect', statusCode: 407 }
116
+ const r = await probeProxy(proxy)
117
+ expect(r.ok).toBe(false)
118
+ expect(r.detail).toContain('407')
119
+ expect(r.detail).toContain('flag')
120
+ })
121
+ test('other status → not ok', async () => {
122
+ connectScript = { kind: 'connect', statusCode: 502 }
123
+ const r = await probeProxy(proxy)
124
+ expect(r.ok).toBe(false)
125
+ expect(r.detail).toContain('HTTP 502')
126
+ })
127
+ test('timeout → not ok', async () => {
128
+ connectScript = { kind: 'timeout' }
129
+ const r = await probeProxy(proxy, 'api.shogo.ai', 1234)
130
+ expect(r.ok).toBe(false)
131
+ expect(r.detail).toContain('1234ms')
132
+ })
133
+ test('socket error with code → not ok with code', async () => {
134
+ connectScript = { kind: 'error', code: 'ECONNREFUSED', message: 'refused' }
135
+ const r = await probeProxy(proxy)
136
+ expect(r.ok).toBe(false)
137
+ expect(r.detail).toContain('ECONNREFUSED')
138
+ })
139
+ test('https proxy url with no port defaults to 443', async () => {
140
+ connectScript = { kind: 'connect', statusCode: 200 }
141
+ await probeProxy({ url: 'https://secure.proxy', source: 'HTTPS_PROXY' })
142
+ expect(lastRequestOpts.port).toBe(443)
143
+ })
144
+ test('http proxy url with no port defaults to 80', async () => {
145
+ await probeProxy({ url: 'http://plain.proxy', source: 'HTTP_PROXY' })
146
+ expect(lastRequestOpts.port).toBe(80)
147
+ })
148
+ test('synchronous throw (bad URL) is caught', async () => {
149
+ connectScript = { kind: 'throw', message: 'boom' }
150
+ const r = await probeProxy(proxy)
151
+ expect(r.ok).toBe(false)
152
+ expect(r.detail).toBe('boom')
153
+ })
154
+ })
155
+
156
+ describe('deriveAllowlist', () => {
157
+ test('three-label host → control + tunnel + artifacts', () => {
158
+ const list = deriveAllowlist('https://studio.shogo.ai')
159
+ expect(list.map((h) => h.purpose)).toEqual(['control', 'tunnel-direct', 'artifacts'])
160
+ expect(list[0]).toMatchObject({ host: 'studio.shogo.ai', criticality: 'fatal' })
161
+ expect(list[1].host).toBe('api-direct.shogo.ai')
162
+ expect(list[2].host).toBe('artifacts.shogo.ai')
163
+ })
164
+ test('two-label root domain kept as-is', () => {
165
+ const list = deriveAllowlist('https://shogo.ai')
166
+ expect(list[1].host).toBe('api-direct.shogo.ai')
167
+ })
168
+ test('preserves non-https scheme', () => {
169
+ const list = deriveAllowlist('http://eu.shogo.dev:8443')
170
+ expect(list[0].url).toBe('http://eu.shogo.dev:8443')
171
+ expect(list[2].url).toBe('http://artifacts.shogo.dev')
172
+ })
173
+ test('invalid URL → empty list', () => {
174
+ expect(deriveAllowlist('not a url')).toEqual([])
175
+ })
176
+ })