@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.
Files changed (57) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +432 -0
  3. package/bin/shogo.mjs +38 -0
  4. package/dist/cli.mjs +64 -0
  5. package/package.json +62 -0
  6. package/src/cli.ts +199 -0
  7. package/src/commands/agent.ts +123 -0
  8. package/src/commands/config.ts +29 -0
  9. package/src/commands/doctor.ts +128 -0
  10. package/src/commands/login.ts +126 -0
  11. package/src/commands/logs.ts +24 -0
  12. package/src/commands/project-checkout.ts +179 -0
  13. package/src/commands/project-pull.ts +133 -0
  14. package/src/commands/project-push.ts +89 -0
  15. package/src/commands/runtime.ts +104 -0
  16. package/src/commands/start.ts +290 -0
  17. package/src/commands/status.ts +22 -0
  18. package/src/commands/stop.ts +13 -0
  19. package/src/lib/__tests__/agent-launch.test.ts +92 -0
  20. package/src/lib/__tests__/cloud-login-coverage.test.ts +210 -0
  21. package/src/lib/__tests__/cloud-login.test.ts +218 -0
  22. package/src/lib/__tests__/cloud-sync-watcher.test.ts +214 -0
  23. package/src/lib/__tests__/config.test.ts +141 -0
  24. package/src/lib/__tests__/db-doctor.test.ts +204 -0
  25. package/src/lib/__tests__/git-cloner.test.ts +382 -0
  26. package/src/lib/__tests__/runtime-manager-auto-pull.test.ts +275 -0
  27. package/src/lib/__tests__/runtime-manager-cloud-sync-gate.test.ts +119 -0
  28. package/src/lib/__tests__/runtime-manager-coverage-gaps.test.ts +768 -0
  29. package/src/lib/__tests__/runtime-manager-describe-rejection.test.ts +42 -0
  30. package/src/lib/__tests__/runtime-manager-git-pull.test.ts +207 -0
  31. package/src/lib/__tests__/runtime-manager-idle-eviction.test.ts +196 -0
  32. package/src/lib/__tests__/runtime-manager-restart-cap.test.ts +310 -0
  33. package/src/lib/__tests__/runtime-manager-tree-sitter-env.test.ts +189 -0
  34. package/src/lib/__tests__/runtime-manager-wait-for-health.test.ts +269 -0
  35. package/src/lib/__tests__/runtime-resolver.test.ts +112 -0
  36. package/src/lib/__tests__/tunnel-coverage.test.ts +1094 -0
  37. package/src/lib/__tests__/tunnel-structured-502.test.ts +101 -0
  38. package/src/lib/__tests__/worker-commands-greenfield.test.ts +463 -0
  39. package/src/lib/__tests__/worker-libs-greenfield.test.ts +355 -0
  40. package/src/lib/__tests__/worker-transport-greenfield.test.ts +176 -0
  41. package/src/lib/api-discovery.ts +36 -0
  42. package/src/lib/cloud-login.ts +320 -0
  43. package/src/lib/cloud-sync-watcher.ts +322 -0
  44. package/src/lib/config.ts +68 -0
  45. package/src/lib/db-doctor.ts +355 -0
  46. package/src/lib/device-id.ts +27 -0
  47. package/src/lib/git-cloner.ts +354 -0
  48. package/src/lib/paths.ts +53 -0
  49. package/src/lib/preflight.ts +158 -0
  50. package/src/lib/process-manager.ts +123 -0
  51. package/src/lib/resource-limits.ts +340 -0
  52. package/src/lib/runtime-install.ts +371 -0
  53. package/src/lib/runtime-manager.ts +1873 -0
  54. package/src/lib/runtime-resolver.ts +136 -0
  55. package/src/lib/transport.ts +202 -0
  56. package/src/lib/tunnel.ts +710 -0
  57. package/src/lib/user-agent.ts +28 -0
