@vellumai/cli 0.8.12-dev.202606151644.f1e3fed → 0.8.12-dev.202606152015.07e281d
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/package.json +1 -1
- package/src/__tests__/nginx-ingress-command.test.ts +69 -0
- package/src/__tests__/nginx-ingress.test.ts +272 -0
- package/src/__tests__/tunnel.test.ts +129 -0
- package/src/commands/nginx-ingress.ts +275 -0
- package/src/commands/sleep.ts +8 -0
- package/src/commands/tunnel.ts +35 -10
- package/src/index.ts +5 -0
- package/src/lib/cloudflare-tunnel.ts +15 -2
- package/src/lib/feature-flags.test.ts +112 -0
- package/src/lib/feature-flags.ts +34 -0
- package/src/lib/local.ts +5 -0
- package/src/lib/nginx-ingress.ts +355 -0
- package/src/lib/ngrok.ts +26 -4
- package/src/lib/retire-local.ts +5 -0
package/package.json
CHANGED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { afterAll, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
|
|
6
|
+
import { resolveNginxIngressTarget } from "../commands/nginx-ingress.js";
|
|
7
|
+
import type { AssistantEntry } from "../lib/assistant-config.js";
|
|
8
|
+
|
|
9
|
+
const testDir = mkdtempSync(join(tmpdir(), "cli-nginx-ingress-command-test-"));
|
|
10
|
+
const workspaceDir = join(testDir, "workspace");
|
|
11
|
+
const originalLockfileDir = process.env.VELLUM_LOCKFILE_DIR;
|
|
12
|
+
const originalWorkspaceDir = process.env.VELLUM_WORKSPACE_DIR;
|
|
13
|
+
|
|
14
|
+
function writeLockfile(
|
|
15
|
+
entries: AssistantEntry[],
|
|
16
|
+
activeAssistant?: string,
|
|
17
|
+
): void {
|
|
18
|
+
mkdirSync(testDir, { recursive: true });
|
|
19
|
+
writeFileSync(
|
|
20
|
+
join(testDir, ".vellum.lock.json"),
|
|
21
|
+
JSON.stringify(
|
|
22
|
+
{
|
|
23
|
+
assistants: entries,
|
|
24
|
+
...(activeAssistant ? { activeAssistant } : {}),
|
|
25
|
+
},
|
|
26
|
+
null,
|
|
27
|
+
2,
|
|
28
|
+
),
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe("resolveNginxIngressTarget", () => {
|
|
33
|
+
beforeEach(() => {
|
|
34
|
+
process.env.VELLUM_LOCKFILE_DIR = testDir;
|
|
35
|
+
process.env.VELLUM_WORKSPACE_DIR = workspaceDir;
|
|
36
|
+
rmSync(join(testDir, ".vellum.lock.json"), { force: true });
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
afterAll(() => {
|
|
40
|
+
if (originalLockfileDir === undefined) {
|
|
41
|
+
delete process.env.VELLUM_LOCKFILE_DIR;
|
|
42
|
+
} else {
|
|
43
|
+
process.env.VELLUM_LOCKFILE_DIR = originalLockfileDir;
|
|
44
|
+
}
|
|
45
|
+
if (originalWorkspaceDir === undefined) {
|
|
46
|
+
delete process.env.VELLUM_WORKSPACE_DIR;
|
|
47
|
+
} else {
|
|
48
|
+
process.env.VELLUM_WORKSPACE_DIR = originalWorkspaceDir;
|
|
49
|
+
}
|
|
50
|
+
rmSync(testDir, { recursive: true, force: true });
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("derives the gateway port from runtimeUrl when resources are absent", () => {
|
|
54
|
+
writeLockfile([
|
|
55
|
+
{
|
|
56
|
+
assistantId: "docker-assistant",
|
|
57
|
+
name: "Docker Assistant",
|
|
58
|
+
runtimeUrl: "http://localhost:9123",
|
|
59
|
+
cloud: "docker",
|
|
60
|
+
},
|
|
61
|
+
]);
|
|
62
|
+
|
|
63
|
+
expect(resolveNginxIngressTarget("Docker Assistant")).toEqual({
|
|
64
|
+
assistantId: "docker-assistant",
|
|
65
|
+
workspaceDir,
|
|
66
|
+
gatewayPort: 9123,
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
});
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import * as childProcess from "node:child_process";
|
|
2
|
+
import {
|
|
3
|
+
existsSync,
|
|
4
|
+
mkdirSync,
|
|
5
|
+
mkdtempSync,
|
|
6
|
+
readFileSync,
|
|
7
|
+
rmSync,
|
|
8
|
+
writeFileSync,
|
|
9
|
+
} from "node:fs";
|
|
10
|
+
import { tmpdir } from "node:os";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
|
|
13
|
+
import { afterEach, describe, expect, mock, test } from "bun:test";
|
|
14
|
+
|
|
15
|
+
const execFileSyncMock = mock(childProcess.execFileSync);
|
|
16
|
+
|
|
17
|
+
mock.module("node:child_process", () => ({
|
|
18
|
+
...childProcess,
|
|
19
|
+
execFileSync: execFileSyncMock,
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
import {
|
|
23
|
+
buildIngressNginxConfig,
|
|
24
|
+
resolveTunnelTargetPort,
|
|
25
|
+
stopIngressNginx,
|
|
26
|
+
} from "../lib/nginx-ingress.js";
|
|
27
|
+
|
|
28
|
+
const originalKill = process.kill;
|
|
29
|
+
|
|
30
|
+
describe("buildIngressNginxConfig", () => {
|
|
31
|
+
const conf = buildIngressNginxConfig({ gatewayPort: 7830, listenPort: 7840 });
|
|
32
|
+
|
|
33
|
+
test("listens on loopback only", () => {
|
|
34
|
+
expect(conf).toContain("listen 127.0.0.1:7840;");
|
|
35
|
+
const listens = conf.match(/listen [^;]+;/g) ?? [];
|
|
36
|
+
expect(listens.length).toBeGreaterThan(0);
|
|
37
|
+
for (const directive of listens) {
|
|
38
|
+
expect(directive).toContain("127.0.0.1");
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("proxies requests to the gateway", () => {
|
|
43
|
+
expect(conf).toContain("location / {");
|
|
44
|
+
expect(conf).toContain("proxy_pass http://127.0.0.1:7830;");
|
|
45
|
+
expect(conf).not.toContain("return 404;");
|
|
46
|
+
expect(conf).not.toContain("return 403;");
|
|
47
|
+
expect(conf).not.toContain("location =");
|
|
48
|
+
expect(conf).not.toContain("location ~");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("supports websockets and SSE streaming", () => {
|
|
52
|
+
expect(conf).toContain("map $http_upgrade $connection_upgrade");
|
|
53
|
+
expect(conf).toContain("proxy_http_version 1.1;");
|
|
54
|
+
expect(conf).toContain("proxy_set_header Upgrade $http_upgrade;");
|
|
55
|
+
expect(conf).toContain("proxy_set_header Connection $connection_upgrade;");
|
|
56
|
+
expect(conf).toContain("proxy_request_buffering off;");
|
|
57
|
+
expect(conf).toContain("proxy_buffering off;");
|
|
58
|
+
expect(conf).toContain("proxy_read_timeout 1h;");
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe("nginx ingress process state", () => {
|
|
63
|
+
const workspaces: string[] = [];
|
|
64
|
+
|
|
65
|
+
afterEach(() => {
|
|
66
|
+
process.kill = originalKill;
|
|
67
|
+
execFileSyncMock.mockReset();
|
|
68
|
+
for (const dir of workspaces.splice(0)) {
|
|
69
|
+
rmSync(dir, { recursive: true, force: true });
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
function makeWorkspace(): string {
|
|
74
|
+
const dir = mkdtempSync(join(tmpdir(), "vellum-ingress-test-"));
|
|
75
|
+
workspaces.push(dir);
|
|
76
|
+
return dir;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function writeIngressState(workspaceDir: string, listenPort: number): void {
|
|
80
|
+
writeFileSync(
|
|
81
|
+
join(workspaceDir, "config.json"),
|
|
82
|
+
JSON.stringify({ ingress: { nginx: { listenPort } } }) + "\n",
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function writePidFile(workspaceDir: string, pid: number): void {
|
|
87
|
+
const dir = join(workspaceDir, "data", "ingress");
|
|
88
|
+
mkdirSync(dir, { recursive: true });
|
|
89
|
+
writeFileSync(join(dir, "nginx.pid"), `${pid}\n`);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function readConfig(workspaceDir: string): Record<string, unknown> {
|
|
93
|
+
return JSON.parse(
|
|
94
|
+
readFileSync(join(workspaceDir, "config.json"), "utf-8"),
|
|
95
|
+
) as Record<string, unknown>;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function pidPath(workspaceDir: string): string {
|
|
99
|
+
return join(workspaceDir, "data", "ingress", "nginx.pid");
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function nginxCommand(workspaceDir: string): string {
|
|
103
|
+
const dir = join(workspaceDir, "data", "ingress");
|
|
104
|
+
return `nginx: master process nginx -p ${dir} -c ${join(dir, "nginx.conf")} -g daemon off;`;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** A PID guaranteed dead: a short-lived child that has already exited. */
|
|
108
|
+
function deadPid(): number {
|
|
109
|
+
const result = childProcess.spawnSync("sh", ["-c", "exit 0"]);
|
|
110
|
+
if (!result.pid) throw new Error("failed to spawn probe process");
|
|
111
|
+
return result.pid;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
test("falls back to the gateway port when no ingress state exists", () => {
|
|
115
|
+
const ws = makeWorkspace();
|
|
116
|
+
expect(resolveTunnelTargetPort(ws, 7830)).toEqual({
|
|
117
|
+
port: 7830,
|
|
118
|
+
viaIngress: false,
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("falls back when ingress state exists but the process is dead", () => {
|
|
123
|
+
const ws = makeWorkspace();
|
|
124
|
+
writeIngressState(ws, 7841);
|
|
125
|
+
writePidFile(ws, deadPid());
|
|
126
|
+
expect(resolveTunnelTargetPort(ws, 7830)).toEqual({
|
|
127
|
+
port: 7830,
|
|
128
|
+
viaIngress: false,
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("falls back when the recorded PID belongs to a non-nginx process", () => {
|
|
133
|
+
const ws = makeWorkspace();
|
|
134
|
+
writeIngressState(ws, 7841);
|
|
135
|
+
writePidFile(ws, process.pid);
|
|
136
|
+
execFileSyncMock.mockReturnValue("bun test");
|
|
137
|
+
expect(resolveTunnelTargetPort(ws, 7830)).toEqual({
|
|
138
|
+
port: 7830,
|
|
139
|
+
viaIngress: false,
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("falls back when the recorded PID belongs to another nginx instance", () => {
|
|
144
|
+
const ws = makeWorkspace();
|
|
145
|
+
writeIngressState(ws, 7841);
|
|
146
|
+
writePidFile(ws, process.pid);
|
|
147
|
+
execFileSyncMock.mockReturnValue(
|
|
148
|
+
"nginx: master process nginx -p /tmp/other-ingress -c /tmp/other-ingress/nginx.conf",
|
|
149
|
+
);
|
|
150
|
+
expect(resolveTunnelTargetPort(ws, 7830)).toEqual({
|
|
151
|
+
port: 7830,
|
|
152
|
+
viaIngress: false,
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("targets the ingress when state exists and the PID is this nginx", () => {
|
|
157
|
+
const ws = makeWorkspace();
|
|
158
|
+
writeIngressState(ws, 7841);
|
|
159
|
+
writePidFile(ws, process.pid);
|
|
160
|
+
execFileSyncMock.mockReturnValue(nginxCommand(ws));
|
|
161
|
+
expect(resolveTunnelTargetPort(ws, 7830)).toEqual({
|
|
162
|
+
port: 7841,
|
|
163
|
+
viaIngress: true,
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("falls back when nginx ingress is not preferred", () => {
|
|
168
|
+
const ws = makeWorkspace();
|
|
169
|
+
writeIngressState(ws, 7841);
|
|
170
|
+
writePidFile(ws, process.pid);
|
|
171
|
+
execFileSyncMock.mockReturnValue(nginxCommand(ws));
|
|
172
|
+
expect(
|
|
173
|
+
resolveTunnelTargetPort(ws, 7830, { preferNginxIngress: false }),
|
|
174
|
+
).toEqual({
|
|
175
|
+
port: 7830,
|
|
176
|
+
viaIngress: false,
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("clears ingress state after nginx is confirmed stopped", async () => {
|
|
181
|
+
const ws = makeWorkspace();
|
|
182
|
+
const pid = 123_456;
|
|
183
|
+
let alive = true;
|
|
184
|
+
writeIngressState(ws, 7841);
|
|
185
|
+
writePidFile(ws, pid);
|
|
186
|
+
execFileSyncMock.mockReturnValue(nginxCommand(ws));
|
|
187
|
+
process.kill = mock((targetPid: number, signal?: string | number) => {
|
|
188
|
+
if (targetPid !== pid) return originalKill(targetPid, signal);
|
|
189
|
+
if (signal === 0) {
|
|
190
|
+
if (!alive) throw new Error("dead");
|
|
191
|
+
return true;
|
|
192
|
+
}
|
|
193
|
+
if (signal === "SIGTERM") {
|
|
194
|
+
alive = false;
|
|
195
|
+
return true;
|
|
196
|
+
}
|
|
197
|
+
return true;
|
|
198
|
+
}) as unknown as typeof process.kill;
|
|
199
|
+
|
|
200
|
+
await expect(stopIngressNginx(ws)).resolves.toBe(true);
|
|
201
|
+
|
|
202
|
+
const config = readConfig(ws);
|
|
203
|
+
expect((config.ingress as Record<string, unknown>).nginx).toBeUndefined();
|
|
204
|
+
expect(existsSync(pidPath(ws))).toBe(false);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test("keeps ingress state when nginx kill fails", async () => {
|
|
208
|
+
const ws = makeWorkspace();
|
|
209
|
+
const pid = 123_457;
|
|
210
|
+
writeIngressState(ws, 7841);
|
|
211
|
+
writePidFile(ws, pid);
|
|
212
|
+
execFileSyncMock.mockReturnValue(nginxCommand(ws));
|
|
213
|
+
process.kill = mock((targetPid: number, signal?: string | number) => {
|
|
214
|
+
if (targetPid !== pid) return originalKill(targetPid, signal);
|
|
215
|
+
if (signal === 0) return true;
|
|
216
|
+
throw new Error("operation not permitted");
|
|
217
|
+
}) as unknown as typeof process.kill;
|
|
218
|
+
|
|
219
|
+
await expect(stopIngressNginx(ws)).resolves.toBe(false);
|
|
220
|
+
|
|
221
|
+
const config = readConfig(ws);
|
|
222
|
+
expect((config.ingress as Record<string, unknown>).nginx).toEqual({
|
|
223
|
+
listenPort: 7841,
|
|
224
|
+
});
|
|
225
|
+
expect(existsSync(pidPath(ws))).toBe(true);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test("clears ingress state when nginx exits before SIGTERM", async () => {
|
|
229
|
+
const ws = makeWorkspace();
|
|
230
|
+
const pid = 123_458;
|
|
231
|
+
let aliveChecks = 0;
|
|
232
|
+
writeIngressState(ws, 7841);
|
|
233
|
+
writePidFile(ws, pid);
|
|
234
|
+
execFileSyncMock.mockReturnValue(nginxCommand(ws));
|
|
235
|
+
process.kill = mock((targetPid: number, signal?: string | number) => {
|
|
236
|
+
if (targetPid !== pid) return originalKill(targetPid, signal);
|
|
237
|
+
if (signal === 0) {
|
|
238
|
+
aliveChecks++;
|
|
239
|
+
if (aliveChecks === 1) return true;
|
|
240
|
+
throw new Error("dead");
|
|
241
|
+
}
|
|
242
|
+
throw new Error("no such process");
|
|
243
|
+
}) as unknown as typeof process.kill;
|
|
244
|
+
|
|
245
|
+
await expect(stopIngressNginx(ws)).resolves.toBe(true);
|
|
246
|
+
|
|
247
|
+
const config = readConfig(ws);
|
|
248
|
+
expect((config.ingress as Record<string, unknown>).nginx).toBeUndefined();
|
|
249
|
+
expect(existsSync(pidPath(ws))).toBe(false);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
test("does not kill another nginx instance when clearing stale state", async () => {
|
|
253
|
+
const ws = makeWorkspace();
|
|
254
|
+
const pid = 123_459;
|
|
255
|
+
writeIngressState(ws, 7841);
|
|
256
|
+
writePidFile(ws, pid);
|
|
257
|
+
execFileSyncMock.mockReturnValue(
|
|
258
|
+
"nginx: master process nginx -p /tmp/other-ingress -c /tmp/other-ingress/nginx.conf",
|
|
259
|
+
);
|
|
260
|
+
process.kill = mock((targetPid: number, signal?: string | number) => {
|
|
261
|
+
if (targetPid !== pid) return originalKill(targetPid, signal);
|
|
262
|
+
if (signal === 0) return true;
|
|
263
|
+
throw new Error("should not kill another nginx instance");
|
|
264
|
+
}) as unknown as typeof process.kill;
|
|
265
|
+
|
|
266
|
+
await expect(stopIngressNginx(ws)).resolves.toBe(false);
|
|
267
|
+
|
|
268
|
+
const config = readConfig(ws);
|
|
269
|
+
expect((config.ingress as Record<string, unknown>).nginx).toBeUndefined();
|
|
270
|
+
expect(existsSync(pidPath(ws))).toBe(false);
|
|
271
|
+
});
|
|
272
|
+
});
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import {
|
|
2
|
+
afterAll,
|
|
3
|
+
afterEach,
|
|
4
|
+
beforeEach,
|
|
5
|
+
describe,
|
|
6
|
+
expect,
|
|
7
|
+
mock,
|
|
8
|
+
test,
|
|
9
|
+
} from "bun:test";
|
|
10
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
11
|
+
import { tmpdir } from "node:os";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
|
|
14
|
+
import * as cloudflareTunnel from "../lib/cloudflare-tunnel.js";
|
|
15
|
+
import * as ngrok from "../lib/ngrok.js";
|
|
16
|
+
import type { AssistantEntry } from "../lib/assistant-config.js";
|
|
17
|
+
|
|
18
|
+
const realCloudflareTunnel = { ...cloudflareTunnel };
|
|
19
|
+
const realNgrok = { ...ngrok };
|
|
20
|
+
|
|
21
|
+
const runCloudflareTunnelMock = mock<
|
|
22
|
+
typeof cloudflareTunnel.runCloudflareTunnel
|
|
23
|
+
>(async () => {});
|
|
24
|
+
mock.module("../lib/cloudflare-tunnel.js", () => ({
|
|
25
|
+
...realCloudflareTunnel,
|
|
26
|
+
runCloudflareTunnel: runCloudflareTunnelMock,
|
|
27
|
+
}));
|
|
28
|
+
|
|
29
|
+
const runNgrokTunnelMock = mock<typeof ngrok.runNgrokTunnel>(async () => {});
|
|
30
|
+
mock.module("../lib/ngrok", () => ({
|
|
31
|
+
...realNgrok,
|
|
32
|
+
runNgrokTunnel: runNgrokTunnelMock,
|
|
33
|
+
}));
|
|
34
|
+
|
|
35
|
+
const { tunnel } = await import("../commands/tunnel.js");
|
|
36
|
+
|
|
37
|
+
const originalArgv = [...process.argv];
|
|
38
|
+
const originalFetch = globalThis.fetch;
|
|
39
|
+
const originalLockfileDir = process.env.VELLUM_LOCKFILE_DIR;
|
|
40
|
+
const tempDirs: string[] = [];
|
|
41
|
+
|
|
42
|
+
function makeLocalEntry(): AssistantEntry {
|
|
43
|
+
const instanceDir = mkdtempSync(join(tmpdir(), "vellum-tunnel-test-"));
|
|
44
|
+
tempDirs.push(instanceDir);
|
|
45
|
+
return {
|
|
46
|
+
assistantId: "assistant-1",
|
|
47
|
+
runtimeUrl: "http://127.0.0.1:7830",
|
|
48
|
+
cloud: "local",
|
|
49
|
+
resources: {
|
|
50
|
+
instanceDir,
|
|
51
|
+
daemonPort: 7821,
|
|
52
|
+
gatewayPort: 7830,
|
|
53
|
+
qdrantPort: 6333,
|
|
54
|
+
cesPort: 7822,
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function writeLockfile(entry: AssistantEntry): void {
|
|
60
|
+
const lockfileDir = mkdtempSync(join(tmpdir(), "vellum-tunnel-lockfile-"));
|
|
61
|
+
tempDirs.push(lockfileDir);
|
|
62
|
+
process.env.VELLUM_LOCKFILE_DIR = lockfileDir;
|
|
63
|
+
mkdirSync(lockfileDir, { recursive: true });
|
|
64
|
+
writeFileSync(
|
|
65
|
+
join(lockfileDir, ".vellum.lock.json"),
|
|
66
|
+
JSON.stringify(
|
|
67
|
+
{
|
|
68
|
+
activeAssistant: entry.assistantId,
|
|
69
|
+
assistants: [entry],
|
|
70
|
+
},
|
|
71
|
+
null,
|
|
72
|
+
2,
|
|
73
|
+
),
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
describe("tunnel nginx ingress feature flag", () => {
|
|
78
|
+
beforeEach(() => {
|
|
79
|
+
process.argv = ["bun", "vellum", "tunnel"];
|
|
80
|
+
writeLockfile(makeLocalEntry());
|
|
81
|
+
globalThis.fetch = (async () => {
|
|
82
|
+
throw new Error("gateway unavailable");
|
|
83
|
+
}) as unknown as typeof globalThis.fetch;
|
|
84
|
+
runCloudflareTunnelMock.mockReset();
|
|
85
|
+
runCloudflareTunnelMock.mockResolvedValue(undefined);
|
|
86
|
+
runNgrokTunnelMock.mockReset();
|
|
87
|
+
runNgrokTunnelMock.mockResolvedValue(undefined);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
afterEach(() => {
|
|
91
|
+
process.argv = originalArgv;
|
|
92
|
+
globalThis.fetch = originalFetch;
|
|
93
|
+
if (originalLockfileDir === undefined) {
|
|
94
|
+
delete process.env.VELLUM_LOCKFILE_DIR;
|
|
95
|
+
} else {
|
|
96
|
+
process.env.VELLUM_LOCKFILE_DIR = originalLockfileDir;
|
|
97
|
+
}
|
|
98
|
+
for (const dir of tempDirs.splice(0)) {
|
|
99
|
+
rmSync(dir, { recursive: true, force: true });
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
afterAll(() => {
|
|
104
|
+
mock.module("../lib/cloudflare-tunnel.js", () => realCloudflareTunnel);
|
|
105
|
+
mock.module("../lib/ngrok", () => realNgrok);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("does not start ngrok when the flag lookup fails", async () => {
|
|
109
|
+
process.argv = ["bun", "vellum", "tunnel", "--provider", "ngrok"];
|
|
110
|
+
|
|
111
|
+
await expect(tunnel()).rejects.toThrow(
|
|
112
|
+
"Could not verify the `web-remote-ingress` feature flag",
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
expect(runNgrokTunnelMock).not.toHaveBeenCalled();
|
|
116
|
+
expect(runCloudflareTunnelMock).not.toHaveBeenCalled();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("does not start cloudflared when the flag lookup fails", async () => {
|
|
120
|
+
process.argv = ["bun", "vellum", "tunnel", "--provider", "cloudflare"];
|
|
121
|
+
|
|
122
|
+
await expect(tunnel()).rejects.toThrow(
|
|
123
|
+
"Could not verify the `web-remote-ingress` feature flag",
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
expect(runNgrokTunnelMock).not.toHaveBeenCalled();
|
|
127
|
+
expect(runCloudflareTunnelMock).not.toHaveBeenCalled();
|
|
128
|
+
});
|
|
129
|
+
});
|