@vellumai/cli 0.6.6 → 0.7.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.
- package/AGENTS.md +8 -2
- package/README.md +49 -0
- package/package.json +1 -1
- package/src/__tests__/assistant-config.test.ts +1 -7
- package/src/__tests__/backup.test.ts +475 -0
- package/src/__tests__/config-utils.test.ts +146 -0
- package/src/__tests__/env-drift.test.ts +10 -32
- package/src/__tests__/llm-provider-env-var-parity.test.ts +1 -21
- package/src/__tests__/multi-local.test.ts +0 -5
- package/src/__tests__/sleep.test.ts +1 -2
- package/src/__tests__/teleport.test.ts +988 -1266
- package/src/commands/backup.ts +117 -71
- package/src/commands/client.ts +10 -9
- package/src/commands/env.ts +93 -0
- package/src/commands/events.ts +2 -0
- package/src/commands/exec.ts +58 -13
- package/src/commands/login.ts +77 -12
- package/src/commands/logs.ts +2 -7
- package/src/commands/ps.ts +144 -25
- package/src/commands/restore.ts +26 -47
- package/src/commands/sleep.ts +5 -2
- package/src/commands/ssh.ts +17 -7
- package/src/commands/teleport.ts +462 -584
- package/src/commands/terminal.ts +9 -221
- package/src/commands/tunnel.ts +2 -7
- package/src/commands/upgrade.ts +108 -7
- package/src/commands/wake.ts +2 -1
- package/src/components/DefaultMainScreen.tsx +328 -154
- package/src/index.ts +5 -7
- package/src/lib/__tests__/docker.test.ts +50 -74
- package/src/lib/__tests__/job-polling.test.ts +278 -0
- package/src/lib/__tests__/local-runtime-client.test.ts +480 -0
- package/src/lib/__tests__/platform-client-signed-url.test.ts +405 -0
- package/src/lib/__tests__/runtime-url.test.ts +87 -0
- package/src/lib/__tests__/terminal-session.test.ts +202 -0
- package/src/lib/assistant-client.ts +5 -21
- package/src/lib/assistant-config.ts +46 -24
- package/src/lib/cli-error.ts +1 -0
- package/src/lib/client-identity.ts +67 -0
- package/src/lib/docker.ts +75 -77
- package/src/lib/environments/__tests__/paths.test.ts +2 -0
- package/src/lib/environments/resolve.ts +89 -7
- package/src/lib/environments/seeds.ts +8 -5
- package/src/lib/environments/types.ts +10 -0
- package/src/lib/hatch-local.ts +15 -120
- package/src/lib/health-check.ts +98 -0
- package/src/lib/job-polling.ts +195 -0
- package/src/lib/local-runtime-client.ts +231 -0
- package/src/lib/local.ts +165 -72
- package/src/lib/orphan-detection.ts +2 -35
- package/src/lib/platform-client.ts +190 -194
- package/src/lib/platform-releases.ts +23 -0
- package/src/lib/retire-local.ts +6 -2
- package/src/lib/runtime-url.ts +30 -0
- package/src/lib/sync-cloud-assistants.ts +126 -0
- package/src/lib/terminal-client.ts +6 -1
- package/src/lib/terminal-session.ts +536 -0
- package/src/lib/tui-log.ts +60 -0
- package/src/lib/xdg-log.ts +10 -4
- package/src/shared/provider-env-vars.ts +2 -3
- package/src/__tests__/orphan-detection.test.ts +0 -214
|
@@ -1,11 +1,9 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, test, expect } from "bun:test";
|
|
2
2
|
import {
|
|
3
3
|
ASSISTANT_INTERNAL_PORT,
|
|
4
|
-
|
|
4
|
+
AVATAR_DEVICE_ENV_VAR,
|
|
5
5
|
dockerResourceNames,
|
|
6
|
-
|
|
7
|
-
MEET_AVATAR_ENV_VAR,
|
|
8
|
-
resolveMeetAvatarDevicePath,
|
|
6
|
+
resolveAvatarDevicePath,
|
|
9
7
|
serviceDockerRunArgs,
|
|
10
8
|
type ServiceName,
|
|
11
9
|
} from "../docker.js";
|
|
@@ -17,21 +15,42 @@ const imageTags: Record<ServiceName, string> = {
|
|
|
17
15
|
gateway: "vellumai/vellum-gateway:test",
|
|
18
16
|
};
|
|
19
17
|
|
|
20
|
-
function buildAssistantArgs(
|
|
18
|
+
function buildAssistantArgs(
|
|
19
|
+
overrides: Partial<Parameters<typeof serviceDockerRunArgs>[0]> = {},
|
|
20
|
+
): string[] {
|
|
21
21
|
const res = dockerResourceNames(instanceName);
|
|
22
22
|
const builders = serviceDockerRunArgs({
|
|
23
23
|
gatewayPort: 7830,
|
|
24
24
|
imageTags,
|
|
25
25
|
instanceName,
|
|
26
26
|
res,
|
|
27
|
+
...overrides,
|
|
27
28
|
});
|
|
28
29
|
return builders.assistant();
|
|
29
30
|
}
|
|
30
31
|
|
|
31
32
|
describe("serviceDockerRunArgs — assistant", () => {
|
|
32
|
-
test("
|
|
33
|
+
test("grants the minimum capability set needed for DinD (SYS_ADMIN + NET_ADMIN) rather than --privileged", () => {
|
|
33
34
|
const args = buildAssistantArgs();
|
|
34
|
-
expect(args).toContain("--privileged");
|
|
35
|
+
expect(args).not.toContain("--privileged");
|
|
36
|
+
// --cap-add SYS_ADMIN and --cap-add NET_ADMIN are each passed as two
|
|
37
|
+
// adjacent args: "--cap-add" followed by the capability name.
|
|
38
|
+
const sysAdminIdx = args.indexOf("SYS_ADMIN");
|
|
39
|
+
expect(sysAdminIdx).toBeGreaterThan(0);
|
|
40
|
+
expect(args[sysAdminIdx - 1]).toBe("--cap-add");
|
|
41
|
+
const netAdminIdx = args.indexOf("NET_ADMIN");
|
|
42
|
+
expect(netAdminIdx).toBeGreaterThan(0);
|
|
43
|
+
expect(args[netAdminIdx - 1]).toBe("--cap-add");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("disables the default seccomp and AppArmor profiles so the inner dockerd can mount overlayfs and run pivot_root", () => {
|
|
47
|
+
const args = buildAssistantArgs();
|
|
48
|
+
const seccompIdx = args.indexOf("seccomp=unconfined");
|
|
49
|
+
expect(seccompIdx).toBeGreaterThan(0);
|
|
50
|
+
expect(args[seccompIdx - 1]).toBe("--security-opt");
|
|
51
|
+
const apparmorIdx = args.indexOf("apparmor=unconfined");
|
|
52
|
+
expect(apparmorIdx).toBeGreaterThan(0);
|
|
53
|
+
expect(args[apparmorIdx - 1]).toBe("--security-opt");
|
|
35
54
|
});
|
|
36
55
|
|
|
37
56
|
test("mounts a dedicated named volume at /var/lib/docker for the inner dockerd data store", () => {
|
|
@@ -79,90 +98,47 @@ describe("serviceDockerRunArgs — assistant", () => {
|
|
|
79
98
|
expect(portIndex).toBeGreaterThan(0);
|
|
80
99
|
expect(args[portIndex - 1]).toBe("-p");
|
|
81
100
|
});
|
|
82
|
-
});
|
|
83
|
-
|
|
84
|
-
describe("Meet avatar device passthrough (VELLUM_MEET_AVATAR opt-in)", () => {
|
|
85
|
-
// Snapshot + restore the process env so tests can flip the env-var
|
|
86
|
-
// without leaking state to later suites or other CLI tests.
|
|
87
|
-
const originalEnv: Record<string, string | undefined> = {};
|
|
88
101
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
delete process.env[key];
|
|
93
|
-
}
|
|
102
|
+
test("forwards GUARDIAN_BOOTSTRAP_SECRET into the assistant container when provided, so the runtime can validate the gateway's x-bootstrap-secret header and close the published-port bypass", () => {
|
|
103
|
+
const args = buildAssistantArgs({ bootstrapSecret: "super-secret-abc" });
|
|
104
|
+
expect(args).toContain("GUARDIAN_BOOTSTRAP_SECRET=super-secret-abc");
|
|
94
105
|
});
|
|
95
106
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
107
|
+
test("omits GUARDIAN_BOOTSTRAP_SECRET when no bootstrapSecret is provided (bare-metal-style caller should not inherit a stale secret)", () => {
|
|
108
|
+
const args = buildAssistantArgs();
|
|
109
|
+
expect(args.some((a) => a.startsWith("GUARDIAN_BOOTSTRAP_SECRET="))).toBe(
|
|
110
|
+
false,
|
|
111
|
+
);
|
|
101
112
|
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
describe("VELLUM_AVATAR_DEVICE passthrough", () => {
|
|
116
|
+
const savedValue = process.env[AVATAR_DEVICE_ENV_VAR];
|
|
102
117
|
|
|
103
|
-
|
|
104
|
-
|
|
118
|
+
beforeEach(() => {
|
|
119
|
+
delete process.env[AVATAR_DEVICE_ENV_VAR];
|
|
105
120
|
});
|
|
106
121
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
null,
|
|
111
|
-
);
|
|
112
|
-
}
|
|
122
|
+
afterEach(() => {
|
|
123
|
+
if (savedValue === undefined) delete process.env[AVATAR_DEVICE_ENV_VAR];
|
|
124
|
+
else process.env[AVATAR_DEVICE_ENV_VAR] = savedValue;
|
|
113
125
|
});
|
|
114
126
|
|
|
115
|
-
test("
|
|
116
|
-
|
|
117
|
-
expect(resolveMeetAvatarDevicePath({ [MEET_AVATAR_ENV_VAR]: value })).toBe(
|
|
118
|
-
DEFAULT_MEET_AVATAR_DEVICE_PATH,
|
|
119
|
-
);
|
|
120
|
-
}
|
|
127
|
+
test("resolveAvatarDevicePath returns default when env var is unset", () => {
|
|
128
|
+
expect(resolveAvatarDevicePath({})).toBe("/dev/video10");
|
|
121
129
|
});
|
|
122
130
|
|
|
123
|
-
test("
|
|
131
|
+
test("resolveAvatarDevicePath honors override", () => {
|
|
124
132
|
expect(
|
|
125
|
-
|
|
126
|
-
[MEET_AVATAR_ENV_VAR]: "1",
|
|
127
|
-
[MEET_AVATAR_DEVICE_ENV_VAR]: "/dev/video11",
|
|
128
|
-
}),
|
|
133
|
+
resolveAvatarDevicePath({ [AVATAR_DEVICE_ENV_VAR]: "/dev/video11" }),
|
|
129
134
|
).toBe("/dev/video11");
|
|
130
135
|
});
|
|
131
136
|
|
|
132
|
-
test("assistant args omit --device and
|
|
137
|
+
test("assistant args omit --device and env var when device node is absent", () => {
|
|
133
138
|
const args = buildAssistantArgs();
|
|
134
139
|
expect(args).not.toContain("--device");
|
|
135
|
-
expect(
|
|
136
|
-
|
|
137
|
-
).toBe(false);
|
|
138
|
-
expect(
|
|
139
|
-
args.some((a) => a.startsWith(`${MEET_AVATAR_DEVICE_ENV_VAR}=`)),
|
|
140
|
-
).toBe(false);
|
|
141
|
-
});
|
|
142
|
-
|
|
143
|
-
test("assistant args include --device=/dev/video10:/dev/video10 when VELLUM_MEET_AVATAR=1", () => {
|
|
144
|
-
process.env[MEET_AVATAR_ENV_VAR] = "1";
|
|
145
|
-
const args = buildAssistantArgs();
|
|
146
|
-
const deviceIdx = args.indexOf("--device");
|
|
147
|
-
expect(deviceIdx).toBeGreaterThan(0);
|
|
148
|
-
expect(args[deviceIdx + 1]).toBe(
|
|
149
|
-
`${DEFAULT_MEET_AVATAR_DEVICE_PATH}:${DEFAULT_MEET_AVATAR_DEVICE_PATH}`,
|
|
150
|
-
);
|
|
151
|
-
// The env var must also be propagated into the container so the daemon
|
|
152
|
-
// knows to turn on avatar passthrough when spawning the bot.
|
|
153
|
-
expect(args).toContain(`${MEET_AVATAR_ENV_VAR}=1`);
|
|
154
|
-
expect(args).toContain(
|
|
155
|
-
`${MEET_AVATAR_DEVICE_ENV_VAR}=${DEFAULT_MEET_AVATAR_DEVICE_PATH}`,
|
|
140
|
+
expect(args.some((a) => a.startsWith(`${AVATAR_DEVICE_ENV_VAR}=`))).toBe(
|
|
141
|
+
false,
|
|
156
142
|
);
|
|
157
143
|
});
|
|
158
|
-
|
|
159
|
-
test("assistant args honor a custom device path from VELLUM_MEET_AVATAR_DEVICE", () => {
|
|
160
|
-
process.env[MEET_AVATAR_ENV_VAR] = "1";
|
|
161
|
-
process.env[MEET_AVATAR_DEVICE_ENV_VAR] = "/dev/video11";
|
|
162
|
-
const args = buildAssistantArgs();
|
|
163
|
-
const deviceIdx = args.indexOf("--device");
|
|
164
|
-
expect(deviceIdx).toBeGreaterThan(0);
|
|
165
|
-
expect(args[deviceIdx + 1]).toBe("/dev/video11:/dev/video11");
|
|
166
|
-
expect(args).toContain(`${MEET_AVATAR_DEVICE_ENV_VAR}=/dev/video11`);
|
|
167
|
-
});
|
|
168
144
|
});
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { pollJobUntilDone } from "../job-polling.js";
|
|
4
|
+
import type { UnifiedJobStatus } from "../platform-client.js";
|
|
5
|
+
|
|
6
|
+
describe("pollJobUntilDone", () => {
|
|
7
|
+
test("returns terminal 'complete' after N processing polls", async () => {
|
|
8
|
+
const statuses: UnifiedJobStatus[] = [
|
|
9
|
+
{ jobId: "j1", type: "export", status: "processing" },
|
|
10
|
+
{ jobId: "j1", type: "export", status: "processing" },
|
|
11
|
+
{
|
|
12
|
+
jobId: "j1",
|
|
13
|
+
type: "export",
|
|
14
|
+
status: "complete",
|
|
15
|
+
bundleKey: "bundles/j1.tar.gz",
|
|
16
|
+
},
|
|
17
|
+
];
|
|
18
|
+
let i = 0;
|
|
19
|
+
const result = await pollJobUntilDone({
|
|
20
|
+
poll: async () => statuses[i++]!,
|
|
21
|
+
intervalMs: 1,
|
|
22
|
+
timeoutMs: 1_000,
|
|
23
|
+
label: "test export",
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
expect(result.status).toBe("complete");
|
|
27
|
+
if (result.status === "complete") {
|
|
28
|
+
expect(result.bundleKey).toBe("bundles/j1.tar.gz");
|
|
29
|
+
}
|
|
30
|
+
expect(i).toBe(3);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("propagates terminal 'failed' status to caller without throwing", async () => {
|
|
34
|
+
const result = await pollJobUntilDone({
|
|
35
|
+
poll: async () => ({
|
|
36
|
+
jobId: "j2",
|
|
37
|
+
type: "import",
|
|
38
|
+
status: "failed",
|
|
39
|
+
error: "bad bundle",
|
|
40
|
+
}),
|
|
41
|
+
intervalMs: 1,
|
|
42
|
+
timeoutMs: 1_000,
|
|
43
|
+
label: "test import",
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
expect(result.status).toBe("failed");
|
|
47
|
+
if (result.status === "failed") {
|
|
48
|
+
expect(result.error).toBe("bad bundle");
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("throws with label when polling exceeds timeoutMs", async () => {
|
|
53
|
+
let calls = 0;
|
|
54
|
+
await expect(
|
|
55
|
+
pollJobUntilDone({
|
|
56
|
+
poll: async () => {
|
|
57
|
+
calls += 1;
|
|
58
|
+
return { jobId: "j3", type: "export", status: "processing" };
|
|
59
|
+
},
|
|
60
|
+
intervalMs: 20,
|
|
61
|
+
timeoutMs: 10,
|
|
62
|
+
label: "slow export",
|
|
63
|
+
}),
|
|
64
|
+
).rejects.toThrow(/slow export/);
|
|
65
|
+
|
|
66
|
+
// The loop does one poll before checking the deadline, so calls ≥ 1.
|
|
67
|
+
expect(calls).toBeGreaterThanOrEqual(1);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("uses defaults when intervalMs/timeoutMs are omitted (fast path)", async () => {
|
|
71
|
+
// Fast path: first poll is already terminal so neither default matters.
|
|
72
|
+
const result = await pollJobUntilDone({
|
|
73
|
+
poll: async () => ({ jobId: "j4", type: "export", status: "complete" }),
|
|
74
|
+
label: "defaults test",
|
|
75
|
+
});
|
|
76
|
+
expect(result.status).toBe("complete");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe("transient-error retry", () => {
|
|
80
|
+
let warnSpy: ReturnType<typeof spyOn>;
|
|
81
|
+
|
|
82
|
+
beforeEach(() => {
|
|
83
|
+
warnSpy = spyOn(console, "warn").mockImplementation(() => {});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
afterEach(() => {
|
|
87
|
+
warnSpy.mockRestore();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("retries N-1 transient errors then returns terminal status", async () => {
|
|
91
|
+
const maxTransientErrors = 3;
|
|
92
|
+
let calls = 0;
|
|
93
|
+
const result = await pollJobUntilDone({
|
|
94
|
+
label: "flaky export",
|
|
95
|
+
intervalMs: 1,
|
|
96
|
+
timeoutMs: 1_000,
|
|
97
|
+
maxTransientErrors,
|
|
98
|
+
poll: async () => {
|
|
99
|
+
calls += 1;
|
|
100
|
+
if (calls < maxTransientErrors) {
|
|
101
|
+
throw new Error(
|
|
102
|
+
`Local job status check failed: 503 Service Unavailable`,
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
return {
|
|
106
|
+
jobId: "j5",
|
|
107
|
+
type: "export",
|
|
108
|
+
status: "complete",
|
|
109
|
+
} as UnifiedJobStatus;
|
|
110
|
+
},
|
|
111
|
+
});
|
|
112
|
+
expect(result.status).toBe("complete");
|
|
113
|
+
expect(calls).toBe(maxTransientErrors);
|
|
114
|
+
// One warning per retried transient error (first two attempts).
|
|
115
|
+
expect(warnSpy).toHaveBeenCalledTimes(maxTransientErrors - 1);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("propagates the last error once maxTransientErrors is exceeded", async () => {
|
|
119
|
+
const maxTransientErrors = 2;
|
|
120
|
+
let calls = 0;
|
|
121
|
+
await expect(
|
|
122
|
+
pollJobUntilDone({
|
|
123
|
+
label: "always broken",
|
|
124
|
+
intervalMs: 1,
|
|
125
|
+
timeoutMs: 1_000,
|
|
126
|
+
maxTransientErrors,
|
|
127
|
+
poll: async () => {
|
|
128
|
+
calls += 1;
|
|
129
|
+
throw new Error(`Local job status check failed: 502 Bad Gateway`);
|
|
130
|
+
},
|
|
131
|
+
}),
|
|
132
|
+
).rejects.toThrow(/502 Bad Gateway/);
|
|
133
|
+
// Helper makes `maxTransientErrors + 1` attempts before giving up: the
|
|
134
|
+
// first attempt plus N retries, counted against the budget.
|
|
135
|
+
expect(calls).toBe(maxTransientErrors + 1);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("permanent 4xx errors (except 429) propagate immediately", async () => {
|
|
139
|
+
let calls = 0;
|
|
140
|
+
await expect(
|
|
141
|
+
pollJobUntilDone({
|
|
142
|
+
label: "auth broken",
|
|
143
|
+
intervalMs: 1,
|
|
144
|
+
timeoutMs: 1_000,
|
|
145
|
+
maxTransientErrors: 5,
|
|
146
|
+
poll: async () => {
|
|
147
|
+
calls += 1;
|
|
148
|
+
throw new Error(`Local job status check failed: 403 Forbidden`);
|
|
149
|
+
},
|
|
150
|
+
}),
|
|
151
|
+
).rejects.toThrow(/403 Forbidden/);
|
|
152
|
+
expect(calls).toBe(1);
|
|
153
|
+
expect(warnSpy).not.toHaveBeenCalled();
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("429 rate-limit is retried as transient", async () => {
|
|
157
|
+
let calls = 0;
|
|
158
|
+
const result = await pollJobUntilDone({
|
|
159
|
+
label: "rate limited",
|
|
160
|
+
intervalMs: 1,
|
|
161
|
+
timeoutMs: 1_000,
|
|
162
|
+
maxTransientErrors: 3,
|
|
163
|
+
poll: async () => {
|
|
164
|
+
calls += 1;
|
|
165
|
+
if (calls === 1) {
|
|
166
|
+
throw new Error(`Local job status check failed: 429 Too Many`);
|
|
167
|
+
}
|
|
168
|
+
return {
|
|
169
|
+
jobId: "j6",
|
|
170
|
+
type: "export",
|
|
171
|
+
status: "complete",
|
|
172
|
+
} as UnifiedJobStatus;
|
|
173
|
+
},
|
|
174
|
+
});
|
|
175
|
+
expect(result.status).toBe("complete");
|
|
176
|
+
expect(calls).toBe(2);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("refreshOn401 is invoked on 401 and polling continues after refresh", async () => {
|
|
180
|
+
let calls = 0;
|
|
181
|
+
let refreshes = 0;
|
|
182
|
+
const result = await pollJobUntilDone({
|
|
183
|
+
label: "expiring auth",
|
|
184
|
+
intervalMs: 1,
|
|
185
|
+
timeoutMs: 1_000,
|
|
186
|
+
maxTransientErrors: 0,
|
|
187
|
+
refreshOn401: async () => {
|
|
188
|
+
refreshes += 1;
|
|
189
|
+
},
|
|
190
|
+
poll: async () => {
|
|
191
|
+
calls += 1;
|
|
192
|
+
if (calls === 1) {
|
|
193
|
+
throw new Error("Local job status check failed: 401 Unauthorized");
|
|
194
|
+
}
|
|
195
|
+
return {
|
|
196
|
+
jobId: "j401",
|
|
197
|
+
type: "export",
|
|
198
|
+
status: "complete",
|
|
199
|
+
} as UnifiedJobStatus;
|
|
200
|
+
},
|
|
201
|
+
});
|
|
202
|
+
expect(result.status).toBe("complete");
|
|
203
|
+
expect(refreshes).toBe(1);
|
|
204
|
+
expect(calls).toBe(2);
|
|
205
|
+
// The 401 branch logs its own distinct warning (not the generic
|
|
206
|
+
// "polling failed, retrying" one) so operators can distinguish an
|
|
207
|
+
// auth refresh from a transient-error retry in the output.
|
|
208
|
+
expect(warnSpy).toHaveBeenCalledWith(
|
|
209
|
+
expect.stringContaining("refreshing auth"),
|
|
210
|
+
);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test("propagates 401 once maxAuthRefreshes is exceeded", async () => {
|
|
214
|
+
const maxAuthRefreshes = 2;
|
|
215
|
+
let calls = 0;
|
|
216
|
+
let refreshes = 0;
|
|
217
|
+
await expect(
|
|
218
|
+
pollJobUntilDone({
|
|
219
|
+
label: "persistently unauthorized",
|
|
220
|
+
intervalMs: 1,
|
|
221
|
+
timeoutMs: 1_000,
|
|
222
|
+
maxAuthRefreshes,
|
|
223
|
+
refreshOn401: async () => {
|
|
224
|
+
refreshes += 1;
|
|
225
|
+
},
|
|
226
|
+
poll: async () => {
|
|
227
|
+
calls += 1;
|
|
228
|
+
throw new Error("Local job status check failed: 401 Unauthorized");
|
|
229
|
+
},
|
|
230
|
+
}),
|
|
231
|
+
).rejects.toThrow(/401 Unauthorized/);
|
|
232
|
+
// Helper allows `maxAuthRefreshes` successful refresh-and-retry cycles
|
|
233
|
+
// (each counted against the budget after the poll fails), plus one
|
|
234
|
+
// final attempt on the refreshed token that exceeds the budget.
|
|
235
|
+
expect(calls).toBe(maxAuthRefreshes + 1);
|
|
236
|
+
expect(refreshes).toBe(maxAuthRefreshes);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
test("without refreshOn401, 401 still propagates as a permanent 4xx", async () => {
|
|
240
|
+
let calls = 0;
|
|
241
|
+
await expect(
|
|
242
|
+
pollJobUntilDone({
|
|
243
|
+
label: "no refresh hook",
|
|
244
|
+
intervalMs: 1,
|
|
245
|
+
timeoutMs: 1_000,
|
|
246
|
+
poll: async () => {
|
|
247
|
+
calls += 1;
|
|
248
|
+
throw new Error("Local job status check failed: 401 Unauthorized");
|
|
249
|
+
},
|
|
250
|
+
}),
|
|
251
|
+
).rejects.toThrow(/401 Unauthorized/);
|
|
252
|
+
expect(calls).toBe(1);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
test("unclassified network-style errors are treated as transient", async () => {
|
|
256
|
+
let calls = 0;
|
|
257
|
+
const result = await pollJobUntilDone({
|
|
258
|
+
label: "network blip",
|
|
259
|
+
intervalMs: 1,
|
|
260
|
+
timeoutMs: 1_000,
|
|
261
|
+
maxTransientErrors: 3,
|
|
262
|
+
poll: async () => {
|
|
263
|
+
calls += 1;
|
|
264
|
+
if (calls === 1) {
|
|
265
|
+
throw new Error("fetch failed");
|
|
266
|
+
}
|
|
267
|
+
return {
|
|
268
|
+
jobId: "j7",
|
|
269
|
+
type: "export",
|
|
270
|
+
status: "complete",
|
|
271
|
+
} as UnifiedJobStatus;
|
|
272
|
+
},
|
|
273
|
+
});
|
|
274
|
+
expect(result.status).toBe("complete");
|
|
275
|
+
expect(calls).toBe(2);
|
|
276
|
+
});
|
|
277
|
+
});
|
|
278
|
+
});
|