@@ -0,0 +1,92 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Copyright (C) 2026 Shogo Technologies, Inc.
3
+ /**
4
+ * Smoke tests for the MIT `shogo` agent launcher (argv + env shape).
5
+ * buildAgentSpawn is pure, so no spawn / network / runtime binary is needed.
6
+ */
7
+ import { describe, test, expect } from 'bun:test'
8
+ import { resolve as resolvePath } from 'node:path'
9
+ import { buildAgentSpawn } from '../../commands/agent.ts'
10
+ import type { ResolvedRuntime } from '../runtime-resolver.ts'
11
+
12
+ const runtime: ResolvedRuntime = { path: '/home/u/.shogo/runtime/agent-runtime', source: 'home' }
13
+ const config = { apiKey: 'shogo_sk_test123', cloudUrl: 'https://studio.shogo.ai' }
14
+
15
+ describe('buildAgentSpawn', () => {
16
+ test('interactive launch: argv + billing env point at the proxy', () => {
17
+ const plan = buildAgentSpawn({
18
+ flags: { cwd: '/work/repo' },
19
+ config,
20
+ runtime,
21
+ baseEnv: {},
22
+ })
23
+
24
+ expect(plan.bin).toBe('/home/u/.shogo/runtime/agent-runtime')
25
+ expect(plan.args).toEqual(['interactive'])
26
+ expect(plan.cwd).toBe(resolvePath('/work/repo'))
27
+
28
+ expect(plan.env.SHOGO_INTERACTIVE).toBe('1')
29
+ expect(plan.env.SHOGO_INTERACTIVE_CWD).toBe(resolvePath('/work/repo'))
30
+ expect(plan.env.PROJECT_DIR).toBe(resolvePath('/work/repo'))
31
+ expect(plan.env.WORKSPACE_DIR).toBe(resolvePath('/work/repo'))
32
+ expect(plan.env.SHOGO_API_URL).toBe('https://studio.shogo.ai')
33
+ expect(plan.env.SHOGO_API_KEY).toBe('shogo_sk_test123')
34
+ expect(plan.env.AI_PROXY_URL).toBe('https://studio.shogo.ai/api/ai/v1')
35
+ expect(plan.env.AI_PROXY_TOKEN).toBe('shogo_sk_test123')
36
+ })
37
+
38
+ test('headless -p adds the print argv + SHOGO_PRINT_PROMPT', () => {
39
+ const plan = buildAgentSpawn({
40
+ flags: { print: 'review this', cwd: '/work/repo' },
41
+ config,
42
+ runtime,
43
+ baseEnv: {},
44
+ })
45
+ expect(plan.args).toEqual(['interactive', '-p', 'review this'])
46
+ expect(plan.env.SHOGO_PRINT_PROMPT).toBe('review this')
47
+ })
48
+
49
+ test('--no-tui and --model are threaded through', () => {
50
+ const plan = buildAgentSpawn({
51
+ flags: { noTui: true, model: 'claude-sonnet', cwd: '/work/repo' },
52
+ config,
53
+ runtime,
54
+ baseEnv: {},
55
+ })
56
+ expect(plan.args).toEqual(['interactive', '--no-tui'])
57
+ expect(plan.env.SHOGO_MODEL).toBe('claude-sonnet')
58
+ })
59
+
60
+ test('trailing slash on cloudUrl is stripped before building proxy URL', () => {
61
+ const plan = buildAgentSpawn({
62
+ flags: { cwd: '/work/repo' },
63
+ config: { apiKey: 'shogo_sk_x', cloudUrl: 'https://example.dev/' },
64
+ runtime,
65
+ baseEnv: {},
66
+ })
67
+ expect(plan.env.SHOGO_API_URL).toBe('https://example.dev')
68
+ expect(plan.env.AI_PROXY_URL).toBe('https://example.dev/api/ai/v1')
69
+ })
70
+
71
+ test('base env is preserved (e.g. PATH) and not clobbered', () => {
72
+ const plan = buildAgentSpawn({
73
+ flags: { cwd: '/work/repo' },
74
+ config,
75
+ runtime,
76
+ baseEnv: { PATH: '/usr/bin', HOME: '/home/u' },
77
+ })
78
+ expect(plan.env.PATH).toBe('/usr/bin')
79
+ expect(plan.env.HOME).toBe('/home/u')
80
+ })
81
+
82
+ test('empty -p (no value) still routes to headless argv', () => {
83
+ const plan = buildAgentSpawn({
84
+ flags: { print: '', cwd: '/work/repo' },
85
+ config,
86
+ runtime,
87
+ baseEnv: {},
88
+ })
89
+ expect(plan.args).toEqual(['interactive', '-p', ''])
90
+ expect(plan.env.SHOGO_PRINT_PROMPT).toBe('')
91
+ })
92
+ })
@@ -0,0 +1,210 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Copyright (C) 2026 Shogo Technologies, Inc.
3
+ /**
4
+ * Coverage gaps for cloud-login.ts.
5
+ *
6
+ * Targets:
7
+ * L201-205 sigHandler body (SIGINT/SIGTERM aborts the poll loop)
8
+ * L239-244 poll fetch network error → .catch swallows and retries
9
+ * L287-289 sleep() onAbort handler (signal fires DURING sleep)
10
+ * L323-337 openInBrowser (default openBrowser path)
11
+ */
12
+ import { describe, it, expect } from 'bun:test';
13
+ import { runCloudLogin, CloudLoginError } from '../cloud-login.ts';
14
+
15
+ function jsonResponse(body: unknown, status = 200) {
16
+ return new Response(JSON.stringify(body), {
17
+ status,
18
+ headers: { 'content-type': 'application/json' },
19
+ });
20
+ }
21
+
22
+ const startBody = {
23
+ ok: true,
24
+ state: 'state-abc',
25
+ userCode: 'XXXXXX',
26
+ authUrl: 'https://cloud.example.com/auth/cli-link?state=state-abc',
27
+ expiresInMs: 60_000,
28
+ pollIntervalMs: 1,
29
+ };
30
+
31
+ describe('runCloudLogin — sigHandler (L201-205)', () => {
32
+ it('SIGINT during the poll loop unwinds via cancelled CloudLoginError', async () => {
33
+ // Issue SIGINT after start; the sigHandler should abort the local controller.
34
+ let pollCount = 0;
35
+ const fetchImpl = (async (input: RequestInfo | URL) => {
36
+ const url = typeof input === 'string' ? input : input.toString();
37
+ if (url.endsWith('/api/cli/login/start')) {
38
+ return jsonResponse(startBody);
39
+ }
40
+ pollCount += 1;
41
+ if (pollCount === 1) {
42
+ // Fire SIGINT in the next microtask while the loop is about to sleep
43
+ setImmediate(() => process.emit('SIGINT' as any));
44
+ return jsonResponse({ ok: true, status: 'pending' });
45
+ }
46
+ return jsonResponse({ ok: true, status: 'pending' });
47
+ }) as unknown as typeof fetch;
48
+
49
+ try {
50
+ await runCloudLogin({
51
+ cloudUrl: 'https://cloud.example.com',
52
+ deviceId: 'dev-abc',
53
+ openBrowser: false,
54
+ pollIntervalMs: 100,
55
+ installSignalHandlers: true,
56
+ log: () => {},
57
+ fetchImpl,
58
+ });
59
+ throw new Error('should have thrown');
60
+ } catch (err) {
61
+ expect(err).toBeInstanceOf(CloudLoginError);
62
+ expect((err as CloudLoginError).kind).toBe('cancelled');
63
+ }
64
+ });
65
+ });
66
+
67
+ describe('runCloudLogin — poll network error .catch (L239-244)', () => {
68
+ it('soft network error during poll is logged and the loop retries until approved', async () => {
69
+ let pollCount = 0;
70
+ const logs: string[] = [];
71
+ const fetchImpl = (async (input: RequestInfo | URL) => {
72
+ const url = typeof input === 'string' ? input : input.toString();
73
+ if (url.endsWith('/api/cli/login/start')) {
74
+ return jsonResponse(startBody);
75
+ }
76
+ pollCount += 1;
77
+ if (pollCount === 1) {
78
+ throw new Error('ECONNRESET on first poll');
79
+ }
80
+ return jsonResponse({
81
+ ok: true, status: 'approved', key: 'shogo_sk_recovered',
82
+ email: 'u@e.com', workspace: 'w', deviceId: 'd',
83
+ });
84
+ }) as unknown as typeof fetch;
85
+
86
+ const result = await runCloudLogin({
87
+ cloudUrl: 'https://cloud.example.com',
88
+ deviceId: 'd',
89
+ openBrowser: false,
90
+ pollIntervalMs: 1,
91
+ installSignalHandlers: false,
92
+ log: (s) => logs.push(s),
93
+ fetchImpl,
94
+ });
95
+ expect(result.key).toBe('shogo_sk_recovered');
96
+ expect(logs.some((l) => l.includes('poll error'))).toBe(true);
97
+ });
98
+ });
99
+
100
+ describe('runCloudLogin — sleep onAbort (L287-289)', () => {
101
+ it('abort signal fires DURING the inter-poll sleep → cancelled CloudLoginError', async () => {
102
+ // Use a long pollIntervalMs so sleep is in flight when we abort.
103
+ const ac = new AbortController();
104
+ let pollCount = 0;
105
+ const fetchImpl = (async (input: RequestInfo | URL) => {
106
+ const url = typeof input === 'string' ? input : input.toString();
107
+ if (url.endsWith('/api/cli/login/start')) {
108
+ return jsonResponse({ ...startBody, pollIntervalMs: 5000 });
109
+ }
110
+ pollCount += 1;
111
+ if (pollCount === 1) {
112
+ // After this poll, sleep(5000) begins. Schedule abort 20ms later.
113
+ setTimeout(() => ac.abort(), 20);
114
+ return jsonResponse({ ok: true, status: 'pending' });
115
+ }
116
+ return jsonResponse({ ok: true, status: 'pending' });
117
+ }) as unknown as typeof fetch;
118
+
119
+ try {
120
+ await runCloudLogin({
121
+ cloudUrl: 'https://cloud.example.com',
122
+ deviceId: 'd',
123
+ openBrowser: false,
124
+ pollIntervalMs: 5000, // long sleep
125
+ installSignalHandlers: false,
126
+ log: () => {},
127
+ fetchImpl,
128
+ abortSignal: ac.signal,
129
+ });
130
+ throw new Error('should have thrown');
131
+ } catch (err) {
132
+ expect(err).toBeInstanceOf(CloudLoginError);
133
+ expect((err as CloudLoginError).kind).toBe('cancelled');
134
+ }
135
+ });
136
+ });
137
+
138
+ describe('runCloudLogin — openInBrowser default path (L323-337)', () => {
139
+ it('with openBrowser undefined: invokes openInBrowser (default path) without breaking the flow', async () => {
140
+ // Default openBrowser triggers spawn('xdg-open' | 'open' | 'cmd'). On a
141
+ // sandbox the spawn may fail or succeed; in BOTH cases the .catch on the
142
+ // call site swallows it. We're after coverage of L323-334 / L335-337.
143
+ let pollCount = 0;
144
+ const fetchImpl = (async (input: RequestInfo | URL) => {
145
+ const url = typeof input === 'string' ? input : input.toString();
146
+ if (url.endsWith('/api/cli/login/start')) {
147
+ return jsonResponse(startBody);
148
+ }
149
+ pollCount += 1;
150
+ return jsonResponse({
151
+ ok: true, status: 'approved', key: 'shogo_sk_via_browser',
152
+ email: 'u@e.com', workspace: 'w', deviceId: 'd',
153
+ });
154
+ }) as unknown as typeof fetch;
155
+
156
+ const result = await runCloudLogin({
157
+ cloudUrl: 'https://cloud.example.com',
158
+ deviceId: 'd',
159
+ // openBrowser omitted → defaults to true → openInBrowser runs
160
+ pollIntervalMs: 1,
161
+ installSignalHandlers: false,
162
+ log: () => {},
163
+ fetchImpl,
164
+ });
165
+ expect(result.key).toBe('shogo_sk_via_browser');
166
+ });
167
+
168
+ it('openInBrowser swallows synchronous spawn errors via the outer try/catch (L335-337)', async () => {
169
+ // Force spawn to throw synchronously by temporarily replacing it via
170
+ // process.binding-style override. The cleanest cross-runtime way: monkey-
171
+ // patch the resolved module's spawn. cloud-login imports `spawn` at the
172
+ // top, so we can't change it post-import. Instead, indirectly: pass a
173
+ // platform that resolves to a binary that almost certainly does NOT exist
174
+ // — and rely on the catch branch firing if spawn throws.
175
+ //
176
+ // In practice on the sandbox `spawn('xdg-open', ...)` resolves to ENOENT
177
+ // asynchronously via child.on('error'), not synchronously, so this test
178
+ // mainly documents the contract: a synchronous spawn failure is silently
179
+ // swallowed and the flow still completes.
180
+ let pollCount = 0;
181
+ const fetchImpl = (async (input: RequestInfo | URL) => {
182
+ const url = typeof input === 'string' ? input : input.toString();
183
+ if (url.endsWith('/api/cli/login/start')) {
184
+ return jsonResponse(startBody);
185
+ }
186
+ pollCount += 1;
187
+ return jsonResponse({
188
+ ok: true, status: 'approved', key: 'shogo_sk_browser_failed',
189
+ email: 'u@e.com', workspace: 'w', deviceId: 'd',
190
+ });
191
+ }) as unknown as typeof fetch;
192
+
193
+ // Custom openBrowser function that throws — runCloudLogin .catch()es it.
194
+ // This exercises L189-192 (custom openBrowser branch). For the actual
195
+ // spawn-throws branch in openInBrowser we'd need module mocking, which
196
+ // would conflict with the git-cloner.test.ts spawn mock running in the
197
+ // same Bun process. The default-openBrowser test above already exercises
198
+ // openInBrowser's spawn path with whatever the sandbox provides.
199
+ const result = await runCloudLogin({
200
+ cloudUrl: 'https://cloud.example.com',
201
+ deviceId: 'd',
202
+ openBrowser: () => { throw new Error('user opener boom'); },
203
+ pollIntervalMs: 1,
204
+ installSignalHandlers: false,
205
+ log: () => {},
206
+ fetchImpl,
207
+ });
208
+ expect(result.key).toBe('shogo_sk_browser_failed');
209
+ });
210
+ });
@@ -0,0 +1,218 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Copyright (C) 2026 Shogo Technologies, Inc.
3
+ import { describe, it, expect } from 'bun:test';
4
+ import { runCloudLogin, CloudLoginError } from '../cloud-login.ts';
5
+
6
+ /**
7
+ * Drive `runCloudLogin` against a scripted fetch implementation so we
8
+ * can exercise the start → poll → approved happy path plus all four
9
+ * documented terminal states (denied / expired / state-mismatch via
10
+ * malformed responses / network error).
11
+ *
12
+ * The CLI client should:
13
+ * - never auto-open a browser when openBrowser:false
14
+ * - poll until status='approved' or terminal state
15
+ * - return the key + email + workspace from the approved poll exactly
16
+ * once and not call any further endpoints
17
+ * - throw CloudLoginError with the right `kind` for each failure mode
18
+ */
19
+
20
+ function scripted(handlers: Array<(url: string) => Response | Promise<Response>>) {
21
+ let i = 0;
22
+ const fetchImpl = async (input: RequestInfo | URL, _init?: RequestInit) => {
23
+ const url = typeof input === 'string' ? input : input.toString();
24
+ if (i >= handlers.length) {
25
+ throw new Error(`scripted fetch ran out of handlers at request #${i + 1} (${url})`);
26
+ }
27
+ return handlers[i++](url);
28
+ };
29
+ return { fetchImpl: fetchImpl as unknown as typeof fetch, calls: () => i };
30
+ }
31
+
32
+ function jsonResponse(body: unknown, status = 200) {
33
+ return new Response(JSON.stringify(body), {
34
+ status,
35
+ headers: { 'content-type': 'application/json' },
36
+ });
37
+ }
38
+
39
+ describe('runCloudLogin', () => {
40
+ it('returns the minted key on the second poll (start → pending → approved)', async () => {
41
+ const { fetchImpl } = scripted([
42
+ (url) => {
43
+ expect(url).toEndWith('/api/cli/login/start');
44
+ return jsonResponse({
45
+ ok: true,
46
+ state: 'abcdef0123456789',
47
+ userCode: '456789',
48
+ authUrl: 'https://cloud.example.com/auth/cli-link?state=abcdef0123456789',
49
+ expiresInMs: 60_000,
50
+ pollIntervalMs: 1_000,
51
+ });
52
+ },
53
+ (url) => {
54
+ expect(url).toContain('/api/cli/login/poll?state=abcdef0123456789');
55
+ return jsonResponse({ ok: true, status: 'pending' });
56
+ },
57
+ (url) => {
58
+ expect(url).toContain('/api/cli/login/poll?state=abcdef0123456789');
59
+ return jsonResponse({
60
+ ok: true,
61
+ status: 'approved',
62
+ key: 'shogo_sk_test_key_123',
63
+ email: 'user@example.com',
64
+ workspace: 'My Workspace',
65
+ deviceId: 'dev-abc',
66
+ });
67
+ },
68
+ ]);
69
+
70
+ const result = await runCloudLogin({
71
+ cloudUrl: 'https://cloud.example.com',
72
+ deviceId: 'dev-abc',
73
+ openBrowser: false,
74
+ pollIntervalMs: 1, // tighten so the test runs fast
75
+ log: () => { /* silence */ },
76
+ fetchImpl,
77
+ });
78
+
79
+ expect(result.key).toBe('shogo_sk_test_key_123');
80
+ expect(result.email).toBe('user@example.com');
81
+ expect(result.workspace).toBe('My Workspace');
82
+ expect(result.deviceId).toBe('dev-abc');
83
+ });
84
+
85
+ it('throws CloudLoginError(kind="denied") when the user clicks Cancel', async () => {
86
+ const { fetchImpl } = scripted([
87
+ () =>
88
+ jsonResponse({
89
+ ok: true,
90
+ state: 's',
91
+ userCode: 'XXXXXX',
92
+ authUrl: 'https://cloud.example.com/auth/cli-link?state=s',
93
+ expiresInMs: 60_000,
94
+ pollIntervalMs: 1,
95
+ }),
96
+ () => jsonResponse({ ok: true, status: 'denied' }),
97
+ ]);
98
+
99
+ await expect(
100
+ runCloudLogin({
101
+ cloudUrl: 'https://cloud.example.com',
102
+ deviceId: 'dev-abc',
103
+ openBrowser: false,
104
+ pollIntervalMs: 1,
105
+ log: () => { /* silence */ },
106
+ fetchImpl,
107
+ }),
108
+ ).rejects.toBeInstanceOf(CloudLoginError);
109
+ });
110
+
111
+ it('throws CloudLoginError(kind="expired") when the cloud says expired', async () => {
112
+ const { fetchImpl } = scripted([
113
+ () =>
114
+ jsonResponse({
115
+ ok: true,
116
+ state: 's',
117
+ userCode: 'XXXXXX',
118
+ authUrl: 'https://cloud.example.com/auth/cli-link?state=s',
119
+ expiresInMs: 60_000,
120
+ pollIntervalMs: 1,
121
+ }),
122
+ () => jsonResponse({ ok: true, status: 'expired' }),
123
+ ]);
124
+
125
+ try {
126
+ await runCloudLogin({
127
+ cloudUrl: 'https://cloud.example.com',
128
+ deviceId: 'dev-abc',
129
+ openBrowser: false,
130
+ pollIntervalMs: 1,
131
+ log: () => { /* silence */ },
132
+ fetchImpl,
133
+ });
134
+ throw new Error('should have thrown');
135
+ } catch (err) {
136
+ expect(err).toBeInstanceOf(CloudLoginError);
137
+ expect((err as CloudLoginError).kind).toBe('expired');
138
+ }
139
+ });
140
+
141
+ it('respects abortSignal and throws cancelled', async () => {
142
+ const ac = new AbortController();
143
+ const { fetchImpl } = scripted([
144
+ () =>
145
+ jsonResponse({
146
+ ok: true,
147
+ state: 's',
148
+ userCode: 'XXXXXX',
149
+ authUrl: 'https://cloud.example.com/auth/cli-link?state=s',
150
+ expiresInMs: 60_000,
151
+ pollIntervalMs: 50,
152
+ }),
153
+ () => {
154
+ // Abort right before the next sleep so the wait raises cancelled.
155
+ ac.abort();
156
+ return jsonResponse({ ok: true, status: 'pending' });
157
+ },
158
+ ]);
159
+
160
+ try {
161
+ await runCloudLogin({
162
+ cloudUrl: 'https://cloud.example.com',
163
+ deviceId: 'dev-abc',
164
+ openBrowser: false,
165
+ pollIntervalMs: 50,
166
+ log: () => { /* silence */ },
167
+ fetchImpl,
168
+ abortSignal: ac.signal,
169
+ });
170
+ throw new Error('should have thrown');
171
+ } catch (err) {
172
+ expect(err).toBeInstanceOf(CloudLoginError);
173
+ expect((err as CloudLoginError).kind).toBe('cancelled');
174
+ }
175
+ });
176
+
177
+ it('throws transport when start endpoint is unreachable', async () => {
178
+ const fetchImpl = (async () => {
179
+ throw new Error('ECONNREFUSED');
180
+ }) as unknown as typeof fetch;
181
+
182
+ try {
183
+ await runCloudLogin({
184
+ cloudUrl: 'https://cloud.example.com',
185
+ deviceId: 'dev-abc',
186
+ openBrowser: false,
187
+ pollIntervalMs: 1,
188
+ log: () => { /* silence */ },
189
+ fetchImpl,
190
+ });
191
+ throw new Error('should have thrown');
192
+ } catch (err) {
193
+ expect(err).toBeInstanceOf(CloudLoginError);
194
+ expect((err as CloudLoginError).kind).toBe('transport');
195
+ }
196
+ });
197
+
198
+ it('throws transport when start returns malformed body', async () => {
199
+ const { fetchImpl } = scripted([
200
+ () => jsonResponse({ ok: false, error: 'bad request' }, 400),
201
+ ]);
202
+
203
+ try {
204
+ await runCloudLogin({
205
+ cloudUrl: 'https://cloud.example.com',
206
+ deviceId: 'dev-abc',
207
+ openBrowser: false,
208
+ pollIntervalMs: 1,
209
+ log: () => { /* silence */ },
210
+ fetchImpl,
211
+ });
212
+ throw new Error('should have thrown');
213
+ } catch (err) {
214
+ expect(err).toBeInstanceOf(CloudLoginError);
215
+ expect((err as CloudLoginError).kind).toBe('transport');
216
+ }
217
+ });
218
+ });