@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
|
@@ -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
|
+
})
|