@mycadet/cli 0.12.10 → 0.13.0
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/README.md +13 -0
- package/node_modules/@mycadet/assistant-api/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +10 -10
- package/node_modules/@mycadet/assistant-api/node_modules/@mycadet/service-contracts/src/ingress.ts +3 -3
- package/node_modules/@mycadet/local-mode/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +10 -10
- package/node_modules/@mycadet/local-mode/node_modules/@mycadet/service-contracts/src/ingress.ts +3 -3
- package/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +10 -10
- package/node_modules/@mycadet/service-contracts/src/ingress.ts +3 -3
- package/package.json +3 -2
- package/src/__tests__/assistant-client-cloud-auth.test.ts +2 -2
- package/src/__tests__/backup-ops-matching.test.ts +15 -13
- package/src/__tests__/backup-ops.test.ts +105 -0
- package/src/__tests__/backup.test.ts +114 -673
- package/src/__tests__/cloud-login-poll.test.ts +69 -0
- package/src/__tests__/cloud-releases.test.ts +82 -0
- package/src/__tests__/commands-mock.ts +101 -0
- package/src/__tests__/debug.test.ts +17 -91
- package/src/__tests__/devices.test.ts +64 -2
- package/src/__tests__/ensure-binary.test.ts +52 -0
- package/src/__tests__/gateway-token.test.ts +267 -0
- package/src/__tests__/hatch-next-steps.test.ts +38 -0
- package/src/__tests__/host-teleport-docker.test.ts +371 -0
- package/src/__tests__/host-teleport.test.ts +405 -0
- package/src/__tests__/multi-local.test.ts +3 -3
- package/src/__tests__/nginx-ingress.test.ts +233 -36
- package/src/__tests__/orphan-detection.test.ts +1 -1
- package/src/__tests__/paired-lifecycle.test.ts +2 -2
- package/src/__tests__/ps-cloud-status.test.ts +8 -7
- package/src/__tests__/root-backup-docker.test.ts +294 -0
- package/src/__tests__/root-backup.test.ts +527 -0
- package/src/__tests__/root-restore-command.test.ts +147 -0
- package/src/__tests__/root-signing-key.test.ts +50 -0
- package/src/__tests__/setup.test.ts +17 -7
- package/src/__tests__/sleep.test.ts +11 -0
- package/src/__tests__/teleport.test.ts +68 -2996
- package/src/__tests__/tunnel-startup-summary.test.ts +110 -0
- package/src/__tests__/tunnel-stop.test.ts +226 -0
- package/src/__tests__/tunnel.test.ts +13 -3
- package/src/__tests__/upgrade-local.test.ts +21 -11
- package/src/__tests__/upgrade-replay-env.test.ts +2 -2
- package/src/__tests__/upgrade-root-recovery.test.ts +185 -0
- package/src/__tests__/windows-lifecycle.test.ts +25 -0
- package/src/commands/backup.ts +44 -399
- package/src/commands/client.ts +3 -3
- package/src/commands/completions.ts +0 -2
- package/src/commands/debug.ts +75 -131
- package/src/commands/devices.ts +117 -47
- package/src/commands/gateway/token.ts +77 -23
- package/src/commands/hatch.ts +10 -52
- package/src/commands/pair.ts +9 -3
- package/src/commands/ps.ts +4 -2
- package/src/commands/restore.ts +77 -444
- package/src/commands/retire.ts +2 -3
- package/src/commands/sleep.ts +15 -2
- package/src/commands/teleport.ts +47 -1559
- package/src/commands/tunnel.ts +43 -4
- package/src/commands/upgrade.ts +84 -157
- package/src/commands/wake.ts +1 -1
- package/src/index.ts +0 -4
- package/src/lib/__tests__/http-client-version.test.ts +29 -0
- package/src/lib/__tests__/runtime-url.test.ts +3 -75
- package/src/lib/__tests__/web-dist.test.ts +27 -1
- package/src/lib/assistant-client.ts +1 -1
- package/src/lib/assistant-config.ts +20 -23
- package/src/lib/backup-ops.ts +37 -251
- package/src/lib/cloud-client.ts +10 -13
- package/src/lib/cloud-login-poll.ts +75 -0
- package/src/lib/cloud-login.ts +5 -2
- package/src/lib/cloud-releases.ts +54 -10
- package/src/lib/cloud-values.ts +1 -1
- package/src/lib/confirm-action.ts +2 -2
- package/src/lib/docker.ts +65 -34
- package/src/lib/ensure-binary.ts +65 -6
- package/src/lib/github-repository.ts +2 -0
- package/src/lib/guardian-token.ts +1 -23
- package/src/lib/hatch-local.ts +18 -52
- package/src/lib/hatch-next-steps.ts +15 -2
- package/src/lib/host-teleport.ts +506 -0
- package/src/lib/http-client.ts +25 -0
- package/src/lib/live-voice/tunnel.test.ts +1 -1
- package/src/lib/live-voice/tunnel.ts +3 -3
- package/src/lib/local.ts +10 -8
- package/src/lib/nginx-ingress.ts +254 -36
- package/src/lib/process.ts +13 -0
- package/src/lib/root-backup.ts +917 -0
- package/src/lib/root-signing-key.ts +31 -0
- package/src/lib/runtime-url.ts +0 -29
- package/src/lib/teleport-backup.ts +3 -16
- package/src/lib/tunnel.ts +86 -34
- package/src/lib/tunnels/cadet.ts +12 -1
- package/src/lib/tunnels/cloudflare.ts +36 -1
- package/src/lib/tunnels/ngrok.ts +16 -2
- package/src/lib/tunnels/process-lifecycle.ts +26 -0
- package/src/lib/tunnels/tailscale.ts +79 -1
- package/src/lib/upgrade-lifecycle.ts +63 -141
- package/src/lib/web-dist.ts +23 -1
- package/src/lib/xdg-log.ts +11 -0
- package/src/__tests__/bundle-staging.test.ts +0 -268
- package/src/__tests__/unpair.test.ts +0 -160
- package/src/commands/rollback.ts +0 -479
- package/src/commands/unpair.ts +0 -122
- package/src/lib/__tests__/job-polling.test.ts +0 -278
- package/src/lib/__tests__/local-runtime-client.test.ts +0 -666
- package/src/lib/bundle-staging.ts +0 -220
- package/src/lib/job-polling.ts +0 -195
- package/src/lib/local-runtime-client.ts +0 -374
|
@@ -1,3009 +1,81 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
import { mkdtempSync, rmSync } from "node:fs";
|
|
13
|
-
import { tmpdir } from "node:os";
|
|
14
|
-
import { join } from "node:path";
|
|
15
|
-
|
|
16
|
-
// ---------------------------------------------------------------------------
|
|
17
|
-
// Temp directory for lockfile isolation
|
|
18
|
-
// ---------------------------------------------------------------------------
|
|
19
|
-
|
|
20
|
-
const testDir = mkdtempSync(join(tmpdir(), "cli-teleport-test-"));
|
|
21
|
-
process.env.CADET_LOCKFILE_DIR = testDir;
|
|
22
|
-
|
|
23
|
-
// ---------------------------------------------------------------------------
|
|
24
|
-
// Mocks — must be set up before importing the module under test.
|
|
25
|
-
//
|
|
26
|
-
// We use Bun's `mock.module()` API (rather than `spyOn` on the imported
|
|
27
|
-
// module namespace). `spyOn` mutates the shared module namespace, and
|
|
28
|
-
// `mockRestore()` in `afterAll` was not reliably restoring on CI — the
|
|
29
|
-
// mutations leaked into sibling test files (notably `guardian-token.test.ts`)
|
|
30
|
-
// and caused flaky failures when the runtime function for that file returned
|
|
31
|
-
// teleport's mock value instead of the real implementation.
|
|
32
|
-
//
|
|
33
|
-
// `mock.module()` registers a per-module factory in Bun's loader. To prevent
|
|
34
|
-
// the mock from leaking across files in the same `bun test` run, we capture
|
|
35
|
-
// the real exports up front and re-register them in `afterAll` to restore
|
|
36
|
-
// the original module bindings.
|
|
37
|
-
// ---------------------------------------------------------------------------
|
|
38
|
-
|
|
39
|
-
import * as assistantConfig from "../lib/assistant-config.js";
|
|
40
|
-
import * as guardianToken from "../lib/guardian-token.js";
|
|
41
|
-
import * as cloudClient from "../lib/cloud-client.js";
|
|
42
|
-
import * as localRuntimeClient from "../lib/local-runtime-client.js";
|
|
43
|
-
import * as backupOps from "../lib/backup-ops.js";
|
|
44
|
-
import * as teleportBackup from "../lib/teleport-backup.js";
|
|
45
|
-
|
|
46
|
-
// Snapshot the real exports before any `mock.module()` call so we can
|
|
47
|
-
// reliably restore them after this file's tests complete, regardless of
|
|
48
|
-
// how Bun's loader treats namespace bindings post-mock.
|
|
49
|
-
const realAssistantConfig = { ...assistantConfig };
|
|
50
|
-
const realGuardianToken = { ...guardianToken };
|
|
51
|
-
|
|
52
|
-
const realCloudClient = { ...cloudClient };
|
|
53
|
-
const realLocalRuntimeClient = { ...localRuntimeClient };
|
|
54
|
-
const realBackupOps = { ...backupOps };
|
|
55
|
-
const realTeleportBackup = { ...teleportBackup };
|
|
56
|
-
|
|
57
|
-
const findAssistantByNameMock = mock<
|
|
58
|
-
typeof assistantConfig.findAssistantByName
|
|
59
|
-
>(() => null);
|
|
60
|
-
const saveAssistantEntryMock = mock<typeof assistantConfig.saveAssistantEntry>(
|
|
61
|
-
() => {},
|
|
62
|
-
);
|
|
63
|
-
const loadAllAssistantsMock = mock<typeof assistantConfig.loadAllAssistants>(
|
|
64
|
-
() => [],
|
|
65
|
-
);
|
|
66
|
-
const removeAssistantEntryMock = mock<
|
|
67
|
-
typeof assistantConfig.removeAssistantEntry
|
|
68
|
-
>(() => {});
|
|
69
|
-
|
|
70
|
-
mock.module("../lib/assistant-config.js", () => ({
|
|
71
|
-
...realAssistantConfig,
|
|
72
|
-
findAssistantByName: findAssistantByNameMock,
|
|
73
|
-
saveAssistantEntry: saveAssistantEntryMock,
|
|
74
|
-
loadAllAssistants: loadAllAssistantsMock,
|
|
75
|
-
removeAssistantEntry: removeAssistantEntryMock,
|
|
76
|
-
}));
|
|
77
|
-
|
|
78
|
-
const loadGuardianTokenMock = mock<typeof guardianToken.loadGuardianToken>(() =>
|
|
79
|
-
makeGuardianTokenData({
|
|
80
|
-
accessToken: "local-token",
|
|
81
|
-
accessTokenExpiresAt: new Date(Date.now() + 60_000).toISOString(),
|
|
82
|
-
}),
|
|
83
|
-
);
|
|
84
|
-
|
|
85
|
-
const leaseGuardianTokenMock = mock<typeof guardianToken.leaseGuardianToken>(
|
|
86
|
-
async () =>
|
|
87
|
-
makeGuardianTokenData({
|
|
88
|
-
accessToken: "leased-token",
|
|
89
|
-
accessTokenExpiresAt: new Date(Date.now() + 60_000).toISOString(),
|
|
90
|
-
}),
|
|
91
|
-
);
|
|
92
|
-
|
|
93
|
-
const computeDeviceIdMock = mock<typeof guardianToken.computeDeviceId>(
|
|
94
|
-
() => "device-id-123",
|
|
95
|
-
);
|
|
96
|
-
|
|
97
|
-
mock.module("../lib/guardian-token.js", () => ({
|
|
98
|
-
...realGuardianToken,
|
|
99
|
-
loadGuardianToken: loadGuardianTokenMock,
|
|
100
|
-
leaseGuardianToken: leaseGuardianTokenMock,
|
|
101
|
-
computeDeviceId: computeDeviceIdMock,
|
|
102
|
-
}));
|
|
103
|
-
|
|
104
|
-
const readCloudTokenMock = mock<typeof cloudClient.readCloudToken>(
|
|
105
|
-
() => "platform-token",
|
|
106
|
-
);
|
|
107
|
-
|
|
108
|
-
const getCloudUrlMock = mock<typeof cloudClient.getCloudUrl>(
|
|
109
|
-
() => "https://cloud.mycadet.ai",
|
|
110
|
-
);
|
|
111
|
-
|
|
112
|
-
const hatchAssistantMock = mock<typeof cloudClient.hatchAssistant>(
|
|
113
|
-
async () => ({
|
|
114
|
-
assistant: {
|
|
115
|
-
id: "platform-new-id",
|
|
116
|
-
name: "platform-new",
|
|
117
|
-
status: "active",
|
|
118
|
-
},
|
|
119
|
-
reusedExisting: false,
|
|
120
|
-
}),
|
|
121
|
-
);
|
|
122
|
-
|
|
123
|
-
const platformPollJobStatusMock = mock<
|
|
124
|
-
typeof cloudClient.platformPollJobStatus
|
|
125
|
-
>(async () => ({
|
|
126
|
-
jobId: "platform-job-1",
|
|
127
|
-
type: "export",
|
|
128
|
-
status: "complete",
|
|
129
|
-
bundleKey: "platform-bundle-key-abc",
|
|
130
|
-
}));
|
|
131
|
-
|
|
132
|
-
const cloudRequestSignedUrlMock = mock<
|
|
133
|
-
typeof cloudClient.cloudRequestSignedUrl
|
|
134
|
-
>(async (params) => ({
|
|
135
|
-
url:
|
|
136
|
-
params.operation === "upload"
|
|
137
|
-
? "https://storage.googleapis.com/bucket/signed-upload"
|
|
138
|
-
: "https://storage.googleapis.com/bucket/signed-download",
|
|
139
|
-
bundleKey: params.bundleKey ?? "bundle-key-123",
|
|
140
|
-
expiresAt: new Date(Date.now() + 3600_000).toISOString(),
|
|
141
|
-
}));
|
|
142
|
-
|
|
143
|
-
const platformImportBundleFromGcsMock = mock<
|
|
144
|
-
typeof cloudClient.platformImportBundleFromGcs
|
|
145
|
-
>(async () => ({
|
|
146
|
-
statusCode: 200,
|
|
147
|
-
body: {
|
|
148
|
-
success: true,
|
|
149
|
-
summary: {
|
|
150
|
-
total_files: 3,
|
|
151
|
-
files_created: 2,
|
|
152
|
-
files_overwritten: 1,
|
|
153
|
-
files_skipped: 0,
|
|
154
|
-
backups_created: 1,
|
|
155
|
-
},
|
|
156
|
-
},
|
|
157
|
-
}));
|
|
158
|
-
|
|
159
|
-
const platformImportPreflightFromGcsMock = mock<
|
|
160
|
-
typeof cloudClient.platformImportPreflightFromGcs
|
|
161
|
-
>(async () => ({
|
|
162
|
-
statusCode: 200,
|
|
163
|
-
body: {
|
|
164
|
-
can_import: true,
|
|
165
|
-
summary: {
|
|
166
|
-
files_to_create: 2,
|
|
167
|
-
files_to_overwrite: 1,
|
|
168
|
-
files_unchanged: 0,
|
|
169
|
-
total_files: 3,
|
|
170
|
-
},
|
|
171
|
-
},
|
|
172
|
-
}));
|
|
173
|
-
|
|
174
|
-
const checkExistingCloudAssistantMock = mock<
|
|
175
|
-
typeof cloudClient.checkExistingCloudAssistant
|
|
176
|
-
>(async () => null);
|
|
177
|
-
|
|
178
|
-
const ensureSelfHostedLocalRegistrationMock = mock<
|
|
179
|
-
typeof cloudClient.ensureSelfHostedLocalRegistration
|
|
180
|
-
>(async () => ({
|
|
181
|
-
assistant: { id: "platform-assistant-1", name: "my-assistant" },
|
|
182
|
-
registration: {
|
|
183
|
-
client_installation_id: "device-id-123",
|
|
184
|
-
runtime_assistant_id: "target-local",
|
|
185
|
-
client_platform: "cli",
|
|
186
|
-
},
|
|
187
|
-
assistant_api_key: "api-key-123",
|
|
188
|
-
webhook_secret: "webhook-secret-123",
|
|
189
|
-
}));
|
|
190
|
-
|
|
191
|
-
const injectCredentialsIntoAssistantMock = mock<
|
|
192
|
-
typeof cloudClient.injectCredentialsIntoAssistant
|
|
193
|
-
>(async () => true);
|
|
194
|
-
|
|
195
|
-
const fetchCurrentUserMock = mock<typeof cloudClient.fetchCurrentUser>(
|
|
196
|
-
async () => ({
|
|
197
|
-
id: "user-1",
|
|
198
|
-
email: "test@example.com",
|
|
199
|
-
display: "Test",
|
|
200
|
-
}),
|
|
201
|
-
);
|
|
202
|
-
|
|
203
|
-
const fetchOrganizationIdMock = mock<typeof cloudClient.fetchOrganizationId>(
|
|
204
|
-
async () => "org-1",
|
|
205
|
-
);
|
|
206
|
-
|
|
207
|
-
mock.module("../lib/cloud-client.js", () => ({
|
|
208
|
-
...realCloudClient,
|
|
209
|
-
readCloudToken: readCloudTokenMock,
|
|
210
|
-
getCloudUrl: getCloudUrlMock,
|
|
211
|
-
hatchAssistant: hatchAssistantMock,
|
|
212
|
-
platformPollJobStatus: platformPollJobStatusMock,
|
|
213
|
-
cloudRequestSignedUrl: cloudRequestSignedUrlMock,
|
|
214
|
-
platformImportBundleFromGcs: platformImportBundleFromGcsMock,
|
|
215
|
-
platformImportPreflightFromGcs: platformImportPreflightFromGcsMock,
|
|
216
|
-
checkExistingCloudAssistant: checkExistingCloudAssistantMock,
|
|
217
|
-
ensureSelfHostedLocalRegistration: ensureSelfHostedLocalRegistrationMock,
|
|
218
|
-
injectCredentialsIntoAssistant: injectCredentialsIntoAssistantMock,
|
|
219
|
-
fetchCurrentUser: fetchCurrentUserMock,
|
|
220
|
-
fetchOrganizationId: fetchOrganizationIdMock,
|
|
221
|
-
}));
|
|
222
|
-
|
|
223
|
-
const localRuntimeExportToGcsMock = mock<
|
|
224
|
-
typeof localRuntimeClient.localRuntimeExportToGcs
|
|
225
|
-
>(async () => ({ jobId: "local-export-job-1" }));
|
|
226
|
-
|
|
227
|
-
const localRuntimeImportFromGcsMock = mock<
|
|
228
|
-
typeof localRuntimeClient.localRuntimeImportFromGcs
|
|
229
|
-
>(async () => ({ jobId: "local-import-job-1" }));
|
|
230
|
-
|
|
231
|
-
// Default to a fixed version string. Tests that exercise the version-gate
|
|
232
|
-
// surface override this mock per-case to assert the value flows from the
|
|
233
|
-
// target runtime's `/v1/identity` (NOT from `cliPkg.version`) into the
|
|
234
|
-
// download signed-URL request.
|
|
235
|
-
const localRuntimeIdentityMock = mock<
|
|
236
|
-
typeof localRuntimeClient.localRuntimeIdentity
|
|
237
|
-
>(async () => ({ version: "0.6.5" }));
|
|
238
|
-
|
|
239
|
-
const localRuntimePollJobStatusMock = mock<
|
|
240
|
-
typeof localRuntimeClient.localRuntimePollJobStatus
|
|
241
|
-
>(async (_runtimeUrl, _token, jobId) => ({
|
|
242
|
-
jobId,
|
|
243
|
-
type: jobId.includes("import") ? "import" : "export",
|
|
244
|
-
status: "complete",
|
|
245
|
-
result: {
|
|
246
|
-
success: true,
|
|
247
|
-
summary: {
|
|
248
|
-
total_files: 3,
|
|
249
|
-
files_created: 2,
|
|
250
|
-
files_overwritten: 1,
|
|
251
|
-
files_skipped: 0,
|
|
252
|
-
backups_created: 1,
|
|
253
|
-
},
|
|
254
|
-
},
|
|
255
|
-
}));
|
|
256
|
-
|
|
257
|
-
const localRuntimePreflightFromGcsMock = mock<
|
|
258
|
-
typeof localRuntimeClient.localRuntimePreflightFromGcs
|
|
259
|
-
>(async () => ({
|
|
260
|
-
can_import: true,
|
|
261
|
-
summary: {
|
|
262
|
-
files_to_create: 2,
|
|
263
|
-
files_to_overwrite: 1,
|
|
264
|
-
files_unchanged: 0,
|
|
265
|
-
total_files: 3,
|
|
266
|
-
},
|
|
267
|
-
files: [],
|
|
268
|
-
conflicts: [],
|
|
269
|
-
}));
|
|
270
|
-
|
|
271
|
-
mock.module("../lib/local-runtime-client.js", () => ({
|
|
272
|
-
...realLocalRuntimeClient,
|
|
273
|
-
localRuntimeExportToGcs: localRuntimeExportToGcsMock,
|
|
274
|
-
localRuntimeImportFromGcs: localRuntimeImportFromGcsMock,
|
|
275
|
-
localRuntimeIdentity: localRuntimeIdentityMock,
|
|
276
|
-
localRuntimePollJobStatus: localRuntimePollJobStatusMock,
|
|
277
|
-
localRuntimePreflightFromGcs: localRuntimePreflightFromGcsMock,
|
|
278
|
-
}));
|
|
279
|
-
|
|
280
|
-
// Pre-export source backup. Default to "no backups yet" so the create path
|
|
281
|
-
// runs; individual tests override to exercise reuse and failure.
|
|
282
|
-
const listAssistantBackupTimesMock = mock<
|
|
283
|
-
typeof backupOps.listAssistantBackupTimes
|
|
284
|
-
>(() => []);
|
|
285
|
-
const createBackupMock = mock<typeof backupOps.createBackup>(
|
|
286
|
-
async () => "/tmp/backups/my-local-pre-teleport-2026.vbundle",
|
|
287
|
-
);
|
|
288
|
-
const pruneOldBackupsMock = mock<typeof backupOps.pruneOldBackups>(() => {});
|
|
289
|
-
|
|
290
|
-
mock.module("../lib/backup-ops.js", () => ({
|
|
291
|
-
...realBackupOps,
|
|
292
|
-
listAssistantBackupTimes: listAssistantBackupTimesMock,
|
|
293
|
-
createBackup: createBackupMock,
|
|
294
|
-
pruneOldBackups: pruneOldBackupsMock,
|
|
295
|
-
}));
|
|
296
|
-
|
|
297
|
-
const listPlatformBackupsMock = mock<typeof teleportBackup.listPlatformBackups>(
|
|
298
|
-
async () => [],
|
|
299
|
-
);
|
|
300
|
-
const createPlatformBackupMock = mock<
|
|
301
|
-
typeof teleportBackup.createPlatformBackup
|
|
302
|
-
>(async () => {});
|
|
303
|
-
|
|
304
|
-
mock.module("../lib/teleport-backup.js", () => ({
|
|
305
|
-
...realTeleportBackup,
|
|
306
|
-
listPlatformBackups: listPlatformBackupsMock,
|
|
307
|
-
createPlatformBackup: createPlatformBackupMock,
|
|
308
|
-
}));
|
|
309
|
-
|
|
310
|
-
// Snapshot the remaining real modules before mocking so `afterAll` can
|
|
311
|
-
// restore them too — otherwise these mocks leak into sibling test files that
|
|
312
|
-
// import the same modules in the same `bun test` run.
|
|
313
|
-
const realHatchLocal = { ...(await import("../lib/hatch-local.js")) };
|
|
314
|
-
const realDocker = { ...(await import("../lib/docker.js")) };
|
|
315
|
-
const realProcess = { ...(await import("../lib/process.js")) };
|
|
316
|
-
const realRetireLocal = { ...(await import("../lib/retire-local.js")) };
|
|
317
|
-
const realUpgradeLifecycle = {
|
|
318
|
-
...(await import("../lib/upgrade-lifecycle.js")),
|
|
1
|
+
import { afterAll, afterEach, beforeEach, expect, spyOn, test } from "bun:test";
|
|
2
|
+
import * as assistants from "../lib/assistant-config.js";
|
|
3
|
+
import * as host from "../lib/host-teleport.js";
|
|
4
|
+
import { parseArgs, teleport } from "../commands/teleport.js";
|
|
5
|
+
|
|
6
|
+
const entry: assistants.AssistantEntry = {
|
|
7
|
+
assistantId: "source-1",
|
|
8
|
+
name: "source",
|
|
9
|
+
cloud: "local",
|
|
10
|
+
runtimeUrl: "http://127.0.0.1:7821",
|
|
11
|
+
hatchedAt: "2026-01-01T00:00:00Z",
|
|
319
12
|
};
|
|
320
|
-
|
|
321
|
-
const
|
|
322
|
-
|
|
323
|
-
mock.module("../lib/hatch-local.js", () => ({
|
|
324
|
-
hatchLocal: hatchLocalMock,
|
|
325
|
-
}));
|
|
326
|
-
|
|
327
|
-
const hatchDockerMock = mock(async () => {});
|
|
328
|
-
const retireDockerMock = mock(async () => {});
|
|
329
|
-
|
|
330
|
-
const sleepContainersMock = mock(async () => {});
|
|
331
|
-
const dockerResourceNamesMock = mock((name: string) => ({
|
|
332
|
-
assistantContainer: `${name}-assistant`,
|
|
333
|
-
cesContainer: `${name}-credential-executor`,
|
|
334
|
-
cesSecurityVolume: `${name}-ces-sec`,
|
|
335
|
-
dockerdDataVolume: `${name}-dockerd-data`,
|
|
336
|
-
gatewayContainer: `${name}-gateway`,
|
|
337
|
-
gatewaySecurityVolume: `${name}-gateway-sec`,
|
|
338
|
-
network: `${name}-net`,
|
|
339
|
-
socketVolume: `${name}-socket`,
|
|
340
|
-
workspaceVolume: `${name}-workspace`,
|
|
341
|
-
}));
|
|
342
|
-
|
|
343
|
-
mock.module("../lib/docker.js", () => ({
|
|
344
|
-
hatchDocker: hatchDockerMock,
|
|
345
|
-
retireDocker: retireDockerMock,
|
|
346
|
-
sleepContainers: sleepContainersMock,
|
|
347
|
-
dockerResourceNames: dockerResourceNamesMock,
|
|
348
|
-
}));
|
|
349
|
-
|
|
350
|
-
const stopProcessByPidFileMock = mock(async () => true);
|
|
351
|
-
|
|
352
|
-
mock.module("../lib/process.js", () => ({
|
|
353
|
-
...realProcess,
|
|
354
|
-
stopProcessByPidFile: stopProcessByPidFileMock,
|
|
355
|
-
}));
|
|
356
|
-
|
|
357
|
-
const retireLocalMock = mock(async () => {});
|
|
358
|
-
|
|
359
|
-
mock.module("../lib/retire-local.js", () => ({
|
|
360
|
-
retireLocal: retireLocalMock,
|
|
361
|
-
}));
|
|
362
|
-
|
|
363
|
-
const fetchCurrentVersionMock = mock(
|
|
364
|
-
async (_runtimeUrl: string): Promise<string | undefined> => undefined,
|
|
13
|
+
const find = spyOn(assistants, "findAssistantByName");
|
|
14
|
+
const move = spyOn(host, "teleportHostInstallation").mockResolvedValue(
|
|
15
|
+
undefined,
|
|
365
16
|
);
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
fetchCurrentVersion: fetchCurrentVersionMock,
|
|
369
|
-
}));
|
|
370
|
-
|
|
371
|
-
import {
|
|
372
|
-
teleport,
|
|
373
|
-
parseArgs,
|
|
374
|
-
resolveOrHatchTarget,
|
|
375
|
-
} from "../commands/teleport.js";
|
|
376
|
-
import type { AssistantEntry } from "../lib/assistant-config.js";
|
|
377
|
-
import { withPreconnect } from "./helpers/http.js";
|
|
378
|
-
import { makeGuardianTokenData } from "./helpers/guardian-token.js";
|
|
379
|
-
|
|
380
|
-
// ---------------------------------------------------------------------------
|
|
381
|
-
// Helpers
|
|
382
|
-
// ---------------------------------------------------------------------------
|
|
383
|
-
|
|
384
|
-
afterAll(() => {
|
|
385
|
-
// Restore the real module exports so the mocks do not leak into sibling
|
|
386
|
-
// test files (e.g. guardian-token.test.ts, cloud-client tests) that
|
|
387
|
-
// run in the same `bun test` invocation.
|
|
388
|
-
mock.module("../lib/assistant-config.js", () => realAssistantConfig);
|
|
389
|
-
mock.module("../lib/guardian-token.js", () => realGuardianToken);
|
|
390
|
-
mock.module("../lib/cloud-client.js", () => realCloudClient);
|
|
391
|
-
mock.module("../lib/local-runtime-client.js", () => realLocalRuntimeClient);
|
|
392
|
-
mock.module("../lib/backup-ops.js", () => realBackupOps);
|
|
393
|
-
mock.module("../lib/teleport-backup.js", () => realTeleportBackup);
|
|
394
|
-
mock.module("../lib/hatch-local.js", () => realHatchLocal);
|
|
395
|
-
mock.module("../lib/docker.js", () => realDocker);
|
|
396
|
-
mock.module("../lib/process.js", () => realProcess);
|
|
397
|
-
mock.module("../lib/retire-local.js", () => realRetireLocal);
|
|
398
|
-
mock.module("../lib/upgrade-lifecycle.js", () => realUpgradeLifecycle);
|
|
399
|
-
rmSync(testDir, { recursive: true, force: true });
|
|
400
|
-
delete process.env.CADET_LOCKFILE_DIR;
|
|
17
|
+
const exit = spyOn(process, "exit").mockImplementation((code): never => {
|
|
18
|
+
throw new Error(`exit:${code}`);
|
|
401
19
|
});
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
let exitMock: Mock<(code?: number | string | undefined) => never>;
|
|
405
|
-
let originalExit: typeof process.exit;
|
|
406
|
-
let consoleLogSpy: ReturnType<typeof spyOn>;
|
|
407
|
-
let consoleErrorSpy: ReturnType<typeof spyOn>;
|
|
408
|
-
let fetchCalls: Array<{ url: string; body: unknown }>;
|
|
409
|
-
|
|
410
|
-
function defaultLocalRuntimePollImpl(
|
|
411
|
-
_entry: unknown,
|
|
412
|
-
_token: string,
|
|
413
|
-
jobId: string,
|
|
414
|
-
): Promise<{
|
|
415
|
-
jobId: string;
|
|
416
|
-
type: "export" | "import";
|
|
417
|
-
status: "complete";
|
|
418
|
-
result: Record<string, unknown>;
|
|
419
|
-
}> {
|
|
420
|
-
return Promise.resolve({
|
|
421
|
-
jobId,
|
|
422
|
-
type: jobId.includes("import") ? "import" : "export",
|
|
423
|
-
status: "complete",
|
|
424
|
-
result: {
|
|
425
|
-
success: true,
|
|
426
|
-
summary: {
|
|
427
|
-
total_files: 3,
|
|
428
|
-
files_created: 2,
|
|
429
|
-
files_overwritten: 1,
|
|
430
|
-
files_skipped: 0,
|
|
431
|
-
backups_created: 1,
|
|
432
|
-
},
|
|
433
|
-
},
|
|
434
|
-
});
|
|
435
|
-
}
|
|
436
|
-
|
|
20
|
+
const errors = spyOn(console, "error").mockImplementation(() => {});
|
|
21
|
+
const argv = process.argv;
|
|
437
22
|
beforeEach(() => {
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
loadGuardianTokenMock.mockReturnValue(
|
|
452
|
-
makeGuardianTokenData({
|
|
453
|
-
accessToken: "local-token",
|
|
454
|
-
accessTokenExpiresAt: new Date(Date.now() + 60_000).toISOString(),
|
|
455
|
-
}),
|
|
456
|
-
);
|
|
457
|
-
leaseGuardianTokenMock.mockReset();
|
|
458
|
-
|
|
459
|
-
readCloudTokenMock.mockReset();
|
|
460
|
-
readCloudTokenMock.mockReturnValue("platform-token");
|
|
461
|
-
getCloudUrlMock.mockReset();
|
|
462
|
-
getCloudUrlMock.mockReturnValue("https://cloud.mycadet.ai");
|
|
463
|
-
hatchAssistantMock.mockReset();
|
|
464
|
-
hatchAssistantMock.mockResolvedValue({
|
|
465
|
-
assistant: {
|
|
466
|
-
id: "platform-new-id",
|
|
467
|
-
name: "platform-new",
|
|
468
|
-
status: "active",
|
|
469
|
-
},
|
|
470
|
-
reusedExisting: false,
|
|
471
|
-
});
|
|
472
|
-
platformPollJobStatusMock.mockReset();
|
|
473
|
-
platformPollJobStatusMock.mockResolvedValue({
|
|
474
|
-
jobId: "platform-job-1",
|
|
475
|
-
type: "export",
|
|
476
|
-
status: "complete",
|
|
477
|
-
bundleKey: "platform-bundle-key-abc",
|
|
478
|
-
});
|
|
479
|
-
cloudRequestSignedUrlMock.mockReset();
|
|
480
|
-
cloudRequestSignedUrlMock.mockImplementation(async (params) => ({
|
|
481
|
-
url:
|
|
482
|
-
params.operation === "upload"
|
|
483
|
-
? "https://storage.googleapis.com/bucket/signed-upload"
|
|
484
|
-
: "https://storage.googleapis.com/bucket/signed-download",
|
|
485
|
-
bundleKey: params.bundleKey ?? "bundle-key-123",
|
|
486
|
-
expiresAt: new Date(Date.now() + 3600_000).toISOString(),
|
|
487
|
-
}));
|
|
488
|
-
platformImportBundleFromGcsMock.mockReset();
|
|
489
|
-
platformImportBundleFromGcsMock.mockResolvedValue({
|
|
490
|
-
statusCode: 200,
|
|
491
|
-
body: {
|
|
492
|
-
success: true,
|
|
493
|
-
summary: {
|
|
494
|
-
total_files: 3,
|
|
495
|
-
files_created: 2,
|
|
496
|
-
files_overwritten: 1,
|
|
497
|
-
files_skipped: 0,
|
|
498
|
-
backups_created: 1,
|
|
499
|
-
},
|
|
500
|
-
},
|
|
501
|
-
});
|
|
502
|
-
platformImportPreflightFromGcsMock.mockReset();
|
|
503
|
-
platformImportPreflightFromGcsMock.mockResolvedValue({
|
|
504
|
-
statusCode: 200,
|
|
505
|
-
body: {
|
|
506
|
-
can_import: true,
|
|
507
|
-
summary: {
|
|
508
|
-
files_to_create: 2,
|
|
509
|
-
files_to_overwrite: 1,
|
|
510
|
-
files_unchanged: 0,
|
|
511
|
-
total_files: 3,
|
|
512
|
-
},
|
|
513
|
-
},
|
|
514
|
-
});
|
|
515
|
-
checkExistingCloudAssistantMock.mockReset();
|
|
516
|
-
checkExistingCloudAssistantMock.mockResolvedValue(null);
|
|
517
|
-
ensureSelfHostedLocalRegistrationMock.mockReset();
|
|
518
|
-
ensureSelfHostedLocalRegistrationMock.mockResolvedValue({
|
|
519
|
-
assistant: { id: "platform-assistant-1", name: "my-assistant" },
|
|
520
|
-
registration: {
|
|
521
|
-
client_installation_id: "device-id-123",
|
|
522
|
-
runtime_assistant_id: "target-local",
|
|
523
|
-
client_platform: "cli",
|
|
524
|
-
},
|
|
525
|
-
assistant_api_key: "api-key-123",
|
|
526
|
-
webhook_secret: "webhook-secret-123",
|
|
527
|
-
});
|
|
528
|
-
injectCredentialsIntoAssistantMock.mockReset();
|
|
529
|
-
injectCredentialsIntoAssistantMock.mockResolvedValue(true);
|
|
530
|
-
fetchCurrentUserMock.mockReset();
|
|
531
|
-
fetchCurrentUserMock.mockResolvedValue({
|
|
532
|
-
id: "user-1",
|
|
533
|
-
email: "test@example.com",
|
|
534
|
-
display: "Test",
|
|
535
|
-
});
|
|
536
|
-
fetchOrganizationIdMock.mockReset();
|
|
537
|
-
fetchOrganizationIdMock.mockResolvedValue("org-1");
|
|
538
|
-
computeDeviceIdMock.mockReset();
|
|
539
|
-
computeDeviceIdMock.mockReturnValue("device-id-123");
|
|
540
|
-
|
|
541
|
-
localRuntimeExportToGcsMock.mockReset();
|
|
542
|
-
localRuntimeExportToGcsMock.mockResolvedValue({
|
|
543
|
-
jobId: "local-export-job-1",
|
|
544
|
-
});
|
|
545
|
-
localRuntimeImportFromGcsMock.mockReset();
|
|
546
|
-
localRuntimeImportFromGcsMock.mockResolvedValue({
|
|
547
|
-
jobId: "local-import-job-1",
|
|
548
|
-
});
|
|
549
|
-
localRuntimePreflightFromGcsMock.mockReset();
|
|
550
|
-
localRuntimePreflightFromGcsMock.mockResolvedValue({
|
|
551
|
-
can_import: true,
|
|
552
|
-
summary: {
|
|
553
|
-
files_to_create: 2,
|
|
554
|
-
files_to_overwrite: 1,
|
|
555
|
-
files_unchanged: 0,
|
|
556
|
-
total_files: 3,
|
|
557
|
-
},
|
|
558
|
-
files: [],
|
|
559
|
-
conflicts: [],
|
|
560
|
-
});
|
|
561
|
-
localRuntimeIdentityMock.mockReset();
|
|
562
|
-
localRuntimeIdentityMock.mockResolvedValue({ version: "0.6.5" });
|
|
563
|
-
localRuntimePollJobStatusMock.mockReset();
|
|
564
|
-
localRuntimePollJobStatusMock.mockImplementation(defaultLocalRuntimePollImpl);
|
|
565
|
-
|
|
566
|
-
listAssistantBackupTimesMock.mockReset();
|
|
567
|
-
listAssistantBackupTimesMock.mockReturnValue([]);
|
|
568
|
-
createBackupMock.mockReset();
|
|
569
|
-
createBackupMock.mockResolvedValue(
|
|
570
|
-
"/tmp/backups/my-local-pre-teleport-2026.vbundle",
|
|
571
|
-
);
|
|
572
|
-
pruneOldBackupsMock.mockReset();
|
|
573
|
-
pruneOldBackupsMock.mockImplementation(() => {});
|
|
574
|
-
listPlatformBackupsMock.mockReset();
|
|
575
|
-
listPlatformBackupsMock.mockResolvedValue([]);
|
|
576
|
-
createPlatformBackupMock.mockReset();
|
|
577
|
-
createPlatformBackupMock.mockResolvedValue(undefined);
|
|
578
|
-
|
|
579
|
-
hatchLocalMock.mockReset();
|
|
580
|
-
hatchLocalMock.mockResolvedValue(undefined);
|
|
581
|
-
hatchDockerMock.mockReset();
|
|
582
|
-
hatchDockerMock.mockResolvedValue(undefined);
|
|
583
|
-
retireDockerMock.mockReset();
|
|
584
|
-
retireDockerMock.mockResolvedValue(undefined);
|
|
585
|
-
retireLocalMock.mockReset();
|
|
586
|
-
retireLocalMock.mockResolvedValue(undefined);
|
|
587
|
-
fetchCurrentVersionMock.mockReset();
|
|
588
|
-
fetchCurrentVersionMock.mockResolvedValue(undefined);
|
|
589
|
-
sleepContainersMock.mockReset();
|
|
590
|
-
sleepContainersMock.mockResolvedValue(undefined);
|
|
591
|
-
stopProcessByPidFileMock.mockReset();
|
|
592
|
-
stopProcessByPidFileMock.mockResolvedValue(true);
|
|
593
|
-
|
|
594
|
-
// Mock process.exit to throw so we can catch it
|
|
595
|
-
exitMock = mock((code?: number | string | undefined): never => {
|
|
596
|
-
throw new Error(`process.exit:${code}`);
|
|
597
|
-
});
|
|
598
|
-
originalExit = process.exit;
|
|
599
|
-
process.exit = exitMock;
|
|
600
|
-
|
|
601
|
-
consoleLogSpy = spyOn(console, "log").mockImplementation(() => {});
|
|
602
|
-
consoleErrorSpy = spyOn(console, "error").mockImplementation(() => {});
|
|
23
|
+
find.mockReturnValue(entry);
|
|
24
|
+
move.mockClear();
|
|
25
|
+
errors.mockClear();
|
|
26
|
+
process.argv = [
|
|
27
|
+
"bun",
|
|
28
|
+
"cadet",
|
|
29
|
+
"teleport",
|
|
30
|
+
"--from",
|
|
31
|
+
"source",
|
|
32
|
+
"--docker",
|
|
33
|
+
"target",
|
|
34
|
+
"--keep-source",
|
|
35
|
+
];
|
|
603
36
|
});
|
|
604
|
-
|
|
605
37
|
afterEach(() => {
|
|
606
|
-
process.argv =
|
|
607
|
-
process.exit = originalExit;
|
|
608
|
-
consoleLogSpy.mockRestore();
|
|
609
|
-
consoleErrorSpy.mockRestore();
|
|
38
|
+
process.argv = argv;
|
|
610
39
|
});
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
)
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
* never sends a bundle-sized request body. With the new GCS-unified flow
|
|
631
|
-
* all bundle bytes travel between the runtime and GCS directly, so the CLI
|
|
632
|
-
* should make zero fetch calls carrying binary payloads.
|
|
633
|
-
*/
|
|
634
|
-
function installTrackingFetch(): () => void {
|
|
635
|
-
const originalFetch = globalThis.fetch;
|
|
636
|
-
globalThis.fetch = withPreconnect(
|
|
637
|
-
async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
638
|
-
const urlStr = String(input);
|
|
639
|
-
fetchCalls.push({ url: urlStr, body: init?.body });
|
|
640
|
-
return new Response("not found", { status: 404 });
|
|
641
|
-
},
|
|
40
|
+
afterAll(() => {
|
|
41
|
+
find.mockRestore();
|
|
42
|
+
move.mockRestore();
|
|
43
|
+
exit.mockRestore();
|
|
44
|
+
errors.mockRestore();
|
|
45
|
+
});
|
|
46
|
+
test("routes local and Docker installation moves through the host tar implementation", async () => {
|
|
47
|
+
await teleport();
|
|
48
|
+
expect(move).toHaveBeenCalledWith(entry, "docker", "target", {
|
|
49
|
+
keepSource: true,
|
|
50
|
+
dryRun: false,
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
test("cloud destination refuses before starting any host operation", async () => {
|
|
54
|
+
process.argv = ["bun", "cadet", "teleport", "--from", "source", "--cloud"];
|
|
55
|
+
await expect(teleport()).rejects.toThrow("exit:1");
|
|
56
|
+
expect(move).not.toHaveBeenCalled();
|
|
57
|
+
expect(errors.mock.calls.flat().join(" ")).toContain(
|
|
58
|
+
"Cloud teleport is unavailable",
|
|
642
59
|
);
|
|
643
|
-
return () => {
|
|
644
|
-
globalThis.fetch = originalFetch;
|
|
645
|
-
};
|
|
646
|
-
}
|
|
647
|
-
|
|
648
|
-
// ---------------------------------------------------------------------------
|
|
649
|
-
// Arg parsing tests
|
|
650
|
-
// ---------------------------------------------------------------------------
|
|
651
|
-
|
|
652
|
-
describe("teleport arg parsing", () => {
|
|
653
|
-
test("--help prints usage and exits 0", async () => {
|
|
654
|
-
setArgv("--help");
|
|
655
|
-
await expect(teleport()).rejects.toThrow("process.exit:0");
|
|
656
|
-
expect(consoleLogSpy).toHaveBeenCalledWith(
|
|
657
|
-
expect.stringContaining("Usage:"),
|
|
658
|
-
);
|
|
659
|
-
});
|
|
660
|
-
|
|
661
|
-
test("-h prints usage and exits 0", async () => {
|
|
662
|
-
setArgv("-h");
|
|
663
|
-
await expect(teleport()).rejects.toThrow("process.exit:0");
|
|
664
|
-
expect(consoleLogSpy).toHaveBeenCalledWith(
|
|
665
|
-
expect.stringContaining("Usage:"),
|
|
666
|
-
);
|
|
667
|
-
});
|
|
668
|
-
|
|
669
|
-
test("missing --from and env flag prints help and exits 1", async () => {
|
|
670
|
-
setArgv();
|
|
671
|
-
await expect(teleport()).rejects.toThrow("process.exit:1");
|
|
672
|
-
expect(consoleLogSpy).toHaveBeenCalledWith(
|
|
673
|
-
expect.stringContaining("Usage:"),
|
|
674
|
-
);
|
|
675
|
-
});
|
|
676
|
-
|
|
677
|
-
test("missing env flag prints help and exits 1", async () => {
|
|
678
|
-
setArgv("--from", "source");
|
|
679
|
-
await expect(teleport()).rejects.toThrow("process.exit:1");
|
|
680
|
-
expect(consoleLogSpy).toHaveBeenCalledWith(
|
|
681
|
-
expect.stringContaining("Usage:"),
|
|
682
|
-
);
|
|
683
|
-
});
|
|
684
|
-
|
|
685
|
-
test("--local sets targetEnv to 'local' with no name", () => {
|
|
686
|
-
const result = parseArgs(["--from", "source", "--local"]);
|
|
687
|
-
expect(result.from).toBe("source");
|
|
688
|
-
expect(result.targetEnv).toBe("local");
|
|
689
|
-
expect(result.targetName).toBeUndefined();
|
|
690
|
-
});
|
|
691
|
-
|
|
692
|
-
test("--docker my-name sets targetEnv to 'docker' and targetName to 'my-name'", () => {
|
|
693
|
-
const result = parseArgs(["--from", "source", "--docker", "my-name"]);
|
|
694
|
-
expect(result.from).toBe("source");
|
|
695
|
-
expect(result.targetEnv).toBe("docker");
|
|
696
|
-
expect(result.targetName).toBe("my-name");
|
|
697
|
-
});
|
|
698
|
-
|
|
699
|
-
test("--platform sets targetEnv to 'platform'", () => {
|
|
700
|
-
const result = parseArgs(["--from", "source", "--platform"]);
|
|
701
|
-
expect(result.from).toBe("source");
|
|
702
|
-
expect(result.targetEnv).toBe("platform");
|
|
703
|
-
expect(result.targetName).toBeUndefined();
|
|
704
|
-
});
|
|
705
|
-
|
|
706
|
-
test("multiple env flags error", () => {
|
|
707
|
-
expect(() => parseArgs(["--from", "src", "--local", "--docker"])).toThrow(
|
|
708
|
-
"process.exit:1",
|
|
709
|
-
);
|
|
710
|
-
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
711
|
-
expect.stringContaining("Only one environment flag"),
|
|
712
|
-
);
|
|
713
|
-
});
|
|
714
|
-
|
|
715
|
-
test("--keep-source is parsed", () => {
|
|
716
|
-
const result = parseArgs(["--from", "source", "--docker", "--keep-source"]);
|
|
717
|
-
expect(result.keepSource).toBe(true);
|
|
718
|
-
});
|
|
719
|
-
|
|
720
|
-
test("--dry-run is parsed", () => {
|
|
721
|
-
const result = parseArgs(["--from", "source", "--local", "--dry-run"]);
|
|
722
|
-
expect(result.dryRun).toBe(true);
|
|
723
|
-
});
|
|
724
|
-
|
|
725
|
-
test("target name after env flag is consumed but --flags are not", () => {
|
|
726
|
-
const result = parseArgs(["--from", "source", "--docker", "--keep-source"]);
|
|
727
|
-
expect(result.targetEnv).toBe("docker");
|
|
728
|
-
expect(result.targetName).toBeUndefined();
|
|
729
|
-
expect(result.keepSource).toBe(true);
|
|
730
|
-
});
|
|
731
|
-
});
|
|
732
|
-
|
|
733
|
-
// ---------------------------------------------------------------------------
|
|
734
|
-
// Same-environment rejection tests
|
|
735
|
-
// ---------------------------------------------------------------------------
|
|
736
|
-
|
|
737
|
-
describe("same-environment rejection", () => {
|
|
738
|
-
test("source local, target local -> error (after resolving target)", async () => {
|
|
739
|
-
setArgv("--from", "src", "--local", "dst");
|
|
740
|
-
|
|
741
|
-
const srcEntry = makeEntry("src", { cloud: "local" });
|
|
742
|
-
const dstEntry = makeEntry("dst", { cloud: "local" });
|
|
743
|
-
|
|
744
|
-
findAssistantByNameMock.mockImplementation((name: string) => {
|
|
745
|
-
if (name === "src") return srcEntry;
|
|
746
|
-
if (name === "dst") return dstEntry;
|
|
747
|
-
return null;
|
|
748
|
-
});
|
|
749
|
-
|
|
750
|
-
await expect(teleport()).rejects.toThrow("process.exit:1");
|
|
751
|
-
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
752
|
-
expect.stringContaining("Cannot teleport between two local assistants"),
|
|
753
|
-
);
|
|
754
|
-
});
|
|
755
|
-
|
|
756
|
-
test("source docker, target docker -> error", async () => {
|
|
757
|
-
setArgv("--from", "src", "--docker", "dst");
|
|
758
|
-
|
|
759
|
-
const srcEntry = makeEntry("src", { cloud: "docker" });
|
|
760
|
-
const dstEntry = makeEntry("dst", { cloud: "docker" });
|
|
761
|
-
|
|
762
|
-
findAssistantByNameMock.mockImplementation((name: string) => {
|
|
763
|
-
if (name === "src") return srcEntry;
|
|
764
|
-
if (name === "dst") return dstEntry;
|
|
765
|
-
return null;
|
|
766
|
-
});
|
|
767
|
-
|
|
768
|
-
await expect(teleport()).rejects.toThrow("process.exit:1");
|
|
769
|
-
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
770
|
-
expect.stringContaining("Cannot teleport between two docker assistants"),
|
|
771
|
-
);
|
|
772
|
-
});
|
|
773
|
-
|
|
774
|
-
test("source cadet, target platform -> error", async () => {
|
|
775
|
-
setArgv("--from", "src", "--platform", "dst");
|
|
776
|
-
|
|
777
|
-
const srcEntry = makeEntry("src", {
|
|
778
|
-
cloud: "cadet",
|
|
779
|
-
runtimeUrl: "https://cloud.mycadet.ai",
|
|
780
|
-
});
|
|
781
|
-
const dstEntry = makeEntry("dst", {
|
|
782
|
-
cloud: "cadet",
|
|
783
|
-
runtimeUrl: "https://cloud.mycadet.ai",
|
|
784
|
-
});
|
|
785
|
-
|
|
786
|
-
findAssistantByNameMock.mockImplementation((name: string) => {
|
|
787
|
-
if (name === "src") return srcEntry;
|
|
788
|
-
if (name === "dst") return dstEntry;
|
|
789
|
-
return null;
|
|
790
|
-
});
|
|
791
|
-
|
|
792
|
-
await expect(teleport()).rejects.toThrow("process.exit:1");
|
|
793
|
-
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
794
|
-
expect.stringContaining(
|
|
795
|
-
"Cannot teleport between two platform assistants",
|
|
796
|
-
),
|
|
797
|
-
);
|
|
798
|
-
});
|
|
799
|
-
|
|
800
|
-
test("same-env rejection happens before hatching (no orphaned assistants)", async () => {
|
|
801
|
-
setArgv("--from", "my-local", "--local");
|
|
802
|
-
|
|
803
|
-
const localEntry = makeEntry("my-local", { cloud: "local" });
|
|
804
|
-
findAssistantByNameMock.mockImplementation((name: string) => {
|
|
805
|
-
if (name === "my-local") return localEntry;
|
|
806
|
-
return null;
|
|
807
|
-
});
|
|
808
|
-
|
|
809
|
-
await expect(teleport()).rejects.toThrow("process.exit:1");
|
|
810
|
-
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
811
|
-
expect.stringContaining("Cannot teleport between two local assistants"),
|
|
812
|
-
);
|
|
813
|
-
expect(hatchLocalMock).not.toHaveBeenCalled();
|
|
814
|
-
expect(hatchDockerMock).not.toHaveBeenCalled();
|
|
815
|
-
expect(hatchAssistantMock).not.toHaveBeenCalled();
|
|
816
|
-
});
|
|
817
|
-
|
|
818
|
-
test("flag says docker but resolved target is local -> rejects cloud mismatch", async () => {
|
|
819
|
-
setArgv("--from", "src", "--docker", "misidentified");
|
|
820
|
-
|
|
821
|
-
const srcEntry = makeEntry("src", { cloud: "cadet" });
|
|
822
|
-
const dstEntry = makeEntry("misidentified", { cloud: "local" });
|
|
823
|
-
|
|
824
|
-
findAssistantByNameMock.mockImplementation((name: string) => {
|
|
825
|
-
if (name === "src") return srcEntry;
|
|
826
|
-
if (name === "misidentified") return dstEntry;
|
|
827
|
-
return null;
|
|
828
|
-
});
|
|
829
|
-
|
|
830
|
-
await expect(teleport()).rejects.toThrow("process.exit:1");
|
|
831
|
-
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
832
|
-
expect.stringContaining("is a local assistant, not docker"),
|
|
833
|
-
);
|
|
834
|
-
});
|
|
835
|
-
});
|
|
836
|
-
|
|
837
|
-
// ---------------------------------------------------------------------------
|
|
838
|
-
// resolveOrHatchTarget tests
|
|
839
|
-
// ---------------------------------------------------------------------------
|
|
840
|
-
|
|
841
|
-
describe("resolveOrHatchTarget", () => {
|
|
842
|
-
test("existing assistant is returned without hatching", async () => {
|
|
843
|
-
const dockerEntry = makeEntry("my-docker", { cloud: "docker" });
|
|
844
|
-
findAssistantByNameMock.mockImplementation((name: string) => {
|
|
845
|
-
if (name === "my-docker") return dockerEntry;
|
|
846
|
-
return null;
|
|
847
|
-
});
|
|
848
|
-
|
|
849
|
-
const result = await resolveOrHatchTarget("docker", "my-docker");
|
|
850
|
-
expect(result).toBe(dockerEntry);
|
|
851
|
-
expect(hatchDockerMock).not.toHaveBeenCalled();
|
|
852
|
-
});
|
|
853
|
-
|
|
854
|
-
test("name not found -> hatch docker", async () => {
|
|
855
|
-
const newEntry = makeEntry("new-one", { cloud: "docker" });
|
|
856
|
-
findAssistantByNameMock.mockImplementation((name: string) => {
|
|
857
|
-
if (name === "new-one" && hatchDockerMock.mock.calls.length > 0) {
|
|
858
|
-
return newEntry;
|
|
859
|
-
}
|
|
860
|
-
return null;
|
|
861
|
-
});
|
|
862
|
-
|
|
863
|
-
const result = await resolveOrHatchTarget("docker", "new-one");
|
|
864
|
-
expect(hatchDockerMock).toHaveBeenCalledWith({
|
|
865
|
-
name: "new-one",
|
|
866
|
-
setupProviderCredentials: false,
|
|
867
|
-
});
|
|
868
|
-
expect(result).toBe(newEntry);
|
|
869
|
-
});
|
|
870
|
-
|
|
871
|
-
test("no name -> hatch local, discovers via diff", async () => {
|
|
872
|
-
const existingEntry = makeEntry("existing-local", { cloud: "local" });
|
|
873
|
-
const newEntry = makeEntry("auto-generated", { cloud: "local" });
|
|
874
|
-
|
|
875
|
-
loadAllAssistantsMock.mockImplementation(() => {
|
|
876
|
-
if (hatchLocalMock.mock.calls.length > 0) {
|
|
877
|
-
return [existingEntry, newEntry];
|
|
878
|
-
}
|
|
879
|
-
return [existingEntry];
|
|
880
|
-
});
|
|
881
|
-
|
|
882
|
-
const result = await resolveOrHatchTarget("local");
|
|
883
|
-
expect(hatchLocalMock).toHaveBeenCalled();
|
|
884
|
-
expect(result).toBe(newEntry);
|
|
885
|
-
});
|
|
886
|
-
|
|
887
|
-
test("platform with existing ID -> returns existing without hatching", async () => {
|
|
888
|
-
const cloudEntry = makeEntry("uuid-123", {
|
|
889
|
-
cloud: "cadet",
|
|
890
|
-
runtimeUrl: "https://cloud.mycadet.ai",
|
|
891
|
-
});
|
|
892
|
-
findAssistantByNameMock.mockImplementation((name: string) => {
|
|
893
|
-
if (name === "uuid-123") return cloudEntry;
|
|
894
|
-
return null;
|
|
895
|
-
});
|
|
896
|
-
|
|
897
|
-
const result = await resolveOrHatchTarget("platform", "uuid-123");
|
|
898
|
-
expect(result).toBe(cloudEntry);
|
|
899
|
-
expect(hatchAssistantMock).not.toHaveBeenCalled();
|
|
900
|
-
});
|
|
901
|
-
|
|
902
|
-
test("platform with unknown name -> hatches via hatchAssistant", async () => {
|
|
903
|
-
findAssistantByNameMock.mockReturnValue(null);
|
|
904
|
-
|
|
905
|
-
const result = await resolveOrHatchTarget("platform", "nonexistent");
|
|
906
|
-
expect(hatchAssistantMock).toHaveBeenCalledWith("platform-token");
|
|
907
|
-
expect(result.assistantId).toBe("platform-new-id");
|
|
908
|
-
});
|
|
909
|
-
|
|
910
|
-
test("platform with no name -> blocks when hatch returns reusedExisting", async () => {
|
|
911
|
-
findAssistantByNameMock.mockReturnValue(null);
|
|
912
|
-
hatchAssistantMock.mockResolvedValue({
|
|
913
|
-
assistant: {
|
|
914
|
-
id: "existing-platform-id",
|
|
915
|
-
name: "existing-platform",
|
|
916
|
-
status: "active",
|
|
917
|
-
},
|
|
918
|
-
reusedExisting: true,
|
|
919
|
-
});
|
|
920
|
-
|
|
921
|
-
await expect(resolveOrHatchTarget("platform", undefined)).rejects.toThrow(
|
|
922
|
-
"process.exit:1",
|
|
923
|
-
);
|
|
924
|
-
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
925
|
-
expect.stringContaining("already have a cloud assistant"),
|
|
926
|
-
);
|
|
927
|
-
});
|
|
928
|
-
|
|
929
|
-
test("existing assistant with wrong cloud -> rejects", async () => {
|
|
930
|
-
const localEntry = makeEntry("my-local", { cloud: "local" });
|
|
931
|
-
findAssistantByNameMock.mockImplementation((name: string) => {
|
|
932
|
-
if (name === "my-local") return localEntry;
|
|
933
|
-
return null;
|
|
934
|
-
});
|
|
935
|
-
|
|
936
|
-
await expect(resolveOrHatchTarget("docker", "my-local")).rejects.toThrow(
|
|
937
|
-
"process.exit:1",
|
|
938
|
-
);
|
|
939
|
-
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
940
|
-
expect.stringContaining("is a local assistant, not docker"),
|
|
941
|
-
);
|
|
942
|
-
});
|
|
943
|
-
|
|
944
|
-
test("name with path traversal -> rejects before hatching", async () => {
|
|
945
|
-
findAssistantByNameMock.mockReturnValue(null);
|
|
946
|
-
|
|
947
|
-
await expect(
|
|
948
|
-
resolveOrHatchTarget("docker", "../../../etc/passwd"),
|
|
949
|
-
).rejects.toThrow("process.exit:1");
|
|
950
|
-
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
951
|
-
expect.stringContaining("invalid characters"),
|
|
952
|
-
);
|
|
953
|
-
expect(hatchDockerMock).not.toHaveBeenCalled();
|
|
954
|
-
});
|
|
955
60
|
});
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
describe("unified GCS flow — four directions", () => {
|
|
962
|
-
test("local → platform: requests upload URL, drives local runtime export, imports from GCS", async () => {
|
|
963
|
-
setArgv("--from", "my-local", "--platform");
|
|
964
|
-
|
|
965
|
-
const localEntry = makeEntry("my-local", { cloud: "local" });
|
|
966
|
-
findAssistantByNameMock.mockImplementation((name: string) =>
|
|
967
|
-
name === "my-local" ? localEntry : null,
|
|
968
|
-
);
|
|
969
|
-
|
|
970
|
-
const restoreFetch = installTrackingFetch();
|
|
971
|
-
try {
|
|
972
|
-
await teleport();
|
|
973
|
-
|
|
974
|
-
// Signed-URL request for upload — pinned to the cloud target's URL
|
|
975
|
-
// so upload and download land on the same platform.
|
|
976
|
-
expect(cloudRequestSignedUrlMock).toHaveBeenCalledWith(
|
|
977
|
-
expect.objectContaining({
|
|
978
|
-
operation: "upload",
|
|
979
|
-
minRuntimeVersion: "0.6.5",
|
|
980
|
-
maxRuntimeVersion: null,
|
|
981
|
-
}),
|
|
982
|
-
"platform-token",
|
|
983
|
-
"https://cloud.mycadet.ai",
|
|
984
|
-
);
|
|
985
|
-
|
|
986
|
-
// Runtime export-to-gcs kicked off with the signed upload URL.
|
|
987
|
-
// Helper takes an entry, not a bare URL — the entry's cloud drives
|
|
988
|
-
// URL construction (local → gateway loopback path).
|
|
989
|
-
expect(localRuntimeExportToGcsMock).toHaveBeenCalledWith(
|
|
990
|
-
expect.objectContaining({
|
|
991
|
-
cloud: "local",
|
|
992
|
-
runtimeUrl: "http://localhost:7821",
|
|
993
|
-
}),
|
|
994
|
-
"local-token",
|
|
995
|
-
expect.objectContaining({
|
|
996
|
-
uploadUrl: "https://storage.googleapis.com/bucket/signed-upload",
|
|
997
|
-
}),
|
|
998
|
-
);
|
|
999
|
-
|
|
1000
|
-
// Poll continued until complete
|
|
1001
|
-
expect(localRuntimePollJobStatusMock).toHaveBeenCalled();
|
|
1002
|
-
|
|
1003
|
-
// Import via GCS with the bundleKey returned from signed-URL request
|
|
1004
|
-
expect(platformImportBundleFromGcsMock).toHaveBeenCalledWith(
|
|
1005
|
-
"bundle-key-123",
|
|
1006
|
-
"platform-token",
|
|
1007
|
-
expect.any(String),
|
|
1008
|
-
);
|
|
1009
|
-
|
|
1010
|
-
// No download-URL request on the import side (platform target pulls
|
|
1011
|
-
// directly from GCS).
|
|
1012
|
-
const downloadOps = cloudRequestSignedUrlMock.mock.calls.filter(
|
|
1013
|
-
(call) => call[0].operation === "download",
|
|
1014
|
-
);
|
|
1015
|
-
expect(downloadOps.length).toBe(0);
|
|
1016
|
-
} finally {
|
|
1017
|
-
restoreFetch();
|
|
1018
|
-
}
|
|
1019
|
-
});
|
|
1020
|
-
|
|
1021
|
-
test("platform → local: drives platform export, reads bundle_key, requests download URL for runtime import", async () => {
|
|
1022
|
-
setArgv("--from", "my-platform", "--local", "my-local");
|
|
1023
|
-
|
|
1024
|
-
const cloudEntry = makeEntry("my-platform", {
|
|
1025
|
-
cloud: "cadet",
|
|
1026
|
-
runtimeUrl: "https://cloud.mycadet.ai",
|
|
1027
|
-
});
|
|
1028
|
-
const localEntry = makeEntry("my-local", {
|
|
1029
|
-
cloud: "local",
|
|
1030
|
-
bearerToken: "local-bearer",
|
|
1031
|
-
});
|
|
1032
|
-
|
|
1033
|
-
findAssistantByNameMock.mockImplementation((name: string) => {
|
|
1034
|
-
if (name === "my-platform") return cloudEntry;
|
|
1035
|
-
if (name === "my-local") return localEntry;
|
|
1036
|
-
return null;
|
|
1037
|
-
});
|
|
1038
|
-
|
|
1039
|
-
// Platform poll returns export-complete with a bundle_key.
|
|
1040
|
-
platformPollJobStatusMock.mockResolvedValue({
|
|
1041
|
-
jobId: "platform-export-job-1",
|
|
1042
|
-
type: "export",
|
|
1043
|
-
status: "complete",
|
|
1044
|
-
bundleKey: "platform-exports/org-1/bundle-abc.vbundle",
|
|
1045
|
-
});
|
|
1046
|
-
|
|
1047
|
-
// The bundle key now flows from the upload signed-URL request rather than
|
|
1048
|
-
// the job-status payload — pin it so the download-URL assertion below
|
|
1049
|
-
// still uses the same expected key.
|
|
1050
|
-
cloudRequestSignedUrlMock.mockImplementation(async (params) => ({
|
|
1051
|
-
url:
|
|
1052
|
-
params.operation === "upload"
|
|
1053
|
-
? "https://storage.googleapis.com/bucket/signed-upload"
|
|
1054
|
-
: "https://storage.googleapis.com/bucket/signed-download",
|
|
1055
|
-
bundleKey:
|
|
1056
|
-
params.bundleKey ?? "platform-exports/org-1/bundle-abc.vbundle",
|
|
1057
|
-
expiresAt: new Date(Date.now() + 3600_000).toISOString(),
|
|
1058
|
-
}));
|
|
1059
|
-
|
|
1060
|
-
const restoreFetch = installTrackingFetch();
|
|
1061
|
-
try {
|
|
1062
|
-
await teleport();
|
|
1063
|
-
|
|
1064
|
-
// Platform side: requested an upload URL, kicked off a runtime export to
|
|
1065
|
-
// GCS, and polled the unified job status.
|
|
1066
|
-
expect(cloudRequestSignedUrlMock).toHaveBeenCalledWith(
|
|
1067
|
-
expect.objectContaining({
|
|
1068
|
-
operation: "upload",
|
|
1069
|
-
minRuntimeVersion: "0.6.5",
|
|
1070
|
-
maxRuntimeVersion: null,
|
|
1071
|
-
}),
|
|
1072
|
-
"platform-token",
|
|
1073
|
-
"https://cloud.mycadet.ai",
|
|
1074
|
-
);
|
|
1075
|
-
// For platform sources, export-to-gcs is reached via the cloud's
|
|
1076
|
-
// wildcard runtime proxy. The helper builds the assistant-scoped URL
|
|
1077
|
-
// from the entry (`/v1/assistants/<id>/migrations/export-to-gcs`) and
|
|
1078
|
-
// sends platform-token auth — no guardian-token bootstrap.
|
|
1079
|
-
expect(localRuntimeExportToGcsMock).toHaveBeenCalledWith(
|
|
1080
|
-
expect.objectContaining({
|
|
1081
|
-
cloud: "cadet",
|
|
1082
|
-
runtimeUrl: "https://cloud.mycadet.ai",
|
|
1083
|
-
assistantId: "my-platform",
|
|
1084
|
-
}),
|
|
1085
|
-
"platform-token",
|
|
1086
|
-
expect.objectContaining({
|
|
1087
|
-
uploadUrl: "https://storage.googleapis.com/bucket/signed-upload",
|
|
1088
|
-
description: "teleport export",
|
|
1089
|
-
}),
|
|
1090
|
-
);
|
|
1091
|
-
// Polling for platform sources also goes through the wildcard via
|
|
1092
|
-
// localRuntimePollJobStatus(entry, ...) — the dedicated
|
|
1093
|
-
// `/v1/migrations/jobs/{id}/` endpoint queries platform-side
|
|
1094
|
-
// ImportJob records and would 404 on runtime-created job IDs.
|
|
1095
|
-
expect(localRuntimePollJobStatusMock).toHaveBeenCalledWith(
|
|
1096
|
-
expect.objectContaining({
|
|
1097
|
-
cloud: "cadet",
|
|
1098
|
-
runtimeUrl: "https://cloud.mycadet.ai",
|
|
1099
|
-
}),
|
|
1100
|
-
"platform-token",
|
|
1101
|
-
"local-export-job-1",
|
|
1102
|
-
);
|
|
1103
|
-
|
|
1104
|
-
// For the local target we request a download URL keyed by the
|
|
1105
|
-
// platform's bundle_key. The URL must target the SOURCE platform
|
|
1106
|
-
// (where the bundle was written) — pinned so a lockfile change
|
|
1107
|
-
// can't split upload and download across instances.
|
|
1108
|
-
//
|
|
1109
|
-
// `targetRuntimeVersion` MUST come from the target runtime's
|
|
1110
|
-
// `/v1/identity` (mocked to "0.6.5"), NOT from the CLI's package
|
|
1111
|
-
// version. The local target's daemon can be on a different version
|
|
1112
|
-
// than the CLI orchestrating the teleport.
|
|
1113
|
-
expect(cloudRequestSignedUrlMock).toHaveBeenCalledWith(
|
|
1114
|
-
expect.objectContaining({
|
|
1115
|
-
operation: "download",
|
|
1116
|
-
bundleKey: "platform-exports/org-1/bundle-abc.vbundle",
|
|
1117
|
-
targetRuntimeVersion: "0.6.5",
|
|
1118
|
-
}),
|
|
1119
|
-
"platform-token",
|
|
1120
|
-
"https://cloud.mycadet.ai",
|
|
1121
|
-
);
|
|
1122
|
-
expect(localRuntimeIdentityMock).toHaveBeenCalledWith(
|
|
1123
|
-
expect.objectContaining({ cloud: "local" }),
|
|
1124
|
-
expect.any(String),
|
|
1125
|
-
);
|
|
1126
|
-
|
|
1127
|
-
// Runtime import-from-gcs was kicked off with that URL.
|
|
1128
|
-
expect(localRuntimeImportFromGcsMock).toHaveBeenCalledWith(
|
|
1129
|
-
expect.objectContaining({
|
|
1130
|
-
cloud: "local",
|
|
1131
|
-
runtimeUrl: "http://localhost:7821",
|
|
1132
|
-
}),
|
|
1133
|
-
"local-token",
|
|
1134
|
-
expect.objectContaining({
|
|
1135
|
-
bundleUrl: "https://storage.googleapis.com/bucket/signed-download",
|
|
1136
|
-
}),
|
|
1137
|
-
);
|
|
1138
|
-
expect(localRuntimePollJobStatusMock).toHaveBeenCalled();
|
|
1139
|
-
|
|
1140
|
-
// No legacy inline-import helpers were touched.
|
|
1141
|
-
// (Verified by the absence of fetch calls carrying bundle bodies —
|
|
1142
|
-
// see "never buffers bundle bytes" assertion below.)
|
|
1143
|
-
} finally {
|
|
1144
|
-
restoreFetch();
|
|
1145
|
-
}
|
|
1146
|
-
});
|
|
1147
|
-
|
|
1148
|
-
test("local → docker: export via upload URL, import via download URL", async () => {
|
|
1149
|
-
setArgv("--from", "my-local", "--docker");
|
|
1150
|
-
|
|
1151
|
-
const localEntry = makeEntry("my-local", { cloud: "local" });
|
|
1152
|
-
const dockerEntry = makeEntry("new-docker", {
|
|
1153
|
-
cloud: "docker",
|
|
1154
|
-
runtimeUrl: "http://localhost:7822",
|
|
1155
|
-
});
|
|
1156
|
-
|
|
1157
|
-
findAssistantByNameMock.mockImplementation((name: string) =>
|
|
1158
|
-
name === "my-local" ? localEntry : null,
|
|
1159
|
-
);
|
|
1160
|
-
|
|
1161
|
-
loadAllAssistantsMock.mockImplementation(() => {
|
|
1162
|
-
if (hatchDockerMock.mock.calls.length > 0) {
|
|
1163
|
-
return [localEntry, dockerEntry];
|
|
1164
|
-
}
|
|
1165
|
-
return [localEntry];
|
|
1166
|
-
});
|
|
1167
|
-
|
|
1168
|
-
const restoreFetch = installTrackingFetch();
|
|
1169
|
-
try {
|
|
1170
|
-
await teleport();
|
|
1171
|
-
|
|
1172
|
-
// Export and import must pin the same platform URL so the bundle
|
|
1173
|
-
// lives in one place end-to-end. For local→docker neither side is
|
|
1174
|
-
// platform, so we default to getCloudUrl() (resolved once).
|
|
1175
|
-
expect(cloudRequestSignedUrlMock).toHaveBeenCalledWith(
|
|
1176
|
-
expect.objectContaining({
|
|
1177
|
-
operation: "upload",
|
|
1178
|
-
minRuntimeVersion: "0.6.5",
|
|
1179
|
-
maxRuntimeVersion: null,
|
|
1180
|
-
}),
|
|
1181
|
-
"platform-token",
|
|
1182
|
-
"https://cloud.mycadet.ai",
|
|
1183
|
-
);
|
|
1184
|
-
expect(localRuntimeExportToGcsMock).toHaveBeenCalled();
|
|
1185
|
-
|
|
1186
|
-
// Import: download-URL for the docker target, then runtime import.
|
|
1187
|
-
// targetRuntimeVersion comes from the docker target's runtime
|
|
1188
|
-
// identity (mocked to "0.6.5"), not from cliPkg.version.
|
|
1189
|
-
expect(cloudRequestSignedUrlMock).toHaveBeenCalledWith(
|
|
1190
|
-
expect.objectContaining({
|
|
1191
|
-
operation: "download",
|
|
1192
|
-
bundleKey: "bundle-key-123",
|
|
1193
|
-
targetRuntimeVersion: "0.6.5",
|
|
1194
|
-
}),
|
|
1195
|
-
"platform-token",
|
|
1196
|
-
"https://cloud.mycadet.ai",
|
|
1197
|
-
);
|
|
1198
|
-
expect(localRuntimeIdentityMock).toHaveBeenCalledWith(
|
|
1199
|
-
expect.objectContaining({ cloud: "docker" }),
|
|
1200
|
-
expect.any(String),
|
|
1201
|
-
);
|
|
1202
|
-
expect(localRuntimeImportFromGcsMock).toHaveBeenCalledWith(
|
|
1203
|
-
expect.objectContaining({
|
|
1204
|
-
cloud: "docker",
|
|
1205
|
-
runtimeUrl: "http://localhost:7822",
|
|
1206
|
-
}),
|
|
1207
|
-
"local-token",
|
|
1208
|
-
expect.objectContaining({
|
|
1209
|
-
bundleUrl: "https://storage.googleapis.com/bucket/signed-download",
|
|
1210
|
-
}),
|
|
1211
|
-
);
|
|
1212
|
-
|
|
1213
|
-
// Source retirement still happens on success for local↔docker.
|
|
1214
|
-
expect(retireLocalMock).toHaveBeenCalledWith("my-local", localEntry);
|
|
1215
|
-
expect(removeAssistantEntryMock).toHaveBeenCalledWith("my-local");
|
|
1216
|
-
} finally {
|
|
1217
|
-
restoreFetch();
|
|
1218
|
-
}
|
|
1219
|
-
});
|
|
1220
|
-
|
|
1221
|
-
test("docker → local: export via upload URL, import via download URL", async () => {
|
|
1222
|
-
setArgv("--from", "my-docker", "--local");
|
|
1223
|
-
|
|
1224
|
-
const dockerEntry = makeEntry("my-docker", {
|
|
1225
|
-
cloud: "docker",
|
|
1226
|
-
runtimeUrl: "http://localhost:7822",
|
|
1227
|
-
});
|
|
1228
|
-
const localEntry = makeEntry("new-local", {
|
|
1229
|
-
cloud: "local",
|
|
1230
|
-
runtimeUrl: "http://localhost:7823",
|
|
1231
|
-
});
|
|
1232
|
-
|
|
1233
|
-
findAssistantByNameMock.mockImplementation((name: string) =>
|
|
1234
|
-
name === "my-docker" ? dockerEntry : null,
|
|
1235
|
-
);
|
|
1236
|
-
|
|
1237
|
-
loadAllAssistantsMock.mockImplementation(() => {
|
|
1238
|
-
if (hatchLocalMock.mock.calls.length > 0) {
|
|
1239
|
-
return [dockerEntry, localEntry];
|
|
1240
|
-
}
|
|
1241
|
-
return [dockerEntry];
|
|
1242
|
-
});
|
|
1243
|
-
|
|
1244
|
-
const restoreFetch = installTrackingFetch();
|
|
1245
|
-
try {
|
|
1246
|
-
await teleport();
|
|
1247
|
-
|
|
1248
|
-
// Docker source should be put to sleep first.
|
|
1249
|
-
expect(sleepContainersMock).toHaveBeenCalled();
|
|
1250
|
-
|
|
1251
|
-
// Export leg: upload-URL (pinned to the same platform as import),
|
|
1252
|
-
// then runtime export.
|
|
1253
|
-
expect(cloudRequestSignedUrlMock).toHaveBeenCalledWith(
|
|
1254
|
-
expect.objectContaining({
|
|
1255
|
-
operation: "upload",
|
|
1256
|
-
minRuntimeVersion: "0.6.5",
|
|
1257
|
-
maxRuntimeVersion: null,
|
|
1258
|
-
}),
|
|
1259
|
-
"platform-token",
|
|
1260
|
-
"https://cloud.mycadet.ai",
|
|
1261
|
-
);
|
|
1262
|
-
expect(localRuntimeExportToGcsMock).toHaveBeenCalledWith(
|
|
1263
|
-
expect.objectContaining({
|
|
1264
|
-
cloud: "docker",
|
|
1265
|
-
runtimeUrl: "http://localhost:7822",
|
|
1266
|
-
}),
|
|
1267
|
-
"local-token",
|
|
1268
|
-
expect.objectContaining({
|
|
1269
|
-
uploadUrl: "https://storage.googleapis.com/bucket/signed-upload",
|
|
1270
|
-
}),
|
|
1271
|
-
);
|
|
1272
|
-
|
|
1273
|
-
// Import leg: download-URL targets the new local runtime
|
|
1274
|
-
expect(localRuntimeImportFromGcsMock).toHaveBeenCalledWith(
|
|
1275
|
-
expect.objectContaining({
|
|
1276
|
-
cloud: "local",
|
|
1277
|
-
runtimeUrl: "http://localhost:7823",
|
|
1278
|
-
}),
|
|
1279
|
-
"local-token",
|
|
1280
|
-
expect.anything(),
|
|
1281
|
-
);
|
|
1282
|
-
|
|
1283
|
-
// Source retirement
|
|
1284
|
-
expect(retireDockerMock).toHaveBeenCalledWith("my-docker");
|
|
1285
|
-
expect(removeAssistantEntryMock).toHaveBeenCalledWith("my-docker");
|
|
1286
|
-
} finally {
|
|
1287
|
-
restoreFetch();
|
|
1288
|
-
}
|
|
1289
|
-
});
|
|
61
|
+
test("cloud source refuses before starting any host operation", async () => {
|
|
62
|
+
find.mockReturnValue({ ...entry, cloud: "cadet" });
|
|
63
|
+
await expect(teleport()).rejects.toThrow("exit:1");
|
|
64
|
+
expect(move).not.toHaveBeenCalled();
|
|
1290
65
|
});
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
// ---------------------------------------------------------------------------
|
|
1296
|
-
|
|
1297
|
-
describe("signed-URL request targets the bundle-owning platform", () => {
|
|
1298
|
-
test("local → existing platform target with non-default runtimeUrl: upload URL pinned to target's runtimeUrl", async () => {
|
|
1299
|
-
setArgv("--from", "my-local", "--platform", "existing-platform");
|
|
1300
|
-
|
|
1301
|
-
const localEntry = makeEntry("my-local", { cloud: "local" });
|
|
1302
|
-
// Crucially, the target's runtimeUrl is NOT the default getCloudUrl()
|
|
1303
|
-
// return value — this is the regression case Codex flagged.
|
|
1304
|
-
const cloudEntry = makeEntry("existing-platform", {
|
|
1305
|
-
cloud: "cadet",
|
|
1306
|
-
runtimeUrl: "https://staging-cloud.mycadet.ai",
|
|
1307
|
-
});
|
|
1308
|
-
|
|
1309
|
-
findAssistantByNameMock.mockImplementation((name: string) => {
|
|
1310
|
-
if (name === "my-local") return localEntry;
|
|
1311
|
-
if (name === "existing-platform") return cloudEntry;
|
|
1312
|
-
return null;
|
|
1313
|
-
});
|
|
1314
|
-
|
|
1315
|
-
const restoreFetch = installTrackingFetch();
|
|
1316
|
-
try {
|
|
1317
|
-
await teleport();
|
|
1318
|
-
|
|
1319
|
-
// The signed-URL request for upload MUST target the existing
|
|
1320
|
-
// platform assistant's runtimeUrl, not the default platform URL.
|
|
1321
|
-
expect(cloudRequestSignedUrlMock).toHaveBeenCalledWith(
|
|
1322
|
-
expect.objectContaining({
|
|
1323
|
-
operation: "upload",
|
|
1324
|
-
minRuntimeVersion: "0.6.5",
|
|
1325
|
-
maxRuntimeVersion: null,
|
|
1326
|
-
}),
|
|
1327
|
-
"platform-token",
|
|
1328
|
-
"https://staging-cloud.mycadet.ai",
|
|
1329
|
-
);
|
|
1330
|
-
|
|
1331
|
-
// And the import must run against the same platform.
|
|
1332
|
-
expect(platformImportBundleFromGcsMock).toHaveBeenCalledWith(
|
|
1333
|
-
"bundle-key-123",
|
|
1334
|
-
"platform-token",
|
|
1335
|
-
"https://staging-cloud.mycadet.ai",
|
|
1336
|
-
);
|
|
1337
|
-
|
|
1338
|
-
// Assert none of the signed-URL calls used the default URL — if any
|
|
1339
|
-
// did, upload and download would hit different platforms.
|
|
1340
|
-
for (const call of cloudRequestSignedUrlMock.mock.calls) {
|
|
1341
|
-
expect(call[2]).toBe("https://staging-cloud.mycadet.ai");
|
|
1342
|
-
}
|
|
1343
|
-
} finally {
|
|
1344
|
-
restoreFetch();
|
|
1345
|
-
}
|
|
1346
|
-
});
|
|
1347
|
-
|
|
1348
|
-
test("platform → local with non-default source runtimeUrl: download URL pinned to source's runtimeUrl", async () => {
|
|
1349
|
-
setArgv("--from", "my-platform", "--local", "my-local");
|
|
1350
|
-
|
|
1351
|
-
const cloudEntry = makeEntry("my-platform", {
|
|
1352
|
-
cloud: "cadet",
|
|
1353
|
-
runtimeUrl: "https://dev-cloud.mycadet.ai",
|
|
1354
|
-
});
|
|
1355
|
-
const localEntry = makeEntry("my-local", { cloud: "local" });
|
|
1356
|
-
|
|
1357
|
-
findAssistantByNameMock.mockImplementation((name: string) => {
|
|
1358
|
-
if (name === "my-platform") return cloudEntry;
|
|
1359
|
-
if (name === "my-local") return localEntry;
|
|
1360
|
-
return null;
|
|
1361
|
-
});
|
|
1362
|
-
|
|
1363
|
-
platformPollJobStatusMock.mockResolvedValue({
|
|
1364
|
-
jobId: "platform-export-job-1",
|
|
1365
|
-
type: "export",
|
|
1366
|
-
status: "complete",
|
|
1367
|
-
bundleKey: "dev-bundle-key",
|
|
1368
|
-
});
|
|
1369
|
-
|
|
1370
|
-
// Bundle key flows from the upload signed-URL request now; pin it so the
|
|
1371
|
-
// download-URL assertion below uses the same key.
|
|
1372
|
-
cloudRequestSignedUrlMock.mockImplementation(async (params) => ({
|
|
1373
|
-
url:
|
|
1374
|
-
params.operation === "upload"
|
|
1375
|
-
? "https://storage.googleapis.com/bucket/signed-upload"
|
|
1376
|
-
: "https://storage.googleapis.com/bucket/signed-download",
|
|
1377
|
-
bundleKey: params.bundleKey ?? "dev-bundle-key",
|
|
1378
|
-
expiresAt: new Date(Date.now() + 3600_000).toISOString(),
|
|
1379
|
-
}));
|
|
1380
|
-
|
|
1381
|
-
const restoreFetch = installTrackingFetch();
|
|
1382
|
-
try {
|
|
1383
|
-
await teleport();
|
|
1384
|
-
|
|
1385
|
-
// The download URL must be requested from the SOURCE platform (where
|
|
1386
|
-
// the bundle was written by the runtime export), not the default.
|
|
1387
|
-
// targetRuntimeVersion comes from the local target's `/v1/identity`
|
|
1388
|
-
// (mocked to "0.6.5"), not from cliPkg.version.
|
|
1389
|
-
expect(cloudRequestSignedUrlMock).toHaveBeenCalledWith(
|
|
1390
|
-
expect.objectContaining({
|
|
1391
|
-
operation: "download",
|
|
1392
|
-
bundleKey: "dev-bundle-key",
|
|
1393
|
-
targetRuntimeVersion: "0.6.5",
|
|
1394
|
-
}),
|
|
1395
|
-
"platform-token",
|
|
1396
|
-
"https://dev-cloud.mycadet.ai",
|
|
1397
|
-
);
|
|
1398
|
-
} finally {
|
|
1399
|
-
restoreFetch();
|
|
1400
|
-
}
|
|
1401
|
-
});
|
|
66
|
+
test("same environment refuses before starting any host operation", async () => {
|
|
67
|
+
find.mockReturnValue({ ...entry, cloud: "docker" });
|
|
68
|
+
await expect(teleport()).rejects.toThrow("exit:1");
|
|
69
|
+
expect(move).not.toHaveBeenCalled();
|
|
1402
70
|
});
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
const BUNDLE_BODY_THRESHOLD_BYTES = 1024;
|
|
1412
|
-
|
|
1413
|
-
function bodySize(body: unknown): number {
|
|
1414
|
-
if (typeof body === "string") return body.length;
|
|
1415
|
-
if (body instanceof Uint8Array) return body.byteLength;
|
|
1416
|
-
if (body instanceof ArrayBuffer) return body.byteLength;
|
|
1417
|
-
if (body instanceof Blob) return body.size;
|
|
1418
|
-
return 0;
|
|
1419
|
-
}
|
|
1420
|
-
|
|
1421
|
-
test("local → platform: no fetch call carries a bundle-sized body", async () => {
|
|
1422
|
-
setArgv("--from", "my-local", "--platform");
|
|
1423
|
-
|
|
1424
|
-
const localEntry = makeEntry("my-local", { cloud: "local" });
|
|
1425
|
-
findAssistantByNameMock.mockImplementation((name: string) =>
|
|
1426
|
-
name === "my-local" ? localEntry : null,
|
|
1427
|
-
);
|
|
1428
|
-
|
|
1429
|
-
const restoreFetch = installTrackingFetch();
|
|
1430
|
-
try {
|
|
1431
|
-
await teleport();
|
|
1432
|
-
|
|
1433
|
-
for (const call of fetchCalls) {
|
|
1434
|
-
expect(bodySize(call.body)).toBeLessThan(BUNDLE_BODY_THRESHOLD_BYTES);
|
|
1435
|
-
}
|
|
1436
|
-
} finally {
|
|
1437
|
-
restoreFetch();
|
|
1438
|
-
}
|
|
1439
|
-
});
|
|
1440
|
-
|
|
1441
|
-
test("platform → local: no fetch call carries a bundle-sized body", async () => {
|
|
1442
|
-
setArgv("--from", "my-platform", "--local", "my-local");
|
|
1443
|
-
|
|
1444
|
-
const cloudEntry = makeEntry("my-platform", {
|
|
1445
|
-
cloud: "cadet",
|
|
1446
|
-
runtimeUrl: "https://cloud.mycadet.ai",
|
|
1447
|
-
});
|
|
1448
|
-
const localEntry = makeEntry("my-local", { cloud: "local" });
|
|
1449
|
-
|
|
1450
|
-
findAssistantByNameMock.mockImplementation((name: string) => {
|
|
1451
|
-
if (name === "my-platform") return cloudEntry;
|
|
1452
|
-
if (name === "my-local") return localEntry;
|
|
1453
|
-
return null;
|
|
1454
|
-
});
|
|
1455
|
-
|
|
1456
|
-
platformPollJobStatusMock.mockResolvedValue({
|
|
1457
|
-
jobId: "platform-export-job-1",
|
|
1458
|
-
type: "export",
|
|
1459
|
-
status: "complete",
|
|
1460
|
-
bundleKey: "platform-exports/org-1/bundle.vbundle",
|
|
1461
|
-
});
|
|
1462
|
-
|
|
1463
|
-
const restoreFetch = installTrackingFetch();
|
|
1464
|
-
try {
|
|
1465
|
-
await teleport();
|
|
1466
|
-
|
|
1467
|
-
for (const call of fetchCalls) {
|
|
1468
|
-
expect(bodySize(call.body)).toBeLessThan(BUNDLE_BODY_THRESHOLD_BYTES);
|
|
1469
|
-
}
|
|
1470
|
-
} finally {
|
|
1471
|
-
restoreFetch();
|
|
1472
|
-
}
|
|
71
|
+
test("parses dry run and rejects multiple destinations", () => {
|
|
72
|
+
expect(
|
|
73
|
+
parseArgs(["--from", "source", "--local", "target", "--dry-run"]),
|
|
74
|
+
).toMatchObject({
|
|
75
|
+
from: "source",
|
|
76
|
+
targetEnv: "local",
|
|
77
|
+
targetName: "target",
|
|
78
|
+
dryRun: true,
|
|
1473
79
|
});
|
|
80
|
+
expect(() => parseArgs(["--local", "--docker"])).toThrow("exit:1");
|
|
1474
81
|
});
|
|
1475
|
-
|
|
1476
|
-
// ---------------------------------------------------------------------------
|
|
1477
|
-
// Polling behavior
|
|
1478
|
-
// ---------------------------------------------------------------------------
|
|
1479
|
-
|
|
1480
|
-
describe("polling", () => {
|
|
1481
|
-
test("local-runtime export poll continues until complete", async () => {
|
|
1482
|
-
setArgv("--from", "my-local", "--platform");
|
|
1483
|
-
|
|
1484
|
-
const localEntry = makeEntry("my-local", { cloud: "local" });
|
|
1485
|
-
findAssistantByNameMock.mockImplementation((name: string) =>
|
|
1486
|
-
name === "my-local" ? localEntry : null,
|
|
1487
|
-
);
|
|
1488
|
-
|
|
1489
|
-
let callIdx = 0;
|
|
1490
|
-
localRuntimePollJobStatusMock.mockImplementation(
|
|
1491
|
-
async (_runtimeUrl, _token, jobId) => {
|
|
1492
|
-
callIdx++;
|
|
1493
|
-
if (callIdx < 3) {
|
|
1494
|
-
return {
|
|
1495
|
-
jobId,
|
|
1496
|
-
type: "export" as const,
|
|
1497
|
-
status: "processing" as const,
|
|
1498
|
-
};
|
|
1499
|
-
}
|
|
1500
|
-
return {
|
|
1501
|
-
jobId,
|
|
1502
|
-
type: "export" as const,
|
|
1503
|
-
status: "complete" as const,
|
|
1504
|
-
result: undefined,
|
|
1505
|
-
};
|
|
1506
|
-
},
|
|
1507
|
-
);
|
|
1508
|
-
|
|
1509
|
-
const restoreFetch = installTrackingFetch();
|
|
1510
|
-
try {
|
|
1511
|
-
await teleport();
|
|
1512
|
-
expect(callIdx).toBeGreaterThanOrEqual(3);
|
|
1513
|
-
} finally {
|
|
1514
|
-
restoreFetch();
|
|
1515
|
-
}
|
|
1516
|
-
});
|
|
1517
|
-
|
|
1518
|
-
test("local-runtime export failure → teleport exits 1", async () => {
|
|
1519
|
-
setArgv("--from", "my-local", "--platform");
|
|
1520
|
-
|
|
1521
|
-
const localEntry = makeEntry("my-local", { cloud: "local" });
|
|
1522
|
-
findAssistantByNameMock.mockImplementation((name: string) =>
|
|
1523
|
-
name === "my-local" ? localEntry : null,
|
|
1524
|
-
);
|
|
1525
|
-
|
|
1526
|
-
localRuntimePollJobStatusMock.mockResolvedValue({
|
|
1527
|
-
jobId: "local-export-job-1",
|
|
1528
|
-
type: "export",
|
|
1529
|
-
status: "failed",
|
|
1530
|
-
error: "simulated failure",
|
|
1531
|
-
});
|
|
1532
|
-
|
|
1533
|
-
const restoreFetch = installTrackingFetch();
|
|
1534
|
-
try {
|
|
1535
|
-
await expect(teleport()).rejects.toThrow("process.exit:1");
|
|
1536
|
-
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
1537
|
-
expect.stringContaining("simulated failure"),
|
|
1538
|
-
);
|
|
1539
|
-
} finally {
|
|
1540
|
-
restoreFetch();
|
|
1541
|
-
}
|
|
1542
|
-
});
|
|
1543
|
-
|
|
1544
|
-
test("local-runtime import failure → teleport exits 1", async () => {
|
|
1545
|
-
setArgv("--from", "my-platform", "--local", "my-local");
|
|
1546
|
-
|
|
1547
|
-
const cloudEntry = makeEntry("my-platform", {
|
|
1548
|
-
cloud: "cadet",
|
|
1549
|
-
runtimeUrl: "https://cloud.mycadet.ai",
|
|
1550
|
-
});
|
|
1551
|
-
const localEntry = makeEntry("my-local", { cloud: "local" });
|
|
1552
|
-
|
|
1553
|
-
findAssistantByNameMock.mockImplementation((name: string) => {
|
|
1554
|
-
if (name === "my-platform") return cloudEntry;
|
|
1555
|
-
if (name === "my-local") return localEntry;
|
|
1556
|
-
return null;
|
|
1557
|
-
});
|
|
1558
|
-
|
|
1559
|
-
platformPollJobStatusMock.mockResolvedValue({
|
|
1560
|
-
jobId: "platform-export-job-1",
|
|
1561
|
-
type: "export",
|
|
1562
|
-
status: "complete",
|
|
1563
|
-
bundleKey: "key-1",
|
|
1564
|
-
});
|
|
1565
|
-
|
|
1566
|
-
localRuntimePollJobStatusMock.mockImplementation(
|
|
1567
|
-
async (_runtimeUrl, _token, jobId) => {
|
|
1568
|
-
if (jobId.includes("import")) {
|
|
1569
|
-
return {
|
|
1570
|
-
jobId,
|
|
1571
|
-
type: "import" as const,
|
|
1572
|
-
status: "failed" as const,
|
|
1573
|
-
error: "import blew up",
|
|
1574
|
-
};
|
|
1575
|
-
}
|
|
1576
|
-
return {
|
|
1577
|
-
jobId,
|
|
1578
|
-
type: "export" as const,
|
|
1579
|
-
status: "complete" as const,
|
|
1580
|
-
result: undefined,
|
|
1581
|
-
};
|
|
1582
|
-
},
|
|
1583
|
-
);
|
|
1584
|
-
|
|
1585
|
-
const restoreFetch = installTrackingFetch();
|
|
1586
|
-
try {
|
|
1587
|
-
await expect(teleport()).rejects.toThrow("process.exit:1");
|
|
1588
|
-
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
1589
|
-
expect.stringContaining("import blew up"),
|
|
1590
|
-
);
|
|
1591
|
-
} finally {
|
|
1592
|
-
restoreFetch();
|
|
1593
|
-
}
|
|
1594
|
-
});
|
|
1595
|
-
});
|
|
1596
|
-
|
|
1597
|
-
// ---------------------------------------------------------------------------
|
|
1598
|
-
// MigrationInProgressError handling
|
|
1599
|
-
// ---------------------------------------------------------------------------
|
|
1600
|
-
|
|
1601
|
-
describe("MigrationInProgressError handling", () => {
|
|
1602
|
-
test("local-runtime export already in flight → fail fast with existing job id", async () => {
|
|
1603
|
-
setArgv("--from", "my-local", "--platform");
|
|
1604
|
-
|
|
1605
|
-
const localEntry = makeEntry("my-local", { cloud: "local" });
|
|
1606
|
-
findAssistantByNameMock.mockImplementation((name: string) =>
|
|
1607
|
-
name === "my-local" ? localEntry : null,
|
|
1608
|
-
);
|
|
1609
|
-
|
|
1610
|
-
localRuntimeExportToGcsMock.mockRejectedValue(
|
|
1611
|
-
new localRuntimeClient.MigrationInProgressError(
|
|
1612
|
-
"export_in_progress",
|
|
1613
|
-
"existing-job-42",
|
|
1614
|
-
),
|
|
1615
|
-
);
|
|
1616
|
-
|
|
1617
|
-
const restoreFetch = installTrackingFetch();
|
|
1618
|
-
try {
|
|
1619
|
-
await expect(teleport()).rejects.toThrow("process.exit:1");
|
|
1620
|
-
|
|
1621
|
-
// Must not have polled the existing job — the existing job's bundle
|
|
1622
|
-
// lives at a different GCS key (its caller's signed URL), so polling
|
|
1623
|
-
// it would leave the teleport pointing at an empty/unrelated bundle.
|
|
1624
|
-
const polledIds = localRuntimePollJobStatusMock.mock.calls.map(
|
|
1625
|
-
(call: unknown[]) => call[2],
|
|
1626
|
-
);
|
|
1627
|
-
expect(polledIds).not.toContain("existing-job-42");
|
|
1628
|
-
|
|
1629
|
-
// Error must mention the existing job id so the user can act on it.
|
|
1630
|
-
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
1631
|
-
expect.stringContaining("existing-job-42"),
|
|
1632
|
-
);
|
|
1633
|
-
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
1634
|
-
expect.stringContaining("already in progress"),
|
|
1635
|
-
);
|
|
1636
|
-
} finally {
|
|
1637
|
-
restoreFetch();
|
|
1638
|
-
}
|
|
1639
|
-
});
|
|
1640
|
-
|
|
1641
|
-
test("local-runtime import already in flight → fail fast with existing job id", async () => {
|
|
1642
|
-
setArgv("--from", "my-platform", "--local", "my-local");
|
|
1643
|
-
|
|
1644
|
-
const cloudEntry = makeEntry("my-platform", {
|
|
1645
|
-
cloud: "cadet",
|
|
1646
|
-
runtimeUrl: "https://cloud.mycadet.ai",
|
|
1647
|
-
});
|
|
1648
|
-
const localEntry = makeEntry("my-local", { cloud: "local" });
|
|
1649
|
-
|
|
1650
|
-
findAssistantByNameMock.mockImplementation((name: string) => {
|
|
1651
|
-
if (name === "my-platform") return cloudEntry;
|
|
1652
|
-
if (name === "my-local") return localEntry;
|
|
1653
|
-
return null;
|
|
1654
|
-
});
|
|
1655
|
-
|
|
1656
|
-
platformPollJobStatusMock.mockResolvedValue({
|
|
1657
|
-
jobId: "platform-export-job-1",
|
|
1658
|
-
type: "export",
|
|
1659
|
-
status: "complete",
|
|
1660
|
-
bundleKey: "bundle-key-from-platform",
|
|
1661
|
-
});
|
|
1662
|
-
|
|
1663
|
-
localRuntimeImportFromGcsMock.mockRejectedValue(
|
|
1664
|
-
new localRuntimeClient.MigrationInProgressError(
|
|
1665
|
-
"import_in_progress",
|
|
1666
|
-
"existing-import-99",
|
|
1667
|
-
),
|
|
1668
|
-
);
|
|
1669
|
-
|
|
1670
|
-
const restoreFetch = installTrackingFetch();
|
|
1671
|
-
try {
|
|
1672
|
-
await expect(teleport()).rejects.toThrow("process.exit:1");
|
|
1673
|
-
|
|
1674
|
-
// Must not poll the existing import — it's importing somebody else's
|
|
1675
|
-
// bundle, not ours, so reporting on it would be misleading.
|
|
1676
|
-
const polledIds = localRuntimePollJobStatusMock.mock.calls.map(
|
|
1677
|
-
(call: unknown[]) => call[2],
|
|
1678
|
-
);
|
|
1679
|
-
expect(polledIds).not.toContain("existing-import-99");
|
|
1680
|
-
|
|
1681
|
-
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
1682
|
-
expect.stringContaining("existing-import-99"),
|
|
1683
|
-
);
|
|
1684
|
-
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
1685
|
-
expect.stringContaining("already in progress"),
|
|
1686
|
-
);
|
|
1687
|
-
} finally {
|
|
1688
|
-
restoreFetch();
|
|
1689
|
-
}
|
|
1690
|
-
});
|
|
1691
|
-
});
|
|
1692
|
-
|
|
1693
|
-
// ---------------------------------------------------------------------------
|
|
1694
|
-
// VersionMismatchError handling — 422 from cloudRequestSignedUrl on the
|
|
1695
|
-
// download leg is terminal: surface a friendly message + exit 1, no retry.
|
|
1696
|
-
// ---------------------------------------------------------------------------
|
|
1697
|
-
|
|
1698
|
-
describe("VersionMismatchError handling", () => {
|
|
1699
|
-
test("local target: 422 on download signed-URL exits 1 with friendly message", async () => {
|
|
1700
|
-
setArgv("--from", "my-platform", "--local", "my-local");
|
|
1701
|
-
|
|
1702
|
-
const cloudEntry = makeEntry("my-platform", {
|
|
1703
|
-
cloud: "cadet",
|
|
1704
|
-
runtimeUrl: "https://cloud.mycadet.ai",
|
|
1705
|
-
});
|
|
1706
|
-
const localEntry = makeEntry("my-local", { cloud: "local" });
|
|
1707
|
-
|
|
1708
|
-
findAssistantByNameMock.mockImplementation((name: string) => {
|
|
1709
|
-
if (name === "my-platform") return cloudEntry;
|
|
1710
|
-
if (name === "my-local") return localEntry;
|
|
1711
|
-
return null;
|
|
1712
|
-
});
|
|
1713
|
-
|
|
1714
|
-
platformPollJobStatusMock.mockResolvedValue({
|
|
1715
|
-
jobId: "platform-export-job-1",
|
|
1716
|
-
type: "export",
|
|
1717
|
-
status: "complete",
|
|
1718
|
-
bundleKey: "platform-bundle-key-abc",
|
|
1719
|
-
});
|
|
1720
|
-
|
|
1721
|
-
// Upload signed-URL succeeds; download signed-URL throws version-mismatch.
|
|
1722
|
-
cloudRequestSignedUrlMock.mockImplementation(async (params) => {
|
|
1723
|
-
if (params.operation === "download") {
|
|
1724
|
-
throw new cloudClient.VersionMismatchError(
|
|
1725
|
-
{ min_runtime_version: "99.0.0", max_runtime_version: null },
|
|
1726
|
-
"0.7.1",
|
|
1727
|
-
);
|
|
1728
|
-
}
|
|
1729
|
-
return {
|
|
1730
|
-
url: "https://storage.googleapis.com/bucket/signed-upload",
|
|
1731
|
-
bundleKey: params.bundleKey ?? "bundle-key-123",
|
|
1732
|
-
expiresAt: new Date(Date.now() + 3600_000).toISOString(),
|
|
1733
|
-
};
|
|
1734
|
-
});
|
|
1735
|
-
|
|
1736
|
-
const restoreFetch = installTrackingFetch();
|
|
1737
|
-
try {
|
|
1738
|
-
await expect(teleport()).rejects.toThrow("process.exit:1");
|
|
1739
|
-
|
|
1740
|
-
// Friendly message uses the prefix from VersionMismatchError.formatMessage.
|
|
1741
|
-
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
1742
|
-
expect.stringContaining("Cannot import: bundle requires runtime"),
|
|
1743
|
-
);
|
|
1744
|
-
|
|
1745
|
-
// Terminal: no retry of the download signed-URL request.
|
|
1746
|
-
const downloadCalls = cloudRequestSignedUrlMock.mock.calls.filter(
|
|
1747
|
-
(c) => c[0].operation === "download",
|
|
1748
|
-
);
|
|
1749
|
-
expect(downloadCalls).toHaveLength(1);
|
|
1750
|
-
|
|
1751
|
-
// Runtime import-from-gcs must NOT be kicked off after the 422.
|
|
1752
|
-
expect(localRuntimeImportFromGcsMock).not.toHaveBeenCalled();
|
|
1753
|
-
} finally {
|
|
1754
|
-
restoreFetch();
|
|
1755
|
-
}
|
|
1756
|
-
});
|
|
1757
|
-
|
|
1758
|
-
test("docker target: 422 on download signed-URL exits 1 with friendly message", async () => {
|
|
1759
|
-
setArgv("--from", "my-local", "--docker");
|
|
1760
|
-
|
|
1761
|
-
const localEntry = makeEntry("my-local", { cloud: "local" });
|
|
1762
|
-
const dockerEntry = makeEntry("new-docker", {
|
|
1763
|
-
cloud: "docker",
|
|
1764
|
-
runtimeUrl: "http://localhost:7822",
|
|
1765
|
-
});
|
|
1766
|
-
|
|
1767
|
-
findAssistantByNameMock.mockImplementation((name: string) =>
|
|
1768
|
-
name === "my-local" ? localEntry : null,
|
|
1769
|
-
);
|
|
1770
|
-
|
|
1771
|
-
loadAllAssistantsMock.mockImplementation(() => {
|
|
1772
|
-
if (hatchDockerMock.mock.calls.length > 0) {
|
|
1773
|
-
return [localEntry, dockerEntry];
|
|
1774
|
-
}
|
|
1775
|
-
return [localEntry];
|
|
1776
|
-
});
|
|
1777
|
-
|
|
1778
|
-
cloudRequestSignedUrlMock.mockImplementation(async (params) => {
|
|
1779
|
-
if (params.operation === "download") {
|
|
1780
|
-
throw new cloudClient.VersionMismatchError(
|
|
1781
|
-
{ min_runtime_version: "99.0.0", max_runtime_version: null },
|
|
1782
|
-
"0.7.1",
|
|
1783
|
-
);
|
|
1784
|
-
}
|
|
1785
|
-
return {
|
|
1786
|
-
url: "https://storage.googleapis.com/bucket/signed-upload",
|
|
1787
|
-
bundleKey: params.bundleKey ?? "bundle-key-123",
|
|
1788
|
-
expiresAt: new Date(Date.now() + 3600_000).toISOString(),
|
|
1789
|
-
};
|
|
1790
|
-
});
|
|
1791
|
-
|
|
1792
|
-
const restoreFetch = installTrackingFetch();
|
|
1793
|
-
try {
|
|
1794
|
-
await expect(teleport()).rejects.toThrow("process.exit:1");
|
|
1795
|
-
|
|
1796
|
-
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
1797
|
-
expect.stringContaining("Cannot import: bundle requires runtime"),
|
|
1798
|
-
);
|
|
1799
|
-
|
|
1800
|
-
expect(localRuntimeImportFromGcsMock).not.toHaveBeenCalled();
|
|
1801
|
-
} finally {
|
|
1802
|
-
restoreFetch();
|
|
1803
|
-
}
|
|
1804
|
-
});
|
|
1805
|
-
|
|
1806
|
-
test("non-VersionMismatchError on download signed-URL re-raises", async () => {
|
|
1807
|
-
setArgv("--from", "my-platform", "--local", "my-local");
|
|
1808
|
-
|
|
1809
|
-
const cloudEntry = makeEntry("my-platform", {
|
|
1810
|
-
cloud: "cadet",
|
|
1811
|
-
runtimeUrl: "https://cloud.mycadet.ai",
|
|
1812
|
-
});
|
|
1813
|
-
const localEntry = makeEntry("my-local", { cloud: "local" });
|
|
1814
|
-
|
|
1815
|
-
findAssistantByNameMock.mockImplementation((name: string) => {
|
|
1816
|
-
if (name === "my-platform") return cloudEntry;
|
|
1817
|
-
if (name === "my-local") return localEntry;
|
|
1818
|
-
return null;
|
|
1819
|
-
});
|
|
1820
|
-
|
|
1821
|
-
platformPollJobStatusMock.mockResolvedValue({
|
|
1822
|
-
jobId: "platform-export-job-1",
|
|
1823
|
-
type: "export",
|
|
1824
|
-
status: "complete",
|
|
1825
|
-
bundleKey: "platform-bundle-key-abc",
|
|
1826
|
-
});
|
|
1827
|
-
|
|
1828
|
-
cloudRequestSignedUrlMock.mockImplementation(async (params) => {
|
|
1829
|
-
if (params.operation === "download") {
|
|
1830
|
-
throw new Error("network blew up");
|
|
1831
|
-
}
|
|
1832
|
-
return {
|
|
1833
|
-
url: "https://storage.googleapis.com/bucket/signed-upload",
|
|
1834
|
-
bundleKey: params.bundleKey ?? "bundle-key-123",
|
|
1835
|
-
expiresAt: new Date(Date.now() + 3600_000).toISOString(),
|
|
1836
|
-
};
|
|
1837
|
-
});
|
|
1838
|
-
|
|
1839
|
-
const restoreFetch = installTrackingFetch();
|
|
1840
|
-
try {
|
|
1841
|
-
await expect(teleport()).rejects.toThrow("network blew up");
|
|
1842
|
-
} finally {
|
|
1843
|
-
restoreFetch();
|
|
1844
|
-
}
|
|
1845
|
-
});
|
|
1846
|
-
});
|
|
1847
|
-
|
|
1848
|
-
// ---------------------------------------------------------------------------
|
|
1849
|
-
// Target-runtime version fetch — the download signed-URL request must use
|
|
1850
|
-
// the TARGET runtime's reported version, not the orchestrating CLI's
|
|
1851
|
-
// version (which can diverge when the target was upgraded independently).
|
|
1852
|
-
// ---------------------------------------------------------------------------
|
|
1853
|
-
|
|
1854
|
-
describe("target runtime version fetch", () => {
|
|
1855
|
-
test("local target: targetRuntimeVersion comes from /v1/identity, not cliPkg", async () => {
|
|
1856
|
-
setArgv("--from", "my-platform", "--local", "my-local");
|
|
1857
|
-
|
|
1858
|
-
const cloudEntry = makeEntry("my-platform", {
|
|
1859
|
-
cloud: "cadet",
|
|
1860
|
-
runtimeUrl: "https://cloud.mycadet.ai",
|
|
1861
|
-
});
|
|
1862
|
-
const localEntry = makeEntry("my-local", { cloud: "local" });
|
|
1863
|
-
|
|
1864
|
-
findAssistantByNameMock.mockImplementation((name: string) => {
|
|
1865
|
-
if (name === "my-platform") return cloudEntry;
|
|
1866
|
-
if (name === "my-local") return localEntry;
|
|
1867
|
-
return null;
|
|
1868
|
-
});
|
|
1869
|
-
|
|
1870
|
-
platformPollJobStatusMock.mockResolvedValue({
|
|
1871
|
-
jobId: "platform-export-job-1",
|
|
1872
|
-
type: "export",
|
|
1873
|
-
status: "complete",
|
|
1874
|
-
bundleKey: "platform-bundle-key-abc",
|
|
1875
|
-
});
|
|
1876
|
-
|
|
1877
|
-
// Choose a version unlikely to match cliPkg.version so a regression
|
|
1878
|
-
// would be obvious.
|
|
1879
|
-
localRuntimeIdentityMock.mockResolvedValue({ version: "1.2.3-runtime" });
|
|
1880
|
-
|
|
1881
|
-
const restoreFetch = installTrackingFetch();
|
|
1882
|
-
try {
|
|
1883
|
-
await teleport();
|
|
1884
|
-
|
|
1885
|
-
const downloadCall = cloudRequestSignedUrlMock.mock.calls.find(
|
|
1886
|
-
(c) => c[0].operation === "download",
|
|
1887
|
-
);
|
|
1888
|
-
expect(downloadCall).toBeDefined();
|
|
1889
|
-
expect(downloadCall![0]).toMatchObject({
|
|
1890
|
-
targetRuntimeVersion: "1.2.3-runtime",
|
|
1891
|
-
});
|
|
1892
|
-
|
|
1893
|
-
// Identity was fetched against the TARGET (local) entry, not the
|
|
1894
|
-
// platform source.
|
|
1895
|
-
expect(localRuntimeIdentityMock).toHaveBeenCalledWith(
|
|
1896
|
-
expect.objectContaining({
|
|
1897
|
-
cloud: "local",
|
|
1898
|
-
assistantId: "my-local",
|
|
1899
|
-
}),
|
|
1900
|
-
expect.any(String),
|
|
1901
|
-
);
|
|
1902
|
-
} finally {
|
|
1903
|
-
restoreFetch();
|
|
1904
|
-
}
|
|
1905
|
-
});
|
|
1906
|
-
|
|
1907
|
-
test("identity fetch failure aborts before requesting download URL", async () => {
|
|
1908
|
-
setArgv("--from", "my-platform", "--local", "my-local");
|
|
1909
|
-
|
|
1910
|
-
const cloudEntry = makeEntry("my-platform", {
|
|
1911
|
-
cloud: "cadet",
|
|
1912
|
-
runtimeUrl: "https://cloud.mycadet.ai",
|
|
1913
|
-
});
|
|
1914
|
-
const localEntry = makeEntry("my-local", { cloud: "local" });
|
|
1915
|
-
|
|
1916
|
-
findAssistantByNameMock.mockImplementation((name: string) => {
|
|
1917
|
-
if (name === "my-platform") return cloudEntry;
|
|
1918
|
-
if (name === "my-local") return localEntry;
|
|
1919
|
-
return null;
|
|
1920
|
-
});
|
|
1921
|
-
|
|
1922
|
-
platformPollJobStatusMock.mockResolvedValue({
|
|
1923
|
-
jobId: "platform-export-job-1",
|
|
1924
|
-
type: "export",
|
|
1925
|
-
status: "complete",
|
|
1926
|
-
bundleKey: "platform-bundle-key-abc",
|
|
1927
|
-
});
|
|
1928
|
-
|
|
1929
|
-
// Identity is fetched twice in this flow: once on the source (platform)
|
|
1930
|
-
// for `min_runtime_version` on the upload signed-URL, then once on the
|
|
1931
|
-
// target (local) for `targetRuntimeVersion` on the download signed-URL.
|
|
1932
|
-
// Succeed on the source call so the flow gets as far as the target
|
|
1933
|
-
// identity fetch — that's the failure this test is exercising.
|
|
1934
|
-
localRuntimeIdentityMock.mockImplementation(async (entry) => {
|
|
1935
|
-
if (entry.cloud === "local") {
|
|
1936
|
-
throw new Error("Local runtime identity failed (502): bad gateway");
|
|
1937
|
-
}
|
|
1938
|
-
return { version: "0.6.5" };
|
|
1939
|
-
});
|
|
1940
|
-
|
|
1941
|
-
const restoreFetch = installTrackingFetch();
|
|
1942
|
-
try {
|
|
1943
|
-
await expect(teleport()).rejects.toThrow("process.exit:1");
|
|
1944
|
-
|
|
1945
|
-
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
1946
|
-
expect.stringContaining("Could not read target runtime version"),
|
|
1947
|
-
);
|
|
1948
|
-
|
|
1949
|
-
// No download signed-URL request was made — we aborted before that.
|
|
1950
|
-
const downloadCalls = cloudRequestSignedUrlMock.mock.calls.filter(
|
|
1951
|
-
(c) => c[0].operation === "download",
|
|
1952
|
-
);
|
|
1953
|
-
expect(downloadCalls).toHaveLength(0);
|
|
1954
|
-
expect(localRuntimeImportFromGcsMock).not.toHaveBeenCalled();
|
|
1955
|
-
} finally {
|
|
1956
|
-
restoreFetch();
|
|
1957
|
-
}
|
|
1958
|
-
});
|
|
1959
|
-
});
|
|
1960
|
-
|
|
1961
|
-
// ---------------------------------------------------------------------------
|
|
1962
|
-
// Dry-run behavior
|
|
1963
|
-
// ---------------------------------------------------------------------------
|
|
1964
|
-
|
|
1965
|
-
describe("dry-run", () => {
|
|
1966
|
-
test("dry-run without existing target does not hatch or export", async () => {
|
|
1967
|
-
setArgv("--from", "my-local", "--docker", "--dry-run");
|
|
1968
|
-
|
|
1969
|
-
const localEntry = makeEntry("my-local", { cloud: "local" });
|
|
1970
|
-
findAssistantByNameMock.mockImplementation((name: string) =>
|
|
1971
|
-
name === "my-local" ? localEntry : null,
|
|
1972
|
-
);
|
|
1973
|
-
|
|
1974
|
-
await teleport();
|
|
1975
|
-
|
|
1976
|
-
expect(hatchDockerMock).not.toHaveBeenCalled();
|
|
1977
|
-
expect(hatchLocalMock).not.toHaveBeenCalled();
|
|
1978
|
-
expect(hatchAssistantMock).not.toHaveBeenCalled();
|
|
1979
|
-
expect(retireLocalMock).not.toHaveBeenCalled();
|
|
1980
|
-
expect(retireDockerMock).not.toHaveBeenCalled();
|
|
1981
|
-
expect(localRuntimeExportToGcsMock).not.toHaveBeenCalled();
|
|
1982
|
-
expect(localRuntimeImportFromGcsMock).not.toHaveBeenCalled();
|
|
1983
|
-
});
|
|
1984
|
-
|
|
1985
|
-
test("dry-run with existing platform target runs preflight-from-gcs", async () => {
|
|
1986
|
-
setArgv(
|
|
1987
|
-
"--from",
|
|
1988
|
-
"my-local",
|
|
1989
|
-
"--platform",
|
|
1990
|
-
"existing-platform",
|
|
1991
|
-
"--dry-run",
|
|
1992
|
-
);
|
|
1993
|
-
|
|
1994
|
-
const localEntry = makeEntry("my-local", { cloud: "local" });
|
|
1995
|
-
const cloudEntry = makeEntry("existing-platform", {
|
|
1996
|
-
cloud: "cadet",
|
|
1997
|
-
runtimeUrl: "https://cloud.mycadet.ai",
|
|
1998
|
-
});
|
|
1999
|
-
|
|
2000
|
-
findAssistantByNameMock.mockImplementation((name: string) => {
|
|
2001
|
-
if (name === "my-local") return localEntry;
|
|
2002
|
-
if (name === "existing-platform") return cloudEntry;
|
|
2003
|
-
return null;
|
|
2004
|
-
});
|
|
2005
|
-
|
|
2006
|
-
const restoreFetch = installTrackingFetch();
|
|
2007
|
-
try {
|
|
2008
|
-
await teleport();
|
|
2009
|
-
expect(platformImportPreflightFromGcsMock).toHaveBeenCalledWith(
|
|
2010
|
-
"bundle-key-123",
|
|
2011
|
-
"platform-token",
|
|
2012
|
-
"https://cloud.mycadet.ai",
|
|
2013
|
-
);
|
|
2014
|
-
expect(hatchAssistantMock).not.toHaveBeenCalled();
|
|
2015
|
-
} finally {
|
|
2016
|
-
restoreFetch();
|
|
2017
|
-
}
|
|
2018
|
-
});
|
|
2019
|
-
|
|
2020
|
-
test("dry-run with existing local target calls preflight-from-gcs on runtime", async () => {
|
|
2021
|
-
setArgv("--from", "my-platform", "--local", "my-local", "--dry-run");
|
|
2022
|
-
|
|
2023
|
-
const cloudEntry = makeEntry("my-platform", {
|
|
2024
|
-
cloud: "cadet",
|
|
2025
|
-
runtimeUrl: "https://cloud.mycadet.ai",
|
|
2026
|
-
});
|
|
2027
|
-
const localEntry = makeEntry("my-local", { cloud: "local" });
|
|
2028
|
-
|
|
2029
|
-
findAssistantByNameMock.mockImplementation((name: string) => {
|
|
2030
|
-
if (name === "my-platform") return cloudEntry;
|
|
2031
|
-
if (name === "my-local") return localEntry;
|
|
2032
|
-
return null;
|
|
2033
|
-
});
|
|
2034
|
-
|
|
2035
|
-
const restoreFetch = installTrackingFetch();
|
|
2036
|
-
try {
|
|
2037
|
-
await teleport();
|
|
2038
|
-
|
|
2039
|
-
// Preflight was run against the local runtime
|
|
2040
|
-
expect(localRuntimePreflightFromGcsMock).toHaveBeenCalledTimes(1);
|
|
2041
|
-
expect(localRuntimePreflightFromGcsMock).toHaveBeenCalledWith(
|
|
2042
|
-
expect.objectContaining({ assistantId: "my-local" }),
|
|
2043
|
-
expect.any(String),
|
|
2044
|
-
{ bundleUrl: "https://storage.googleapis.com/bucket/signed-download" },
|
|
2045
|
-
);
|
|
2046
|
-
|
|
2047
|
-
// No actual import happened — dry-run only
|
|
2048
|
-
expect(localRuntimeImportFromGcsMock).not.toHaveBeenCalled();
|
|
2049
|
-
|
|
2050
|
-
// Source was not retired
|
|
2051
|
-
expect(retireLocalMock).not.toHaveBeenCalled();
|
|
2052
|
-
expect(retireDockerMock).not.toHaveBeenCalled();
|
|
2053
|
-
} finally {
|
|
2054
|
-
restoreFetch();
|
|
2055
|
-
}
|
|
2056
|
-
});
|
|
2057
|
-
});
|
|
2058
|
-
|
|
2059
|
-
// ---------------------------------------------------------------------------
|
|
2060
|
-
// Pre-check: block teleport to platform when existing assistant detected
|
|
2061
|
-
// ---------------------------------------------------------------------------
|
|
2062
|
-
|
|
2063
|
-
describe("pre-export source backup", () => {
|
|
2064
|
-
test("local source with no recent backup: exports a host-side backup before exporting", async () => {
|
|
2065
|
-
setArgv("--from", "my-local", "--platform");
|
|
2066
|
-
const localEntry = makeEntry("my-local", { cloud: "local" });
|
|
2067
|
-
findAssistantByNameMock.mockImplementation((name: string) =>
|
|
2068
|
-
name === "my-local" ? localEntry : null,
|
|
2069
|
-
);
|
|
2070
|
-
|
|
2071
|
-
const order: string[] = [];
|
|
2072
|
-
createBackupMock.mockImplementation(async () => {
|
|
2073
|
-
order.push("backup");
|
|
2074
|
-
return "/tmp/backups/my-local-pre-teleport-2026.vbundle";
|
|
2075
|
-
});
|
|
2076
|
-
localRuntimeExportToGcsMock.mockImplementation(async () => {
|
|
2077
|
-
order.push("export");
|
|
2078
|
-
return { jobId: "local-export-job-1" };
|
|
2079
|
-
});
|
|
2080
|
-
|
|
2081
|
-
const restoreFetch = installTrackingFetch();
|
|
2082
|
-
try {
|
|
2083
|
-
await teleport();
|
|
2084
|
-
} finally {
|
|
2085
|
-
restoreFetch();
|
|
2086
|
-
}
|
|
2087
|
-
|
|
2088
|
-
expect(listAssistantBackupTimesMock).toHaveBeenCalledWith("my-local");
|
|
2089
|
-
expect(createBackupMock).toHaveBeenCalledWith(
|
|
2090
|
-
"http://localhost:7821",
|
|
2091
|
-
"my-local",
|
|
2092
|
-
expect.objectContaining({ prefix: "my-local-pre-teleport" }),
|
|
2093
|
-
);
|
|
2094
|
-
expect(pruneOldBackupsMock).toHaveBeenCalledWith(
|
|
2095
|
-
"my-local",
|
|
2096
|
-
3,
|
|
2097
|
-
"pre-teleport",
|
|
2098
|
-
);
|
|
2099
|
-
expect(createPlatformBackupMock).not.toHaveBeenCalled();
|
|
2100
|
-
expect(order).toEqual(["backup", "export"]);
|
|
2101
|
-
});
|
|
2102
|
-
|
|
2103
|
-
test("local source with a recent backup: reuses it and skips the export", async () => {
|
|
2104
|
-
setArgv("--from", "my-local", "--platform");
|
|
2105
|
-
const localEntry = makeEntry("my-local", { cloud: "local" });
|
|
2106
|
-
findAssistantByNameMock.mockImplementation((name: string) =>
|
|
2107
|
-
name === "my-local" ? localEntry : null,
|
|
2108
|
-
);
|
|
2109
|
-
listAssistantBackupTimesMock.mockReturnValue([
|
|
2110
|
-
new Date(Date.now() - 5 * 60_000).toISOString(),
|
|
2111
|
-
]);
|
|
2112
|
-
|
|
2113
|
-
const restoreFetch = installTrackingFetch();
|
|
2114
|
-
try {
|
|
2115
|
-
await teleport();
|
|
2116
|
-
} finally {
|
|
2117
|
-
restoreFetch();
|
|
2118
|
-
}
|
|
2119
|
-
|
|
2120
|
-
expect(createBackupMock).not.toHaveBeenCalled();
|
|
2121
|
-
expect(localRuntimeExportToGcsMock).toHaveBeenCalled();
|
|
2122
|
-
});
|
|
2123
|
-
|
|
2124
|
-
test("local source with only stale backups: takes a fresh one", async () => {
|
|
2125
|
-
setArgv("--from", "my-local", "--platform");
|
|
2126
|
-
const localEntry = makeEntry("my-local", { cloud: "local" });
|
|
2127
|
-
findAssistantByNameMock.mockImplementation((name: string) =>
|
|
2128
|
-
name === "my-local" ? localEntry : null,
|
|
2129
|
-
);
|
|
2130
|
-
listAssistantBackupTimesMock.mockReturnValue([
|
|
2131
|
-
new Date(Date.now() - 3 * 60 * 60_000).toISOString(),
|
|
2132
|
-
]);
|
|
2133
|
-
|
|
2134
|
-
const restoreFetch = installTrackingFetch();
|
|
2135
|
-
try {
|
|
2136
|
-
await teleport();
|
|
2137
|
-
} finally {
|
|
2138
|
-
restoreFetch();
|
|
2139
|
-
}
|
|
2140
|
-
|
|
2141
|
-
expect(createBackupMock).toHaveBeenCalledTimes(1);
|
|
2142
|
-
});
|
|
2143
|
-
|
|
2144
|
-
test("docker source: backs up host-side through its runtime URL", async () => {
|
|
2145
|
-
setArgv("--from", "my-docker", "--local");
|
|
2146
|
-
const dockerEntry = makeEntry("my-docker", {
|
|
2147
|
-
cloud: "docker",
|
|
2148
|
-
runtimeUrl: "http://localhost:9821",
|
|
2149
|
-
});
|
|
2150
|
-
findAssistantByNameMock.mockImplementation((name: string) =>
|
|
2151
|
-
name === "my-docker" ? dockerEntry : null,
|
|
2152
|
-
);
|
|
2153
|
-
const freshLocal = makeEntry("fresh-local", { cloud: "local" });
|
|
2154
|
-
loadAllAssistantsMock.mockImplementation(() =>
|
|
2155
|
-
hatchLocalMock.mock.calls.length > 0
|
|
2156
|
-
? [dockerEntry, freshLocal]
|
|
2157
|
-
: [dockerEntry],
|
|
2158
|
-
);
|
|
2159
|
-
|
|
2160
|
-
const restoreFetch = installTrackingFetch();
|
|
2161
|
-
try {
|
|
2162
|
-
await teleport();
|
|
2163
|
-
} finally {
|
|
2164
|
-
restoreFetch();
|
|
2165
|
-
}
|
|
2166
|
-
|
|
2167
|
-
expect(createBackupMock).toHaveBeenCalledWith(
|
|
2168
|
-
"http://localhost:9821",
|
|
2169
|
-
"my-docker",
|
|
2170
|
-
expect.objectContaining({ prefix: "my-docker-pre-teleport" }),
|
|
2171
|
-
);
|
|
2172
|
-
});
|
|
2173
|
-
|
|
2174
|
-
test("platform source: takes a PVC snapshot through the source platform before exporting", async () => {
|
|
2175
|
-
setArgv("--from", "my-platform", "--local", "my-local");
|
|
2176
|
-
const cloudEntry = makeEntry("my-platform", {
|
|
2177
|
-
cloud: "cadet",
|
|
2178
|
-
runtimeUrl: "https://cloud.mycadet.ai",
|
|
2179
|
-
});
|
|
2180
|
-
const localEntry = makeEntry("my-local", { cloud: "local" });
|
|
2181
|
-
findAssistantByNameMock.mockImplementation((name: string) => {
|
|
2182
|
-
if (name === "my-platform") return cloudEntry;
|
|
2183
|
-
if (name === "my-local") return localEntry;
|
|
2184
|
-
return null;
|
|
2185
|
-
});
|
|
2186
|
-
|
|
2187
|
-
const order: string[] = [];
|
|
2188
|
-
createPlatformBackupMock.mockImplementation(async () => {
|
|
2189
|
-
order.push("backup");
|
|
2190
|
-
});
|
|
2191
|
-
localRuntimeExportToGcsMock.mockImplementation(async () => {
|
|
2192
|
-
order.push("export");
|
|
2193
|
-
return { jobId: "local-export-job-1" };
|
|
2194
|
-
});
|
|
2195
|
-
|
|
2196
|
-
const restoreFetch = installTrackingFetch();
|
|
2197
|
-
try {
|
|
2198
|
-
await teleport();
|
|
2199
|
-
} finally {
|
|
2200
|
-
restoreFetch();
|
|
2201
|
-
}
|
|
2202
|
-
|
|
2203
|
-
expect(listPlatformBackupsMock).toHaveBeenCalledWith(
|
|
2204
|
-
expect.objectContaining({
|
|
2205
|
-
assistantId: "my-platform",
|
|
2206
|
-
runtimeUrl: "https://cloud.mycadet.ai",
|
|
2207
|
-
}),
|
|
2208
|
-
"platform-token",
|
|
2209
|
-
);
|
|
2210
|
-
expect(createPlatformBackupMock).toHaveBeenCalledWith(
|
|
2211
|
-
expect.objectContaining({ assistantId: "my-platform" }),
|
|
2212
|
-
"platform-token",
|
|
2213
|
-
);
|
|
2214
|
-
expect(createBackupMock).not.toHaveBeenCalled();
|
|
2215
|
-
expect(order).toEqual(["backup", "export"]);
|
|
2216
|
-
});
|
|
2217
|
-
|
|
2218
|
-
test("platform source with a recent ready snapshot: reuses it", async () => {
|
|
2219
|
-
setArgv("--from", "my-platform", "--local", "my-local");
|
|
2220
|
-
const cloudEntry = makeEntry("my-platform", {
|
|
2221
|
-
cloud: "cadet",
|
|
2222
|
-
runtimeUrl: "https://cloud.mycadet.ai",
|
|
2223
|
-
});
|
|
2224
|
-
const localEntry = makeEntry("my-local", { cloud: "local" });
|
|
2225
|
-
findAssistantByNameMock.mockImplementation((name: string) => {
|
|
2226
|
-
if (name === "my-platform") return cloudEntry;
|
|
2227
|
-
if (name === "my-local") return localEntry;
|
|
2228
|
-
return null;
|
|
2229
|
-
});
|
|
2230
|
-
listPlatformBackupsMock.mockResolvedValue([
|
|
2231
|
-
new Date(Date.now() - 20 * 60_000).toISOString(),
|
|
2232
|
-
]);
|
|
2233
|
-
|
|
2234
|
-
const restoreFetch = installTrackingFetch();
|
|
2235
|
-
try {
|
|
2236
|
-
await teleport();
|
|
2237
|
-
} finally {
|
|
2238
|
-
restoreFetch();
|
|
2239
|
-
}
|
|
2240
|
-
|
|
2241
|
-
expect(createPlatformBackupMock).not.toHaveBeenCalled();
|
|
2242
|
-
expect(localRuntimeExportToGcsMock).toHaveBeenCalled();
|
|
2243
|
-
});
|
|
2244
|
-
|
|
2245
|
-
test("host-side backup failure aborts before export and before hatching", async () => {
|
|
2246
|
-
setArgv("--from", "my-local", "--platform");
|
|
2247
|
-
const localEntry = makeEntry("my-local", { cloud: "local" });
|
|
2248
|
-
findAssistantByNameMock.mockImplementation((name: string) =>
|
|
2249
|
-
name === "my-local" ? localEntry : null,
|
|
2250
|
-
);
|
|
2251
|
-
createBackupMock.mockResolvedValue(null);
|
|
2252
|
-
|
|
2253
|
-
const restoreFetch = installTrackingFetch();
|
|
2254
|
-
try {
|
|
2255
|
-
await expect(teleport()).rejects.toThrow("process.exit:1");
|
|
2256
|
-
} finally {
|
|
2257
|
-
restoreFetch();
|
|
2258
|
-
}
|
|
2259
|
-
|
|
2260
|
-
expect(localRuntimeExportToGcsMock).not.toHaveBeenCalled();
|
|
2261
|
-
expect(hatchAssistantMock).not.toHaveBeenCalled();
|
|
2262
|
-
expect(platformImportBundleFromGcsMock).not.toHaveBeenCalled();
|
|
2263
|
-
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
2264
|
-
expect.stringContaining("Could not back up 'my-local'"),
|
|
2265
|
-
);
|
|
2266
|
-
});
|
|
2267
|
-
|
|
2268
|
-
test("platform snapshot failure (never ready) aborts too", async () => {
|
|
2269
|
-
setArgv("--from", "my-platform", "--local", "my-local");
|
|
2270
|
-
const cloudEntry = makeEntry("my-platform", {
|
|
2271
|
-
cloud: "cadet",
|
|
2272
|
-
runtimeUrl: "https://cloud.mycadet.ai",
|
|
2273
|
-
});
|
|
2274
|
-
const localEntry = makeEntry("my-local", { cloud: "local" });
|
|
2275
|
-
findAssistantByNameMock.mockImplementation((name: string) => {
|
|
2276
|
-
if (name === "my-platform") return cloudEntry;
|
|
2277
|
-
if (name === "my-local") return localEntry;
|
|
2278
|
-
return null;
|
|
2279
|
-
});
|
|
2280
|
-
createPlatformBackupMock.mockRejectedValue(
|
|
2281
|
-
new Error("Cadet Cloud backup snap-1 was not ready after 600s"),
|
|
2282
|
-
);
|
|
2283
|
-
|
|
2284
|
-
const restoreFetch = installTrackingFetch();
|
|
2285
|
-
try {
|
|
2286
|
-
await expect(teleport()).rejects.toThrow("process.exit:1");
|
|
2287
|
-
} finally {
|
|
2288
|
-
restoreFetch();
|
|
2289
|
-
}
|
|
2290
|
-
|
|
2291
|
-
expect(localRuntimeExportToGcsMock).not.toHaveBeenCalled();
|
|
2292
|
-
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
2293
|
-
expect.stringContaining("was not ready"),
|
|
2294
|
-
);
|
|
2295
|
-
});
|
|
2296
|
-
|
|
2297
|
-
test("dry-run never backs up the source", async () => {
|
|
2298
|
-
setArgv("--from", "my-local", "--platform", "--dry-run");
|
|
2299
|
-
const localEntry = makeEntry("my-local", { cloud: "local" });
|
|
2300
|
-
findAssistantByNameMock.mockImplementation((name: string) =>
|
|
2301
|
-
name === "my-local" ? localEntry : null,
|
|
2302
|
-
);
|
|
2303
|
-
|
|
2304
|
-
await teleport();
|
|
2305
|
-
|
|
2306
|
-
expect(listAssistantBackupTimesMock).not.toHaveBeenCalled();
|
|
2307
|
-
expect(createBackupMock).not.toHaveBeenCalled();
|
|
2308
|
-
});
|
|
2309
|
-
});
|
|
2310
|
-
|
|
2311
|
-
describe("pre-check: existing platform assistant", () => {
|
|
2312
|
-
test("blocks before any work when pre-check finds existing assistant", async () => {
|
|
2313
|
-
setArgv("--from", "my-local", "--platform");
|
|
2314
|
-
|
|
2315
|
-
const localEntry = makeEntry("my-local", { cloud: "local" });
|
|
2316
|
-
findAssistantByNameMock.mockImplementation((name: string) =>
|
|
2317
|
-
name === "my-local" ? localEntry : null,
|
|
2318
|
-
);
|
|
2319
|
-
|
|
2320
|
-
checkExistingCloudAssistantMock.mockResolvedValue({
|
|
2321
|
-
id: "existing-platform-id",
|
|
2322
|
-
name: "existing-platform",
|
|
2323
|
-
status: "active",
|
|
2324
|
-
});
|
|
2325
|
-
|
|
2326
|
-
await expect(teleport()).rejects.toThrow("process.exit:1");
|
|
2327
|
-
|
|
2328
|
-
expect(checkExistingCloudAssistantMock).toHaveBeenCalledWith(
|
|
2329
|
-
"platform-token",
|
|
2330
|
-
undefined,
|
|
2331
|
-
);
|
|
2332
|
-
// No signed-URL or runtime calls
|
|
2333
|
-
expect(cloudRequestSignedUrlMock).not.toHaveBeenCalled();
|
|
2334
|
-
expect(localRuntimeExportToGcsMock).not.toHaveBeenCalled();
|
|
2335
|
-
expect(hatchAssistantMock).not.toHaveBeenCalled();
|
|
2336
|
-
|
|
2337
|
-
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
2338
|
-
expect.stringContaining("already have a cloud assistant"),
|
|
2339
|
-
);
|
|
2340
|
-
});
|
|
2341
|
-
});
|
|
2342
|
-
|
|
2343
|
-
// ---------------------------------------------------------------------------
|
|
2344
|
-
// Version guard
|
|
2345
|
-
// ---------------------------------------------------------------------------
|
|
2346
|
-
|
|
2347
|
-
describe("version guard", () => {
|
|
2348
|
-
test("blocks platform→local when local version is behind", async () => {
|
|
2349
|
-
setArgv("--from", "my-platform", "--local", "my-local");
|
|
2350
|
-
|
|
2351
|
-
const cloudEntry = makeEntry("my-platform", {
|
|
2352
|
-
cloud: "cadet",
|
|
2353
|
-
runtimeUrl: "https://cloud.mycadet.ai",
|
|
2354
|
-
});
|
|
2355
|
-
const localEntry = makeEntry("my-local", { cloud: "local" });
|
|
2356
|
-
|
|
2357
|
-
findAssistantByNameMock.mockImplementation((name: string) => {
|
|
2358
|
-
if (name === "my-platform") return cloudEntry;
|
|
2359
|
-
if (name === "my-local") return localEntry;
|
|
2360
|
-
return null;
|
|
2361
|
-
});
|
|
2362
|
-
|
|
2363
|
-
fetchCurrentVersionMock.mockImplementation((url: string) => {
|
|
2364
|
-
if (url === "https://cloud.mycadet.ai") return Promise.resolve("0.7.0");
|
|
2365
|
-
return Promise.resolve("0.6.0");
|
|
2366
|
-
});
|
|
2367
|
-
|
|
2368
|
-
const restoreFetch = installTrackingFetch();
|
|
2369
|
-
try {
|
|
2370
|
-
await expect(teleport()).rejects.toThrow("process.exit:1");
|
|
2371
|
-
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
2372
|
-
expect.stringContaining("is running 0.6.0"),
|
|
2373
|
-
);
|
|
2374
|
-
} finally {
|
|
2375
|
-
restoreFetch();
|
|
2376
|
-
}
|
|
2377
|
-
});
|
|
2378
|
-
|
|
2379
|
-
test("allows equal versions", async () => {
|
|
2380
|
-
setArgv("--from", "my-platform", "--local", "my-local");
|
|
2381
|
-
|
|
2382
|
-
const cloudEntry = makeEntry("my-platform", {
|
|
2383
|
-
cloud: "cadet",
|
|
2384
|
-
runtimeUrl: "https://cloud.mycadet.ai",
|
|
2385
|
-
});
|
|
2386
|
-
const localEntry = makeEntry("my-local", { cloud: "local" });
|
|
2387
|
-
|
|
2388
|
-
findAssistantByNameMock.mockImplementation((name: string) => {
|
|
2389
|
-
if (name === "my-platform") return cloudEntry;
|
|
2390
|
-
if (name === "my-local") return localEntry;
|
|
2391
|
-
return null;
|
|
2392
|
-
});
|
|
2393
|
-
|
|
2394
|
-
fetchCurrentVersionMock.mockResolvedValue("0.7.0");
|
|
2395
|
-
platformPollJobStatusMock.mockResolvedValue({
|
|
2396
|
-
jobId: "platform-export-job-1",
|
|
2397
|
-
type: "export",
|
|
2398
|
-
status: "complete",
|
|
2399
|
-
bundleKey: "b",
|
|
2400
|
-
});
|
|
2401
|
-
|
|
2402
|
-
const restoreFetch = installTrackingFetch();
|
|
2403
|
-
try {
|
|
2404
|
-
await teleport();
|
|
2405
|
-
expect(consoleLogSpy).toHaveBeenCalledWith(
|
|
2406
|
-
expect.stringContaining("Teleport complete"),
|
|
2407
|
-
);
|
|
2408
|
-
} finally {
|
|
2409
|
-
restoreFetch();
|
|
2410
|
-
}
|
|
2411
|
-
});
|
|
2412
|
-
|
|
2413
|
-
test("newly hatched target is cleaned up when version check fails", async () => {
|
|
2414
|
-
setArgv("--from", "my-platform", "--local");
|
|
2415
|
-
|
|
2416
|
-
const cloudEntry = makeEntry("my-platform", {
|
|
2417
|
-
cloud: "cadet",
|
|
2418
|
-
runtimeUrl: "https://cloud.mycadet.ai",
|
|
2419
|
-
});
|
|
2420
|
-
const newLocalEntry = makeEntry("new-local", { cloud: "local" });
|
|
2421
|
-
|
|
2422
|
-
findAssistantByNameMock.mockImplementation((name: string) =>
|
|
2423
|
-
name === "my-platform" ? cloudEntry : null,
|
|
2424
|
-
);
|
|
2425
|
-
|
|
2426
|
-
loadAllAssistantsMock.mockImplementation(() => {
|
|
2427
|
-
if (hatchLocalMock.mock.calls.length > 0) {
|
|
2428
|
-
return [cloudEntry, newLocalEntry];
|
|
2429
|
-
}
|
|
2430
|
-
return [cloudEntry];
|
|
2431
|
-
});
|
|
2432
|
-
|
|
2433
|
-
platformPollJobStatusMock.mockResolvedValue({
|
|
2434
|
-
jobId: "platform-export-job-1",
|
|
2435
|
-
type: "export",
|
|
2436
|
-
status: "complete",
|
|
2437
|
-
bundleKey: "b",
|
|
2438
|
-
});
|
|
2439
|
-
|
|
2440
|
-
fetchCurrentVersionMock.mockImplementation((url: string) => {
|
|
2441
|
-
if (url === "https://cloud.mycadet.ai") return Promise.resolve("0.7.0");
|
|
2442
|
-
return Promise.resolve("0.6.0");
|
|
2443
|
-
});
|
|
2444
|
-
|
|
2445
|
-
const restoreFetch = installTrackingFetch();
|
|
2446
|
-
try {
|
|
2447
|
-
await expect(teleport()).rejects.toThrow("process.exit:1");
|
|
2448
|
-
expect(hatchLocalMock).toHaveBeenCalled();
|
|
2449
|
-
expect(retireLocalMock).toHaveBeenCalledWith("new-local", newLocalEntry);
|
|
2450
|
-
expect(removeAssistantEntryMock).toHaveBeenCalledWith("new-local");
|
|
2451
|
-
} finally {
|
|
2452
|
-
restoreFetch();
|
|
2453
|
-
}
|
|
2454
|
-
});
|
|
2455
|
-
});
|
|
2456
|
-
|
|
2457
|
-
// ---------------------------------------------------------------------------
|
|
2458
|
-
// Credential import display
|
|
2459
|
-
// ---------------------------------------------------------------------------
|
|
2460
|
-
|
|
2461
|
-
describe("credential import display", () => {
|
|
2462
|
-
test("prints credential counts when credentialsImported is present (platform target)", async () => {
|
|
2463
|
-
setArgv("--from", "my-local", "--platform");
|
|
2464
|
-
|
|
2465
|
-
const localEntry = makeEntry("my-local", { cloud: "local" });
|
|
2466
|
-
findAssistantByNameMock.mockImplementation((name: string) =>
|
|
2467
|
-
name === "my-local" ? localEntry : null,
|
|
2468
|
-
);
|
|
2469
|
-
|
|
2470
|
-
platformImportBundleFromGcsMock.mockResolvedValue({
|
|
2471
|
-
statusCode: 200,
|
|
2472
|
-
body: {
|
|
2473
|
-
success: true,
|
|
2474
|
-
summary: {
|
|
2475
|
-
total_files: 3,
|
|
2476
|
-
files_created: 2,
|
|
2477
|
-
files_overwritten: 1,
|
|
2478
|
-
files_skipped: 0,
|
|
2479
|
-
backups_created: 1,
|
|
2480
|
-
},
|
|
2481
|
-
credentialsImported: {
|
|
2482
|
-
total: 5,
|
|
2483
|
-
succeeded: 5,
|
|
2484
|
-
failed: 0,
|
|
2485
|
-
failedAccounts: [],
|
|
2486
|
-
},
|
|
2487
|
-
},
|
|
2488
|
-
});
|
|
2489
|
-
|
|
2490
|
-
const restoreFetch = installTrackingFetch();
|
|
2491
|
-
try {
|
|
2492
|
-
await teleport();
|
|
2493
|
-
expect(consoleLogSpy).toHaveBeenCalledWith(" Credentials imported: 5/5");
|
|
2494
|
-
} finally {
|
|
2495
|
-
restoreFetch();
|
|
2496
|
-
}
|
|
2497
|
-
});
|
|
2498
|
-
|
|
2499
|
-
test("does not print credential line when absent", async () => {
|
|
2500
|
-
setArgv("--from", "my-local", "--platform");
|
|
2501
|
-
|
|
2502
|
-
const localEntry = makeEntry("my-local", { cloud: "local" });
|
|
2503
|
-
findAssistantByNameMock.mockImplementation((name: string) =>
|
|
2504
|
-
name === "my-local" ? localEntry : null,
|
|
2505
|
-
);
|
|
2506
|
-
|
|
2507
|
-
const restoreFetch = installTrackingFetch();
|
|
2508
|
-
try {
|
|
2509
|
-
await teleport();
|
|
2510
|
-
const allLogCalls = consoleLogSpy.mock.calls.map((c: unknown[]) => c[0]);
|
|
2511
|
-
const credentialLines = allLogCalls.filter(
|
|
2512
|
-
(msg: string) =>
|
|
2513
|
-
typeof msg === "string" && msg.includes("Credentials imported"),
|
|
2514
|
-
);
|
|
2515
|
-
expect(credentialLines).toHaveLength(0);
|
|
2516
|
-
} finally {
|
|
2517
|
-
restoreFetch();
|
|
2518
|
-
}
|
|
2519
|
-
});
|
|
2520
|
-
});
|
|
2521
|
-
|
|
2522
|
-
// ---------------------------------------------------------------------------
|
|
2523
|
-
// Platform credential injection after teleport
|
|
2524
|
-
// ---------------------------------------------------------------------------
|
|
2525
|
-
|
|
2526
|
-
describe("platform credential injection", () => {
|
|
2527
|
-
test("platform→local teleport calls ensureSelfHostedLocalRegistration and injectCredentialsIntoAssistant", async () => {
|
|
2528
|
-
setArgv("--from", "my-platform", "--local", "my-local");
|
|
2529
|
-
|
|
2530
|
-
const cloudEntry = makeEntry("my-platform", {
|
|
2531
|
-
cloud: "cadet",
|
|
2532
|
-
runtimeUrl: "https://cloud.mycadet.ai",
|
|
2533
|
-
});
|
|
2534
|
-
const localEntry = makeEntry("my-local", {
|
|
2535
|
-
cloud: "local",
|
|
2536
|
-
bearerToken: "local-bearer",
|
|
2537
|
-
});
|
|
2538
|
-
|
|
2539
|
-
findAssistantByNameMock.mockImplementation((name: string) => {
|
|
2540
|
-
if (name === "my-platform") return cloudEntry;
|
|
2541
|
-
if (name === "my-local") return localEntry;
|
|
2542
|
-
return null;
|
|
2543
|
-
});
|
|
2544
|
-
|
|
2545
|
-
platformPollJobStatusMock.mockResolvedValue({
|
|
2546
|
-
jobId: "platform-export-job-1",
|
|
2547
|
-
type: "export",
|
|
2548
|
-
status: "complete",
|
|
2549
|
-
bundleKey: "bundle-xyz",
|
|
2550
|
-
});
|
|
2551
|
-
|
|
2552
|
-
const restoreFetch = installTrackingFetch();
|
|
2553
|
-
try {
|
|
2554
|
-
await teleport();
|
|
2555
|
-
expect(ensureSelfHostedLocalRegistrationMock).toHaveBeenCalledWith(
|
|
2556
|
-
"platform-token",
|
|
2557
|
-
"org-1",
|
|
2558
|
-
"device-id-123",
|
|
2559
|
-
"my-local",
|
|
2560
|
-
"cli",
|
|
2561
|
-
undefined, // assistantVersion (gateway unreachable in test)
|
|
2562
|
-
expect.any(String), // cloudUrl from getCloudUrl()
|
|
2563
|
-
undefined, // ingressUrl (gateway unreachable in test)
|
|
2564
|
-
);
|
|
2565
|
-
expect(injectCredentialsIntoAssistantMock).toHaveBeenCalledWith({
|
|
2566
|
-
gatewayUrl: "http://localhost:7821",
|
|
2567
|
-
bearerToken: "local-bearer",
|
|
2568
|
-
assistantApiKey: "api-key-123",
|
|
2569
|
-
cloudAssistantId: "platform-assistant-1",
|
|
2570
|
-
organizationId: "org-1",
|
|
2571
|
-
userId: "user-1",
|
|
2572
|
-
webhookSecret: "webhook-secret-123",
|
|
2573
|
-
});
|
|
2574
|
-
expect(saveAssistantEntryMock).toHaveBeenCalledWith(
|
|
2575
|
-
expect.objectContaining({
|
|
2576
|
-
assistantId: "my-local",
|
|
2577
|
-
cloudAssistantId: "platform-assistant-1",
|
|
2578
|
-
cloudBaseUrl: "https://cloud.mycadet.ai",
|
|
2579
|
-
cloudOrganizationId: "org-1",
|
|
2580
|
-
}),
|
|
2581
|
-
);
|
|
2582
|
-
} finally {
|
|
2583
|
-
restoreFetch();
|
|
2584
|
-
}
|
|
2585
|
-
});
|
|
2586
|
-
|
|
2587
|
-
test("local→docker teleport does NOT call credential injection", async () => {
|
|
2588
|
-
setArgv("--from", "my-local", "--docker", "my-docker");
|
|
2589
|
-
|
|
2590
|
-
const localEntry = makeEntry("my-local", { cloud: "local" });
|
|
2591
|
-
const dockerEntry = makeEntry("my-docker", {
|
|
2592
|
-
cloud: "docker",
|
|
2593
|
-
runtimeUrl: "http://localhost:8821",
|
|
2594
|
-
});
|
|
2595
|
-
|
|
2596
|
-
findAssistantByNameMock.mockImplementation((name: string) => {
|
|
2597
|
-
if (name === "my-local") return localEntry;
|
|
2598
|
-
if (name === "my-docker") return dockerEntry;
|
|
2599
|
-
return null;
|
|
2600
|
-
});
|
|
2601
|
-
|
|
2602
|
-
const restoreFetch = installTrackingFetch();
|
|
2603
|
-
try {
|
|
2604
|
-
await teleport();
|
|
2605
|
-
expect(ensureSelfHostedLocalRegistrationMock).not.toHaveBeenCalled();
|
|
2606
|
-
expect(injectCredentialsIntoAssistantMock).not.toHaveBeenCalled();
|
|
2607
|
-
} finally {
|
|
2608
|
-
restoreFetch();
|
|
2609
|
-
}
|
|
2610
|
-
});
|
|
2611
|
-
});
|
|
2612
|
-
|
|
2613
|
-
// ---------------------------------------------------------------------------
|
|
2614
|
-
// Auth / transient-error resilience (Codex P1/P2 regression guards)
|
|
2615
|
-
// ---------------------------------------------------------------------------
|
|
2616
|
-
|
|
2617
|
-
describe("auth + transient-error resilience", () => {
|
|
2618
|
-
test("runtime 401 on export kickoff triggers token refresh and retry", async () => {
|
|
2619
|
-
setArgv("--from", "my-local", "--platform");
|
|
2620
|
-
|
|
2621
|
-
const localEntry = makeEntry("my-local", { cloud: "local" });
|
|
2622
|
-
findAssistantByNameMock.mockImplementation((name: string) =>
|
|
2623
|
-
name === "my-local" ? localEntry : null,
|
|
2624
|
-
);
|
|
2625
|
-
|
|
2626
|
-
// First kickoff call fails with 401, second succeeds.
|
|
2627
|
-
localRuntimeExportToGcsMock.mockImplementationOnce(async () => {
|
|
2628
|
-
throw new Error("Local runtime export-to-gcs failed (401): stale token");
|
|
2629
|
-
});
|
|
2630
|
-
localRuntimeExportToGcsMock.mockImplementationOnce(async () => ({
|
|
2631
|
-
jobId: "local-export-job-after-refresh",
|
|
2632
|
-
}));
|
|
2633
|
-
|
|
2634
|
-
// Ensure the refresh path returns a distinguishable token.
|
|
2635
|
-
leaseGuardianTokenMock.mockResolvedValueOnce(
|
|
2636
|
-
makeGuardianTokenData({ accessToken: "refreshed-token" }),
|
|
2637
|
-
);
|
|
2638
|
-
|
|
2639
|
-
const restoreFetch = installTrackingFetch();
|
|
2640
|
-
try {
|
|
2641
|
-
await teleport();
|
|
2642
|
-
} finally {
|
|
2643
|
-
restoreFetch();
|
|
2644
|
-
}
|
|
2645
|
-
|
|
2646
|
-
// Kickoff was attempted twice: once with the cached token, once after
|
|
2647
|
-
// a forced refresh lease.
|
|
2648
|
-
expect(localRuntimeExportToGcsMock).toHaveBeenCalledTimes(2);
|
|
2649
|
-
|
|
2650
|
-
const firstTokenArg = localRuntimeExportToGcsMock.mock.calls[0][1];
|
|
2651
|
-
const secondTokenArg = localRuntimeExportToGcsMock.mock.calls[1][1];
|
|
2652
|
-
expect(firstTokenArg).toBe("local-token");
|
|
2653
|
-
expect(secondTokenArg).toBe("refreshed-token");
|
|
2654
|
-
|
|
2655
|
-
// A fresh lease was requested exactly once (the forceRefresh path).
|
|
2656
|
-
expect(leaseGuardianTokenMock).toHaveBeenCalledTimes(1);
|
|
2657
|
-
});
|
|
2658
|
-
|
|
2659
|
-
test("runtime 401 on import kickoff triggers token refresh and retry", async () => {
|
|
2660
|
-
setArgv("--from", "my-platform", "--local", "my-local");
|
|
2661
|
-
|
|
2662
|
-
const cloudEntry = makeEntry("my-platform", {
|
|
2663
|
-
cloud: "cadet",
|
|
2664
|
-
runtimeUrl: "https://cloud.mycadet.ai",
|
|
2665
|
-
});
|
|
2666
|
-
const localEntry = makeEntry("my-local", { cloud: "local" });
|
|
2667
|
-
|
|
2668
|
-
findAssistantByNameMock.mockImplementation((name: string) => {
|
|
2669
|
-
if (name === "my-platform") return cloudEntry;
|
|
2670
|
-
if (name === "my-local") return localEntry;
|
|
2671
|
-
return null;
|
|
2672
|
-
});
|
|
2673
|
-
|
|
2674
|
-
platformPollJobStatusMock.mockResolvedValue({
|
|
2675
|
-
jobId: "platform-export-job-1",
|
|
2676
|
-
type: "export",
|
|
2677
|
-
status: "complete",
|
|
2678
|
-
bundleKey: "b-key",
|
|
2679
|
-
});
|
|
2680
|
-
|
|
2681
|
-
localRuntimeImportFromGcsMock.mockImplementationOnce(async () => {
|
|
2682
|
-
throw new Error(
|
|
2683
|
-
"Local runtime import-from-gcs failed (401): stale token",
|
|
2684
|
-
);
|
|
2685
|
-
});
|
|
2686
|
-
localRuntimeImportFromGcsMock.mockImplementationOnce(async () => ({
|
|
2687
|
-
jobId: "local-import-after-refresh",
|
|
2688
|
-
}));
|
|
2689
|
-
|
|
2690
|
-
leaseGuardianTokenMock.mockResolvedValueOnce(
|
|
2691
|
-
makeGuardianTokenData({ accessToken: "refreshed-import-token" }),
|
|
2692
|
-
);
|
|
2693
|
-
|
|
2694
|
-
const restoreFetch = installTrackingFetch();
|
|
2695
|
-
try {
|
|
2696
|
-
await teleport();
|
|
2697
|
-
} finally {
|
|
2698
|
-
restoreFetch();
|
|
2699
|
-
}
|
|
2700
|
-
|
|
2701
|
-
expect(localRuntimeImportFromGcsMock).toHaveBeenCalledTimes(2);
|
|
2702
|
-
expect(localRuntimeImportFromGcsMock.mock.calls[0][1]).toBe("local-token");
|
|
2703
|
-
expect(localRuntimeImportFromGcsMock.mock.calls[1][1]).toBe(
|
|
2704
|
-
"refreshed-import-token",
|
|
2705
|
-
);
|
|
2706
|
-
});
|
|
2707
|
-
|
|
2708
|
-
test("runtime non-401 errors do NOT trigger token refresh", async () => {
|
|
2709
|
-
setArgv("--from", "my-local", "--platform");
|
|
2710
|
-
|
|
2711
|
-
const localEntry = makeEntry("my-local", { cloud: "local" });
|
|
2712
|
-
findAssistantByNameMock.mockImplementation((name: string) =>
|
|
2713
|
-
name === "my-local" ? localEntry : null,
|
|
2714
|
-
);
|
|
2715
|
-
|
|
2716
|
-
localRuntimeExportToGcsMock.mockRejectedValue(
|
|
2717
|
-
new Error("Local runtime export-to-gcs failed (500): boom"),
|
|
2718
|
-
);
|
|
2719
|
-
|
|
2720
|
-
const restoreFetch = installTrackingFetch();
|
|
2721
|
-
try {
|
|
2722
|
-
await expect(teleport()).rejects.toThrow(/500.*boom/);
|
|
2723
|
-
} finally {
|
|
2724
|
-
restoreFetch();
|
|
2725
|
-
}
|
|
2726
|
-
|
|
2727
|
-
// One attempt, no forced-refresh lease.
|
|
2728
|
-
expect(localRuntimeExportToGcsMock).toHaveBeenCalledTimes(1);
|
|
2729
|
-
expect(leaseGuardianTokenMock).not.toHaveBeenCalled();
|
|
2730
|
-
});
|
|
2731
|
-
|
|
2732
|
-
test("runtime poll 401 mid-migration triggers forceRefresh lease and completes", async () => {
|
|
2733
|
-
setArgv("--from", "my-local", "--platform");
|
|
2734
|
-
|
|
2735
|
-
const localEntry = makeEntry("my-local", { cloud: "local" });
|
|
2736
|
-
findAssistantByNameMock.mockImplementation((name: string) =>
|
|
2737
|
-
name === "my-local" ? localEntry : null,
|
|
2738
|
-
);
|
|
2739
|
-
|
|
2740
|
-
// Export kickoff succeeds with the cached "local-token".
|
|
2741
|
-
// During polling, the first status check fails with 401 (token expired
|
|
2742
|
-
// mid-migration), the poll loop calls refreshOn401 → leaseGuardianToken,
|
|
2743
|
-
// then the next poll succeeds with the new token.
|
|
2744
|
-
const tokensSeenByPoll: string[] = [];
|
|
2745
|
-
localRuntimePollJobStatusMock.mockImplementation(
|
|
2746
|
-
async (_runtimeUrl, token, jobId) => {
|
|
2747
|
-
tokensSeenByPoll.push(token);
|
|
2748
|
-
if (tokensSeenByPoll.length === 1) {
|
|
2749
|
-
throw new Error("Local job status check failed: 401 Unauthorized");
|
|
2750
|
-
}
|
|
2751
|
-
return {
|
|
2752
|
-
jobId,
|
|
2753
|
-
type: "export" as const,
|
|
2754
|
-
status: "complete" as const,
|
|
2755
|
-
result: undefined,
|
|
2756
|
-
};
|
|
2757
|
-
},
|
|
2758
|
-
);
|
|
2759
|
-
|
|
2760
|
-
leaseGuardianTokenMock.mockResolvedValueOnce(
|
|
2761
|
-
makeGuardianTokenData({ accessToken: "poll-refreshed-token" }),
|
|
2762
|
-
);
|
|
2763
|
-
|
|
2764
|
-
const warnSpy = spyOn(console, "warn").mockImplementation(() => {});
|
|
2765
|
-
const restoreFetch = installTrackingFetch();
|
|
2766
|
-
try {
|
|
2767
|
-
await teleport();
|
|
2768
|
-
|
|
2769
|
-
// The first poll used the cached token; the second (post-refresh) poll
|
|
2770
|
-
// used the freshly leased one.
|
|
2771
|
-
expect(tokensSeenByPoll.length).toBeGreaterThanOrEqual(2);
|
|
2772
|
-
expect(tokensSeenByPoll[0]).toBe("local-token");
|
|
2773
|
-
expect(tokensSeenByPoll[1]).toBe("poll-refreshed-token");
|
|
2774
|
-
|
|
2775
|
-
// leaseGuardianToken was invoked for the forceRefresh path.
|
|
2776
|
-
expect(leaseGuardianTokenMock).toHaveBeenCalledTimes(1);
|
|
2777
|
-
|
|
2778
|
-
// The 401 branch emits its own warning — distinct from the generic
|
|
2779
|
-
// transient-error warning — so this asserts the refresh path fired.
|
|
2780
|
-
expect(warnSpy).toHaveBeenCalledWith(
|
|
2781
|
-
expect.stringContaining("refreshing auth"),
|
|
2782
|
-
);
|
|
2783
|
-
} finally {
|
|
2784
|
-
restoreFetch();
|
|
2785
|
-
warnSpy.mockRestore();
|
|
2786
|
-
}
|
|
2787
|
-
});
|
|
2788
|
-
|
|
2789
|
-
test("transient poll error does not abort teleport (job completes after retry)", async () => {
|
|
2790
|
-
setArgv("--from", "my-local", "--platform");
|
|
2791
|
-
|
|
2792
|
-
const localEntry = makeEntry("my-local", { cloud: "local" });
|
|
2793
|
-
findAssistantByNameMock.mockImplementation((name: string) =>
|
|
2794
|
-
name === "my-local" ? localEntry : null,
|
|
2795
|
-
);
|
|
2796
|
-
|
|
2797
|
-
// Throw once with a 503 (transient), then succeed with terminal complete.
|
|
2798
|
-
let pollCalls = 0;
|
|
2799
|
-
localRuntimePollJobStatusMock.mockImplementation(
|
|
2800
|
-
async (_runtimeUrl, _token, jobId) => {
|
|
2801
|
-
pollCalls += 1;
|
|
2802
|
-
if (pollCalls === 1) {
|
|
2803
|
-
throw new Error(
|
|
2804
|
-
"Local job status check failed: 503 Service Unavailable",
|
|
2805
|
-
);
|
|
2806
|
-
}
|
|
2807
|
-
return {
|
|
2808
|
-
jobId,
|
|
2809
|
-
type: "export" as const,
|
|
2810
|
-
status: "complete" as const,
|
|
2811
|
-
result: undefined,
|
|
2812
|
-
};
|
|
2813
|
-
},
|
|
2814
|
-
);
|
|
2815
|
-
|
|
2816
|
-
const warnSpy = spyOn(console, "warn").mockImplementation(() => {});
|
|
2817
|
-
const restoreFetch = installTrackingFetch();
|
|
2818
|
-
try {
|
|
2819
|
-
// Should NOT reject — a single transient 503 is retried, not fatal.
|
|
2820
|
-
await teleport();
|
|
2821
|
-
expect(pollCalls).toBeGreaterThanOrEqual(2);
|
|
2822
|
-
expect(warnSpy).toHaveBeenCalledWith(
|
|
2823
|
-
expect.stringContaining("polling failed:"),
|
|
2824
|
-
);
|
|
2825
|
-
} finally {
|
|
2826
|
-
restoreFetch();
|
|
2827
|
-
warnSpy.mockRestore();
|
|
2828
|
-
}
|
|
2829
|
-
});
|
|
2830
|
-
});
|
|
2831
|
-
|
|
2832
|
-
// ---------------------------------------------------------------------------
|
|
2833
|
-
// Source-runtime version is sourced from the daemon, not the CLI
|
|
2834
|
-
// (Codex P1 regression guard for PR #29436)
|
|
2835
|
-
// ---------------------------------------------------------------------------
|
|
2836
|
-
|
|
2837
|
-
describe("upload signed-URL records source runtime version (not CLI version)", () => {
|
|
2838
|
-
test("local source: identity is fetched BEFORE the upload signed-URL request", async () => {
|
|
2839
|
-
setArgv("--from", "my-local", "--platform");
|
|
2840
|
-
|
|
2841
|
-
const localEntry = makeEntry("my-local", { cloud: "local" });
|
|
2842
|
-
findAssistantByNameMock.mockImplementation((name: string) =>
|
|
2843
|
-
name === "my-local" ? localEntry : null,
|
|
2844
|
-
);
|
|
2845
|
-
|
|
2846
|
-
// Distinguish the daemon's version from anything else hardcoded.
|
|
2847
|
-
localRuntimeIdentityMock.mockResolvedValue({ version: "0.5.9" });
|
|
2848
|
-
|
|
2849
|
-
// Order tracker: capture which mock was called first.
|
|
2850
|
-
const callOrder: string[] = [];
|
|
2851
|
-
localRuntimeIdentityMock.mockImplementationOnce(async () => {
|
|
2852
|
-
callOrder.push("identity");
|
|
2853
|
-
return { version: "0.5.9" };
|
|
2854
|
-
});
|
|
2855
|
-
cloudRequestSignedUrlMock.mockImplementationOnce(async (params) => {
|
|
2856
|
-
callOrder.push("signed-url");
|
|
2857
|
-
return {
|
|
2858
|
-
url: "https://storage.googleapis.com/bucket/signed-upload",
|
|
2859
|
-
bundleKey: params.bundleKey ?? "bundle-key-123",
|
|
2860
|
-
expiresAt: new Date(Date.now() + 3600_000).toISOString(),
|
|
2861
|
-
};
|
|
2862
|
-
});
|
|
2863
|
-
|
|
2864
|
-
const restoreFetch = installTrackingFetch();
|
|
2865
|
-
try {
|
|
2866
|
-
await teleport();
|
|
2867
|
-
|
|
2868
|
-
expect(callOrder[0]).toBe("identity");
|
|
2869
|
-
expect(callOrder[1]).toBe("signed-url");
|
|
2870
|
-
|
|
2871
|
-
// Upload request stamps minRuntimeVersion with the daemon's version,
|
|
2872
|
-
// NOT cliPkg.version.
|
|
2873
|
-
expect(cloudRequestSignedUrlMock).toHaveBeenCalledWith(
|
|
2874
|
-
expect.objectContaining({
|
|
2875
|
-
operation: "upload",
|
|
2876
|
-
minRuntimeVersion: "0.5.9",
|
|
2877
|
-
maxRuntimeVersion: null,
|
|
2878
|
-
}),
|
|
2879
|
-
"platform-token",
|
|
2880
|
-
"https://cloud.mycadet.ai",
|
|
2881
|
-
);
|
|
2882
|
-
} finally {
|
|
2883
|
-
restoreFetch();
|
|
2884
|
-
}
|
|
2885
|
-
});
|
|
2886
|
-
|
|
2887
|
-
test("local source: identity fetch failure aborts before signed-URL request", async () => {
|
|
2888
|
-
setArgv("--from", "my-local", "--platform");
|
|
2889
|
-
|
|
2890
|
-
const localEntry = makeEntry("my-local", { cloud: "local" });
|
|
2891
|
-
findAssistantByNameMock.mockImplementation((name: string) =>
|
|
2892
|
-
name === "my-local" ? localEntry : null,
|
|
2893
|
-
);
|
|
2894
|
-
|
|
2895
|
-
localRuntimeIdentityMock.mockRejectedValue(
|
|
2896
|
-
new Error("Failed to fetch runtime identity: 503 Service Unavailable"),
|
|
2897
|
-
);
|
|
2898
|
-
|
|
2899
|
-
const restoreFetch = installTrackingFetch();
|
|
2900
|
-
try {
|
|
2901
|
-
await expect(teleport()).rejects.toThrow("process.exit:1");
|
|
2902
|
-
|
|
2903
|
-
// Must NOT have proceeded to signed URL or export.
|
|
2904
|
-
expect(cloudRequestSignedUrlMock).not.toHaveBeenCalled();
|
|
2905
|
-
expect(localRuntimeExportToGcsMock).not.toHaveBeenCalled();
|
|
2906
|
-
|
|
2907
|
-
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
2908
|
-
expect.stringContaining("Could not fetch runtime identity"),
|
|
2909
|
-
);
|
|
2910
|
-
} finally {
|
|
2911
|
-
restoreFetch();
|
|
2912
|
-
}
|
|
2913
|
-
});
|
|
2914
|
-
|
|
2915
|
-
test("platform source: managed runtime's identity is fetched and recorded", async () => {
|
|
2916
|
-
setArgv("--from", "my-platform", "--local", "my-local");
|
|
2917
|
-
|
|
2918
|
-
const cloudEntry = makeEntry("my-platform", {
|
|
2919
|
-
cloud: "cadet",
|
|
2920
|
-
runtimeUrl: "https://cloud.mycadet.ai",
|
|
2921
|
-
});
|
|
2922
|
-
const localEntry = makeEntry("my-local", { cloud: "local" });
|
|
2923
|
-
|
|
2924
|
-
findAssistantByNameMock.mockImplementation((name: string) => {
|
|
2925
|
-
if (name === "my-platform") return cloudEntry;
|
|
2926
|
-
if (name === "my-local") return localEntry;
|
|
2927
|
-
return null;
|
|
2928
|
-
});
|
|
2929
|
-
|
|
2930
|
-
platformPollJobStatusMock.mockResolvedValue({
|
|
2931
|
-
jobId: "platform-export-job-1",
|
|
2932
|
-
type: "export",
|
|
2933
|
-
status: "complete",
|
|
2934
|
-
bundleKey: "platform-bundle",
|
|
2935
|
-
});
|
|
2936
|
-
|
|
2937
|
-
localRuntimeIdentityMock.mockResolvedValue({ version: "0.7.2" });
|
|
2938
|
-
|
|
2939
|
-
const restoreFetch = installTrackingFetch();
|
|
2940
|
-
try {
|
|
2941
|
-
await teleport();
|
|
2942
|
-
|
|
2943
|
-
// Identity was fetched against the cloud-managed runtime entry
|
|
2944
|
-
// (cloud=cadet) with the cloud token — not via guardian token.
|
|
2945
|
-
expect(localRuntimeIdentityMock).toHaveBeenCalledWith(
|
|
2946
|
-
expect.objectContaining({
|
|
2947
|
-
cloud: "cadet",
|
|
2948
|
-
runtimeUrl: "https://cloud.mycadet.ai",
|
|
2949
|
-
assistantId: "my-platform",
|
|
2950
|
-
}),
|
|
2951
|
-
"platform-token",
|
|
2952
|
-
);
|
|
2953
|
-
|
|
2954
|
-
// The recorded version came from the cloud runtime, not the CLI.
|
|
2955
|
-
expect(cloudRequestSignedUrlMock).toHaveBeenCalledWith(
|
|
2956
|
-
expect.objectContaining({
|
|
2957
|
-
operation: "upload",
|
|
2958
|
-
minRuntimeVersion: "0.7.2",
|
|
2959
|
-
maxRuntimeVersion: null,
|
|
2960
|
-
}),
|
|
2961
|
-
"platform-token",
|
|
2962
|
-
"https://cloud.mycadet.ai",
|
|
2963
|
-
);
|
|
2964
|
-
} finally {
|
|
2965
|
-
restoreFetch();
|
|
2966
|
-
}
|
|
2967
|
-
});
|
|
2968
|
-
|
|
2969
|
-
test("platform source: identity fetch failure aborts before signed-URL request", async () => {
|
|
2970
|
-
setArgv("--from", "my-platform", "--local", "my-local");
|
|
2971
|
-
|
|
2972
|
-
const cloudEntry = makeEntry("my-platform", {
|
|
2973
|
-
cloud: "cadet",
|
|
2974
|
-
runtimeUrl: "https://cloud.mycadet.ai",
|
|
2975
|
-
});
|
|
2976
|
-
const localEntry = makeEntry("my-local", { cloud: "local" });
|
|
2977
|
-
|
|
2978
|
-
findAssistantByNameMock.mockImplementation((name: string) => {
|
|
2979
|
-
if (name === "my-platform") return cloudEntry;
|
|
2980
|
-
if (name === "my-local") return localEntry;
|
|
2981
|
-
return null;
|
|
2982
|
-
});
|
|
2983
|
-
|
|
2984
|
-
localRuntimeIdentityMock.mockRejectedValue(
|
|
2985
|
-
new Error("Failed to fetch runtime identity: 502 Bad Gateway"),
|
|
2986
|
-
);
|
|
2987
|
-
|
|
2988
|
-
const restoreFetch = installTrackingFetch();
|
|
2989
|
-
try {
|
|
2990
|
-
await expect(teleport()).rejects.toThrow("process.exit:1");
|
|
2991
|
-
|
|
2992
|
-
// Signed-URL upload request must not have happened.
|
|
2993
|
-
const uploadCalls = cloudRequestSignedUrlMock.mock.calls.filter(
|
|
2994
|
-
(call) => call[0].operation === "upload",
|
|
2995
|
-
);
|
|
2996
|
-
expect(uploadCalls.length).toBe(0);
|
|
2997
|
-
|
|
2998
|
-
// Runtime export was never kicked off.
|
|
2999
|
-
expect(localRuntimeExportToGcsMock).not.toHaveBeenCalled();
|
|
3000
|
-
|
|
3001
|
-
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
3002
|
-
expect.stringContaining("Could not fetch runtime identity"),
|
|
3003
|
-
);
|
|
3004
|
-
} finally {
|
|
3005
|
-
restoreFetch();
|
|
3006
|
-
}
|
|
3007
|
-
});
|
|
3008
|
-
});
|
|
3009
|
-
|