@shogo-ai/worker 1.0.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +432 -0
- package/bin/shogo.mjs +38 -0
- package/dist/cli.mjs +64 -0
- package/package.json +62 -0
- package/src/cli.ts +199 -0
- package/src/commands/agent.ts +123 -0
- package/src/commands/config.ts +29 -0
- package/src/commands/doctor.ts +128 -0
- package/src/commands/login.ts +126 -0
- package/src/commands/logs.ts +24 -0
- package/src/commands/project-checkout.ts +179 -0
- package/src/commands/project-pull.ts +133 -0
- package/src/commands/project-push.ts +89 -0
- package/src/commands/runtime.ts +104 -0
- package/src/commands/start.ts +290 -0
- package/src/commands/status.ts +22 -0
- package/src/commands/stop.ts +13 -0
- package/src/lib/__tests__/agent-launch.test.ts +92 -0
- package/src/lib/__tests__/cloud-login-coverage.test.ts +210 -0
- package/src/lib/__tests__/cloud-login.test.ts +218 -0
- package/src/lib/__tests__/cloud-sync-watcher.test.ts +214 -0
- package/src/lib/__tests__/config.test.ts +141 -0
- package/src/lib/__tests__/db-doctor.test.ts +204 -0
- package/src/lib/__tests__/git-cloner.test.ts +382 -0
- package/src/lib/__tests__/runtime-manager-auto-pull.test.ts +275 -0
- package/src/lib/__tests__/runtime-manager-cloud-sync-gate.test.ts +119 -0
- package/src/lib/__tests__/runtime-manager-coverage-gaps.test.ts +768 -0
- package/src/lib/__tests__/runtime-manager-describe-rejection.test.ts +42 -0
- package/src/lib/__tests__/runtime-manager-git-pull.test.ts +207 -0
- package/src/lib/__tests__/runtime-manager-idle-eviction.test.ts +196 -0
- package/src/lib/__tests__/runtime-manager-restart-cap.test.ts +310 -0
- package/src/lib/__tests__/runtime-manager-tree-sitter-env.test.ts +189 -0
- package/src/lib/__tests__/runtime-manager-wait-for-health.test.ts +269 -0
- package/src/lib/__tests__/runtime-resolver.test.ts +112 -0
- package/src/lib/__tests__/tunnel-coverage.test.ts +1094 -0
- package/src/lib/__tests__/tunnel-structured-502.test.ts +101 -0
- package/src/lib/__tests__/worker-commands-greenfield.test.ts +463 -0
- package/src/lib/__tests__/worker-libs-greenfield.test.ts +355 -0
- package/src/lib/__tests__/worker-transport-greenfield.test.ts +176 -0
- package/src/lib/api-discovery.ts +36 -0
- package/src/lib/cloud-login.ts +320 -0
- package/src/lib/cloud-sync-watcher.ts +322 -0
- package/src/lib/config.ts +68 -0
- package/src/lib/db-doctor.ts +355 -0
- package/src/lib/device-id.ts +27 -0
- package/src/lib/git-cloner.ts +354 -0
- package/src/lib/paths.ts +53 -0
- package/src/lib/preflight.ts +158 -0
- package/src/lib/process-manager.ts +123 -0
- package/src/lib/resource-limits.ts +340 -0
- package/src/lib/runtime-install.ts +371 -0
- package/src/lib/runtime-manager.ts +1873 -0
- package/src/lib/runtime-resolver.ts +136 -0
- package/src/lib/transport.ts +202 -0
- package/src/lib/tunnel.ts +710 -0
- package/src/lib/user-agent.ts +28 -0
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Copyright (C) 2026 Shogo Technologies, Inc.
|
|
3
|
+
/**
|
|
4
|
+
* Pins {@link WorkerRuntimeManager.waitForHealth} against the readiness
|
|
5
|
+
* contract every downstream caller of `status === 'running'` assumes:
|
|
6
|
+
* the agent runtime can actually serve HTTP, not just "the kernel
|
|
7
|
+
* accepted a TCP listener on the port". The historical context:
|
|
8
|
+
*
|
|
9
|
+
* - First pass (pre-2026-05): HTTP /health was the only signal. Bun
|
|
10
|
+
* on Windows takes 30-45s to JIT the runtime TS dep graph during
|
|
11
|
+
* cold boot, and /health was starved on the busy event loop the
|
|
12
|
+
* whole time, so the 30s timeout SIGTERM'd the still-booting
|
|
13
|
+
* child and the restart loop chewed through `MAX_CONSECUTIVE_RESTARTS`.
|
|
14
|
+
* - Second pass: added a TCP-listening + recent stdout fast path
|
|
15
|
+
* that returned "ready" as soon as `Bun.serve()` bound the port
|
|
16
|
+
* and the child kept emitting log lines. This stopped the SIGTERM
|
|
17
|
+
* storm but tricked `/sandbox/url` into reporting ready before
|
|
18
|
+
* HTTP actually worked, so the canvas iframe and AgentProxy
|
|
19
|
+
* started chasing a black hole and surfaced as "Connection timed
|
|
20
|
+
* out — The agent runtime could not be reached".
|
|
21
|
+
* - Current pass (2026-05): keep waiting for a real /health 2xx with
|
|
22
|
+
* HEALTH_BOOT_TIMEOUT_MS=30 s (now realistic on Windows because
|
|
23
|
+
* LSP + IndexEngine were moved out of the critical-path boot
|
|
24
|
+
* sequence in agent-runtime). TCP-listening + silent stdout >
|
|
25
|
+
* STDOUT_PROGRESS_WINDOW_MS=25 s acts as a *wedge detector* —
|
|
26
|
+
* a child that bound the port but stopped producing output for
|
|
27
|
+
* 25 s is genuinely stuck and the restart loop should recover
|
|
28
|
+
* it instead of waiting for the hard timeout.
|
|
29
|
+
*
|
|
30
|
+
* The contracts pinned here:
|
|
31
|
+
*
|
|
32
|
+
* 1. HTTP /health 200 returns immediately — the happy path.
|
|
33
|
+
* 2. /health that never responds (with TCP up + stdout fresh) keeps
|
|
34
|
+
* waiting until the timeout, instead of declaring ready early.
|
|
35
|
+
* 3. TCP-listening but stdout silent past STDOUT_PROGRESS_WINDOW_MS
|
|
36
|
+
* throws the "wedged" error so restart-with-backoff fires.
|
|
37
|
+
* 4. Neither TCP nor HTTP ever come up → clean timeout error.
|
|
38
|
+
* 5. Process death (exitCode/signalCode/killed) short-circuits
|
|
39
|
+
* immediately with the exit info.
|
|
40
|
+
*/
|
|
41
|
+
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
|
42
|
+
import { WorkerRuntimeManager } from '../runtime-manager.ts';
|
|
43
|
+
|
|
44
|
+
interface FakeProc {
|
|
45
|
+
exitCode: number | null;
|
|
46
|
+
signalCode: NodeJS.Signals | null;
|
|
47
|
+
killed: boolean;
|
|
48
|
+
pid: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
interface SlotShape {
|
|
52
|
+
projectId: string;
|
|
53
|
+
agentPort: number;
|
|
54
|
+
apiServerPort: number;
|
|
55
|
+
status: string;
|
|
56
|
+
proc: FakeProc;
|
|
57
|
+
pid: number;
|
|
58
|
+
startedAt: number;
|
|
59
|
+
lastStdoutAt: number;
|
|
60
|
+
lastUsedAt: number;
|
|
61
|
+
restarts: number;
|
|
62
|
+
consecutiveFailures: number;
|
|
63
|
+
lastFailureAt: number;
|
|
64
|
+
graceTimer: null;
|
|
65
|
+
restartTimer: null;
|
|
66
|
+
idleTimer: null;
|
|
67
|
+
spawnConfig: unknown;
|
|
68
|
+
startPromise: null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function makeSlot(overrides: Partial<SlotShape> = {}): SlotShape {
|
|
72
|
+
const now = Date.now();
|
|
73
|
+
const proc: FakeProc = {
|
|
74
|
+
exitCode: null,
|
|
75
|
+
signalCode: null,
|
|
76
|
+
killed: false,
|
|
77
|
+
pid: 12345,
|
|
78
|
+
};
|
|
79
|
+
return {
|
|
80
|
+
projectId: 'proj-test',
|
|
81
|
+
agentPort: 41234,
|
|
82
|
+
apiServerPort: 41235,
|
|
83
|
+
status: 'starting',
|
|
84
|
+
proc,
|
|
85
|
+
pid: proc.pid,
|
|
86
|
+
startedAt: now,
|
|
87
|
+
lastStdoutAt: now,
|
|
88
|
+
lastUsedAt: now,
|
|
89
|
+
restarts: 0,
|
|
90
|
+
consecutiveFailures: 0,
|
|
91
|
+
lastFailureAt: 0,
|
|
92
|
+
graceTimer: null,
|
|
93
|
+
restartTimer: null,
|
|
94
|
+
idleTimer: null,
|
|
95
|
+
spawnConfig: {},
|
|
96
|
+
startPromise: null,
|
|
97
|
+
...overrides,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const SILENT = { log: () => {}, warn: () => {}, error: () => {} } as const;
|
|
102
|
+
|
|
103
|
+
function callWaitForHealth(
|
|
104
|
+
mgr: WorkerRuntimeManager,
|
|
105
|
+
slot: SlotShape,
|
|
106
|
+
timeoutMs: number,
|
|
107
|
+
): Promise<void> {
|
|
108
|
+
return (mgr as unknown as {
|
|
109
|
+
waitForHealth: (s: SlotShape, t: number) => Promise<void>;
|
|
110
|
+
}).waitForHealth(slot, timeoutMs);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function stubTcpProbe(mgr: WorkerRuntimeManager, listening: boolean | (() => boolean)): void {
|
|
114
|
+
(mgr as unknown as { tcpProbe: (port: number) => Promise<boolean> }).tcpProbe = async () =>
|
|
115
|
+
typeof listening === 'function' ? listening() : listening;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const ORIGINAL_FETCH = globalThis.fetch;
|
|
119
|
+
|
|
120
|
+
describe('WorkerRuntimeManager.waitForHealth (private)', () => {
|
|
121
|
+
let mgr: WorkerRuntimeManager;
|
|
122
|
+
|
|
123
|
+
beforeEach(() => {
|
|
124
|
+
mgr = new WorkerRuntimeManager({ logger: SILENT });
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
afterEach(() => {
|
|
128
|
+
globalThis.fetch = ORIGINAL_FETCH;
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('returns immediately when /health responds 200', async () => {
|
|
132
|
+
globalThis.fetch = (async () => new Response('ok', { status: 200 })) as typeof fetch;
|
|
133
|
+
// Even with TCP probe returning false the HTTP fast path wins.
|
|
134
|
+
stubTcpProbe(mgr, false);
|
|
135
|
+
|
|
136
|
+
const slot = makeSlot();
|
|
137
|
+
const startedAt = Date.now();
|
|
138
|
+
await callWaitForHealth(mgr, slot, 5_000);
|
|
139
|
+
expect(Date.now() - startedAt).toBeLessThan(2_000);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it(
|
|
143
|
+
'keeps waiting for /health even when TCP is up and stdout is fresh',
|
|
144
|
+
async () => {
|
|
145
|
+
// Reproduces the Windows cold-boot saturation pattern: Bun.serve
|
|
146
|
+
// has bound the port and the child is still emitting log lines
|
|
147
|
+
// (LSP-TS init, preview-manager spawn, vite build), but /health
|
|
148
|
+
// never gets event-loop time. The wait must NOT declare the
|
|
149
|
+
// runtime ready on TCP+stdout alone any more — see the file
|
|
150
|
+
// docstring for the canvas-iframe "Connection timed out"
|
|
151
|
+
// regression that fast-path caused. It should keep polling
|
|
152
|
+
// /health and eventually hit the deadline.
|
|
153
|
+
globalThis.fetch = (async () => {
|
|
154
|
+
throw new Error('ECONNREFUSED');
|
|
155
|
+
}) as typeof fetch;
|
|
156
|
+
stubTcpProbe(mgr, true);
|
|
157
|
+
|
|
158
|
+
const slot = makeSlot();
|
|
159
|
+
// Keep stdout "fresh" so the wedge detector stays quiet — we
|
|
160
|
+
// want to exercise the hard timeout, not the wedge bail-out.
|
|
161
|
+
const bumper = setInterval(() => {
|
|
162
|
+
slot.lastStdoutAt = Date.now();
|
|
163
|
+
}, 200);
|
|
164
|
+
|
|
165
|
+
const startedAt = Date.now();
|
|
166
|
+
try {
|
|
167
|
+
await expect(callWaitForHealth(mgr, slot, 1_500)).rejects.toThrow(
|
|
168
|
+
/Timeout waiting for agent-runtime \/health on port 41234.*tcpListening=true/s,
|
|
169
|
+
);
|
|
170
|
+
} finally {
|
|
171
|
+
clearInterval(bumper);
|
|
172
|
+
}
|
|
173
|
+
const elapsed = Date.now() - startedAt;
|
|
174
|
+
// Should have spent close to the full 1.5s window polling
|
|
175
|
+
// /health, not short-circuited early.
|
|
176
|
+
expect(elapsed).toBeGreaterThanOrEqual(1_400);
|
|
177
|
+
},
|
|
178
|
+
5_000,
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
it(
|
|
182
|
+
'throws "wedged" when TCP is up and stdout is silent past the progress window',
|
|
183
|
+
async () => {
|
|
184
|
+
// The silent-but-bound case mimics a process that bound the
|
|
185
|
+
// port and then wedged (infinite loop in top-level code, native
|
|
186
|
+
// crash mid-init that didn't propagate to the parent). Bail
|
|
187
|
+
// out fast with a "wedged" error so the restart-with-backoff
|
|
188
|
+
// loop in handleExit() can SIGTERM and respawn, instead of
|
|
189
|
+
// spinning the full HEALTH_BOOT_TIMEOUT_MS for a process that
|
|
190
|
+
// will never recover.
|
|
191
|
+
globalThis.fetch = (async () => {
|
|
192
|
+
throw new Error('ECONNREFUSED');
|
|
193
|
+
}) as typeof fetch;
|
|
194
|
+
stubTcpProbe(mgr, true);
|
|
195
|
+
|
|
196
|
+
const slot = makeSlot({
|
|
197
|
+
// Pre-age the stdout timestamp so the wedge detector trips
|
|
198
|
+
// on the first iteration. Must exceed STDOUT_PROGRESS_WINDOW_MS
|
|
199
|
+
// (25 s) to fire.
|
|
200
|
+
lastStdoutAt: Date.now() - 30_000,
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
// Generous outer timeout — the wedge detector should fire
|
|
204
|
+
// immediately, well before the 30s timeout would.
|
|
205
|
+
const startedAt = Date.now();
|
|
206
|
+
await expect(callWaitForHealth(mgr, slot, 30_000)).rejects.toThrow(
|
|
207
|
+
/agent-runtime wedged on port 41234.*stdout silent for/s,
|
|
208
|
+
);
|
|
209
|
+
expect(Date.now() - startedAt).toBeLessThan(2_500);
|
|
210
|
+
},
|
|
211
|
+
5_000,
|
|
212
|
+
);
|
|
213
|
+
|
|
214
|
+
it('times out cleanly when neither TCP nor HTTP ever come up', async () => {
|
|
215
|
+
globalThis.fetch = (async () => {
|
|
216
|
+
throw new Error('ECONNREFUSED');
|
|
217
|
+
}) as typeof fetch;
|
|
218
|
+
stubTcpProbe(mgr, false);
|
|
219
|
+
|
|
220
|
+
const slot = makeSlot();
|
|
221
|
+
await expect(callWaitForHealth(mgr, slot, 1_200)).rejects.toThrow(
|
|
222
|
+
/Timeout waiting for agent-runtime \/health on port 41234.*tcpListening=false/s,
|
|
223
|
+
);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it('short-circuits with exit info when the child dies mid-wait', async () => {
|
|
227
|
+
// Without the short-circuit the wait would spin for the full
|
|
228
|
+
// 30s and surface a generic timeout — the operator's first clue
|
|
229
|
+
// to "the process crashed during boot" should be a clear exit
|
|
230
|
+
// code, not the same boilerplate error as a network failure.
|
|
231
|
+
globalThis.fetch = (async () => {
|
|
232
|
+
throw new Error('ECONNREFUSED');
|
|
233
|
+
}) as typeof fetch;
|
|
234
|
+
stubTcpProbe(mgr, false);
|
|
235
|
+
|
|
236
|
+
const slot = makeSlot();
|
|
237
|
+
setTimeout(() => {
|
|
238
|
+
slot.proc.exitCode = 1;
|
|
239
|
+
}, 400);
|
|
240
|
+
|
|
241
|
+
const startedAt = Date.now();
|
|
242
|
+
await expect(callWaitForHealth(mgr, slot, 30_000)).rejects.toThrow(
|
|
243
|
+
/agent-runtime exited \(code=1, signal=null\) before becoming healthy on port 41234/,
|
|
244
|
+
);
|
|
245
|
+
// 2s is a comfortable ceiling: the inner setTimeout for exitCode
|
|
246
|
+
// fires at 400ms and the wait should observe it within one
|
|
247
|
+
// HEALTH_POLL_MS (500ms) tick.
|
|
248
|
+
expect(Date.now() - startedAt).toBeLessThan(2_500);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it('detects SIGKILL-style deaths where exitCode stays null', async () => {
|
|
252
|
+
// Mirrors the jetsam-on-macOS case and the `cleanupStaleProcesses`
|
|
253
|
+
// SIGKILL of a previous run that the manager itself fired —
|
|
254
|
+
// exitCode never gets a number, only signalCode flips.
|
|
255
|
+
globalThis.fetch = (async () => {
|
|
256
|
+
throw new Error('ECONNREFUSED');
|
|
257
|
+
}) as typeof fetch;
|
|
258
|
+
stubTcpProbe(mgr, false);
|
|
259
|
+
|
|
260
|
+
const slot = makeSlot();
|
|
261
|
+
setTimeout(() => {
|
|
262
|
+
slot.proc.signalCode = 'SIGKILL';
|
|
263
|
+
}, 400);
|
|
264
|
+
|
|
265
|
+
await expect(callWaitForHealth(mgr, slot, 30_000)).rejects.toThrow(
|
|
266
|
+
/agent-runtime exited \(code=null, signal=SIGKILL\) before becoming healthy on port 41234/,
|
|
267
|
+
);
|
|
268
|
+
});
|
|
269
|
+
});
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Copyright (C) 2026 Shogo Technologies, Inc.
|
|
3
|
+
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
|
4
|
+
import { mkdtempSync, rmSync, writeFileSync, mkdirSync, chmodSync } from 'node:fs';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import { resolveRuntime, formatMissingRuntimeError } from '../runtime-resolver.ts';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Resolver priority chain:
|
|
11
|
+
* 1. --runtime-bin flag
|
|
12
|
+
* 2. SHOGO_AGENT_RUNTIME_BIN env var
|
|
13
|
+
* 3. ~/.shogo/runtime/agent-runtime (the "home" path; we can't easily
|
|
14
|
+
* override that without mocking paths.ts, so we just assert it's
|
|
15
|
+
* consulted via the missing-binary message).
|
|
16
|
+
* 4. PATH search for the system bin name
|
|
17
|
+
*
|
|
18
|
+
* These tests construct a tmpdir with a fake executable and exercise the
|
|
19
|
+
* first two + the PATH branch directly.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
describe('runtime-resolver: resolveRuntime', () => {
|
|
23
|
+
let tmp: string;
|
|
24
|
+
let realBin: string;
|
|
25
|
+
let dummyDir: string;
|
|
26
|
+
let dummyBin: string;
|
|
27
|
+
|
|
28
|
+
beforeEach(() => {
|
|
29
|
+
tmp = mkdtempSync(join(tmpdir(), 'shogo-resolver-'));
|
|
30
|
+
realBin = join(tmp, 'fake-runtime');
|
|
31
|
+
writeFileSync(realBin, '#!/bin/sh\necho hi\n');
|
|
32
|
+
chmodSync(realBin, 0o755);
|
|
33
|
+
|
|
34
|
+
dummyDir = join(tmp, 'pathdir');
|
|
35
|
+
mkdirSync(dummyDir);
|
|
36
|
+
dummyBin = join(dummyDir, 'shogo-agent-runtime');
|
|
37
|
+
writeFileSync(dummyBin, '#!/bin/sh\necho hi\n');
|
|
38
|
+
chmodSync(dummyBin, 0o755);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
afterEach(() => {
|
|
42
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('--runtime-bin flag wins when set and the file is executable', () => {
|
|
46
|
+
const result = resolveRuntime({ flag: realBin, env: { PATH: '' } });
|
|
47
|
+
expect(result?.path).toBe(realBin);
|
|
48
|
+
expect(result?.source).toBe('flag');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('falls through when the flag points at a non-existent path', () => {
|
|
52
|
+
const result = resolveRuntime({
|
|
53
|
+
flag: join(tmp, 'does-not-exist'),
|
|
54
|
+
env: { SHOGO_AGENT_RUNTIME_BIN: realBin, PATH: '' },
|
|
55
|
+
});
|
|
56
|
+
expect(result?.path).toBe(realBin);
|
|
57
|
+
expect(result?.source).toBe('env');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('falls through when the flag points at a non-executable file', () => {
|
|
61
|
+
const nonExec = join(tmp, 'non-exec');
|
|
62
|
+
writeFileSync(nonExec, 'plain text');
|
|
63
|
+
chmodSync(nonExec, 0o644);
|
|
64
|
+
|
|
65
|
+
const result = resolveRuntime({
|
|
66
|
+
flag: nonExec,
|
|
67
|
+
env: { SHOGO_AGENT_RUNTIME_BIN: realBin, PATH: '' },
|
|
68
|
+
});
|
|
69
|
+
expect(result?.source).toBe('env');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('SHOGO_AGENT_RUNTIME_BIN wins when no flag is set', () => {
|
|
73
|
+
const result = resolveRuntime({ env: { SHOGO_AGENT_RUNTIME_BIN: realBin, PATH: '' } });
|
|
74
|
+
expect(result?.path).toBe(realBin);
|
|
75
|
+
expect(result?.source).toBe('env');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('falls back to PATH when flag/env/home are all unavailable', () => {
|
|
79
|
+
const result = resolveRuntime({
|
|
80
|
+
env: { PATH: dummyDir },
|
|
81
|
+
systemBinName: 'shogo-agent-runtime',
|
|
82
|
+
});
|
|
83
|
+
expect(result?.path).toBe(dummyBin);
|
|
84
|
+
expect(result?.source).toBe('path');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('returns null when nothing resolves', () => {
|
|
88
|
+
const result = resolveRuntime({
|
|
89
|
+
env: { PATH: tmp /* tmp has no bin matching the system name */ },
|
|
90
|
+
systemBinName: 'definitely-not-on-path-xyz',
|
|
91
|
+
});
|
|
92
|
+
expect(result).toBeNull();
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe('runtime-resolver: formatMissingRuntimeError', () => {
|
|
97
|
+
it('mentions the flag if one was passed', () => {
|
|
98
|
+
const msg = formatMissingRuntimeError({ flag: '/tmp/foo' });
|
|
99
|
+
expect(msg).toContain('--runtime-bin /tmp/foo');
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('mentions SHOGO_AGENT_RUNTIME_BIN when set', () => {
|
|
103
|
+
const msg = formatMissingRuntimeError({ env: { SHOGO_AGENT_RUNTIME_BIN: '/opt/foo' } });
|
|
104
|
+
expect(msg).toContain('SHOGO_AGENT_RUNTIME_BIN');
|
|
105
|
+
expect(msg).toContain('/opt/foo');
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('always includes the install hint', () => {
|
|
109
|
+
const msg = formatMissingRuntimeError();
|
|
110
|
+
expect(msg).toContain('shogo runtime install');
|
|
111
|
+
});
|
|
112
|
+
});
|