@vellumai/cli 0.5.16 → 0.6.1

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.
@@ -64,7 +64,6 @@ mock.module("../lib/guardian-token.js", () => ({
64
64
  }));
65
65
 
66
66
  const readPlatformTokenMock = mock((): string | null => "platform-token");
67
- const fetchOrganizationIdMock = mock(async () => "org-123");
68
67
  const getPlatformUrlMock = mock(() => "https://platform.vellum.ai");
69
68
  const hatchAssistantMock = mock(async () => ({
70
69
  id: "platform-new-id",
@@ -108,10 +107,40 @@ const platformImportBundleMock = mock(async () => ({
108
107
  },
109
108
  } as Record<string, unknown>,
110
109
  }));
110
+ const platformRequestUploadUrlMock = mock(async () => ({
111
+ uploadUrl: "https://storage.googleapis.com/bucket/signed-upload-url",
112
+ bundleKey: "bundle-key-123",
113
+ expiresAt: new Date(Date.now() + 3600_000).toISOString(),
114
+ }));
115
+ const platformUploadToSignedUrlMock = mock(async () => {});
116
+ const platformImportPreflightFromGcsMock = mock(async () => ({
117
+ statusCode: 200,
118
+ body: {
119
+ can_import: true,
120
+ summary: {
121
+ files_to_create: 2,
122
+ files_to_overwrite: 1,
123
+ files_unchanged: 0,
124
+ total_files: 3,
125
+ },
126
+ } as Record<string, unknown>,
127
+ }));
128
+ const platformImportBundleFromGcsMock = mock(async () => ({
129
+ statusCode: 200,
130
+ body: {
131
+ success: true,
132
+ summary: {
133
+ total_files: 3,
134
+ files_created: 2,
135
+ files_overwritten: 1,
136
+ files_skipped: 0,
137
+ backups_created: 1,
138
+ },
139
+ } as Record<string, unknown>,
140
+ }));
111
141
 
112
142
  mock.module("../lib/platform-client.js", () => ({
113
143
  readPlatformToken: readPlatformTokenMock,
114
- fetchOrganizationId: fetchOrganizationIdMock,
115
144
  getPlatformUrl: getPlatformUrlMock,
116
145
  hatchAssistant: hatchAssistantMock,
117
146
  platformInitiateExport: platformInitiateExportMock,
@@ -119,6 +148,10 @@ mock.module("../lib/platform-client.js", () => ({
119
148
  platformDownloadExport: platformDownloadExportMock,
120
149
  platformImportPreflight: platformImportPreflightMock,
121
150
  platformImportBundle: platformImportBundleMock,
151
+ platformRequestUploadUrl: platformRequestUploadUrlMock,
152
+ platformUploadToSignedUrl: platformUploadToSignedUrlMock,
153
+ platformImportPreflightFromGcs: platformImportPreflightFromGcsMock,
154
+ platformImportBundleFromGcs: platformImportBundleFromGcsMock,
122
155
  }));
123
156
 
124
157
  const hatchLocalMock = mock(async () => {});
@@ -205,8 +238,6 @@ beforeEach(() => {
205
238
 
206
239
  readPlatformTokenMock.mockReset();
207
240
  readPlatformTokenMock.mockReturnValue("platform-token");
208
- fetchOrganizationIdMock.mockReset();
209
- fetchOrganizationIdMock.mockResolvedValue("org-123");
210
241
  getPlatformUrlMock.mockReset();
211
242
  getPlatformUrlMock.mockReturnValue("https://platform.vellum.ai");
212
243
  hatchAssistantMock.mockReset();
@@ -256,6 +287,41 @@ beforeEach(() => {
256
287
  },
257
288
  },
258
289
  });
290
+ platformRequestUploadUrlMock.mockReset();
291
+ platformRequestUploadUrlMock.mockResolvedValue({
292
+ uploadUrl: "https://storage.googleapis.com/bucket/signed-upload-url",
293
+ bundleKey: "bundle-key-123",
294
+ expiresAt: new Date(Date.now() + 3600_000).toISOString(),
295
+ });
296
+ platformUploadToSignedUrlMock.mockReset();
297
+ platformUploadToSignedUrlMock.mockResolvedValue(undefined);
298
+ platformImportPreflightFromGcsMock.mockReset();
299
+ platformImportPreflightFromGcsMock.mockResolvedValue({
300
+ statusCode: 200,
301
+ body: {
302
+ can_import: true,
303
+ summary: {
304
+ files_to_create: 2,
305
+ files_to_overwrite: 1,
306
+ files_unchanged: 0,
307
+ total_files: 3,
308
+ },
309
+ },
310
+ });
311
+ platformImportBundleFromGcsMock.mockReset();
312
+ platformImportBundleFromGcsMock.mockResolvedValue({
313
+ statusCode: 200,
314
+ body: {
315
+ success: true,
316
+ summary: {
317
+ total_files: 3,
318
+ files_created: 2,
319
+ files_overwritten: 1,
320
+ files_skipped: 0,
321
+ backups_created: 1,
322
+ },
323
+ },
324
+ });
259
325
 
260
326
  hatchLocalMock.mockReset();
261
327
  hatchLocalMock.mockResolvedValue(undefined);
@@ -998,3 +1064,363 @@ describe("teleport full flow", () => {
998
1064
  );
999
1065
  });
1000
1066
  });
1067
+
1068
+ // ---------------------------------------------------------------------------
1069
+ // Signed-URL upload tests
1070
+ // ---------------------------------------------------------------------------
1071
+
1072
+ describe("signed-URL upload flow", () => {
1073
+ test("happy path: signed URL upload succeeds → GCS-based import used", async () => {
1074
+ setArgv("--from", "my-local", "--platform");
1075
+
1076
+ const localEntry = makeEntry("my-local", { cloud: "local" });
1077
+
1078
+ findAssistantByNameMock.mockImplementation((name: string) => {
1079
+ if (name === "my-local") return localEntry;
1080
+ return null;
1081
+ });
1082
+
1083
+ const originalFetch = globalThis.fetch;
1084
+ globalThis.fetch = createFetchMock() as unknown as typeof globalThis.fetch;
1085
+
1086
+ try {
1087
+ await teleport();
1088
+
1089
+ // Signed-URL flow should be used
1090
+ expect(platformRequestUploadUrlMock).toHaveBeenCalled();
1091
+ expect(platformUploadToSignedUrlMock).toHaveBeenCalled();
1092
+ expect(platformImportBundleFromGcsMock).toHaveBeenCalledWith(
1093
+ "bundle-key-123",
1094
+ "platform-token",
1095
+ "https://platform.vellum.ai",
1096
+ );
1097
+ // Inline import should NOT be called
1098
+ expect(platformImportBundleMock).not.toHaveBeenCalled();
1099
+ expect(consoleLogSpy).toHaveBeenCalledWith(
1100
+ expect.stringContaining("Teleport complete"),
1101
+ );
1102
+ } finally {
1103
+ globalThis.fetch = originalFetch;
1104
+ }
1105
+ });
1106
+
1107
+ test("happy path dry-run: signed URL upload succeeds → GCS-based preflight used", async () => {
1108
+ setArgv(
1109
+ "--from",
1110
+ "my-local",
1111
+ "--platform",
1112
+ "existing-platform",
1113
+ "--dry-run",
1114
+ );
1115
+
1116
+ const localEntry = makeEntry("my-local", { cloud: "local" });
1117
+ const platformEntry = makeEntry("existing-platform", {
1118
+ cloud: "vellum",
1119
+ runtimeUrl: "https://platform.vellum.ai",
1120
+ });
1121
+
1122
+ findAssistantByNameMock.mockImplementation((name: string) => {
1123
+ if (name === "my-local") return localEntry;
1124
+ if (name === "existing-platform") return platformEntry;
1125
+ return null;
1126
+ });
1127
+
1128
+ const originalFetch = globalThis.fetch;
1129
+ globalThis.fetch = createFetchMock() as unknown as typeof globalThis.fetch;
1130
+
1131
+ try {
1132
+ await teleport();
1133
+
1134
+ // Signed-URL flow should be used for preflight
1135
+ expect(platformRequestUploadUrlMock).toHaveBeenCalled();
1136
+ expect(platformUploadToSignedUrlMock).toHaveBeenCalled();
1137
+ expect(platformImportPreflightFromGcsMock).toHaveBeenCalledWith(
1138
+ "bundle-key-123",
1139
+ "platform-token",
1140
+ "https://platform.vellum.ai",
1141
+ );
1142
+ // Inline preflight should NOT be called
1143
+ expect(platformImportPreflightMock).not.toHaveBeenCalled();
1144
+ } finally {
1145
+ globalThis.fetch = originalFetch;
1146
+ }
1147
+ });
1148
+
1149
+ test("fallback: platformRequestUploadUrl throws 503 → falls back to inline import", async () => {
1150
+ setArgv("--from", "my-local", "--platform");
1151
+
1152
+ const localEntry = makeEntry("my-local", { cloud: "local" });
1153
+
1154
+ findAssistantByNameMock.mockImplementation((name: string) => {
1155
+ if (name === "my-local") return localEntry;
1156
+ return null;
1157
+ });
1158
+
1159
+ // Simulate 503 — "not available" in the error message
1160
+ platformRequestUploadUrlMock.mockRejectedValue(
1161
+ new Error("Signed uploads are not available on this platform instance"),
1162
+ );
1163
+
1164
+ const originalFetch = globalThis.fetch;
1165
+ globalThis.fetch = createFetchMock() as unknown as typeof globalThis.fetch;
1166
+
1167
+ try {
1168
+ await teleport();
1169
+
1170
+ // Should fall back to inline import
1171
+ expect(platformRequestUploadUrlMock).toHaveBeenCalled();
1172
+ expect(platformUploadToSignedUrlMock).not.toHaveBeenCalled();
1173
+ expect(platformImportBundleFromGcsMock).not.toHaveBeenCalled();
1174
+ expect(platformImportBundleMock).toHaveBeenCalled();
1175
+ expect(consoleLogSpy).toHaveBeenCalledWith(
1176
+ expect.stringContaining("Teleport complete"),
1177
+ );
1178
+ } finally {
1179
+ globalThis.fetch = originalFetch;
1180
+ }
1181
+ });
1182
+
1183
+ test("fallback: platformRequestUploadUrl throws 404 → falls back to inline import", async () => {
1184
+ setArgv("--from", "my-local", "--platform");
1185
+
1186
+ const localEntry = makeEntry("my-local", { cloud: "local" });
1187
+
1188
+ findAssistantByNameMock.mockImplementation((name: string) => {
1189
+ if (name === "my-local") return localEntry;
1190
+ return null;
1191
+ });
1192
+
1193
+ // Simulate 404 — endpoint doesn't exist on older platform versions
1194
+ platformRequestUploadUrlMock.mockRejectedValue(
1195
+ new Error("Signed uploads are not available on this platform instance"),
1196
+ );
1197
+
1198
+ const originalFetch = globalThis.fetch;
1199
+ globalThis.fetch = createFetchMock() as unknown as typeof globalThis.fetch;
1200
+
1201
+ try {
1202
+ await teleport();
1203
+
1204
+ // Should fall back to inline import
1205
+ expect(platformRequestUploadUrlMock).toHaveBeenCalled();
1206
+ expect(platformUploadToSignedUrlMock).not.toHaveBeenCalled();
1207
+ expect(platformImportBundleFromGcsMock).not.toHaveBeenCalled();
1208
+ expect(platformImportBundleMock).toHaveBeenCalled();
1209
+ expect(consoleLogSpy).toHaveBeenCalledWith(
1210
+ expect.stringContaining("Teleport complete"),
1211
+ );
1212
+ } finally {
1213
+ globalThis.fetch = originalFetch;
1214
+ }
1215
+ });
1216
+
1217
+ test("upload error: platformUploadToSignedUrl throws → error propagates", async () => {
1218
+ setArgv("--from", "my-local", "--platform");
1219
+
1220
+ const localEntry = makeEntry("my-local", { cloud: "local" });
1221
+
1222
+ findAssistantByNameMock.mockImplementation((name: string) => {
1223
+ if (name === "my-local") return localEntry;
1224
+ return null;
1225
+ });
1226
+
1227
+ // Upload succeeds at getting URL but fails during PUT
1228
+ platformUploadToSignedUrlMock.mockRejectedValue(
1229
+ new Error("Upload to signed URL failed: 500 Internal Server Error"),
1230
+ );
1231
+
1232
+ const originalFetch = globalThis.fetch;
1233
+ globalThis.fetch = createFetchMock() as unknown as typeof globalThis.fetch;
1234
+
1235
+ try {
1236
+ await expect(teleport()).rejects.toThrow(
1237
+ "Upload to signed URL failed: 500 Internal Server Error",
1238
+ );
1239
+ // Should NOT fall back to inline import
1240
+ expect(platformImportBundleMock).not.toHaveBeenCalled();
1241
+ expect(platformImportBundleFromGcsMock).not.toHaveBeenCalled();
1242
+ } finally {
1243
+ globalThis.fetch = originalFetch;
1244
+ }
1245
+ });
1246
+
1247
+ test("413 from GCS import: error message includes 'too large'", async () => {
1248
+ setArgv("--from", "my-local", "--platform");
1249
+
1250
+ const localEntry = makeEntry("my-local", { cloud: "local" });
1251
+
1252
+ findAssistantByNameMock.mockImplementation((name: string) => {
1253
+ if (name === "my-local") return localEntry;
1254
+ return null;
1255
+ });
1256
+
1257
+ // GCS import returns 413
1258
+ platformImportBundleFromGcsMock.mockRejectedValue(
1259
+ new Error("Bundle too large to import"),
1260
+ );
1261
+
1262
+ const originalFetch = globalThis.fetch;
1263
+ globalThis.fetch = createFetchMock() as unknown as typeof globalThis.fetch;
1264
+
1265
+ try {
1266
+ await expect(teleport()).rejects.toThrow("too large");
1267
+ } finally {
1268
+ globalThis.fetch = originalFetch;
1269
+ }
1270
+ });
1271
+ });
1272
+
1273
+ // ---------------------------------------------------------------------------
1274
+ // Platform teleport org ID and reordered flow tests
1275
+ // ---------------------------------------------------------------------------
1276
+
1277
+ describe("platform teleport org ID and reordered flow", () => {
1278
+ test("hatchAssistant is called without orgId (authHeaders fetches it internally)", async () => {
1279
+ setArgv("--from", "my-local", "--platform");
1280
+
1281
+ const localEntry = makeEntry("my-local", { cloud: "local" });
1282
+
1283
+ findAssistantByNameMock.mockImplementation((name: string) => {
1284
+ if (name === "my-local") return localEntry;
1285
+ return null;
1286
+ });
1287
+
1288
+ const originalFetch = globalThis.fetch;
1289
+ globalThis.fetch = createFetchMock() as unknown as typeof globalThis.fetch;
1290
+
1291
+ try {
1292
+ await teleport();
1293
+
1294
+ // hatchAssistant should be called with just the token (orgId is resolved internally by authHeaders)
1295
+ expect(hatchAssistantMock).toHaveBeenCalledWith("platform-token");
1296
+ } finally {
1297
+ globalThis.fetch = originalFetch;
1298
+ }
1299
+ });
1300
+
1301
+ test("upload to GCS happens before hatchAssistant for platform targets", async () => {
1302
+ setArgv("--from", "my-local", "--platform");
1303
+
1304
+ const localEntry = makeEntry("my-local", { cloud: "local" });
1305
+
1306
+ findAssistantByNameMock.mockImplementation((name: string) => {
1307
+ if (name === "my-local") return localEntry;
1308
+ return null;
1309
+ });
1310
+
1311
+ const callOrder: string[] = [];
1312
+
1313
+ platformRequestUploadUrlMock.mockImplementation(async () => {
1314
+ callOrder.push("platformRequestUploadUrl");
1315
+ return {
1316
+ uploadUrl: "https://storage.googleapis.com/bucket/signed-upload-url",
1317
+ bundleKey: "bundle-key-123",
1318
+ expiresAt: new Date(Date.now() + 3600_000).toISOString(),
1319
+ };
1320
+ });
1321
+
1322
+ platformUploadToSignedUrlMock.mockImplementation(async () => {
1323
+ callOrder.push("platformUploadToSignedUrl");
1324
+ });
1325
+
1326
+ hatchAssistantMock.mockImplementation(async () => {
1327
+ callOrder.push("hatchAssistant");
1328
+ return { id: "platform-new-id", name: "platform-new", status: "active" };
1329
+ });
1330
+
1331
+ const originalFetch = globalThis.fetch;
1332
+ globalThis.fetch = createFetchMock() as unknown as typeof globalThis.fetch;
1333
+
1334
+ try {
1335
+ await teleport();
1336
+
1337
+ // Verify ordering: upload steps come before hatch
1338
+ const uploadUrlIdx = callOrder.indexOf("platformRequestUploadUrl");
1339
+ const uploadIdx = callOrder.indexOf("platformUploadToSignedUrl");
1340
+ const hatchIdx = callOrder.indexOf("hatchAssistant");
1341
+
1342
+ expect(uploadUrlIdx).toBeGreaterThanOrEqual(0);
1343
+ expect(uploadIdx).toBeGreaterThanOrEqual(0);
1344
+ expect(hatchIdx).toBeGreaterThanOrEqual(0);
1345
+ expect(uploadUrlIdx).toBeLessThan(hatchIdx);
1346
+ expect(uploadIdx).toBeLessThan(hatchIdx);
1347
+ } finally {
1348
+ globalThis.fetch = originalFetch;
1349
+ }
1350
+ });
1351
+
1352
+ test("signed-URL fallback: when platformRequestUploadUrl throws 'not available', falls back to inline upload via importToAssistant", async () => {
1353
+ setArgv("--from", "my-local", "--platform");
1354
+
1355
+ const localEntry = makeEntry("my-local", { cloud: "local" });
1356
+
1357
+ findAssistantByNameMock.mockImplementation((name: string) => {
1358
+ if (name === "my-local") return localEntry;
1359
+ return null;
1360
+ });
1361
+
1362
+ // Simulate 503 — signed uploads not available
1363
+ platformRequestUploadUrlMock.mockRejectedValue(
1364
+ new Error("Signed uploads are not available on this platform instance"),
1365
+ );
1366
+
1367
+ const originalFetch = globalThis.fetch;
1368
+ globalThis.fetch = createFetchMock() as unknown as typeof globalThis.fetch;
1369
+
1370
+ try {
1371
+ await teleport();
1372
+
1373
+ // Upload URL was attempted but failed
1374
+ expect(platformRequestUploadUrlMock).toHaveBeenCalled();
1375
+ // No signed URL upload should have happened
1376
+ expect(platformUploadToSignedUrlMock).not.toHaveBeenCalled();
1377
+ // Should NOT use GCS-based import
1378
+ expect(platformImportBundleFromGcsMock).not.toHaveBeenCalled();
1379
+ // Should fall back to inline import
1380
+ expect(platformImportBundleMock).toHaveBeenCalled();
1381
+ // Hatch should still succeed
1382
+ expect(hatchAssistantMock).toHaveBeenCalled();
1383
+ expect(consoleLogSpy).toHaveBeenCalledWith(
1384
+ expect.stringContaining("Teleport complete"),
1385
+ );
1386
+ } finally {
1387
+ globalThis.fetch = originalFetch;
1388
+ }
1389
+ });
1390
+
1391
+ test("bundleKey from pre-upload is forwarded to platformImportBundleFromGcs", async () => {
1392
+ setArgv("--from", "my-local", "--platform");
1393
+
1394
+ const localEntry = makeEntry("my-local", { cloud: "local" });
1395
+
1396
+ findAssistantByNameMock.mockImplementation((name: string) => {
1397
+ if (name === "my-local") return localEntry;
1398
+ return null;
1399
+ });
1400
+
1401
+ // Return a specific bundle key from the pre-upload step
1402
+ platformRequestUploadUrlMock.mockResolvedValue({
1403
+ uploadUrl: "https://storage.googleapis.com/bucket/signed-upload-url",
1404
+ bundleKey: "pre-uploaded-key-789",
1405
+ expiresAt: new Date(Date.now() + 3600_000).toISOString(),
1406
+ });
1407
+
1408
+ const originalFetch = globalThis.fetch;
1409
+ globalThis.fetch = createFetchMock() as unknown as typeof globalThis.fetch;
1410
+
1411
+ try {
1412
+ await teleport();
1413
+
1414
+ // The bundle key from the pre-upload step should be forwarded to GCS import
1415
+ expect(platformImportBundleFromGcsMock).toHaveBeenCalledWith(
1416
+ "pre-uploaded-key-789",
1417
+ "platform-token",
1418
+ expect.any(String),
1419
+ );
1420
+ // Inline import should NOT be used since signed upload succeeded
1421
+ expect(platformImportBundleMock).not.toHaveBeenCalled();
1422
+ } finally {
1423
+ globalThis.fetch = originalFetch;
1424
+ }
1425
+ });
1426
+ });
@@ -0,0 +1,206 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import {
4
+ parseVersion,
5
+ compareVersions,
6
+ isVersionCompatible,
7
+ } from "../lib/version-compat.js";
8
+
9
+ describe("parseVersion", () => {
10
+ test("parses basic semver", () => {
11
+ expect(parseVersion("1.2.3")).toEqual({
12
+ major: 1,
13
+ minor: 2,
14
+ patch: 3,
15
+ pre: null,
16
+ });
17
+ });
18
+
19
+ test("strips v prefix", () => {
20
+ expect(parseVersion("v1.2.3")).toEqual({
21
+ major: 1,
22
+ minor: 2,
23
+ patch: 3,
24
+ pre: null,
25
+ });
26
+ });
27
+
28
+ test("strips V prefix", () => {
29
+ expect(parseVersion("V1.2.3")).toEqual({
30
+ major: 1,
31
+ minor: 2,
32
+ patch: 3,
33
+ pre: null,
34
+ });
35
+ });
36
+
37
+ test("defaults missing patch to 0", () => {
38
+ expect(parseVersion("1.2")).toEqual({
39
+ major: 1,
40
+ minor: 2,
41
+ patch: 0,
42
+ pre: null,
43
+ });
44
+ });
45
+
46
+ test("captures pre-release suffix", () => {
47
+ expect(parseVersion("0.6.0-staging.5")).toEqual({
48
+ major: 0,
49
+ minor: 6,
50
+ patch: 0,
51
+ pre: "staging.5",
52
+ });
53
+ });
54
+
55
+ test("captures pre-release with v prefix", () => {
56
+ expect(parseVersion("v0.6.0-staging.1")).toEqual({
57
+ major: 0,
58
+ minor: 6,
59
+ patch: 0,
60
+ pre: "staging.1",
61
+ });
62
+ });
63
+
64
+ test("captures hyphenated pre-release suffix", () => {
65
+ expect(parseVersion("1.0.0-pre-release-1")).toEqual({
66
+ major: 1,
67
+ minor: 0,
68
+ patch: 0,
69
+ pre: "pre-release-1",
70
+ });
71
+ });
72
+
73
+ test("returns null for single segment", () => {
74
+ expect(parseVersion("1")).toBeNull();
75
+ });
76
+
77
+ test("returns null for non-numeric segments", () => {
78
+ expect(parseVersion("abc.def")).toBeNull();
79
+ });
80
+
81
+ test("returns null for empty string", () => {
82
+ expect(parseVersion("")).toBeNull();
83
+ });
84
+ });
85
+
86
+ describe("compareVersions", () => {
87
+ // ── Basic numeric comparison ──────────────────────────────────────
88
+ test("equal versions return 0", () => {
89
+ expect(compareVersions("1.2.3", "1.2.3")).toBe(0);
90
+ });
91
+
92
+ test("higher major returns positive", () => {
93
+ expect(compareVersions("2.0.0", "1.0.0")).toBeGreaterThan(0);
94
+ });
95
+
96
+ test("lower major returns negative", () => {
97
+ expect(compareVersions("1.0.0", "2.0.0")).toBeLessThan(0);
98
+ });
99
+
100
+ test("higher minor returns positive", () => {
101
+ expect(compareVersions("1.3.0", "1.2.0")).toBeGreaterThan(0);
102
+ });
103
+
104
+ test("higher patch returns positive", () => {
105
+ expect(compareVersions("1.2.4", "1.2.3")).toBeGreaterThan(0);
106
+ });
107
+
108
+ // ── v prefix handling ─────────────────────────────────────────────
109
+ test("strips v prefix for comparison", () => {
110
+ expect(compareVersions("v1.2.3", "1.2.3")).toBe(0);
111
+ });
112
+
113
+ // ── Pre-release vs release ────────────────────────────────────────
114
+ test("pre-release sorts lower than release", () => {
115
+ expect(compareVersions("0.6.0-staging.1", "0.6.0")).toBeLessThan(0);
116
+ });
117
+
118
+ test("release sorts higher than pre-release", () => {
119
+ expect(compareVersions("0.6.0", "0.6.0-staging.1")).toBeGreaterThan(0);
120
+ });
121
+
122
+ // ── Pre-release numeric comparison ────────────────────────────────
123
+ test("staging.1 < staging.2", () => {
124
+ expect(compareVersions("0.6.0-staging.1", "0.6.0-staging.2")).toBeLessThan(
125
+ 0,
126
+ );
127
+ });
128
+
129
+ test("staging.10 > staging.2 (numeric, not lexical)", () => {
130
+ expect(
131
+ compareVersions("0.6.0-staging.10", "0.6.0-staging.2"),
132
+ ).toBeGreaterThan(0);
133
+ });
134
+
135
+ // ── Pre-release lexical comparison ────────────────────────────────
136
+ test("alpha < beta (lexical)", () => {
137
+ expect(compareVersions("1.0.0-alpha", "1.0.0-beta")).toBeLessThan(0);
138
+ });
139
+
140
+ // ── Mixed numeric vs non-numeric per §11.4.4 ─────────────────────
141
+ test("numeric identifier sorts lower than non-numeric", () => {
142
+ expect(compareVersions("1.0.0-1", "1.0.0-alpha")).toBeLessThan(0);
143
+ });
144
+
145
+ // ── Fewer pre-release identifiers sorts earlier ───────────────────
146
+ test("fewer pre-release identifiers sorts earlier", () => {
147
+ expect(compareVersions("1.0.0-alpha", "1.0.0-alpha.1")).toBeLessThan(0);
148
+ });
149
+
150
+ // ── Returns null for unparseable input ────────────────────────────
151
+ test("returns null if first version is unparseable", () => {
152
+ expect(compareVersions("bad", "1.0.0")).toBeNull();
153
+ });
154
+
155
+ test("returns null if second version is unparseable", () => {
156
+ expect(compareVersions("1.0.0", "bad")).toBeNull();
157
+ });
158
+
159
+ // ── Different major.minor.patch trumps pre-release ────────────────
160
+ test("higher patch wins regardless of pre-release", () => {
161
+ expect(compareVersions("0.6.1", "0.6.0-staging.99")).toBeGreaterThan(0);
162
+ });
163
+
164
+ // ── Sort integration ──────────────────────────────────────────────
165
+ test("Array.sort produces correct semver order", () => {
166
+ const versions = [
167
+ "0.6.0",
168
+ "0.6.0-staging.2",
169
+ "0.5.9",
170
+ "0.6.0-staging.10",
171
+ "v0.6.0-staging.1",
172
+ "0.6.1",
173
+ ];
174
+ const sorted = [...versions].sort((a, b) => compareVersions(a, b) ?? 0);
175
+ expect(sorted).toEqual([
176
+ "0.5.9",
177
+ "v0.6.0-staging.1",
178
+ "0.6.0-staging.2",
179
+ "0.6.0-staging.10",
180
+ "0.6.0",
181
+ "0.6.1",
182
+ ]);
183
+ });
184
+ });
185
+
186
+ describe("isVersionCompatible", () => {
187
+ test("same major.minor are compatible", () => {
188
+ expect(isVersionCompatible("1.2.3", "1.2.5")).toBe(true);
189
+ });
190
+
191
+ test("different minor are incompatible", () => {
192
+ expect(isVersionCompatible("1.2.3", "1.3.0")).toBe(false);
193
+ });
194
+
195
+ test("different major are incompatible", () => {
196
+ expect(isVersionCompatible("1.2.3", "2.2.3")).toBe(false);
197
+ });
198
+
199
+ test("pre-release on same major.minor is compatible", () => {
200
+ expect(isVersionCompatible("0.6.0-staging.5", "0.6.0")).toBe(true);
201
+ });
202
+
203
+ test("returns false for unparseable input", () => {
204
+ expect(isVersionCompatible("bad", "1.0.0")).toBe(false);
205
+ });
206
+ });