@vellumai/cli 0.12.1 → 0.12.2-dev.202609162314.7778bc8

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.
@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test";
2
2
 
3
3
  import {
4
4
  buildShellInvocation,
5
+ buildShellSpawnFlags,
5
6
  pathListDelimiter,
6
7
  prependUniquePathEntries,
7
8
  } from "./shell.js";
@@ -38,6 +39,26 @@ describe("buildShellInvocation", () => {
38
39
  });
39
40
  });
40
41
 
42
+ describe("buildShellSpawnFlags", () => {
43
+ test("creates a POSIX process group and hides Windows consoles", () => {
44
+ expect(buildShellSpawnFlags("linux")).toEqual({
45
+ detached: true,
46
+ windowsHide: true,
47
+ });
48
+ expect(buildShellSpawnFlags("darwin")).toEqual({
49
+ detached: true,
50
+ windowsHide: true,
51
+ });
52
+ });
53
+
54
+ test("does not detach Windows children that use piped stdio", () => {
55
+ expect(buildShellSpawnFlags("win32")).toEqual({
56
+ detached: false,
57
+ windowsHide: true,
58
+ });
59
+ });
60
+ });
61
+
41
62
  describe("path list handling", () => {
42
63
  test("uses the platform delimiter", () => {
43
64
  expect(pathListDelimiter("win32")).toBe(";");
@@ -5,6 +5,11 @@ export interface ShellInvocation {
5
5
  args: string[];
6
6
  }
7
7
 
8
+ export interface ShellSpawnFlags {
9
+ detached: boolean;
10
+ windowsHide: true;
11
+ }
12
+
8
13
  const WINDOWS_UTF8_PREAMBLE =
9
14
  "try { [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 } catch {}; " +
10
15
  "$OutputEncoding = [System.Text.Encoding]::UTF8; " +
@@ -43,6 +48,25 @@ export function buildShellInvocation(
43
48
  return { command: "bash", args: ["-c", "--", command] };
44
49
  }
45
50
 
51
+ /**
52
+ * Spawn flags for assistant-owned shell children (sandbox bash, local
53
+ * host_bash fallback, sanitized CLI bash, skill runners, scheduled scripts).
54
+ *
55
+ * POSIX uses a new process group so timeout/abort can SIGKILL the tree via
56
+ * `-pid`. Windows process trees are torn down with `taskkill /T`, which does
57
+ * not need a detached process. Combining `DETACHED_PROCESS`,
58
+ * `CREATE_NO_WINDOW`, and piped stdio on Windows can emit `close` with exit
59
+ * 0 and empty pipes without running the encoded command.
60
+ */
61
+ export function buildShellSpawnFlags(
62
+ hostPlatform: NodeJS.Platform = process.platform,
63
+ ): ShellSpawnFlags {
64
+ return {
65
+ detached: hostPlatform !== "win32",
66
+ windowsHide: true,
67
+ };
68
+ }
69
+
46
70
  export function pathListDelimiter(
47
71
  hostPlatform: NodeJS.Platform = process.platform,
48
72
  ): string {
@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test";
2
2
 
3
3
  import {
4
4
  buildShellInvocation,
5
+ buildShellSpawnFlags,
5
6
  pathListDelimiter,
6
7
  prependUniquePathEntries,
7
8
  } from "./shell.js";
@@ -38,6 +39,26 @@ describe("buildShellInvocation", () => {
38
39
  });
39
40
  });
40
41
 
42
+ describe("buildShellSpawnFlags", () => {
43
+ test("creates a POSIX process group and hides Windows consoles", () => {
44
+ expect(buildShellSpawnFlags("linux")).toEqual({
45
+ detached: true,
46
+ windowsHide: true,
47
+ });
48
+ expect(buildShellSpawnFlags("darwin")).toEqual({
49
+ detached: true,
50
+ windowsHide: true,
51
+ });
52
+ });
53
+
54
+ test("does not detach Windows children that use piped stdio", () => {
55
+ expect(buildShellSpawnFlags("win32")).toEqual({
56
+ detached: false,
57
+ windowsHide: true,
58
+ });
59
+ });
60
+ });
61
+
41
62
  describe("path list handling", () => {
42
63
  test("uses the platform delimiter", () => {
43
64
  expect(pathListDelimiter("win32")).toBe(";");
@@ -5,6 +5,11 @@ export interface ShellInvocation {
5
5
  args: string[];
6
6
  }
7
7
 
8
+ export interface ShellSpawnFlags {
9
+ detached: boolean;
10
+ windowsHide: true;
11
+ }
12
+
8
13
  const WINDOWS_UTF8_PREAMBLE =
9
14
  "try { [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 } catch {}; " +
10
15
  "$OutputEncoding = [System.Text.Encoding]::UTF8; " +
@@ -43,6 +48,25 @@ export function buildShellInvocation(
43
48
  return { command: "bash", args: ["-c", "--", command] };
44
49
  }
45
50
 
51
+ /**
52
+ * Spawn flags for assistant-owned shell children (sandbox bash, local
53
+ * host_bash fallback, sanitized CLI bash, skill runners, scheduled scripts).
54
+ *
55
+ * POSIX uses a new process group so timeout/abort can SIGKILL the tree via
56
+ * `-pid`. Windows process trees are torn down with `taskkill /T`, which does
57
+ * not need a detached process. Combining `DETACHED_PROCESS`,
58
+ * `CREATE_NO_WINDOW`, and piped stdio on Windows can emit `close` with exit
59
+ * 0 and empty pipes without running the encoded command.
60
+ */
61
+ export function buildShellSpawnFlags(
62
+ hostPlatform: NodeJS.Platform = process.platform,
63
+ ): ShellSpawnFlags {
64
+ return {
65
+ detached: hostPlatform !== "win32",
66
+ windowsHide: true,
67
+ };
68
+ }
69
+
46
70
  export function pathListDelimiter(
47
71
  hostPlatform: NodeJS.Platform = process.platform,
48
72
  ): string {
@@ -6,7 +6,8 @@
6
6
  "type": "module",
7
7
  "exports": {
8
8
  ".": "./src/index.ts",
9
- "./contract": "./src/lockfile-contract.ts"
9
+ "./contract": "./src/lockfile-contract.ts",
10
+ "./teleport-backup-policy": "./src/teleport-backup-policy.ts"
10
11
  },
11
12
  "scripts": {
12
13
  "typecheck": "bunx tsc --noEmit",
@@ -0,0 +1,88 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import {
4
+ hasRecentBackup,
5
+ managedBackupIsReady,
6
+ readyManagedBackupCreatedAts,
7
+ RECENT_BACKUP_MAX_AGE_MS,
8
+ } from "./teleport-backup-policy";
9
+
10
+ const NOW = Date.parse("2026-09-15T12:00:00Z");
11
+
12
+ function ago(ms: number): string {
13
+ return new Date(NOW - ms).toISOString();
14
+ }
15
+
16
+ describe("hasRecentBackup", () => {
17
+ test("true when a backup is within the max age", () => {
18
+ expect(hasRecentBackup([ago(5 * 60_000)], NOW)).toBe(true);
19
+ expect(hasRecentBackup([ago(RECENT_BACKUP_MAX_AGE_MS)], NOW)).toBe(true);
20
+ });
21
+
22
+ test("false when every backup is older than the max age", () => {
23
+ expect(hasRecentBackup([ago(RECENT_BACKUP_MAX_AGE_MS + 1)], NOW)).toBe(
24
+ false,
25
+ );
26
+ expect(hasRecentBackup([ago(2 * 24 * 60 * 60_000)], NOW)).toBe(false);
27
+ });
28
+
29
+ test("false with no backups", () => {
30
+ expect(hasRecentBackup([], NOW)).toBe(false);
31
+ });
32
+
33
+ test("ignores empty, missing, unparseable and future timestamps", () => {
34
+ expect(
35
+ hasRecentBackup(["", undefined, null, "garbage", ago(-60_000)], NOW),
36
+ ).toBe(false);
37
+ expect(hasRecentBackup(["", "garbage", ago(60_000)], NOW)).toBe(true);
38
+ });
39
+
40
+ test("finds a recent backup anywhere in the list", () => {
41
+ expect(
42
+ hasRecentBackup(
43
+ [ago(3 * RECENT_BACKUP_MAX_AGE_MS), ago(30 * 60_000)],
44
+ NOW,
45
+ ),
46
+ ).toBe(true);
47
+ });
48
+
49
+ test("honors a custom max age", () => {
50
+ expect(hasRecentBackup([ago(10 * 60_000)], NOW, 5 * 60_000)).toBe(false);
51
+ expect(hasRecentBackup([ago(10 * 60_000)], NOW, 15 * 60_000)).toBe(true);
52
+ });
53
+ });
54
+
55
+ describe("readyManagedBackupCreatedAts", () => {
56
+ test("keeps ready snapshots and drops pending ones", () => {
57
+ expect(
58
+ readyManagedBackupCreatedAts([
59
+ {
60
+ snapshot_name: "a",
61
+ created_at: "2026-09-15T11:00:00Z",
62
+ ready_to_use: true,
63
+ },
64
+ {
65
+ snapshot_name: "b",
66
+ created_at: "2026-09-15T11:30:00Z",
67
+ ready_to_use: false,
68
+ },
69
+ { snapshot_name: "c", created_at: "2026-09-15T10:00:00Z" },
70
+ ]),
71
+ ).toEqual(["2026-09-15T11:00:00Z", "2026-09-15T10:00:00Z"]);
72
+ });
73
+ });
74
+
75
+ describe("managedBackupIsReady", () => {
76
+ const backups = [
77
+ { snapshot_name: "ready", ready_to_use: true },
78
+ { snapshot_name: "pending", ready_to_use: false },
79
+ { snapshot_name: "unknown" },
80
+ ];
81
+
82
+ test("true only for a snapshot explicitly marked ready", () => {
83
+ expect(managedBackupIsReady(backups, "ready")).toBe(true);
84
+ expect(managedBackupIsReady(backups, "pending")).toBe(false);
85
+ expect(managedBackupIsReady(backups, "unknown")).toBe(false);
86
+ expect(managedBackupIsReady(backups, "missing")).toBe(false);
87
+ });
88
+ });
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Pre-teleport backup policy shared by every teleport surface (CLI and web).
3
+ *
4
+ * A teleport ends with the source assistant being retired, so each surface
5
+ * snapshots the source before any data leaves it. This module holds the
6
+ * decisions those surfaces must agree on: how fresh an existing backup has to
7
+ * be to stand in for a new one, and how a platform (PVC) snapshot is judged
8
+ * restorable. Transport lives with each client; nothing here touches the
9
+ * network or the filesystem.
10
+ */
11
+
12
+ /** A backup at most this old satisfies the pre-teleport backup requirement. */
13
+ export const RECENT_BACKUP_MAX_AGE_MS = 60 * 60 * 1000;
14
+
15
+ /** How long to wait for a freshly cut platform snapshot to become restorable. */
16
+ export const MANAGED_BACKUP_READY_TIMEOUT_MS = 10 * 60 * 1000;
17
+
18
+ /** How often to re-list platform snapshots while waiting for readiness. */
19
+ export const MANAGED_BACKUP_READY_POLL_INTERVAL_MS = 5_000;
20
+
21
+ /**
22
+ * Whether any of `createdAts` (ISO-8601 timestamps; empty, missing and
23
+ * unparseable values are ignored) falls within `maxAgeMs` of `now`.
24
+ */
25
+ export function hasRecentBackup(
26
+ createdAts: Iterable<string | null | undefined>,
27
+ now: number = Date.now(),
28
+ maxAgeMs: number = RECENT_BACKUP_MAX_AGE_MS,
29
+ ): boolean {
30
+ for (const createdAt of createdAts) {
31
+ if (!createdAt) {
32
+ continue;
33
+ }
34
+ const createdMs = Date.parse(createdAt);
35
+ if (Number.isNaN(createdMs)) {
36
+ continue;
37
+ }
38
+ const age = now - createdMs;
39
+ if (age >= 0 && age <= maxAgeMs) {
40
+ return true;
41
+ }
42
+ }
43
+ return false;
44
+ }
45
+
46
+ /** The fields of a platform backup listing entry the policy reads. */
47
+ export interface ManagedBackupEntry {
48
+ snapshot_name?: string;
49
+ created_at?: string;
50
+ ready_to_use?: boolean;
51
+ }
52
+
53
+ /**
54
+ * `created_at` of every restorable platform snapshot. A snapshot still being
55
+ * cut (`ready_to_use: false`) never counts as a usable restore point.
56
+ */
57
+ export function readyManagedBackupCreatedAts(
58
+ backups: readonly ManagedBackupEntry[],
59
+ ): Array<string | undefined> {
60
+ return backups
61
+ .filter((backup) => backup.ready_to_use !== false)
62
+ .map((backup) => backup.created_at);
63
+ }
64
+
65
+ /** Whether the listing shows `snapshotName` as restorable. */
66
+ export function managedBackupIsReady(
67
+ backups: readonly ManagedBackupEntry[],
68
+ snapshotName: string,
69
+ ): boolean {
70
+ return backups.some(
71
+ (backup) =>
72
+ backup.snapshot_name === snapshotName && backup.ready_to_use === true,
73
+ );
74
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/cli",
3
- "version": "0.12.1",
3
+ "version": "0.12.2-dev.202609162314.7778bc8",
4
4
  "description": "CLI tools for vellum-assistant",
5
5
  "type": "module",
6
6
  "exports": {
@@ -0,0 +1,48 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import { assistantBackupFilenamePattern } from "../lib/backup-ops.js";
4
+
5
+ const STAMP = "2026-09-15T14-27-28-123Z";
6
+
7
+ describe("assistantBackupFilenamePattern", () => {
8
+ test("matches this CLI's backups for exactly the given assistant", () => {
9
+ const pattern = assistantBackupFilenamePattern("alpha");
10
+ expect(pattern.test(`alpha-pre-upgrade-${STAMP}.vbundle`)).toBe(true);
11
+ expect(pattern.test(`alpha-pre-teleport-${STAMP}.vbundle`)).toBe(true);
12
+ });
13
+
14
+ test("does not match an assistant whose id extends this one", () => {
15
+ const pattern = assistantBackupFilenamePattern("alpha");
16
+ expect(pattern.test(`alpha-prod-pre-upgrade-${STAMP}.vbundle`)).toBe(false);
17
+ expect(pattern.test(`alpha-prod-pre-teleport-${STAMP}.vbundle`)).toBe(
18
+ false,
19
+ );
20
+ });
21
+
22
+ test("does not match a shorter id's backups from a longer id", () => {
23
+ const pattern = assistantBackupFilenamePattern("alpha-prod");
24
+ expect(pattern.test(`alpha-pre-upgrade-${STAMP}.vbundle`)).toBe(false);
25
+ });
26
+
27
+ test("ignores user-named vellum backup files and other kinds", () => {
28
+ const pattern = assistantBackupFilenamePattern("alpha");
29
+ expect(pattern.test(`alpha-${STAMP}.vbundle`)).toBe(false);
30
+ expect(pattern.test(`alpha-manual-${STAMP}.vbundle`)).toBe(false);
31
+ expect(pattern.test(`alpha-pre-upgrade-${STAMP}.vbundle.enc`)).toBe(false);
32
+ });
33
+
34
+ test("restricts to the requested kinds", () => {
35
+ const pattern = assistantBackupFilenamePattern("alpha", ["pre-teleport"]);
36
+ expect(pattern.test(`alpha-pre-teleport-${STAMP}.vbundle`)).toBe(true);
37
+ expect(pattern.test(`alpha-pre-upgrade-${STAMP}.vbundle`)).toBe(false);
38
+ expect(
39
+ pattern.test(`alpha-pre-teleport-prod-pre-teleport-${STAMP}.vbundle`),
40
+ ).toBe(false);
41
+ });
42
+
43
+ test("escapes regex metacharacters in ids", () => {
44
+ const pattern = assistantBackupFilenamePattern("a.b");
45
+ expect(pattern.test(`a.b-pre-upgrade-${STAMP}.vbundle`)).toBe(true);
46
+ expect(pattern.test(`axb-pre-upgrade-${STAMP}.vbundle`)).toBe(false);
47
+ });
48
+ });
@@ -0,0 +1,208 @@
1
+ import {
2
+ afterAll,
3
+ afterEach,
4
+ beforeEach,
5
+ describe,
6
+ expect,
7
+ mock,
8
+ spyOn,
9
+ test,
10
+ } from "bun:test";
11
+
12
+ import * as platformClient from "../lib/platform-client.js";
13
+ import {
14
+ createPlatformBackup,
15
+ listPlatformBackups,
16
+ } from "../lib/teleport-backup.js";
17
+
18
+ const authHeadersSpy = spyOn(platformClient, "authHeaders").mockResolvedValue({
19
+ "Content-Type": "application/json",
20
+ "X-Session-Token": "platform-token",
21
+ "Vellum-Organization-Id": "org-1",
22
+ });
23
+ const invalidateOrgIdCacheSpy = spyOn(
24
+ platformClient,
25
+ "invalidateOrgIdCache",
26
+ ).mockImplementation(() => {});
27
+
28
+ afterAll(() => {
29
+ authHeadersSpy.mockRestore();
30
+ invalidateOrgIdCacheSpy.mockRestore();
31
+ });
32
+
33
+ let originalFetch: typeof globalThis.fetch;
34
+ let fetchCalls: Array<{ url: string; method: string }>;
35
+ let responses: Array<() => Response>;
36
+
37
+ beforeEach(() => {
38
+ originalFetch = globalThis.fetch;
39
+ fetchCalls = [];
40
+ responses = [];
41
+ globalThis.fetch = mock(
42
+ async (url: string | URL | Request, init?: RequestInit) => {
43
+ fetchCalls.push({
44
+ url: typeof url === "string" ? url : url.toString(),
45
+ method: init?.method ?? "GET",
46
+ });
47
+ const next = responses.shift();
48
+ if (!next) {
49
+ throw new Error("unexpected fetch");
50
+ }
51
+ return next();
52
+ },
53
+ ) as unknown as typeof globalThis.fetch;
54
+ authHeadersSpy.mockClear();
55
+ invalidateOrgIdCacheSpy.mockClear();
56
+ });
57
+
58
+ afterEach(() => {
59
+ globalThis.fetch = originalFetch;
60
+ });
61
+
62
+ const ENTRY = {
63
+ runtimeUrl: "https://platform.vellum.ai",
64
+ assistantId: "11111111-2222-3333-4444-555555555555",
65
+ };
66
+ const BACKUPS_URL = `https://platform.vellum.ai/v1/assistants/${ENTRY.assistantId}/backups/`;
67
+ const FAST_POLL = { pollIntervalMs: 1, timeoutMs: 200 };
68
+
69
+ function json(body: unknown, status = 200): () => Response {
70
+ return () => Response.json(body, { status });
71
+ }
72
+
73
+ describe("listPlatformBackups", () => {
74
+ test("returns created_at of ready snapshots only", async () => {
75
+ responses.push(
76
+ json({
77
+ backups: [
78
+ { created_at: "2026-09-15T11:00:00Z", ready_to_use: true },
79
+ { created_at: "2026-09-15T11:30:00Z", ready_to_use: false },
80
+ { created_at: "2026-09-15T10:00:00Z" },
81
+ ],
82
+ }),
83
+ );
84
+
85
+ const result = await listPlatformBackups(ENTRY, "platform-token");
86
+
87
+ expect(result).toEqual(["2026-09-15T11:00:00Z", "2026-09-15T10:00:00Z"]);
88
+ expect(fetchCalls[0]).toEqual({ url: BACKUPS_URL, method: "GET" });
89
+ expect(authHeadersSpy).toHaveBeenCalledWith(
90
+ "platform-token",
91
+ "https://platform.vellum.ai",
92
+ );
93
+ });
94
+
95
+ test("refreshes the org id cache and retries once on 401", async () => {
96
+ responses.push(() => new Response("unauthorized", { status: 401 }));
97
+ responses.push(json({ backups: [] }));
98
+
99
+ const result = await listPlatformBackups(ENTRY, "platform-token");
100
+
101
+ expect(result).toEqual([]);
102
+ expect(fetchCalls).toHaveLength(2);
103
+ expect(invalidateOrgIdCacheSpy).toHaveBeenCalledWith(
104
+ "platform-token",
105
+ "https://platform.vellum.ai",
106
+ );
107
+ });
108
+
109
+ test("throws on a persistent error", async () => {
110
+ responses.push(() => new Response("Bad gateway", { status: 502 }));
111
+
112
+ await expect(listPlatformBackups(ENTRY, "platform-token")).rejects.toThrow(
113
+ "Platform backup list failed (502): Bad gateway",
114
+ );
115
+ });
116
+ });
117
+
118
+ describe("createPlatformBackup", () => {
119
+ test("returns immediately when the POST reports the snapshot ready", async () => {
120
+ responses.push(json({ snapshot_name: "snap-1", ready_to_use: true }, 201));
121
+
122
+ await createPlatformBackup(ENTRY, "platform-token", FAST_POLL);
123
+
124
+ expect(fetchCalls).toEqual([{ url: BACKUPS_URL, method: "POST" }]);
125
+ });
126
+
127
+ test("polls the listing until the new snapshot is ready", async () => {
128
+ responses.push(json({ snapshot_name: "snap-1", ready_to_use: false }, 201));
129
+ responses.push(
130
+ json({ backups: [{ snapshot_name: "snap-1", ready_to_use: false }] }),
131
+ );
132
+ responses.push(
133
+ json({
134
+ backups: [
135
+ { snapshot_name: "older", ready_to_use: true },
136
+ { snapshot_name: "snap-1", ready_to_use: true },
137
+ ],
138
+ }),
139
+ );
140
+
141
+ await createPlatformBackup(ENTRY, "platform-token", FAST_POLL);
142
+
143
+ expect(fetchCalls.map((call) => call.method)).toEqual([
144
+ "POST",
145
+ "GET",
146
+ "GET",
147
+ ]);
148
+ });
149
+
150
+ test("another ready snapshot does not satisfy the wait", async () => {
151
+ responses.push(json({ snapshot_name: "snap-1" }, 201));
152
+ responses.push(
153
+ json({ backups: [{ snapshot_name: "older", ready_to_use: true }] }),
154
+ );
155
+ responses.push(
156
+ json({ backups: [{ snapshot_name: "snap-1", ready_to_use: true }] }),
157
+ );
158
+
159
+ await createPlatformBackup(ENTRY, "platform-token", FAST_POLL);
160
+
161
+ expect(fetchCalls).toHaveLength(3);
162
+ });
163
+
164
+ test("throws when the snapshot is not ready before the timeout", async () => {
165
+ responses.push(json({ snapshot_name: "snap-1", ready_to_use: false }, 201));
166
+ const pending = json({
167
+ backups: [{ snapshot_name: "snap-1", ready_to_use: false }],
168
+ });
169
+ for (let i = 0; i < 1000; i++) {
170
+ responses.push(pending);
171
+ }
172
+
173
+ await expect(
174
+ createPlatformBackup(ENTRY, "platform-token", {
175
+ pollIntervalMs: 1,
176
+ timeoutMs: 30,
177
+ }),
178
+ ).rejects.toThrow("Platform backup snap-1 was not ready after 0s");
179
+ });
180
+
181
+ test("throws when the POST fails", async () => {
182
+ responses.push(
183
+ () => new Response('{"detail":"Bad gateway"}', { status: 502 }),
184
+ );
185
+
186
+ await expect(
187
+ createPlatformBackup(ENTRY, "platform-token", FAST_POLL),
188
+ ).rejects.toThrow("Platform backup create failed (502)");
189
+ expect(fetchCalls).toHaveLength(1);
190
+ });
191
+
192
+ test("throws when the POST returns no snapshot name", async () => {
193
+ responses.push(json({}, 201));
194
+
195
+ await expect(
196
+ createPlatformBackup(ENTRY, "platform-token", FAST_POLL),
197
+ ).rejects.toThrow("no snapshot name");
198
+ });
199
+
200
+ test("a listing failure while waiting is fatal", async () => {
201
+ responses.push(json({ snapshot_name: "snap-1" }, 201));
202
+ responses.push(() => new Response("Bad gateway", { status: 502 }));
203
+
204
+ await expect(
205
+ createPlatformBackup(ENTRY, "platform-token", FAST_POLL),
206
+ ).rejects.toThrow("Platform backup list failed (502)");
207
+ });
208
+ });