@vellumai/cli 0.5.15 → 0.6.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/bun.lock +46 -52
- package/package.json +1 -1
- package/src/__tests__/teleport.test.ts +1005 -391
- package/src/commands/hatch.ts +17 -388
- package/src/commands/retire.ts +2 -120
- package/src/commands/rollback.ts +6 -0
- package/src/commands/teleport.ts +757 -198
- package/src/commands/upgrade.ts +7 -0
- package/src/lib/aws.ts +2 -1
- package/src/lib/constants.ts +0 -11
- package/src/lib/docker.ts +27 -13
- package/src/lib/gcp.ts +2 -5
- package/src/lib/hatch-local.ts +403 -0
- package/src/lib/local.ts +9 -120
- package/src/lib/platform-client.ts +142 -8
- package/src/lib/retire-local.ts +124 -0
- package/src/lib/upgrade-lifecycle.ts +8 -0
- package/src/shared/provider-env-vars.ts +19 -0
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
afterAll,
|
|
3
|
+
afterEach,
|
|
3
4
|
beforeEach,
|
|
4
5
|
describe,
|
|
5
6
|
expect,
|
|
@@ -33,6 +34,21 @@ const findAssistantByNameMock = spyOn(
|
|
|
33
34
|
"findAssistantByName",
|
|
34
35
|
).mockReturnValue(null);
|
|
35
36
|
|
|
37
|
+
const saveAssistantEntryMock = spyOn(
|
|
38
|
+
assistantConfig,
|
|
39
|
+
"saveAssistantEntry",
|
|
40
|
+
).mockImplementation(() => {});
|
|
41
|
+
|
|
42
|
+
const loadAllAssistantsMock = spyOn(
|
|
43
|
+
assistantConfig,
|
|
44
|
+
"loadAllAssistants",
|
|
45
|
+
).mockReturnValue([]);
|
|
46
|
+
|
|
47
|
+
const removeAssistantEntryMock = spyOn(
|
|
48
|
+
assistantConfig,
|
|
49
|
+
"removeAssistantEntry",
|
|
50
|
+
).mockImplementation(() => {});
|
|
51
|
+
|
|
36
52
|
const loadGuardianTokenMock = mock((_id: string) => ({
|
|
37
53
|
accessToken: "local-token",
|
|
38
54
|
accessTokenExpiresAt: new Date(Date.now() + 60_000).toISOString(),
|
|
@@ -49,6 +65,12 @@ mock.module("../lib/guardian-token.js", () => ({
|
|
|
49
65
|
|
|
50
66
|
const readPlatformTokenMock = mock((): string | null => "platform-token");
|
|
51
67
|
const fetchOrganizationIdMock = mock(async () => "org-123");
|
|
68
|
+
const getPlatformUrlMock = mock(() => "https://platform.vellum.ai");
|
|
69
|
+
const hatchAssistantMock = mock(async () => ({
|
|
70
|
+
id: "platform-new-id",
|
|
71
|
+
name: "platform-new",
|
|
72
|
+
status: "active",
|
|
73
|
+
}));
|
|
52
74
|
const platformInitiateExportMock = mock(async () => ({
|
|
53
75
|
jobId: "job-1",
|
|
54
76
|
status: "pending",
|
|
@@ -86,18 +108,95 @@ const platformImportBundleMock = mock(async () => ({
|
|
|
86
108
|
},
|
|
87
109
|
} as Record<string, unknown>,
|
|
88
110
|
}));
|
|
111
|
+
const platformRequestUploadUrlMock = mock(async () => ({
|
|
112
|
+
uploadUrl: "https://storage.googleapis.com/bucket/signed-upload-url",
|
|
113
|
+
bundleKey: "bundle-key-123",
|
|
114
|
+
expiresAt: new Date(Date.now() + 3600_000).toISOString(),
|
|
115
|
+
}));
|
|
116
|
+
const platformUploadToSignedUrlMock = mock(async () => {});
|
|
117
|
+
const platformImportPreflightFromGcsMock = mock(async () => ({
|
|
118
|
+
statusCode: 200,
|
|
119
|
+
body: {
|
|
120
|
+
can_import: true,
|
|
121
|
+
summary: {
|
|
122
|
+
files_to_create: 2,
|
|
123
|
+
files_to_overwrite: 1,
|
|
124
|
+
files_unchanged: 0,
|
|
125
|
+
total_files: 3,
|
|
126
|
+
},
|
|
127
|
+
} as Record<string, unknown>,
|
|
128
|
+
}));
|
|
129
|
+
const platformImportBundleFromGcsMock = mock(async () => ({
|
|
130
|
+
statusCode: 200,
|
|
131
|
+
body: {
|
|
132
|
+
success: true,
|
|
133
|
+
summary: {
|
|
134
|
+
total_files: 3,
|
|
135
|
+
files_created: 2,
|
|
136
|
+
files_overwritten: 1,
|
|
137
|
+
files_skipped: 0,
|
|
138
|
+
backups_created: 1,
|
|
139
|
+
},
|
|
140
|
+
} as Record<string, unknown>,
|
|
141
|
+
}));
|
|
89
142
|
|
|
90
143
|
mock.module("../lib/platform-client.js", () => ({
|
|
91
144
|
readPlatformToken: readPlatformTokenMock,
|
|
92
145
|
fetchOrganizationId: fetchOrganizationIdMock,
|
|
146
|
+
getPlatformUrl: getPlatformUrlMock,
|
|
147
|
+
hatchAssistant: hatchAssistantMock,
|
|
93
148
|
platformInitiateExport: platformInitiateExportMock,
|
|
94
149
|
platformPollExportStatus: platformPollExportStatusMock,
|
|
95
150
|
platformDownloadExport: platformDownloadExportMock,
|
|
96
151
|
platformImportPreflight: platformImportPreflightMock,
|
|
97
152
|
platformImportBundle: platformImportBundleMock,
|
|
153
|
+
platformRequestUploadUrl: platformRequestUploadUrlMock,
|
|
154
|
+
platformUploadToSignedUrl: platformUploadToSignedUrlMock,
|
|
155
|
+
platformImportPreflightFromGcs: platformImportPreflightFromGcsMock,
|
|
156
|
+
platformImportBundleFromGcs: platformImportBundleFromGcsMock,
|
|
157
|
+
}));
|
|
158
|
+
|
|
159
|
+
const hatchLocalMock = mock(async () => {});
|
|
160
|
+
|
|
161
|
+
mock.module("../lib/hatch-local.js", () => ({
|
|
162
|
+
hatchLocal: hatchLocalMock,
|
|
163
|
+
}));
|
|
164
|
+
|
|
165
|
+
const hatchDockerMock = mock(async () => {});
|
|
166
|
+
const retireDockerMock = mock(async () => {});
|
|
167
|
+
|
|
168
|
+
const sleepContainersMock = mock(async () => {});
|
|
169
|
+
const dockerResourceNamesMock = mock((name: string) => ({
|
|
170
|
+
assistantContainer: `${name}-assistant`,
|
|
171
|
+
gatewayContainer: `${name}-gateway`,
|
|
172
|
+
cesContainer: `${name}-ces`,
|
|
173
|
+
network: `${name}-net`,
|
|
174
|
+
}));
|
|
175
|
+
|
|
176
|
+
mock.module("../lib/docker.js", () => ({
|
|
177
|
+
hatchDocker: hatchDockerMock,
|
|
178
|
+
retireDocker: retireDockerMock,
|
|
179
|
+
sleepContainers: sleepContainersMock,
|
|
180
|
+
dockerResourceNames: dockerResourceNamesMock,
|
|
181
|
+
}));
|
|
182
|
+
|
|
183
|
+
const stopProcessByPidFileMock = mock(async () => true);
|
|
184
|
+
|
|
185
|
+
mock.module("../lib/process.js", () => ({
|
|
186
|
+
stopProcessByPidFile: stopProcessByPidFileMock,
|
|
187
|
+
}));
|
|
188
|
+
|
|
189
|
+
const retireLocalMock = mock(async () => {});
|
|
190
|
+
|
|
191
|
+
mock.module("../lib/retire-local.js", () => ({
|
|
192
|
+
retireLocal: retireLocalMock,
|
|
98
193
|
}));
|
|
99
194
|
|
|
100
|
-
import {
|
|
195
|
+
import {
|
|
196
|
+
teleport,
|
|
197
|
+
parseArgs,
|
|
198
|
+
resolveOrHatchTarget,
|
|
199
|
+
} from "../commands/teleport.js";
|
|
101
200
|
import type { AssistantEntry } from "../lib/assistant-config.js";
|
|
102
201
|
|
|
103
202
|
// ---------------------------------------------------------------------------
|
|
@@ -106,6 +205,9 @@ import type { AssistantEntry } from "../lib/assistant-config.js";
|
|
|
106
205
|
|
|
107
206
|
afterAll(() => {
|
|
108
207
|
findAssistantByNameMock.mockRestore();
|
|
208
|
+
saveAssistantEntryMock.mockRestore();
|
|
209
|
+
loadAllAssistantsMock.mockRestore();
|
|
210
|
+
removeAssistantEntryMock.mockRestore();
|
|
109
211
|
rmSync(testDir, { recursive: true, force: true });
|
|
110
212
|
delete process.env.VELLUM_LOCKFILE_DIR;
|
|
111
213
|
});
|
|
@@ -122,6 +224,12 @@ beforeEach(() => {
|
|
|
122
224
|
// Reset all mocks
|
|
123
225
|
findAssistantByNameMock.mockReset();
|
|
124
226
|
findAssistantByNameMock.mockReturnValue(null);
|
|
227
|
+
saveAssistantEntryMock.mockReset();
|
|
228
|
+
saveAssistantEntryMock.mockImplementation(() => {});
|
|
229
|
+
loadAllAssistantsMock.mockReset();
|
|
230
|
+
loadAllAssistantsMock.mockReturnValue([]);
|
|
231
|
+
removeAssistantEntryMock.mockReset();
|
|
232
|
+
removeAssistantEntryMock.mockImplementation(() => {});
|
|
125
233
|
|
|
126
234
|
loadGuardianTokenMock.mockReset();
|
|
127
235
|
loadGuardianTokenMock.mockReturnValue({
|
|
@@ -134,6 +242,14 @@ beforeEach(() => {
|
|
|
134
242
|
readPlatformTokenMock.mockReturnValue("platform-token");
|
|
135
243
|
fetchOrganizationIdMock.mockReset();
|
|
136
244
|
fetchOrganizationIdMock.mockResolvedValue("org-123");
|
|
245
|
+
getPlatformUrlMock.mockReset();
|
|
246
|
+
getPlatformUrlMock.mockReturnValue("https://platform.vellum.ai");
|
|
247
|
+
hatchAssistantMock.mockReset();
|
|
248
|
+
hatchAssistantMock.mockResolvedValue({
|
|
249
|
+
id: "platform-new-id",
|
|
250
|
+
name: "platform-new",
|
|
251
|
+
status: "active",
|
|
252
|
+
});
|
|
137
253
|
platformInitiateExportMock.mockReset();
|
|
138
254
|
platformInitiateExportMock.mockResolvedValue({
|
|
139
255
|
jobId: "job-1",
|
|
@@ -175,6 +291,54 @@ beforeEach(() => {
|
|
|
175
291
|
},
|
|
176
292
|
},
|
|
177
293
|
});
|
|
294
|
+
platformRequestUploadUrlMock.mockReset();
|
|
295
|
+
platformRequestUploadUrlMock.mockResolvedValue({
|
|
296
|
+
uploadUrl: "https://storage.googleapis.com/bucket/signed-upload-url",
|
|
297
|
+
bundleKey: "bundle-key-123",
|
|
298
|
+
expiresAt: new Date(Date.now() + 3600_000).toISOString(),
|
|
299
|
+
});
|
|
300
|
+
platformUploadToSignedUrlMock.mockReset();
|
|
301
|
+
platformUploadToSignedUrlMock.mockResolvedValue(undefined);
|
|
302
|
+
platformImportPreflightFromGcsMock.mockReset();
|
|
303
|
+
platformImportPreflightFromGcsMock.mockResolvedValue({
|
|
304
|
+
statusCode: 200,
|
|
305
|
+
body: {
|
|
306
|
+
can_import: true,
|
|
307
|
+
summary: {
|
|
308
|
+
files_to_create: 2,
|
|
309
|
+
files_to_overwrite: 1,
|
|
310
|
+
files_unchanged: 0,
|
|
311
|
+
total_files: 3,
|
|
312
|
+
},
|
|
313
|
+
},
|
|
314
|
+
});
|
|
315
|
+
platformImportBundleFromGcsMock.mockReset();
|
|
316
|
+
platformImportBundleFromGcsMock.mockResolvedValue({
|
|
317
|
+
statusCode: 200,
|
|
318
|
+
body: {
|
|
319
|
+
success: true,
|
|
320
|
+
summary: {
|
|
321
|
+
total_files: 3,
|
|
322
|
+
files_created: 2,
|
|
323
|
+
files_overwritten: 1,
|
|
324
|
+
files_skipped: 0,
|
|
325
|
+
backups_created: 1,
|
|
326
|
+
},
|
|
327
|
+
},
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
hatchLocalMock.mockReset();
|
|
331
|
+
hatchLocalMock.mockResolvedValue(undefined);
|
|
332
|
+
hatchDockerMock.mockReset();
|
|
333
|
+
hatchDockerMock.mockResolvedValue(undefined);
|
|
334
|
+
retireDockerMock.mockReset();
|
|
335
|
+
retireDockerMock.mockResolvedValue(undefined);
|
|
336
|
+
retireLocalMock.mockReset();
|
|
337
|
+
retireLocalMock.mockResolvedValue(undefined);
|
|
338
|
+
sleepContainersMock.mockReset();
|
|
339
|
+
sleepContainersMock.mockResolvedValue(undefined);
|
|
340
|
+
stopProcessByPidFileMock.mockReset();
|
|
341
|
+
stopProcessByPidFileMock.mockResolvedValue(true);
|
|
178
342
|
|
|
179
343
|
// Mock process.exit to throw so we can catch it
|
|
180
344
|
exitMock = mock((code?: number) => {
|
|
@@ -187,8 +351,6 @@ beforeEach(() => {
|
|
|
187
351
|
consoleErrorSpy = spyOn(console, "error").mockImplementation(() => {});
|
|
188
352
|
});
|
|
189
353
|
|
|
190
|
-
import { afterEach } from "bun:test";
|
|
191
|
-
|
|
192
354
|
afterEach(() => {
|
|
193
355
|
process.argv = originalArgv;
|
|
194
356
|
process.exit = originalExit;
|
|
@@ -213,24 +375,44 @@ function makeEntry(
|
|
|
213
375
|
};
|
|
214
376
|
}
|
|
215
377
|
|
|
216
|
-
/**
|
|
217
|
-
function
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
378
|
+
/** Create a mock fetch that handles export and import endpoints. */
|
|
379
|
+
function createFetchMock() {
|
|
380
|
+
return mock(async (url: string | URL | Request) => {
|
|
381
|
+
const urlStr = typeof url === "string" ? url : url.toString();
|
|
382
|
+
if (urlStr.includes("/export")) {
|
|
383
|
+
return new Response(new Uint8Array([1, 2, 3]), { status: 200 });
|
|
384
|
+
}
|
|
385
|
+
if (urlStr.includes("/import-preflight")) {
|
|
386
|
+
return new Response(
|
|
387
|
+
JSON.stringify({
|
|
388
|
+
can_import: true,
|
|
389
|
+
summary: {
|
|
390
|
+
files_to_create: 1,
|
|
391
|
+
files_to_overwrite: 0,
|
|
392
|
+
files_unchanged: 0,
|
|
393
|
+
total_files: 1,
|
|
394
|
+
},
|
|
395
|
+
}),
|
|
396
|
+
{ status: 200, headers: { "Content-Type": "application/json" } },
|
|
397
|
+
);
|
|
398
|
+
}
|
|
399
|
+
if (urlStr.includes("/import")) {
|
|
400
|
+
return new Response(
|
|
401
|
+
JSON.stringify({
|
|
402
|
+
success: true,
|
|
403
|
+
summary: {
|
|
404
|
+
total_files: 1,
|
|
405
|
+
files_created: 1,
|
|
406
|
+
files_overwritten: 0,
|
|
407
|
+
files_skipped: 0,
|
|
408
|
+
backups_created: 0,
|
|
409
|
+
},
|
|
410
|
+
}),
|
|
411
|
+
{ status: 200, headers: { "Content-Type": "application/json" } },
|
|
412
|
+
);
|
|
413
|
+
}
|
|
414
|
+
return new Response("not found", { status: 404 });
|
|
415
|
+
});
|
|
234
416
|
}
|
|
235
417
|
|
|
236
418
|
// ---------------------------------------------------------------------------
|
|
@@ -254,7 +436,7 @@ describe("teleport arg parsing", () => {
|
|
|
254
436
|
);
|
|
255
437
|
});
|
|
256
438
|
|
|
257
|
-
test("missing --from and
|
|
439
|
+
test("missing --from and env flag prints help and exits 1", async () => {
|
|
258
440
|
setArgv();
|
|
259
441
|
await expect(teleport()).rejects.toThrow("process.exit:1");
|
|
260
442
|
expect(consoleLogSpy).toHaveBeenCalledWith(
|
|
@@ -262,7 +444,7 @@ describe("teleport arg parsing", () => {
|
|
|
262
444
|
);
|
|
263
445
|
});
|
|
264
446
|
|
|
265
|
-
test("missing
|
|
447
|
+
test("missing env flag prints help and exits 1", async () => {
|
|
266
448
|
setArgv("--from", "source");
|
|
267
449
|
await expect(teleport()).rejects.toThrow("process.exit:1");
|
|
268
450
|
expect(consoleLogSpy).toHaveBeenCalledWith(
|
|
@@ -270,126 +452,122 @@ describe("teleport arg parsing", () => {
|
|
|
270
452
|
);
|
|
271
453
|
});
|
|
272
454
|
|
|
273
|
-
test("
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
expect(
|
|
277
|
-
|
|
455
|
+
test("--local sets targetEnv to 'local' with no name", () => {
|
|
456
|
+
const result = parseArgs(["--from", "source", "--local"]);
|
|
457
|
+
expect(result.from).toBe("source");
|
|
458
|
+
expect(result.targetEnv).toBe("local");
|
|
459
|
+
expect(result.targetName).toBeUndefined();
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
test("--docker my-name sets targetEnv to 'docker' and targetName to 'my-name'", () => {
|
|
463
|
+
const result = parseArgs(["--from", "source", "--docker", "my-name"]);
|
|
464
|
+
expect(result.from).toBe("source");
|
|
465
|
+
expect(result.targetEnv).toBe("docker");
|
|
466
|
+
expect(result.targetName).toBe("my-name");
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
test("--platform sets targetEnv to 'platform'", () => {
|
|
470
|
+
const result = parseArgs(["--from", "source", "--platform"]);
|
|
471
|
+
expect(result.from).toBe("source");
|
|
472
|
+
expect(result.targetEnv).toBe("platform");
|
|
473
|
+
expect(result.targetName).toBeUndefined();
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
test("multiple env flags error", () => {
|
|
477
|
+
expect(() => parseArgs(["--from", "src", "--local", "--docker"])).toThrow(
|
|
478
|
+
"process.exit:1",
|
|
479
|
+
);
|
|
480
|
+
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
481
|
+
expect.stringContaining("Only one environment flag"),
|
|
278
482
|
);
|
|
279
483
|
});
|
|
280
484
|
|
|
281
|
-
test("--
|
|
282
|
-
|
|
485
|
+
test("--keep-source is parsed", () => {
|
|
486
|
+
const result = parseArgs(["--from", "source", "--docker", "--keep-source"]);
|
|
487
|
+
expect(result.keepSource).toBe(true);
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
test("--dry-run is parsed", () => {
|
|
491
|
+
const result = parseArgs(["--from", "source", "--local", "--dry-run"]);
|
|
492
|
+
expect(result.dryRun).toBe(true);
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
test("target name after env flag is consumed but --flags are not", () => {
|
|
496
|
+
const result = parseArgs(["--from", "source", "--docker", "--keep-source"]);
|
|
497
|
+
expect(result.targetEnv).toBe("docker");
|
|
498
|
+
expect(result.targetName).toBeUndefined();
|
|
499
|
+
expect(result.keepSource).toBe(true);
|
|
500
|
+
});
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
// ---------------------------------------------------------------------------
|
|
504
|
+
// Same-environment rejection tests
|
|
505
|
+
// ---------------------------------------------------------------------------
|
|
506
|
+
|
|
507
|
+
describe("same-environment rejection", () => {
|
|
508
|
+
test("source local, target local -> error (after resolving target)", async () => {
|
|
509
|
+
setArgv("--from", "src", "--local", "dst");
|
|
510
|
+
|
|
511
|
+
const srcEntry = makeEntry("src", { cloud: "local" });
|
|
512
|
+
const dstEntry = makeEntry("dst", { cloud: "local" });
|
|
283
513
|
|
|
284
514
|
findAssistantByNameMock.mockImplementation((name: string) => {
|
|
285
|
-
if (name === "
|
|
286
|
-
if (name === "
|
|
515
|
+
if (name === "src") return srcEntry;
|
|
516
|
+
if (name === "dst") return dstEntry;
|
|
287
517
|
return null;
|
|
288
518
|
});
|
|
289
519
|
|
|
290
|
-
// This will attempt a fetch to the local export endpoint, which will fail.
|
|
291
|
-
// We just want to confirm parsing worked (i.e. it gets past the arg check).
|
|
292
|
-
// Mock global fetch to avoid network calls.
|
|
293
520
|
const originalFetch = globalThis.fetch;
|
|
294
|
-
|
|
295
|
-
const urlStr = typeof url === "string" ? url : url.toString();
|
|
296
|
-
if (urlStr.includes("/export")) {
|
|
297
|
-
return new Response(new Uint8Array([1, 2, 3]), { status: 200 });
|
|
298
|
-
}
|
|
299
|
-
// import endpoint — return valid JSON
|
|
300
|
-
return new Response(
|
|
301
|
-
JSON.stringify({
|
|
302
|
-
success: true,
|
|
303
|
-
summary: {
|
|
304
|
-
total_files: 1,
|
|
305
|
-
files_created: 1,
|
|
306
|
-
files_overwritten: 0,
|
|
307
|
-
files_skipped: 0,
|
|
308
|
-
backups_created: 0,
|
|
309
|
-
},
|
|
310
|
-
}),
|
|
311
|
-
{ status: 200, headers: { "Content-Type": "application/json" } },
|
|
312
|
-
);
|
|
313
|
-
});
|
|
314
|
-
globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch;
|
|
521
|
+
globalThis.fetch = createFetchMock() as unknown as typeof globalThis.fetch;
|
|
315
522
|
|
|
316
523
|
try {
|
|
317
|
-
await teleport();
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
524
|
+
await expect(teleport()).rejects.toThrow("process.exit:1");
|
|
525
|
+
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
526
|
+
expect.stringContaining("Cannot teleport between two local assistants"),
|
|
527
|
+
);
|
|
321
528
|
} finally {
|
|
322
529
|
globalThis.fetch = originalFetch;
|
|
323
530
|
}
|
|
324
531
|
});
|
|
325
532
|
|
|
326
|
-
test("
|
|
327
|
-
setArgv("--from", "
|
|
533
|
+
test("source docker, target docker -> error (after resolving target)", async () => {
|
|
534
|
+
setArgv("--from", "src", "--docker", "dst");
|
|
535
|
+
|
|
536
|
+
const srcEntry = makeEntry("src", { cloud: "docker" });
|
|
537
|
+
const dstEntry = makeEntry("dst", { cloud: "docker" });
|
|
328
538
|
|
|
329
539
|
findAssistantByNameMock.mockImplementation((name: string) => {
|
|
330
|
-
if (name === "
|
|
331
|
-
if (name === "
|
|
540
|
+
if (name === "src") return srcEntry;
|
|
541
|
+
if (name === "dst") return dstEntry;
|
|
332
542
|
return null;
|
|
333
543
|
});
|
|
334
544
|
|
|
335
545
|
const originalFetch = globalThis.fetch;
|
|
336
|
-
|
|
337
|
-
const urlStr = typeof url === "string" ? url : url.toString();
|
|
338
|
-
if (urlStr.includes("/export")) {
|
|
339
|
-
return new Response(new Uint8Array([1, 2, 3]), { status: 200 });
|
|
340
|
-
}
|
|
341
|
-
if (urlStr.includes("/import-preflight")) {
|
|
342
|
-
return new Response(
|
|
343
|
-
JSON.stringify({
|
|
344
|
-
can_import: true,
|
|
345
|
-
summary: {
|
|
346
|
-
files_to_create: 1,
|
|
347
|
-
files_to_overwrite: 0,
|
|
348
|
-
files_unchanged: 0,
|
|
349
|
-
total_files: 1,
|
|
350
|
-
},
|
|
351
|
-
}),
|
|
352
|
-
{ status: 200, headers: { "Content-Type": "application/json" } },
|
|
353
|
-
);
|
|
354
|
-
}
|
|
355
|
-
return new Response("not found", { status: 404 });
|
|
356
|
-
});
|
|
357
|
-
globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch;
|
|
546
|
+
globalThis.fetch = createFetchMock() as unknown as typeof globalThis.fetch;
|
|
358
547
|
|
|
359
548
|
try {
|
|
360
|
-
await teleport();
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
549
|
+
await expect(teleport()).rejects.toThrow("process.exit:1");
|
|
550
|
+
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
551
|
+
expect.stringContaining(
|
|
552
|
+
"Cannot teleport between two docker assistants",
|
|
553
|
+
),
|
|
554
|
+
);
|
|
364
555
|
} finally {
|
|
365
556
|
globalThis.fetch = originalFetch;
|
|
366
557
|
}
|
|
367
558
|
});
|
|
368
559
|
|
|
369
|
-
test("
|
|
370
|
-
setArgv("--from", "
|
|
371
|
-
findAssistantByNameMock.mockReturnValue(null);
|
|
372
|
-
|
|
373
|
-
await expect(teleport()).rejects.toThrow("process.exit:1");
|
|
374
|
-
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
375
|
-
expect.stringContaining("not found in lockfile"),
|
|
376
|
-
);
|
|
377
|
-
});
|
|
378
|
-
});
|
|
379
|
-
|
|
380
|
-
// ---------------------------------------------------------------------------
|
|
381
|
-
// Cloud resolution tests
|
|
382
|
-
// ---------------------------------------------------------------------------
|
|
383
|
-
|
|
384
|
-
describe("teleport cloud resolution", () => {
|
|
385
|
-
test("entry with cloud: 'vellum' resolves to vellum", async () => {
|
|
386
|
-
setArgv("--from", "src", "--to", "dst");
|
|
560
|
+
test("source vellum, target platform -> error (after resolving target)", async () => {
|
|
561
|
+
setArgv("--from", "src", "--platform", "dst");
|
|
387
562
|
|
|
388
563
|
const srcEntry = makeEntry("src", {
|
|
389
564
|
cloud: "vellum",
|
|
390
565
|
runtimeUrl: "https://platform.vellum.ai",
|
|
391
566
|
});
|
|
392
|
-
const dstEntry = makeEntry("dst", {
|
|
567
|
+
const dstEntry = makeEntry("dst", {
|
|
568
|
+
cloud: "vellum",
|
|
569
|
+
runtimeUrl: "https://platform.vellum.ai",
|
|
570
|
+
});
|
|
393
571
|
|
|
394
572
|
findAssistantByNameMock.mockImplementation((name: string) => {
|
|
395
573
|
if (name === "src") return srcEntry;
|
|
@@ -397,397 +575,704 @@ describe("teleport cloud resolution", () => {
|
|
|
397
575
|
return null;
|
|
398
576
|
});
|
|
399
577
|
|
|
400
|
-
// Platform export path: readPlatformToken → fetchOrganizationId → initiateExport → poll → download
|
|
401
|
-
// then local import
|
|
402
578
|
const originalFetch = globalThis.fetch;
|
|
403
|
-
|
|
404
|
-
return new Response(
|
|
405
|
-
JSON.stringify({
|
|
406
|
-
success: true,
|
|
407
|
-
summary: {
|
|
408
|
-
total_files: 1,
|
|
409
|
-
files_created: 1,
|
|
410
|
-
files_overwritten: 0,
|
|
411
|
-
files_skipped: 0,
|
|
412
|
-
backups_created: 0,
|
|
413
|
-
},
|
|
414
|
-
}),
|
|
415
|
-
{ status: 200, headers: { "Content-Type": "application/json" } },
|
|
416
|
-
);
|
|
417
|
-
});
|
|
418
|
-
globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch;
|
|
579
|
+
globalThis.fetch = createFetchMock() as unknown as typeof globalThis.fetch;
|
|
419
580
|
|
|
420
581
|
try {
|
|
421
|
-
await teleport();
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
582
|
+
await expect(teleport()).rejects.toThrow("process.exit:1");
|
|
583
|
+
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
584
|
+
expect.stringContaining(
|
|
585
|
+
"Cannot teleport between two platform assistants",
|
|
586
|
+
),
|
|
587
|
+
);
|
|
426
588
|
} finally {
|
|
427
589
|
globalThis.fetch = originalFetch;
|
|
428
590
|
}
|
|
429
591
|
});
|
|
430
592
|
|
|
431
|
-
test("
|
|
432
|
-
setArgv("--from", "
|
|
433
|
-
|
|
434
|
-
const srcEntry = makeEntry("src", { cloud: "local" });
|
|
435
|
-
const dstEntry = makeEntry("dst", {
|
|
436
|
-
cloud: "vellum",
|
|
437
|
-
runtimeUrl: "https://platform.vellum.ai",
|
|
438
|
-
});
|
|
593
|
+
test("same-env rejection happens before hatching (no orphaned assistants)", async () => {
|
|
594
|
+
setArgv("--from", "my-local", "--local");
|
|
439
595
|
|
|
596
|
+
const localEntry = makeEntry("my-local", { cloud: "local" });
|
|
440
597
|
findAssistantByNameMock.mockImplementation((name: string) => {
|
|
441
|
-
if (name === "
|
|
442
|
-
if (name === "dst") return dstEntry;
|
|
598
|
+
if (name === "my-local") return localEntry;
|
|
443
599
|
return null;
|
|
444
600
|
});
|
|
445
601
|
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
602
|
+
await expect(teleport()).rejects.toThrow("process.exit:1");
|
|
603
|
+
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
604
|
+
expect.stringContaining("Cannot teleport between two local assistants"),
|
|
605
|
+
);
|
|
606
|
+
// Crucially: no hatch should have been called — the early guard fires first
|
|
607
|
+
expect(hatchLocalMock).not.toHaveBeenCalled();
|
|
608
|
+
expect(hatchDockerMock).not.toHaveBeenCalled();
|
|
609
|
+
expect(hatchAssistantMock).not.toHaveBeenCalled();
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
test("same-env rejection before hatching for docker", async () => {
|
|
613
|
+
setArgv("--from", "my-docker", "--docker");
|
|
614
|
+
|
|
615
|
+
const dockerEntry = makeEntry("my-docker", { cloud: "docker" });
|
|
616
|
+
findAssistantByNameMock.mockImplementation((name: string) => {
|
|
617
|
+
if (name === "my-docker") return dockerEntry;
|
|
618
|
+
return null;
|
|
449
619
|
});
|
|
450
|
-
globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch;
|
|
451
620
|
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
} finally {
|
|
460
|
-
globalThis.fetch = originalFetch;
|
|
461
|
-
}
|
|
621
|
+
await expect(teleport()).rejects.toThrow("process.exit:1");
|
|
622
|
+
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
623
|
+
expect.stringContaining("Cannot teleport between two docker assistants"),
|
|
624
|
+
);
|
|
625
|
+
expect(hatchLocalMock).not.toHaveBeenCalled();
|
|
626
|
+
expect(hatchDockerMock).not.toHaveBeenCalled();
|
|
627
|
+
expect(hatchAssistantMock).not.toHaveBeenCalled();
|
|
462
628
|
});
|
|
463
629
|
|
|
464
|
-
test("
|
|
465
|
-
setArgv("--from", "
|
|
630
|
+
test("same-env rejection before hatching for platform (vellum cloud)", async () => {
|
|
631
|
+
setArgv("--from", "my-cloud", "--platform");
|
|
466
632
|
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
project: "my-gcp-project",
|
|
633
|
+
const platformEntry = makeEntry("my-cloud", {
|
|
634
|
+
cloud: "vellum",
|
|
635
|
+
runtimeUrl: "https://platform.vellum.ai",
|
|
471
636
|
});
|
|
472
|
-
const dstEntry = makeEntry("dst", { cloud: "local" });
|
|
473
|
-
|
|
474
637
|
findAssistantByNameMock.mockImplementation((name: string) => {
|
|
475
|
-
if (name === "
|
|
476
|
-
if (name === "dst") return dstEntry;
|
|
638
|
+
if (name === "my-cloud") return platformEntry;
|
|
477
639
|
return null;
|
|
478
640
|
});
|
|
479
641
|
|
|
480
|
-
// GCP is unsupported, should print error and exit
|
|
481
642
|
await expect(teleport()).rejects.toThrow("process.exit:1");
|
|
482
643
|
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
483
|
-
expect.stringContaining(
|
|
644
|
+
expect.stringContaining(
|
|
645
|
+
"Cannot teleport between two platform assistants",
|
|
646
|
+
),
|
|
484
647
|
);
|
|
648
|
+
expect(hatchLocalMock).not.toHaveBeenCalled();
|
|
649
|
+
expect(hatchDockerMock).not.toHaveBeenCalled();
|
|
650
|
+
expect(hatchAssistantMock).not.toHaveBeenCalled();
|
|
485
651
|
});
|
|
486
652
|
|
|
487
|
-
test("
|
|
488
|
-
|
|
653
|
+
test("flag says docker but resolved target is local -> rejects cloud mismatch", async () => {
|
|
654
|
+
// User passes --docker but the named target is actually a local assistant
|
|
655
|
+
setArgv("--from", "src", "--docker", "misidentified");
|
|
489
656
|
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
const dstEntry = makeEntry("
|
|
657
|
+
const srcEntry = makeEntry("src", { cloud: "vellum" });
|
|
658
|
+
// Target is actually local despite the --docker flag
|
|
659
|
+
const dstEntry = makeEntry("misidentified", { cloud: "local" });
|
|
493
660
|
|
|
494
661
|
findAssistantByNameMock.mockImplementation((name: string) => {
|
|
495
662
|
if (name === "src") return srcEntry;
|
|
496
|
-
if (name === "
|
|
663
|
+
if (name === "misidentified") return dstEntry;
|
|
497
664
|
return null;
|
|
498
665
|
});
|
|
499
666
|
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
return new Response(new Uint8Array([1, 2, 3]), { status: 200 });
|
|
505
|
-
}
|
|
506
|
-
// import endpoint
|
|
507
|
-
return new Response(
|
|
508
|
-
JSON.stringify({
|
|
509
|
-
success: true,
|
|
510
|
-
summary: {
|
|
511
|
-
total_files: 1,
|
|
512
|
-
files_created: 1,
|
|
513
|
-
files_overwritten: 0,
|
|
514
|
-
files_skipped: 0,
|
|
515
|
-
backups_created: 0,
|
|
516
|
-
},
|
|
517
|
-
}),
|
|
518
|
-
{ status: 200, headers: { "Content-Type": "application/json" } },
|
|
519
|
-
);
|
|
520
|
-
});
|
|
521
|
-
globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch;
|
|
522
|
-
|
|
523
|
-
try {
|
|
524
|
-
await teleport();
|
|
525
|
-
// Should use local export (fetch to /v1/migrations/export)
|
|
526
|
-
const exportCalls = filterFetchCalls(fetchMock, "/v1/migrations/export");
|
|
527
|
-
expect(exportCalls.length).toBe(1);
|
|
528
|
-
} finally {
|
|
529
|
-
globalThis.fetch = originalFetch;
|
|
530
|
-
}
|
|
667
|
+
await expect(teleport()).rejects.toThrow("process.exit:1");
|
|
668
|
+
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
669
|
+
expect.stringContaining("is a local assistant, not docker"),
|
|
670
|
+
);
|
|
531
671
|
});
|
|
532
672
|
});
|
|
533
673
|
|
|
534
674
|
// ---------------------------------------------------------------------------
|
|
535
|
-
//
|
|
675
|
+
// resolveOrHatchTarget tests
|
|
536
676
|
// ---------------------------------------------------------------------------
|
|
537
677
|
|
|
538
|
-
describe("
|
|
539
|
-
test("
|
|
540
|
-
|
|
541
|
-
|
|
678
|
+
describe("resolveOrHatchTarget", () => {
|
|
679
|
+
test("existing assistant is returned without hatching", async () => {
|
|
680
|
+
const dockerEntry = makeEntry("my-docker", { cloud: "docker" });
|
|
542
681
|
findAssistantByNameMock.mockImplementation((name: string) => {
|
|
543
|
-
if (name === "
|
|
544
|
-
return makeEntry("local-src", { cloud: "local" });
|
|
545
|
-
if (name === "platform-dst")
|
|
546
|
-
return makeEntry("platform-dst", {
|
|
547
|
-
cloud: "vellum",
|
|
548
|
-
runtimeUrl: "https://platform.vellum.ai",
|
|
549
|
-
});
|
|
682
|
+
if (name === "my-docker") return dockerEntry;
|
|
550
683
|
return null;
|
|
551
684
|
});
|
|
552
685
|
|
|
553
|
-
const
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
686
|
+
const result = await resolveOrHatchTarget("docker", "my-docker");
|
|
687
|
+
expect(result).toBe(dockerEntry);
|
|
688
|
+
expect(hatchDockerMock).not.toHaveBeenCalled();
|
|
689
|
+
expect(consoleLogSpy).toHaveBeenCalledWith(
|
|
690
|
+
expect.stringContaining("Target: my-docker (docker)"),
|
|
691
|
+
);
|
|
692
|
+
});
|
|
693
|
+
|
|
694
|
+
test("name not found -> hatch docker", async () => {
|
|
695
|
+
const newEntry = makeEntry("new-one", { cloud: "docker" });
|
|
696
|
+
findAssistantByNameMock.mockImplementation((name: string) => {
|
|
697
|
+
// First call: lookup by name -> not found
|
|
698
|
+
// Second call: after hatch -> found
|
|
699
|
+
if (name === "new-one" && hatchDockerMock.mock.calls.length > 0) {
|
|
700
|
+
return newEntry;
|
|
701
|
+
}
|
|
702
|
+
return null;
|
|
703
|
+
});
|
|
704
|
+
|
|
705
|
+
const result = await resolveOrHatchTarget("docker", "new-one");
|
|
706
|
+
expect(hatchDockerMock).toHaveBeenCalledWith(
|
|
707
|
+
"vellum",
|
|
708
|
+
false,
|
|
709
|
+
"new-one",
|
|
710
|
+
false,
|
|
711
|
+
{},
|
|
712
|
+
);
|
|
713
|
+
expect(result).toBe(newEntry);
|
|
714
|
+
});
|
|
715
|
+
|
|
716
|
+
test("no name -> hatch local with null name, discovers via diff", async () => {
|
|
717
|
+
const existingEntry = makeEntry("existing-local", { cloud: "local" });
|
|
718
|
+
const newEntry = makeEntry("auto-generated", { cloud: "local" });
|
|
719
|
+
|
|
720
|
+
// Before hatch: only the existing entry
|
|
721
|
+
// After hatch: existing + new entry
|
|
722
|
+
loadAllAssistantsMock.mockImplementation(() => {
|
|
723
|
+
if (hatchLocalMock.mock.calls.length > 0) {
|
|
724
|
+
return [existingEntry, newEntry];
|
|
725
|
+
}
|
|
726
|
+
return [existingEntry];
|
|
727
|
+
});
|
|
728
|
+
|
|
729
|
+
const result = await resolveOrHatchTarget("local");
|
|
730
|
+
expect(hatchLocalMock).toHaveBeenCalledWith(
|
|
731
|
+
"vellum",
|
|
732
|
+
null,
|
|
733
|
+
false,
|
|
734
|
+
false,
|
|
735
|
+
false,
|
|
736
|
+
{},
|
|
737
|
+
);
|
|
738
|
+
expect(result).toBe(newEntry);
|
|
739
|
+
});
|
|
740
|
+
|
|
741
|
+
test("platform with existing ID -> returns existing without hatching", async () => {
|
|
742
|
+
const platformEntry = makeEntry("uuid-123", {
|
|
743
|
+
cloud: "vellum",
|
|
744
|
+
runtimeUrl: "https://platform.vellum.ai",
|
|
745
|
+
});
|
|
746
|
+
findAssistantByNameMock.mockImplementation((name: string) => {
|
|
747
|
+
if (name === "uuid-123") return platformEntry;
|
|
748
|
+
return null;
|
|
749
|
+
});
|
|
750
|
+
|
|
751
|
+
const result = await resolveOrHatchTarget("platform", "uuid-123");
|
|
752
|
+
expect(result).toBe(platformEntry);
|
|
753
|
+
expect(hatchAssistantMock).not.toHaveBeenCalled();
|
|
754
|
+
});
|
|
755
|
+
|
|
756
|
+
test("platform with unknown name -> hatches via hatchAssistant", async () => {
|
|
757
|
+
findAssistantByNameMock.mockReturnValue(null);
|
|
758
|
+
|
|
759
|
+
const result = await resolveOrHatchTarget("platform", "nonexistent");
|
|
760
|
+
expect(hatchAssistantMock).toHaveBeenCalledWith(
|
|
761
|
+
"platform-token",
|
|
762
|
+
"org-123",
|
|
763
|
+
);
|
|
764
|
+
expect(saveAssistantEntryMock).toHaveBeenCalledWith(
|
|
765
|
+
expect.objectContaining({
|
|
766
|
+
assistantId: "platform-new-id",
|
|
767
|
+
cloud: "vellum",
|
|
768
|
+
}),
|
|
769
|
+
);
|
|
770
|
+
expect(result.assistantId).toBe("platform-new-id");
|
|
771
|
+
});
|
|
772
|
+
|
|
773
|
+
test("existing assistant with wrong cloud -> rejects", async () => {
|
|
774
|
+
const localEntry = makeEntry("my-local", { cloud: "local" });
|
|
775
|
+
findAssistantByNameMock.mockImplementation((name: string) => {
|
|
776
|
+
if (name === "my-local") return localEntry;
|
|
777
|
+
return null;
|
|
778
|
+
});
|
|
779
|
+
|
|
780
|
+
await expect(resolveOrHatchTarget("docker", "my-local")).rejects.toThrow(
|
|
781
|
+
"process.exit:1",
|
|
782
|
+
);
|
|
783
|
+
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
784
|
+
expect.stringContaining("is a local assistant, not docker"),
|
|
785
|
+
);
|
|
786
|
+
});
|
|
787
|
+
|
|
788
|
+
test("name with path traversal -> rejects before hatching", async () => {
|
|
789
|
+
findAssistantByNameMock.mockReturnValue(null);
|
|
790
|
+
|
|
791
|
+
await expect(
|
|
792
|
+
resolveOrHatchTarget("docker", "../../../etc/passwd"),
|
|
793
|
+
).rejects.toThrow("process.exit:1");
|
|
794
|
+
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
795
|
+
expect.stringContaining("invalid characters"),
|
|
796
|
+
);
|
|
797
|
+
expect(hatchDockerMock).not.toHaveBeenCalled();
|
|
798
|
+
});
|
|
799
|
+
});
|
|
800
|
+
|
|
801
|
+
// ---------------------------------------------------------------------------
|
|
802
|
+
// Auto-retire tests
|
|
803
|
+
// ---------------------------------------------------------------------------
|
|
804
|
+
|
|
805
|
+
describe("auto-retire", () => {
|
|
806
|
+
test("local -> docker: stops source before hatch, retires after import", async () => {
|
|
807
|
+
setArgv("--from", "my-local", "--docker");
|
|
808
|
+
|
|
809
|
+
const localEntry = makeEntry("my-local", {
|
|
810
|
+
cloud: "local",
|
|
811
|
+
resources: {
|
|
812
|
+
instanceDir: "/home/test",
|
|
813
|
+
pidFile: "/home/test/.vellum/assistant.pid",
|
|
814
|
+
signingKey: "key",
|
|
815
|
+
daemonPort: 7821,
|
|
816
|
+
gatewayPort: 7830,
|
|
817
|
+
qdrantPort: 6333,
|
|
818
|
+
cesPort: 8090,
|
|
819
|
+
},
|
|
820
|
+
});
|
|
821
|
+
const dockerEntry = makeEntry("new-docker", { cloud: "docker" });
|
|
822
|
+
|
|
823
|
+
findAssistantByNameMock.mockImplementation((name: string) => {
|
|
824
|
+
if (name === "my-local") return localEntry;
|
|
825
|
+
return null;
|
|
826
|
+
});
|
|
827
|
+
|
|
828
|
+
// Simulate hatch creating a new docker entry
|
|
829
|
+
loadAllAssistantsMock.mockImplementation(() => {
|
|
830
|
+
if (hatchDockerMock.mock.calls.length > 0) {
|
|
831
|
+
return [localEntry, dockerEntry];
|
|
832
|
+
}
|
|
833
|
+
return [localEntry];
|
|
834
|
+
});
|
|
835
|
+
|
|
836
|
+
const originalFetch = globalThis.fetch;
|
|
837
|
+
globalThis.fetch = createFetchMock() as unknown as typeof globalThis.fetch;
|
|
558
838
|
|
|
559
839
|
try {
|
|
560
840
|
await teleport();
|
|
841
|
+
// Source should be stopped (slept) before hatch
|
|
842
|
+
expect(stopProcessByPidFileMock).toHaveBeenCalled();
|
|
843
|
+
// Retire happens after successful import
|
|
844
|
+
expect(retireLocalMock).toHaveBeenCalledWith("my-local", localEntry);
|
|
845
|
+
expect(removeAssistantEntryMock).toHaveBeenCalledWith("my-local");
|
|
846
|
+
} finally {
|
|
847
|
+
globalThis.fetch = originalFetch;
|
|
848
|
+
}
|
|
849
|
+
});
|
|
561
850
|
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
expect(exportCalls.length).toBe(1);
|
|
851
|
+
test("docker -> local: sleeps containers before hatch, retires after import", async () => {
|
|
852
|
+
setArgv("--from", "my-docker", "--local");
|
|
565
853
|
|
|
566
|
-
|
|
567
|
-
|
|
854
|
+
const dockerEntry = makeEntry("my-docker", { cloud: "docker" });
|
|
855
|
+
const localEntry = makeEntry("new-local", { cloud: "local" });
|
|
856
|
+
|
|
857
|
+
findAssistantByNameMock.mockImplementation((name: string) => {
|
|
858
|
+
if (name === "my-docker") return dockerEntry;
|
|
859
|
+
return null;
|
|
860
|
+
});
|
|
861
|
+
|
|
862
|
+
loadAllAssistantsMock.mockImplementation(() => {
|
|
863
|
+
if (hatchLocalMock.mock.calls.length > 0) {
|
|
864
|
+
return [dockerEntry, localEntry];
|
|
865
|
+
}
|
|
866
|
+
return [dockerEntry];
|
|
867
|
+
});
|
|
568
868
|
|
|
569
|
-
|
|
570
|
-
|
|
869
|
+
const originalFetch = globalThis.fetch;
|
|
870
|
+
globalThis.fetch = createFetchMock() as unknown as typeof globalThis.fetch;
|
|
871
|
+
|
|
872
|
+
try {
|
|
873
|
+
await teleport();
|
|
874
|
+
// Docker source should be slept (containers stopped) before hatch
|
|
875
|
+
expect(sleepContainersMock).toHaveBeenCalled();
|
|
876
|
+
// Retire happens after successful import
|
|
877
|
+
expect(retireDockerMock).toHaveBeenCalledWith("my-docker");
|
|
878
|
+
expect(removeAssistantEntryMock).toHaveBeenCalledWith("my-docker");
|
|
571
879
|
} finally {
|
|
572
880
|
globalThis.fetch = originalFetch;
|
|
573
881
|
}
|
|
574
882
|
});
|
|
575
883
|
|
|
576
|
-
test("
|
|
577
|
-
setArgv("--from", "
|
|
884
|
+
test("--keep-source skips retire and removeAssistantEntry", async () => {
|
|
885
|
+
setArgv("--from", "my-local", "--docker", "--keep-source");
|
|
886
|
+
|
|
887
|
+
const localEntry = makeEntry("my-local", { cloud: "local" });
|
|
888
|
+
const dockerEntry = makeEntry("new-docker", { cloud: "docker" });
|
|
578
889
|
|
|
579
890
|
findAssistantByNameMock.mockImplementation((name: string) => {
|
|
580
|
-
if (name === "
|
|
581
|
-
return makeEntry("platform-src", {
|
|
582
|
-
cloud: "vellum",
|
|
583
|
-
runtimeUrl: "https://platform.vellum.ai",
|
|
584
|
-
});
|
|
585
|
-
if (name === "local-dst")
|
|
586
|
-
return makeEntry("local-dst", { cloud: "local" });
|
|
891
|
+
if (name === "my-local") return localEntry;
|
|
587
892
|
return null;
|
|
588
893
|
});
|
|
589
894
|
|
|
895
|
+
loadAllAssistantsMock.mockImplementation(() => {
|
|
896
|
+
if (hatchDockerMock.mock.calls.length > 0) {
|
|
897
|
+
return [localEntry, dockerEntry];
|
|
898
|
+
}
|
|
899
|
+
return [localEntry];
|
|
900
|
+
});
|
|
901
|
+
|
|
590
902
|
const originalFetch = globalThis.fetch;
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
backups_created: 1,
|
|
601
|
-
},
|
|
602
|
-
}),
|
|
603
|
-
{ status: 200, headers: { "Content-Type": "application/json" } },
|
|
903
|
+
globalThis.fetch = createFetchMock() as unknown as typeof globalThis.fetch;
|
|
904
|
+
|
|
905
|
+
try {
|
|
906
|
+
await teleport();
|
|
907
|
+
expect(retireLocalMock).not.toHaveBeenCalled();
|
|
908
|
+
expect(retireDockerMock).not.toHaveBeenCalled();
|
|
909
|
+
expect(removeAssistantEntryMock).not.toHaveBeenCalled();
|
|
910
|
+
expect(consoleLogSpy).toHaveBeenCalledWith(
|
|
911
|
+
expect.stringContaining("kept (--keep-source)"),
|
|
604
912
|
);
|
|
913
|
+
} finally {
|
|
914
|
+
globalThis.fetch = originalFetch;
|
|
915
|
+
}
|
|
916
|
+
});
|
|
917
|
+
|
|
918
|
+
test("platform transfers skip retire", async () => {
|
|
919
|
+
setArgv("--from", "my-local", "--platform");
|
|
920
|
+
|
|
921
|
+
const localEntry = makeEntry("my-local", { cloud: "local" });
|
|
922
|
+
|
|
923
|
+
findAssistantByNameMock.mockImplementation((name: string) => {
|
|
924
|
+
if (name === "my-local") return localEntry;
|
|
925
|
+
return null;
|
|
605
926
|
});
|
|
606
|
-
|
|
927
|
+
|
|
928
|
+
const originalFetch = globalThis.fetch;
|
|
929
|
+
globalThis.fetch = createFetchMock() as unknown as typeof globalThis.fetch;
|
|
607
930
|
|
|
608
931
|
try {
|
|
609
932
|
await teleport();
|
|
933
|
+
expect(retireLocalMock).not.toHaveBeenCalled();
|
|
934
|
+
expect(retireDockerMock).not.toHaveBeenCalled();
|
|
935
|
+
expect(removeAssistantEntryMock).not.toHaveBeenCalled();
|
|
936
|
+
} finally {
|
|
937
|
+
globalThis.fetch = originalFetch;
|
|
938
|
+
}
|
|
939
|
+
});
|
|
610
940
|
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
expect(platformPollExportStatusMock).toHaveBeenCalled();
|
|
614
|
-
expect(platformDownloadExportMock).toHaveBeenCalled();
|
|
941
|
+
test("dry-run without existing target does not hatch or export", async () => {
|
|
942
|
+
setArgv("--from", "my-local", "--docker", "--dry-run");
|
|
615
943
|
|
|
616
|
-
|
|
617
|
-
const importCalls = filterFetchCalls(
|
|
618
|
-
fetchMock,
|
|
619
|
-
"/v1/migrations/import",
|
|
620
|
-
).filter((call) => !extractUrl(call[0]).includes("/import-preflight"));
|
|
621
|
-
expect(importCalls.length).toBe(1);
|
|
944
|
+
const localEntry = makeEntry("my-local", { cloud: "local" });
|
|
622
945
|
|
|
623
|
-
|
|
624
|
-
|
|
946
|
+
findAssistantByNameMock.mockImplementation((name: string) => {
|
|
947
|
+
if (name === "my-local") return localEntry;
|
|
948
|
+
return null;
|
|
949
|
+
});
|
|
950
|
+
|
|
951
|
+
await teleport();
|
|
952
|
+
|
|
953
|
+
// Should NOT hatch, export, import, or retire
|
|
954
|
+
expect(hatchDockerMock).not.toHaveBeenCalled();
|
|
955
|
+
expect(hatchLocalMock).not.toHaveBeenCalled();
|
|
956
|
+
expect(hatchAssistantMock).not.toHaveBeenCalled();
|
|
957
|
+
expect(retireLocalMock).not.toHaveBeenCalled();
|
|
958
|
+
expect(retireDockerMock).not.toHaveBeenCalled();
|
|
959
|
+
expect(removeAssistantEntryMock).not.toHaveBeenCalled();
|
|
960
|
+
});
|
|
961
|
+
|
|
962
|
+
test("dry-run with existing target runs preflight without hatching", async () => {
|
|
963
|
+
setArgv("--from", "my-local", "--docker", "my-docker", "--dry-run");
|
|
964
|
+
|
|
965
|
+
const localEntry = makeEntry("my-local", { cloud: "local" });
|
|
966
|
+
const dockerEntry = makeEntry("my-docker", { cloud: "docker" });
|
|
967
|
+
|
|
968
|
+
findAssistantByNameMock.mockImplementation((name: string) => {
|
|
969
|
+
if (name === "my-local") return localEntry;
|
|
970
|
+
if (name === "my-docker") return dockerEntry;
|
|
971
|
+
return null;
|
|
972
|
+
});
|
|
973
|
+
|
|
974
|
+
const originalFetch = globalThis.fetch;
|
|
975
|
+
globalThis.fetch = createFetchMock() as unknown as typeof globalThis.fetch;
|
|
976
|
+
|
|
977
|
+
try {
|
|
978
|
+
await teleport();
|
|
979
|
+
|
|
980
|
+
// Should NOT hatch or retire
|
|
981
|
+
expect(hatchDockerMock).not.toHaveBeenCalled();
|
|
982
|
+
expect(hatchLocalMock).not.toHaveBeenCalled();
|
|
983
|
+
expect(retireLocalMock).not.toHaveBeenCalled();
|
|
984
|
+
expect(retireDockerMock).not.toHaveBeenCalled();
|
|
985
|
+
expect(removeAssistantEntryMock).not.toHaveBeenCalled();
|
|
625
986
|
} finally {
|
|
626
987
|
globalThis.fetch = originalFetch;
|
|
627
988
|
}
|
|
628
989
|
});
|
|
990
|
+
});
|
|
991
|
+
|
|
992
|
+
// ---------------------------------------------------------------------------
|
|
993
|
+
// Full flow tests
|
|
994
|
+
// ---------------------------------------------------------------------------
|
|
995
|
+
|
|
996
|
+
describe("teleport full flow", () => {
|
|
997
|
+
test("hatch and import: --from my-local --docker", async () => {
|
|
998
|
+
setArgv("--from", "my-local", "--docker");
|
|
629
999
|
|
|
630
|
-
|
|
631
|
-
|
|
1000
|
+
const localEntry = makeEntry("my-local", { cloud: "local" });
|
|
1001
|
+
const dockerEntry = makeEntry("new-docker", { cloud: "docker" });
|
|
632
1002
|
|
|
633
1003
|
findAssistantByNameMock.mockImplementation((name: string) => {
|
|
634
|
-
if (name === "
|
|
635
|
-
if (name === "dst") return makeEntry("dst", { cloud: "local" });
|
|
1004
|
+
if (name === "my-local") return localEntry;
|
|
636
1005
|
return null;
|
|
637
1006
|
});
|
|
638
1007
|
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
if (urlStr.includes("/export")) {
|
|
643
|
-
return new Response(new Uint8Array([1, 2, 3]), { status: 200 });
|
|
1008
|
+
loadAllAssistantsMock.mockImplementation(() => {
|
|
1009
|
+
if (hatchDockerMock.mock.calls.length > 0) {
|
|
1010
|
+
return [localEntry, dockerEntry];
|
|
644
1011
|
}
|
|
645
|
-
|
|
646
|
-
return new Response(
|
|
647
|
-
JSON.stringify({
|
|
648
|
-
can_import: true,
|
|
649
|
-
summary: {
|
|
650
|
-
files_to_create: 1,
|
|
651
|
-
files_to_overwrite: 0,
|
|
652
|
-
files_unchanged: 0,
|
|
653
|
-
total_files: 1,
|
|
654
|
-
},
|
|
655
|
-
}),
|
|
656
|
-
{ status: 200, headers: { "Content-Type": "application/json" } },
|
|
657
|
-
);
|
|
658
|
-
}
|
|
659
|
-
return new Response("not found", { status: 404 });
|
|
1012
|
+
return [localEntry];
|
|
660
1013
|
});
|
|
1014
|
+
|
|
1015
|
+
const originalFetch = globalThis.fetch;
|
|
1016
|
+
const fetchMock = createFetchMock();
|
|
661
1017
|
globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch;
|
|
662
1018
|
|
|
663
1019
|
try {
|
|
664
1020
|
await teleport();
|
|
665
1021
|
|
|
666
|
-
//
|
|
667
|
-
|
|
668
|
-
expect(
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
(call) => !extractUrl(call[0]).includes("preflight"),
|
|
1022
|
+
// Verify sequence: export, hatch, import, retire
|
|
1023
|
+
expect(hatchDockerMock).toHaveBeenCalled();
|
|
1024
|
+
expect(retireLocalMock).toHaveBeenCalledWith("my-local", localEntry);
|
|
1025
|
+
expect(removeAssistantEntryMock).toHaveBeenCalledWith("my-local");
|
|
1026
|
+
expect(consoleLogSpy).toHaveBeenCalledWith(
|
|
1027
|
+
expect.stringContaining("Teleport complete"),
|
|
673
1028
|
);
|
|
674
|
-
expect(importCalls.length).toBe(0);
|
|
675
1029
|
} finally {
|
|
676
1030
|
globalThis.fetch = originalFetch;
|
|
677
1031
|
}
|
|
678
1032
|
});
|
|
679
1033
|
|
|
680
|
-
test("
|
|
681
|
-
setArgv("--from", "
|
|
1034
|
+
test("existing target overwrite: --from my-local --docker my-existing", async () => {
|
|
1035
|
+
setArgv("--from", "my-local", "--docker", "my-existing");
|
|
1036
|
+
|
|
1037
|
+
const localEntry = makeEntry("my-local", { cloud: "local" });
|
|
1038
|
+
const dockerEntry = makeEntry("my-existing", { cloud: "docker" });
|
|
682
1039
|
|
|
683
1040
|
findAssistantByNameMock.mockImplementation((name: string) => {
|
|
684
|
-
if (name === "
|
|
685
|
-
if (name === "
|
|
686
|
-
return makeEntry("dst", {
|
|
687
|
-
cloud: "vellum",
|
|
688
|
-
runtimeUrl: "https://platform.vellum.ai",
|
|
689
|
-
});
|
|
1041
|
+
if (name === "my-local") return localEntry;
|
|
1042
|
+
if (name === "my-existing") return dockerEntry;
|
|
690
1043
|
return null;
|
|
691
1044
|
});
|
|
692
1045
|
|
|
693
1046
|
const originalFetch = globalThis.fetch;
|
|
694
|
-
const fetchMock =
|
|
695
|
-
return new Response(new Uint8Array([1, 2, 3]), { status: 200 });
|
|
696
|
-
});
|
|
1047
|
+
const fetchMock = createFetchMock();
|
|
697
1048
|
globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch;
|
|
698
1049
|
|
|
699
1050
|
try {
|
|
700
1051
|
await teleport();
|
|
701
1052
|
|
|
702
|
-
//
|
|
703
|
-
expect(
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
expect(
|
|
1053
|
+
// No hatch should happen — existing target is used
|
|
1054
|
+
expect(hatchDockerMock).not.toHaveBeenCalled();
|
|
1055
|
+
// Source should still be retired
|
|
1056
|
+
expect(retireLocalMock).toHaveBeenCalledWith("my-local", localEntry);
|
|
1057
|
+
expect(consoleLogSpy).toHaveBeenCalledWith(
|
|
1058
|
+
expect.stringContaining("Teleport complete"),
|
|
1059
|
+
);
|
|
707
1060
|
} finally {
|
|
708
1061
|
globalThis.fetch = originalFetch;
|
|
709
1062
|
}
|
|
710
1063
|
});
|
|
711
1064
|
|
|
712
|
-
test("
|
|
713
|
-
setArgv("--from", "
|
|
1065
|
+
test("legacy --to flag shows deprecation message", async () => {
|
|
1066
|
+
setArgv("--from", "source", "--to", "target");
|
|
1067
|
+
|
|
1068
|
+
await expect(teleport()).rejects.toThrow("process.exit:1");
|
|
1069
|
+
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
1070
|
+
expect.stringContaining("--to is deprecated"),
|
|
1071
|
+
);
|
|
1072
|
+
});
|
|
1073
|
+
});
|
|
1074
|
+
|
|
1075
|
+
// ---------------------------------------------------------------------------
|
|
1076
|
+
// Signed-URL upload tests
|
|
1077
|
+
// ---------------------------------------------------------------------------
|
|
1078
|
+
|
|
1079
|
+
describe("signed-URL upload flow", () => {
|
|
1080
|
+
test("happy path: signed URL upload succeeds → GCS-based import used", async () => {
|
|
1081
|
+
setArgv("--from", "my-local", "--platform");
|
|
1082
|
+
|
|
1083
|
+
const localEntry = makeEntry("my-local", { cloud: "local" });
|
|
714
1084
|
|
|
715
1085
|
findAssistantByNameMock.mockImplementation((name: string) => {
|
|
716
|
-
if (name === "
|
|
717
|
-
return makeEntry("platform-src", {
|
|
718
|
-
cloud: "vellum",
|
|
719
|
-
runtimeUrl: "https://platform.vellum.ai",
|
|
720
|
-
});
|
|
721
|
-
if (name === "platform-dst")
|
|
722
|
-
return makeEntry("platform-dst", {
|
|
723
|
-
cloud: "vellum",
|
|
724
|
-
runtimeUrl: "https://platform2.vellum.ai",
|
|
725
|
-
});
|
|
1086
|
+
if (name === "my-local") return localEntry;
|
|
726
1087
|
return null;
|
|
727
1088
|
});
|
|
728
1089
|
|
|
729
|
-
|
|
1090
|
+
const originalFetch = globalThis.fetch;
|
|
1091
|
+
globalThis.fetch = createFetchMock() as unknown as typeof globalThis.fetch;
|
|
730
1092
|
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
expect(platformPollExportStatusMock).toHaveBeenCalled();
|
|
734
|
-
expect(platformDownloadExportMock).toHaveBeenCalled();
|
|
1093
|
+
try {
|
|
1094
|
+
await teleport();
|
|
735
1095
|
|
|
736
|
-
|
|
737
|
-
|
|
1096
|
+
// Signed-URL flow should be used
|
|
1097
|
+
expect(platformRequestUploadUrlMock).toHaveBeenCalled();
|
|
1098
|
+
expect(platformUploadToSignedUrlMock).toHaveBeenCalled();
|
|
1099
|
+
expect(platformImportBundleFromGcsMock).toHaveBeenCalledWith(
|
|
1100
|
+
"bundle-key-123",
|
|
1101
|
+
"platform-token",
|
|
1102
|
+
"org-123",
|
|
1103
|
+
"https://platform.vellum.ai",
|
|
1104
|
+
);
|
|
1105
|
+
// Inline import should NOT be called
|
|
1106
|
+
expect(platformImportBundleMock).not.toHaveBeenCalled();
|
|
1107
|
+
expect(consoleLogSpy).toHaveBeenCalledWith(
|
|
1108
|
+
expect.stringContaining("Teleport complete"),
|
|
1109
|
+
);
|
|
1110
|
+
} finally {
|
|
1111
|
+
globalThis.fetch = originalFetch;
|
|
1112
|
+
}
|
|
738
1113
|
});
|
|
739
|
-
});
|
|
740
1114
|
|
|
741
|
-
|
|
742
|
-
// Edge case: extra/unrecognized arguments
|
|
743
|
-
// ---------------------------------------------------------------------------
|
|
744
|
-
|
|
745
|
-
describe("teleport extra arguments", () => {
|
|
746
|
-
test("extra unrecognized flags are ignored and command works normally", async () => {
|
|
1115
|
+
test("happy path dry-run: signed URL upload succeeds → GCS-based preflight used", async () => {
|
|
747
1116
|
setArgv(
|
|
748
1117
|
"--from",
|
|
749
|
-
"
|
|
750
|
-
"--
|
|
751
|
-
"
|
|
752
|
-
"--
|
|
753
|
-
"--another-unknown",
|
|
1118
|
+
"my-local",
|
|
1119
|
+
"--platform",
|
|
1120
|
+
"existing-platform",
|
|
1121
|
+
"--dry-run",
|
|
754
1122
|
);
|
|
755
1123
|
|
|
1124
|
+
const localEntry = makeEntry("my-local", { cloud: "local" });
|
|
1125
|
+
const platformEntry = makeEntry("existing-platform", {
|
|
1126
|
+
cloud: "vellum",
|
|
1127
|
+
runtimeUrl: "https://platform.vellum.ai",
|
|
1128
|
+
});
|
|
1129
|
+
|
|
756
1130
|
findAssistantByNameMock.mockImplementation((name: string) => {
|
|
757
|
-
if (name === "
|
|
758
|
-
if (name === "
|
|
1131
|
+
if (name === "my-local") return localEntry;
|
|
1132
|
+
if (name === "existing-platform") return platformEntry;
|
|
759
1133
|
return null;
|
|
760
1134
|
});
|
|
761
1135
|
|
|
762
1136
|
const originalFetch = globalThis.fetch;
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
backups_created: 0,
|
|
777
|
-
},
|
|
778
|
-
}),
|
|
779
|
-
{ status: 200, headers: { "Content-Type": "application/json" } },
|
|
1137
|
+
globalThis.fetch = createFetchMock() as unknown as typeof globalThis.fetch;
|
|
1138
|
+
|
|
1139
|
+
try {
|
|
1140
|
+
await teleport();
|
|
1141
|
+
|
|
1142
|
+
// Signed-URL flow should be used for preflight
|
|
1143
|
+
expect(platformRequestUploadUrlMock).toHaveBeenCalled();
|
|
1144
|
+
expect(platformUploadToSignedUrlMock).toHaveBeenCalled();
|
|
1145
|
+
expect(platformImportPreflightFromGcsMock).toHaveBeenCalledWith(
|
|
1146
|
+
"bundle-key-123",
|
|
1147
|
+
"platform-token",
|
|
1148
|
+
"org-123",
|
|
1149
|
+
"https://platform.vellum.ai",
|
|
780
1150
|
);
|
|
1151
|
+
// Inline preflight should NOT be called
|
|
1152
|
+
expect(platformImportPreflightMock).not.toHaveBeenCalled();
|
|
1153
|
+
} finally {
|
|
1154
|
+
globalThis.fetch = originalFetch;
|
|
1155
|
+
}
|
|
1156
|
+
});
|
|
1157
|
+
|
|
1158
|
+
test("fallback: platformRequestUploadUrl throws 503 → falls back to inline import", async () => {
|
|
1159
|
+
setArgv("--from", "my-local", "--platform");
|
|
1160
|
+
|
|
1161
|
+
const localEntry = makeEntry("my-local", { cloud: "local" });
|
|
1162
|
+
|
|
1163
|
+
findAssistantByNameMock.mockImplementation((name: string) => {
|
|
1164
|
+
if (name === "my-local") return localEntry;
|
|
1165
|
+
return null;
|
|
781
1166
|
});
|
|
782
|
-
|
|
1167
|
+
|
|
1168
|
+
// Simulate 503 — "not available" in the error message
|
|
1169
|
+
platformRequestUploadUrlMock.mockRejectedValue(
|
|
1170
|
+
new Error("Signed uploads are not available on this platform instance"),
|
|
1171
|
+
);
|
|
1172
|
+
|
|
1173
|
+
const originalFetch = globalThis.fetch;
|
|
1174
|
+
globalThis.fetch = createFetchMock() as unknown as typeof globalThis.fetch;
|
|
1175
|
+
|
|
1176
|
+
try {
|
|
1177
|
+
await teleport();
|
|
1178
|
+
|
|
1179
|
+
// Should fall back to inline import
|
|
1180
|
+
expect(platformRequestUploadUrlMock).toHaveBeenCalled();
|
|
1181
|
+
expect(platformUploadToSignedUrlMock).not.toHaveBeenCalled();
|
|
1182
|
+
expect(platformImportBundleFromGcsMock).not.toHaveBeenCalled();
|
|
1183
|
+
expect(platformImportBundleMock).toHaveBeenCalled();
|
|
1184
|
+
expect(consoleLogSpy).toHaveBeenCalledWith(
|
|
1185
|
+
expect.stringContaining("Teleport complete"),
|
|
1186
|
+
);
|
|
1187
|
+
} finally {
|
|
1188
|
+
globalThis.fetch = originalFetch;
|
|
1189
|
+
}
|
|
1190
|
+
});
|
|
1191
|
+
|
|
1192
|
+
test("fallback: platformRequestUploadUrl throws 404 → falls back to inline import", async () => {
|
|
1193
|
+
setArgv("--from", "my-local", "--platform");
|
|
1194
|
+
|
|
1195
|
+
const localEntry = makeEntry("my-local", { cloud: "local" });
|
|
1196
|
+
|
|
1197
|
+
findAssistantByNameMock.mockImplementation((name: string) => {
|
|
1198
|
+
if (name === "my-local") return localEntry;
|
|
1199
|
+
return null;
|
|
1200
|
+
});
|
|
1201
|
+
|
|
1202
|
+
// Simulate 404 — endpoint doesn't exist on older platform versions
|
|
1203
|
+
platformRequestUploadUrlMock.mockRejectedValue(
|
|
1204
|
+
new Error("Signed uploads are not available on this platform instance"),
|
|
1205
|
+
);
|
|
1206
|
+
|
|
1207
|
+
const originalFetch = globalThis.fetch;
|
|
1208
|
+
globalThis.fetch = createFetchMock() as unknown as typeof globalThis.fetch;
|
|
783
1209
|
|
|
784
1210
|
try {
|
|
785
1211
|
await teleport();
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
expect(
|
|
789
|
-
|
|
790
|
-
expect(
|
|
1212
|
+
|
|
1213
|
+
// Should fall back to inline import
|
|
1214
|
+
expect(platformRequestUploadUrlMock).toHaveBeenCalled();
|
|
1215
|
+
expect(platformUploadToSignedUrlMock).not.toHaveBeenCalled();
|
|
1216
|
+
expect(platformImportBundleFromGcsMock).not.toHaveBeenCalled();
|
|
1217
|
+
expect(platformImportBundleMock).toHaveBeenCalled();
|
|
1218
|
+
expect(consoleLogSpy).toHaveBeenCalledWith(
|
|
1219
|
+
expect.stringContaining("Teleport complete"),
|
|
1220
|
+
);
|
|
1221
|
+
} finally {
|
|
1222
|
+
globalThis.fetch = originalFetch;
|
|
1223
|
+
}
|
|
1224
|
+
});
|
|
1225
|
+
|
|
1226
|
+
test("upload error: platformUploadToSignedUrl throws → error propagates", async () => {
|
|
1227
|
+
setArgv("--from", "my-local", "--platform");
|
|
1228
|
+
|
|
1229
|
+
const localEntry = makeEntry("my-local", { cloud: "local" });
|
|
1230
|
+
|
|
1231
|
+
findAssistantByNameMock.mockImplementation((name: string) => {
|
|
1232
|
+
if (name === "my-local") return localEntry;
|
|
1233
|
+
return null;
|
|
1234
|
+
});
|
|
1235
|
+
|
|
1236
|
+
// Upload succeeds at getting URL but fails during PUT
|
|
1237
|
+
platformUploadToSignedUrlMock.mockRejectedValue(
|
|
1238
|
+
new Error("Upload to signed URL failed: 500 Internal Server Error"),
|
|
1239
|
+
);
|
|
1240
|
+
|
|
1241
|
+
const originalFetch = globalThis.fetch;
|
|
1242
|
+
globalThis.fetch = createFetchMock() as unknown as typeof globalThis.fetch;
|
|
1243
|
+
|
|
1244
|
+
try {
|
|
1245
|
+
await expect(teleport()).rejects.toThrow(
|
|
1246
|
+
"Upload to signed URL failed: 500 Internal Server Error",
|
|
1247
|
+
);
|
|
1248
|
+
// Should NOT fall back to inline import
|
|
1249
|
+
expect(platformImportBundleMock).not.toHaveBeenCalled();
|
|
1250
|
+
expect(platformImportBundleFromGcsMock).not.toHaveBeenCalled();
|
|
1251
|
+
} finally {
|
|
1252
|
+
globalThis.fetch = originalFetch;
|
|
1253
|
+
}
|
|
1254
|
+
});
|
|
1255
|
+
|
|
1256
|
+
test("413 from GCS import: error message includes 'too large'", async () => {
|
|
1257
|
+
setArgv("--from", "my-local", "--platform");
|
|
1258
|
+
|
|
1259
|
+
const localEntry = makeEntry("my-local", { cloud: "local" });
|
|
1260
|
+
|
|
1261
|
+
findAssistantByNameMock.mockImplementation((name: string) => {
|
|
1262
|
+
if (name === "my-local") return localEntry;
|
|
1263
|
+
return null;
|
|
1264
|
+
});
|
|
1265
|
+
|
|
1266
|
+
// GCS import returns 413
|
|
1267
|
+
platformImportBundleFromGcsMock.mockRejectedValue(
|
|
1268
|
+
new Error("Bundle too large to import"),
|
|
1269
|
+
);
|
|
1270
|
+
|
|
1271
|
+
const originalFetch = globalThis.fetch;
|
|
1272
|
+
globalThis.fetch = createFetchMock() as unknown as typeof globalThis.fetch;
|
|
1273
|
+
|
|
1274
|
+
try {
|
|
1275
|
+
await expect(teleport()).rejects.toThrow("too large");
|
|
791
1276
|
} finally {
|
|
792
1277
|
globalThis.fetch = originalFetch;
|
|
793
1278
|
}
|
|
@@ -795,35 +1280,164 @@ describe("teleport extra arguments", () => {
|
|
|
795
1280
|
});
|
|
796
1281
|
|
|
797
1282
|
// ---------------------------------------------------------------------------
|
|
798
|
-
//
|
|
1283
|
+
// Platform teleport org ID and reordered flow tests
|
|
799
1284
|
// ---------------------------------------------------------------------------
|
|
800
1285
|
|
|
801
|
-
describe("teleport
|
|
802
|
-
test("
|
|
803
|
-
setArgv("--
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
1286
|
+
describe("platform teleport org ID and reordered flow", () => {
|
|
1287
|
+
test("hatchAssistant is called with the org ID from fetchOrganizationId", async () => {
|
|
1288
|
+
setArgv("--from", "my-local", "--platform");
|
|
1289
|
+
|
|
1290
|
+
const localEntry = makeEntry("my-local", { cloud: "local" });
|
|
1291
|
+
|
|
1292
|
+
findAssistantByNameMock.mockImplementation((name: string) => {
|
|
1293
|
+
if (name === "my-local") return localEntry;
|
|
1294
|
+
return null;
|
|
1295
|
+
});
|
|
1296
|
+
|
|
1297
|
+
fetchOrganizationIdMock.mockResolvedValue("test-org-456");
|
|
1298
|
+
|
|
1299
|
+
const originalFetch = globalThis.fetch;
|
|
1300
|
+
globalThis.fetch = createFetchMock() as unknown as typeof globalThis.fetch;
|
|
1301
|
+
|
|
1302
|
+
try {
|
|
1303
|
+
await teleport();
|
|
1304
|
+
|
|
1305
|
+
// fetchOrganizationId should have been called
|
|
1306
|
+
expect(fetchOrganizationIdMock).toHaveBeenCalled();
|
|
1307
|
+
// hatchAssistant must be called with (token, orgId)
|
|
1308
|
+
expect(hatchAssistantMock).toHaveBeenCalledWith(
|
|
1309
|
+
"platform-token",
|
|
1310
|
+
"test-org-456",
|
|
1311
|
+
);
|
|
1312
|
+
} finally {
|
|
1313
|
+
globalThis.fetch = originalFetch;
|
|
1314
|
+
}
|
|
810
1315
|
});
|
|
811
1316
|
|
|
812
|
-
test("
|
|
813
|
-
setArgv("--from", "
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
)
|
|
1317
|
+
test("upload to GCS happens before hatchAssistant for platform targets", async () => {
|
|
1318
|
+
setArgv("--from", "my-local", "--platform");
|
|
1319
|
+
|
|
1320
|
+
const localEntry = makeEntry("my-local", { cloud: "local" });
|
|
1321
|
+
|
|
1322
|
+
findAssistantByNameMock.mockImplementation((name: string) => {
|
|
1323
|
+
if (name === "my-local") return localEntry;
|
|
1324
|
+
return null;
|
|
1325
|
+
});
|
|
1326
|
+
|
|
1327
|
+
const callOrder: string[] = [];
|
|
1328
|
+
|
|
1329
|
+
platformRequestUploadUrlMock.mockImplementation(async () => {
|
|
1330
|
+
callOrder.push("platformRequestUploadUrl");
|
|
1331
|
+
return {
|
|
1332
|
+
uploadUrl: "https://storage.googleapis.com/bucket/signed-upload-url",
|
|
1333
|
+
bundleKey: "bundle-key-123",
|
|
1334
|
+
expiresAt: new Date(Date.now() + 3600_000).toISOString(),
|
|
1335
|
+
};
|
|
1336
|
+
});
|
|
1337
|
+
|
|
1338
|
+
platformUploadToSignedUrlMock.mockImplementation(async () => {
|
|
1339
|
+
callOrder.push("platformUploadToSignedUrl");
|
|
1340
|
+
});
|
|
1341
|
+
|
|
1342
|
+
hatchAssistantMock.mockImplementation(async () => {
|
|
1343
|
+
callOrder.push("hatchAssistant");
|
|
1344
|
+
return { id: "platform-new-id", name: "platform-new", status: "active" };
|
|
1345
|
+
});
|
|
1346
|
+
|
|
1347
|
+
const originalFetch = globalThis.fetch;
|
|
1348
|
+
globalThis.fetch = createFetchMock() as unknown as typeof globalThis.fetch;
|
|
1349
|
+
|
|
1350
|
+
try {
|
|
1351
|
+
await teleport();
|
|
1352
|
+
|
|
1353
|
+
// Verify ordering: upload steps come before hatch
|
|
1354
|
+
const uploadUrlIdx = callOrder.indexOf("platformRequestUploadUrl");
|
|
1355
|
+
const uploadIdx = callOrder.indexOf("platformUploadToSignedUrl");
|
|
1356
|
+
const hatchIdx = callOrder.indexOf("hatchAssistant");
|
|
1357
|
+
|
|
1358
|
+
expect(uploadUrlIdx).toBeGreaterThanOrEqual(0);
|
|
1359
|
+
expect(uploadIdx).toBeGreaterThanOrEqual(0);
|
|
1360
|
+
expect(hatchIdx).toBeGreaterThanOrEqual(0);
|
|
1361
|
+
expect(uploadUrlIdx).toBeLessThan(hatchIdx);
|
|
1362
|
+
expect(uploadIdx).toBeLessThan(hatchIdx);
|
|
1363
|
+
} finally {
|
|
1364
|
+
globalThis.fetch = originalFetch;
|
|
1365
|
+
}
|
|
818
1366
|
});
|
|
819
1367
|
|
|
820
|
-
test("
|
|
821
|
-
setArgv("--from", "
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
1368
|
+
test("signed-URL fallback: when platformRequestUploadUrl throws 'not available', falls back to inline upload via importToAssistant", async () => {
|
|
1369
|
+
setArgv("--from", "my-local", "--platform");
|
|
1370
|
+
|
|
1371
|
+
const localEntry = makeEntry("my-local", { cloud: "local" });
|
|
1372
|
+
|
|
1373
|
+
findAssistantByNameMock.mockImplementation((name: string) => {
|
|
1374
|
+
if (name === "my-local") return localEntry;
|
|
1375
|
+
return null;
|
|
1376
|
+
});
|
|
1377
|
+
|
|
1378
|
+
// Simulate 503 — signed uploads not available
|
|
1379
|
+
platformRequestUploadUrlMock.mockRejectedValue(
|
|
1380
|
+
new Error("Signed uploads are not available on this platform instance"),
|
|
827
1381
|
);
|
|
1382
|
+
|
|
1383
|
+
const originalFetch = globalThis.fetch;
|
|
1384
|
+
globalThis.fetch = createFetchMock() as unknown as typeof globalThis.fetch;
|
|
1385
|
+
|
|
1386
|
+
try {
|
|
1387
|
+
await teleport();
|
|
1388
|
+
|
|
1389
|
+
// Upload URL was attempted but failed
|
|
1390
|
+
expect(platformRequestUploadUrlMock).toHaveBeenCalled();
|
|
1391
|
+
// No signed URL upload should have happened
|
|
1392
|
+
expect(platformUploadToSignedUrlMock).not.toHaveBeenCalled();
|
|
1393
|
+
// Should NOT use GCS-based import
|
|
1394
|
+
expect(platformImportBundleFromGcsMock).not.toHaveBeenCalled();
|
|
1395
|
+
// Should fall back to inline import
|
|
1396
|
+
expect(platformImportBundleMock).toHaveBeenCalled();
|
|
1397
|
+
// Hatch should still succeed
|
|
1398
|
+
expect(hatchAssistantMock).toHaveBeenCalled();
|
|
1399
|
+
expect(consoleLogSpy).toHaveBeenCalledWith(
|
|
1400
|
+
expect.stringContaining("Teleport complete"),
|
|
1401
|
+
);
|
|
1402
|
+
} finally {
|
|
1403
|
+
globalThis.fetch = originalFetch;
|
|
1404
|
+
}
|
|
1405
|
+
});
|
|
1406
|
+
|
|
1407
|
+
test("bundleKey from pre-upload is forwarded to platformImportBundleFromGcs", async () => {
|
|
1408
|
+
setArgv("--from", "my-local", "--platform");
|
|
1409
|
+
|
|
1410
|
+
const localEntry = makeEntry("my-local", { cloud: "local" });
|
|
1411
|
+
|
|
1412
|
+
findAssistantByNameMock.mockImplementation((name: string) => {
|
|
1413
|
+
if (name === "my-local") return localEntry;
|
|
1414
|
+
return null;
|
|
1415
|
+
});
|
|
1416
|
+
|
|
1417
|
+
// Return a specific bundle key from the pre-upload step
|
|
1418
|
+
platformRequestUploadUrlMock.mockResolvedValue({
|
|
1419
|
+
uploadUrl: "https://storage.googleapis.com/bucket/signed-upload-url",
|
|
1420
|
+
bundleKey: "pre-uploaded-key-789",
|
|
1421
|
+
expiresAt: new Date(Date.now() + 3600_000).toISOString(),
|
|
1422
|
+
});
|
|
1423
|
+
|
|
1424
|
+
const originalFetch = globalThis.fetch;
|
|
1425
|
+
globalThis.fetch = createFetchMock() as unknown as typeof globalThis.fetch;
|
|
1426
|
+
|
|
1427
|
+
try {
|
|
1428
|
+
await teleport();
|
|
1429
|
+
|
|
1430
|
+
// The bundle key from the pre-upload step should be forwarded to GCS import
|
|
1431
|
+
expect(platformImportBundleFromGcsMock).toHaveBeenCalledWith(
|
|
1432
|
+
"pre-uploaded-key-789",
|
|
1433
|
+
"platform-token",
|
|
1434
|
+
expect.any(String),
|
|
1435
|
+
expect.any(String),
|
|
1436
|
+
);
|
|
1437
|
+
// Inline import should NOT be used since signed upload succeeded
|
|
1438
|
+
expect(platformImportBundleMock).not.toHaveBeenCalled();
|
|
1439
|
+
} finally {
|
|
1440
|
+
globalThis.fetch = originalFetch;
|
|
1441
|
+
}
|
|
828
1442
|
});
|
|
829
1443
|
});
|