@vellumai/cli 0.11.2 → 0.11.3-staging.2
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/knip.json +1 -0
- package/node_modules/@vellumai/environments/package.json +2 -1
- package/node_modules/@vellumai/environments/src/__tests__/cloud-assistant-hub-url.test.ts +38 -0
- package/node_modules/@vellumai/environments/src/__tests__/install-layout.test.ts +98 -0
- package/node_modules/@vellumai/environments/src/__tests__/package-boundary.test.ts +5 -5
- package/node_modules/@vellumai/environments/src/index.ts +17 -6
- package/node_modules/@vellumai/environments/src/install-layout.ts +49 -0
- package/node_modules/@vellumai/environments/src/seeds.ts +29 -0
- package/node_modules/@vellumai/ipc-server-utils/package.json +18 -0
- package/node_modules/@vellumai/ipc-server-utils/src/endpoint.test.ts +36 -0
- package/node_modules/@vellumai/ipc-server-utils/src/endpoint.ts +142 -0
- package/node_modules/@vellumai/ipc-server-utils/src/index.ts +18 -0
- package/node_modules/@vellumai/ipc-server-utils/src/ipc-framing.ts +295 -0
- package/node_modules/@vellumai/ipc-server-utils/src/listen-options.ts +3 -0
- package/node_modules/@vellumai/ipc-server-utils/src/socket-watchdog.test.ts +444 -0
- package/node_modules/@vellumai/ipc-server-utils/src/socket-watchdog.ts +236 -0
- package/node_modules/@vellumai/ipc-server-utils/tsconfig.json +20 -0
- package/node_modules/@vellumai/local-mode/node_modules/@vellumai/environments/package.json +2 -1
- package/node_modules/@vellumai/local-mode/node_modules/@vellumai/environments/src/__tests__/cloud-assistant-hub-url.test.ts +38 -0
- package/node_modules/@vellumai/local-mode/node_modules/@vellumai/environments/src/__tests__/install-layout.test.ts +98 -0
- package/node_modules/@vellumai/local-mode/node_modules/@vellumai/environments/src/__tests__/package-boundary.test.ts +5 -5
- package/node_modules/@vellumai/local-mode/node_modules/@vellumai/environments/src/index.ts +17 -6
- package/node_modules/@vellumai/local-mode/node_modules/@vellumai/environments/src/install-layout.ts +49 -0
- package/node_modules/@vellumai/local-mode/node_modules/@vellumai/environments/src/seeds.ts +29 -0
- package/node_modules/@vellumai/local-mode/node_modules/nanoid/LICENSE +20 -0
- package/node_modules/@vellumai/local-mode/node_modules/nanoid/README.md +38 -0
- package/node_modules/@vellumai/local-mode/node_modules/nanoid/bin/nanoid.js +55 -0
- package/node_modules/@vellumai/local-mode/node_modules/nanoid/index.browser.js +29 -0
- package/node_modules/@vellumai/local-mode/node_modules/nanoid/index.d.ts +106 -0
- package/node_modules/@vellumai/local-mode/node_modules/nanoid/index.js +47 -0
- package/node_modules/@vellumai/local-mode/node_modules/nanoid/nanoid.js +1 -0
- package/node_modules/@vellumai/local-mode/node_modules/nanoid/non-secure/index.d.ts +48 -0
- package/node_modules/@vellumai/local-mode/node_modules/nanoid/non-secure/index.js +21 -0
- package/node_modules/@vellumai/local-mode/node_modules/nanoid/package.json +46 -0
- package/node_modules/@vellumai/local-mode/node_modules/nanoid/url-alphabet/index.js +2 -0
- package/node_modules/@vellumai/local-mode/package.json +1 -0
- package/node_modules/@vellumai/local-mode/src/__tests__/environment.test.ts +63 -1
- package/node_modules/@vellumai/local-mode/src/__tests__/gateway-proxy.test.ts +503 -0
- package/node_modules/@vellumai/local-mode/src/__tests__/guardian-token.test.ts +203 -0
- package/node_modules/@vellumai/local-mode/src/__tests__/loopback-auth.test.ts +46 -1
- package/node_modules/@vellumai/local-mode/src/__tests__/package-boundary.test.ts +6 -4
- package/node_modules/@vellumai/local-mode/src/__tests__/pair.test.ts +621 -0
- package/node_modules/@vellumai/local-mode/src/__tests__/status.test.ts +5 -0
- package/node_modules/@vellumai/local-mode/src/__tests__/unpair.test.ts +232 -0
- package/node_modules/@vellumai/local-mode/src/config.ts +126 -20
- package/node_modules/@vellumai/local-mode/src/environment.ts +36 -15
- package/node_modules/@vellumai/local-mode/src/gateway-proxy.ts +313 -25
- package/node_modules/@vellumai/local-mode/src/guardian-token.ts +222 -13
- package/node_modules/@vellumai/local-mode/src/index.ts +35 -3
- package/node_modules/@vellumai/local-mode/src/lockfile-contract.test.ts +18 -0
- package/node_modules/@vellumai/local-mode/src/lockfile-contract.ts +72 -0
- package/node_modules/@vellumai/local-mode/src/lockfile.test.ts +130 -0
- package/node_modules/@vellumai/local-mode/src/lockfile.ts +134 -45
- package/node_modules/@vellumai/local-mode/src/pair.ts +354 -0
- package/node_modules/@vellumai/local-mode/src/paths.ts +49 -0
- package/node_modules/@vellumai/local-mode/src/status.ts +10 -8
- package/node_modules/@vellumai/local-mode/src/unpair.ts +101 -0
- package/node_modules/@vellumai/local-mode/src/util.ts +34 -0
- package/node_modules/@vellumai/service-contracts/src/channels.ts +39 -0
- package/node_modules/@vellumai/service-contracts/src/ingress.ts +10 -0
- package/node_modules/@vellumai/service-contracts/src/remote-web-pairing.ts +6 -0
- package/package.json +3 -1
- package/src/__tests__/client-token.test.ts +26 -1
- package/src/__tests__/guardian-token.test.ts +4 -0
- package/src/__tests__/nginx-ingress-command.test.ts +176 -16
- package/src/__tests__/nginx-ingress.test.ts +1821 -101
- package/src/__tests__/pair.test.ts +104 -2
- package/src/__tests__/sleep.test.ts +7 -3
- package/src/__tests__/tailscale-tunnel.test.ts +0 -1
- package/src/__tests__/tunnel.test.ts +1099 -55
- package/src/__tests__/wake.test.ts +166 -99
- package/src/commands/client.ts +182 -27
- package/src/commands/connect/import.ts +35 -156
- package/src/commands/nginx-ingress.ts +87 -110
- package/src/commands/pair.ts +84 -21
- package/src/commands/tunnel.ts +196 -85
- package/src/commands/upgrade.ts +3 -2
- package/src/commands/wake.ts +5 -163
- package/src/index.ts +1 -1
- package/src/lib/__tests__/local-ces.test.ts +10 -1
- package/src/lib/cloudflare-tunnel.ts +3 -16
- package/src/lib/environments/__tests__/paths.test.ts +20 -1
- package/src/lib/environments/paths.ts +29 -41
- package/src/lib/environments/resolve.ts +7 -5
- package/src/lib/guardian-token.ts +22 -62
- package/src/lib/ingress-config.ts +25 -0
- package/src/lib/local.ts +77 -20
- package/src/lib/nginx-ingress.ts +523 -76
- package/src/lib/ngrok.ts +154 -61
- package/src/lib/orphan-detection.test.ts +167 -0
- package/src/lib/orphan-detection.ts +40 -0
- package/src/lib/pair.test.ts +87 -0
- package/src/lib/pair.ts +55 -0
- package/src/lib/platform-client.ts +8 -5
- package/src/lib/tailscale-tunnel.ts +7 -18
- package/src/lib/tunnel-edge.ts +114 -0
- package/src/lib/xdg-log.ts +2 -2
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
2
|
afterAll,
|
|
3
3
|
afterEach,
|
|
4
|
+
beforeAll,
|
|
4
5
|
beforeEach,
|
|
5
6
|
describe,
|
|
6
7
|
expect,
|
|
@@ -8,16 +9,30 @@ import {
|
|
|
8
9
|
spyOn,
|
|
9
10
|
test,
|
|
10
11
|
} from "bun:test";
|
|
11
|
-
import
|
|
12
|
+
import * as childProcess from "node:child_process";
|
|
13
|
+
import type { ChildProcess } from "node:child_process";
|
|
14
|
+
import { EventEmitter } from "node:events";
|
|
15
|
+
import {
|
|
16
|
+
mkdirSync,
|
|
17
|
+
mkdtempSync,
|
|
18
|
+
readFileSync,
|
|
19
|
+
rmSync,
|
|
20
|
+
writeFileSync,
|
|
21
|
+
} from "node:fs";
|
|
12
22
|
import { tmpdir } from "node:os";
|
|
13
23
|
import { join } from "node:path";
|
|
14
24
|
|
|
15
25
|
import * as cloudflareTunnel from "../lib/cloudflare-tunnel.js";
|
|
16
26
|
import * as ngrok from "../lib/ngrok.js";
|
|
27
|
+
import * as nginxIngress from "../lib/nginx-ingress.js";
|
|
28
|
+
import * as tailscaleTunnel from "../lib/tailscale-tunnel.js";
|
|
17
29
|
import type { AssistantEntry } from "../lib/assistant-config.js";
|
|
18
30
|
|
|
19
31
|
const realCloudflareTunnel = { ...cloudflareTunnel };
|
|
20
32
|
const realNgrok = { ...ngrok };
|
|
33
|
+
const realNginxIngress = { ...nginxIngress };
|
|
34
|
+
const realTailscaleTunnel = { ...tailscaleTunnel };
|
|
35
|
+
const realChildProcess = { ...childProcess };
|
|
21
36
|
|
|
22
37
|
const runCloudflareTunnelMock = mock<
|
|
23
38
|
typeof cloudflareTunnel.runCloudflareTunnel
|
|
@@ -33,18 +48,37 @@ mock.module("../lib/ngrok", () => ({
|
|
|
33
48
|
runNgrokTunnel: runNgrokTunnelMock,
|
|
34
49
|
}));
|
|
35
50
|
|
|
51
|
+
const runTailscaleTunnelMock = mock<typeof tailscaleTunnel.runTailscaleTunnel>(
|
|
52
|
+
async () => {},
|
|
53
|
+
);
|
|
54
|
+
mock.module("../lib/tailscale-tunnel.js", () => ({
|
|
55
|
+
...realTailscaleTunnel,
|
|
56
|
+
runTailscaleTunnel: runTailscaleTunnelMock,
|
|
57
|
+
}));
|
|
58
|
+
|
|
59
|
+
const EDGE_PORT = 18080;
|
|
60
|
+
|
|
61
|
+
const ensureTunnelEdgeMock = mock<typeof nginxIngress.ensureTunnelEdge>(
|
|
62
|
+
async () => ({ port: EDGE_PORT, started: true, includesWebApp: true }),
|
|
63
|
+
);
|
|
64
|
+
mock.module("../lib/nginx-ingress.js", () => ({
|
|
65
|
+
...realNginxIngress,
|
|
66
|
+
ensureTunnelEdge: ensureTunnelEdgeMock,
|
|
67
|
+
}));
|
|
68
|
+
|
|
36
69
|
const { tunnel } = await import("../commands/tunnel.js");
|
|
37
70
|
|
|
38
71
|
const originalArgv = [...process.argv];
|
|
39
72
|
const originalFetch = globalThis.fetch;
|
|
40
73
|
const originalLockfileDir = process.env.VELLUM_LOCKFILE_DIR;
|
|
74
|
+
const originalWorkspaceDir = process.env.VELLUM_WORKSPACE_DIR;
|
|
41
75
|
const tempDirs: string[] = [];
|
|
42
76
|
|
|
43
|
-
function makeLocalEntry(): AssistantEntry {
|
|
77
|
+
function makeLocalEntry(assistantId = "assistant-1"): AssistantEntry {
|
|
44
78
|
const instanceDir = mkdtempSync(join(tmpdir(), "vellum-tunnel-test-"));
|
|
45
79
|
tempDirs.push(instanceDir);
|
|
46
80
|
return {
|
|
47
|
-
assistantId
|
|
81
|
+
assistantId,
|
|
48
82
|
runtimeUrl: "http://127.0.0.1:7830",
|
|
49
83
|
cloud: "local",
|
|
50
84
|
resources: {
|
|
@@ -57,7 +91,47 @@ function makeLocalEntry(): AssistantEntry {
|
|
|
57
91
|
};
|
|
58
92
|
}
|
|
59
93
|
|
|
60
|
-
function
|
|
94
|
+
function makeCloudEntry(assistantId = "cloud-1"): AssistantEntry {
|
|
95
|
+
return {
|
|
96
|
+
assistantId,
|
|
97
|
+
runtimeUrl: `https://runtime.example.com/${assistantId}`,
|
|
98
|
+
cloud: "vellum",
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** A `hatch --remote docker` entry: local container gateway, no `resources`. */
|
|
103
|
+
function makeDockerEntry(assistantId = "docker-1"): AssistantEntry {
|
|
104
|
+
return {
|
|
105
|
+
assistantId,
|
|
106
|
+
runtimeUrl: "http://localhost:7930",
|
|
107
|
+
cloud: "docker",
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** A macOS-app-managed entry: local container gateway, no `resources`. */
|
|
112
|
+
function makeAppleContainerEntry(assistantId = "apple-1"): AssistantEntry {
|
|
113
|
+
return {
|
|
114
|
+
assistantId,
|
|
115
|
+
runtimeUrl: "http://localhost:8030",
|
|
116
|
+
cloud: "apple-container",
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Point the default workspace dir at a temp dir; returns that dir. */
|
|
121
|
+
function useTempDefaultWorkspaceDir(): string {
|
|
122
|
+
const workspaceDir = mkdtempSync(join(tmpdir(), "vellum-tunnel-ws-"));
|
|
123
|
+
tempDirs.push(workspaceDir);
|
|
124
|
+
process.env.VELLUM_WORKSPACE_DIR = workspaceDir;
|
|
125
|
+
return workspaceDir;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function writeLockfile(
|
|
129
|
+
entryOrEntries: AssistantEntry | AssistantEntry[],
|
|
130
|
+
activeAssistant?: string,
|
|
131
|
+
): void {
|
|
132
|
+
const entries = Array.isArray(entryOrEntries)
|
|
133
|
+
? entryOrEntries
|
|
134
|
+
: [entryOrEntries];
|
|
61
135
|
const lockfileDir = mkdtempSync(join(tmpdir(), "vellum-tunnel-lockfile-"));
|
|
62
136
|
tempDirs.push(lockfileDir);
|
|
63
137
|
process.env.VELLUM_LOCKFILE_DIR = lockfileDir;
|
|
@@ -66,8 +140,8 @@ function writeLockfile(entry: AssistantEntry): void {
|
|
|
66
140
|
join(lockfileDir, ".vellum.lock.json"),
|
|
67
141
|
JSON.stringify(
|
|
68
142
|
{
|
|
69
|
-
activeAssistant:
|
|
70
|
-
assistants:
|
|
143
|
+
activeAssistant: activeAssistant ?? entries[0].assistantId,
|
|
144
|
+
assistants: entries,
|
|
71
145
|
},
|
|
72
146
|
null,
|
|
73
147
|
2,
|
|
@@ -75,20 +149,50 @@ function writeLockfile(entry: AssistantEntry): void {
|
|
|
75
149
|
);
|
|
76
150
|
}
|
|
77
151
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
)
|
|
152
|
+
/** Run tunnel() expecting exit(1); returns the joined console.error output. */
|
|
153
|
+
async function runTunnelExpectingExit1(): Promise<{
|
|
154
|
+
exited: boolean;
|
|
155
|
+
errors: string;
|
|
156
|
+
}> {
|
|
157
|
+
const errors: string[] = [];
|
|
158
|
+
const errSpy = spyOn(console, "error").mockImplementation(
|
|
159
|
+
(...a: unknown[]) => {
|
|
160
|
+
errors.push(a.join(" "));
|
|
161
|
+
},
|
|
162
|
+
);
|
|
163
|
+
const exitSpy = spyOn(process, "exit").mockImplementation(((
|
|
164
|
+
code?: number,
|
|
165
|
+
) => {
|
|
166
|
+
throw new Error(`exit:${code}`);
|
|
167
|
+
}) as never);
|
|
168
|
+
|
|
169
|
+
let exited = false;
|
|
170
|
+
try {
|
|
171
|
+
await tunnel();
|
|
172
|
+
} catch (e) {
|
|
173
|
+
exited = (e as Error).message === "exit:1";
|
|
174
|
+
} finally {
|
|
175
|
+
errSpy.mockRestore();
|
|
176
|
+
exitSpy.mockRestore();
|
|
177
|
+
}
|
|
178
|
+
return { exited, errors: errors.join("\n") };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Run tunnel() capturing console.log output; returns the joined lines. */
|
|
182
|
+
async function runTunnelCapturingLogs(): Promise<string> {
|
|
183
|
+
const logs: string[] = [];
|
|
184
|
+
const logSpy = spyOn(console, "log").mockImplementation((...a: unknown[]) => {
|
|
185
|
+
logs.push(a.join(" "));
|
|
86
186
|
});
|
|
87
|
-
|
|
88
|
-
|
|
187
|
+
try {
|
|
188
|
+
await tunnel();
|
|
189
|
+
} finally {
|
|
190
|
+
logSpy.mockRestore();
|
|
191
|
+
}
|
|
192
|
+
return logs.join("\n");
|
|
89
193
|
}
|
|
90
194
|
|
|
91
|
-
describe("tunnel
|
|
195
|
+
describe("tunnel edge targeting", () => {
|
|
92
196
|
beforeEach(() => {
|
|
93
197
|
process.argv = ["bun", "vellum", "tunnel"];
|
|
94
198
|
writeLockfile(makeLocalEntry());
|
|
@@ -99,6 +203,14 @@ describe("tunnel nginx ingress feature flag", () => {
|
|
|
99
203
|
runCloudflareTunnelMock.mockResolvedValue(undefined);
|
|
100
204
|
runNgrokTunnelMock.mockReset();
|
|
101
205
|
runNgrokTunnelMock.mockResolvedValue(undefined);
|
|
206
|
+
runTailscaleTunnelMock.mockReset();
|
|
207
|
+
runTailscaleTunnelMock.mockResolvedValue(undefined);
|
|
208
|
+
ensureTunnelEdgeMock.mockReset();
|
|
209
|
+
ensureTunnelEdgeMock.mockResolvedValue({
|
|
210
|
+
port: EDGE_PORT,
|
|
211
|
+
started: true,
|
|
212
|
+
includesWebApp: true,
|
|
213
|
+
});
|
|
102
214
|
});
|
|
103
215
|
|
|
104
216
|
afterEach(() => {
|
|
@@ -109,6 +221,11 @@ describe("tunnel nginx ingress feature flag", () => {
|
|
|
109
221
|
} else {
|
|
110
222
|
process.env.VELLUM_LOCKFILE_DIR = originalLockfileDir;
|
|
111
223
|
}
|
|
224
|
+
if (originalWorkspaceDir === undefined) {
|
|
225
|
+
delete process.env.VELLUM_WORKSPACE_DIR;
|
|
226
|
+
} else {
|
|
227
|
+
process.env.VELLUM_WORKSPACE_DIR = originalWorkspaceDir;
|
|
228
|
+
}
|
|
112
229
|
for (const dir of tempDirs.splice(0)) {
|
|
113
230
|
rmSync(dir, { recursive: true, force: true });
|
|
114
231
|
}
|
|
@@ -117,86 +234,596 @@ describe("tunnel nginx ingress feature flag", () => {
|
|
|
117
234
|
afterAll(() => {
|
|
118
235
|
mock.module("../lib/cloudflare-tunnel.js", () => realCloudflareTunnel);
|
|
119
236
|
mock.module("../lib/ngrok", () => realNgrok);
|
|
237
|
+
mock.module("../lib/tailscale-tunnel.js", () => realTailscaleTunnel);
|
|
238
|
+
mock.module("../lib/nginx-ingress.js", () => realNginxIngress);
|
|
120
239
|
});
|
|
121
240
|
|
|
122
|
-
test("does not start ngrok when the flag lookup fails", async () => {
|
|
241
|
+
test("does not start ngrok when the edge flag lookup fails", async () => {
|
|
123
242
|
process.argv = ["bun", "vellum", "tunnel", "--provider", "ngrok"];
|
|
243
|
+
ensureTunnelEdgeMock.mockImplementation(realNginxIngress.ensureTunnelEdge);
|
|
244
|
+
|
|
245
|
+
const { exited, errors } = await runTunnelExpectingExit1();
|
|
124
246
|
|
|
125
|
-
|
|
247
|
+
expect(exited).toBe(true);
|
|
248
|
+
expect(errors).toContain(
|
|
126
249
|
"Could not verify the `web-remote-ingress` feature flag",
|
|
127
250
|
);
|
|
128
|
-
|
|
129
251
|
expect(runNgrokTunnelMock).not.toHaveBeenCalled();
|
|
130
252
|
expect(runCloudflareTunnelMock).not.toHaveBeenCalled();
|
|
131
253
|
});
|
|
132
254
|
|
|
133
|
-
test("
|
|
255
|
+
test("targets the edge port returned by ensureTunnelEdge for ngrok", async () => {
|
|
134
256
|
const entry = makeLocalEntry();
|
|
135
257
|
entry.runtimeUrl = "https://stale-tunnel.ngrok-free.dev";
|
|
136
258
|
writeLockfile(entry);
|
|
137
259
|
process.argv = ["bun", "vellum", "tunnel", "--provider", "ngrok"];
|
|
138
|
-
const fetchMock = mockEnabledFlagFetch();
|
|
139
260
|
|
|
140
|
-
await
|
|
261
|
+
const logs = await runTunnelCapturingLogs();
|
|
262
|
+
|
|
263
|
+
const workspaceDir = join(
|
|
264
|
+
entry.resources!.instanceDir,
|
|
265
|
+
".vellum",
|
|
266
|
+
"workspace",
|
|
267
|
+
);
|
|
268
|
+
expect(ensureTunnelEdgeMock).toHaveBeenCalledWith({
|
|
269
|
+
assistantId: "assistant-1",
|
|
270
|
+
workspaceDir,
|
|
271
|
+
gatewayPort: 7830,
|
|
272
|
+
});
|
|
273
|
+
expect(runNgrokTunnelMock).toHaveBeenCalledWith({
|
|
274
|
+
port: EDGE_PORT,
|
|
275
|
+
assistantId: "assistant-1",
|
|
276
|
+
workspaceDir,
|
|
277
|
+
});
|
|
278
|
+
expect(runCloudflareTunnelMock).not.toHaveBeenCalled();
|
|
279
|
+
expect(logs).toContain(`Started the nginx edge on 127.0.0.1:${EDGE_PORT}`);
|
|
280
|
+
expect(logs).toContain("serves remote web + webhooks");
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
test("an active cloud assistant falls back to the sole local entry with a note", async () => {
|
|
284
|
+
const cloud = makeCloudEntry();
|
|
285
|
+
const local = makeLocalEntry();
|
|
286
|
+
writeLockfile([cloud, local], cloud.assistantId);
|
|
287
|
+
process.argv = ["bun", "vellum", "tunnel", "--provider", "ngrok"];
|
|
288
|
+
|
|
289
|
+
const logs = await runTunnelCapturingLogs();
|
|
290
|
+
|
|
291
|
+
const workspaceDir = join(
|
|
292
|
+
local.resources!.instanceDir,
|
|
293
|
+
".vellum",
|
|
294
|
+
"workspace",
|
|
295
|
+
);
|
|
296
|
+
expect(logs).toContain(
|
|
297
|
+
"Assistant 'cloud-1' runs on Vellum Cloud and needs no tunnel. " +
|
|
298
|
+
"Tunneling the local assistant 'assistant-1' instead.",
|
|
299
|
+
);
|
|
300
|
+
expect(ensureTunnelEdgeMock).toHaveBeenCalledWith({
|
|
301
|
+
assistantId: "assistant-1",
|
|
302
|
+
workspaceDir,
|
|
303
|
+
gatewayPort: 7830,
|
|
304
|
+
});
|
|
305
|
+
expect(runNgrokTunnelMock).toHaveBeenCalledWith({
|
|
306
|
+
port: EDGE_PORT,
|
|
307
|
+
assistantId: "assistant-1",
|
|
308
|
+
workspaceDir,
|
|
309
|
+
});
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
test("a positional docker assistant name tunnels via its runtimeUrl gateway port", async () => {
|
|
313
|
+
const workspaceDir = useTempDefaultWorkspaceDir();
|
|
314
|
+
const local = makeLocalEntry();
|
|
315
|
+
writeLockfile([local, makeDockerEntry()], local.assistantId);
|
|
316
|
+
process.argv = [
|
|
317
|
+
"bun",
|
|
318
|
+
"vellum",
|
|
319
|
+
"tunnel",
|
|
320
|
+
"docker-1",
|
|
321
|
+
"--provider",
|
|
322
|
+
"ngrok",
|
|
323
|
+
];
|
|
324
|
+
|
|
325
|
+
await runTunnelCapturingLogs();
|
|
326
|
+
|
|
327
|
+
expect(ensureTunnelEdgeMock).toHaveBeenCalledWith({
|
|
328
|
+
assistantId: "docker-1",
|
|
329
|
+
workspaceDir,
|
|
330
|
+
gatewayPort: 7930,
|
|
331
|
+
});
|
|
332
|
+
expect(runNgrokTunnelMock).toHaveBeenCalledWith({
|
|
333
|
+
port: EDGE_PORT,
|
|
334
|
+
assistantId: "docker-1",
|
|
335
|
+
workspaceDir,
|
|
336
|
+
});
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
test("an active docker assistant tunnels on a bare invocation", async () => {
|
|
340
|
+
const workspaceDir = useTempDefaultWorkspaceDir();
|
|
341
|
+
const docker = makeDockerEntry();
|
|
342
|
+
writeLockfile(docker, docker.assistantId);
|
|
343
|
+
process.argv = ["bun", "vellum", "tunnel", "--provider", "ngrok"];
|
|
344
|
+
|
|
345
|
+
await runTunnelCapturingLogs();
|
|
346
|
+
|
|
347
|
+
expect(ensureTunnelEdgeMock).toHaveBeenCalledWith({
|
|
348
|
+
assistantId: "docker-1",
|
|
349
|
+
workspaceDir,
|
|
350
|
+
gatewayPort: 7930,
|
|
351
|
+
});
|
|
352
|
+
expect(runNgrokTunnelMock).toHaveBeenCalledWith({
|
|
353
|
+
port: EDGE_PORT,
|
|
354
|
+
assistantId: "docker-1",
|
|
355
|
+
workspaceDir,
|
|
356
|
+
});
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
test("an active cloud assistant falls back to a sole docker entry with a note", async () => {
|
|
360
|
+
const workspaceDir = useTempDefaultWorkspaceDir();
|
|
361
|
+
const cloud = makeCloudEntry();
|
|
362
|
+
writeLockfile([cloud, makeDockerEntry()], cloud.assistantId);
|
|
363
|
+
process.argv = ["bun", "vellum", "tunnel", "--provider", "ngrok"];
|
|
364
|
+
|
|
365
|
+
const logs = await runTunnelCapturingLogs();
|
|
366
|
+
|
|
367
|
+
expect(logs).toContain(
|
|
368
|
+
"Assistant 'cloud-1' runs on Vellum Cloud and needs no tunnel. " +
|
|
369
|
+
"Tunneling the local assistant 'docker-1' instead.",
|
|
370
|
+
);
|
|
371
|
+
expect(runNgrokTunnelMock).toHaveBeenCalledWith({
|
|
372
|
+
port: EDGE_PORT,
|
|
373
|
+
assistantId: "docker-1",
|
|
374
|
+
workspaceDir,
|
|
375
|
+
});
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
test("an active cloud assistant with no local entries exits with an error", async () => {
|
|
379
|
+
writeLockfile(makeCloudEntry());
|
|
380
|
+
process.argv = ["bun", "vellum", "tunnel", "--provider", "ngrok"];
|
|
381
|
+
|
|
382
|
+
const { exited, errors } = await runTunnelExpectingExit1();
|
|
383
|
+
|
|
384
|
+
expect(exited).toBe(true);
|
|
385
|
+
expect(errors).toContain(
|
|
386
|
+
"Assistant 'cloud-1' runs on Vellum Cloud and needs no tunnel.",
|
|
387
|
+
);
|
|
388
|
+
expect(errors).toContain("No local assistant found to tunnel");
|
|
389
|
+
expect(ensureTunnelEdgeMock).not.toHaveBeenCalled();
|
|
390
|
+
expect(runNgrokTunnelMock).not.toHaveBeenCalled();
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
test("an active cloud assistant with multiple local entries exits listing them", async () => {
|
|
394
|
+
const cloud = makeCloudEntry();
|
|
395
|
+
writeLockfile(
|
|
396
|
+
[cloud, makeLocalEntry("assistant-a"), makeLocalEntry("assistant-b")],
|
|
397
|
+
cloud.assistantId,
|
|
398
|
+
);
|
|
399
|
+
process.argv = ["bun", "vellum", "tunnel", "--provider", "ngrok"];
|
|
400
|
+
|
|
401
|
+
const { exited, errors } = await runTunnelExpectingExit1();
|
|
402
|
+
|
|
403
|
+
expect(exited).toBe(true);
|
|
404
|
+
expect(errors).toContain(
|
|
405
|
+
"Assistant 'cloud-1' runs on Vellum Cloud and needs no tunnel.",
|
|
406
|
+
);
|
|
407
|
+
expect(errors).toContain(
|
|
408
|
+
"Pass a local assistant as the name argument: assistant-a, assistant-b.",
|
|
409
|
+
);
|
|
410
|
+
expect(ensureTunnelEdgeMock).not.toHaveBeenCalled();
|
|
411
|
+
expect(runNgrokTunnelMock).not.toHaveBeenCalled();
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
test("a positional cloud assistant name errors without auto-fallback", async () => {
|
|
415
|
+
const local = makeLocalEntry();
|
|
416
|
+
writeLockfile([makeCloudEntry(), local], local.assistantId);
|
|
417
|
+
process.argv = [
|
|
418
|
+
"bun",
|
|
419
|
+
"vellum",
|
|
420
|
+
"tunnel",
|
|
421
|
+
"cloud-1",
|
|
422
|
+
"--provider",
|
|
423
|
+
"ngrok",
|
|
424
|
+
];
|
|
141
425
|
|
|
142
|
-
const
|
|
143
|
-
|
|
144
|
-
|
|
426
|
+
const { exited, errors } = await runTunnelExpectingExit1();
|
|
427
|
+
|
|
428
|
+
expect(exited).toBe(true);
|
|
429
|
+
expect(errors).toContain(
|
|
430
|
+
"Assistant 'cloud-1' runs on Vellum Cloud and needs no tunnel.",
|
|
431
|
+
);
|
|
432
|
+
expect(errors).toContain(
|
|
433
|
+
"Pass a local assistant as the name argument: assistant-1.",
|
|
434
|
+
);
|
|
435
|
+
expect(ensureTunnelEdgeMock).not.toHaveBeenCalled();
|
|
436
|
+
expect(runNgrokTunnelMock).not.toHaveBeenCalled();
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
test("a positional display name resolves the local assistant", async () => {
|
|
440
|
+
const local = makeLocalEntry();
|
|
441
|
+
local.name = "Ada";
|
|
442
|
+
writeLockfile([makeCloudEntry(), local], "cloud-1");
|
|
443
|
+
process.argv = ["bun", "vellum", "tunnel", "Ada", "--provider", "ngrok"];
|
|
444
|
+
|
|
445
|
+
await runTunnelCapturingLogs();
|
|
446
|
+
|
|
447
|
+
const workspaceDir = join(
|
|
448
|
+
local.resources!.instanceDir,
|
|
449
|
+
".vellum",
|
|
450
|
+
"workspace",
|
|
451
|
+
);
|
|
452
|
+
expect(runNgrokTunnelMock).toHaveBeenCalledWith({
|
|
453
|
+
port: EDGE_PORT,
|
|
454
|
+
assistantId: "assistant-1",
|
|
455
|
+
workspaceDir,
|
|
456
|
+
});
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
test("an unquoted multi-word display name resolves the local assistant", async () => {
|
|
460
|
+
const local = makeLocalEntry();
|
|
461
|
+
local.name = "Ada Lovelace";
|
|
462
|
+
writeLockfile([makeCloudEntry(), local], "cloud-1");
|
|
463
|
+
process.argv = [
|
|
464
|
+
"bun",
|
|
465
|
+
"vellum",
|
|
466
|
+
"tunnel",
|
|
467
|
+
"Ada",
|
|
468
|
+
"Lovelace",
|
|
469
|
+
"--provider",
|
|
470
|
+
"ngrok",
|
|
471
|
+
];
|
|
472
|
+
|
|
473
|
+
await runTunnelCapturingLogs();
|
|
474
|
+
|
|
475
|
+
const workspaceDir = join(
|
|
476
|
+
local.resources!.instanceDir,
|
|
477
|
+
".vellum",
|
|
478
|
+
"workspace",
|
|
479
|
+
);
|
|
480
|
+
expect(runNgrokTunnelMock).toHaveBeenCalledWith({
|
|
481
|
+
port: EDGE_PORT,
|
|
482
|
+
assistantId: "assistant-1",
|
|
483
|
+
workspaceDir,
|
|
484
|
+
});
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
test("a positional apple-container assistant name tunnels via its runtimeUrl gateway port", async () => {
|
|
488
|
+
const workspaceDir = useTempDefaultWorkspaceDir();
|
|
489
|
+
const local = makeLocalEntry();
|
|
490
|
+
writeLockfile([local, makeAppleContainerEntry()], local.assistantId);
|
|
491
|
+
process.argv = [
|
|
492
|
+
"bun",
|
|
493
|
+
"vellum",
|
|
494
|
+
"tunnel",
|
|
495
|
+
"apple-1",
|
|
496
|
+
"--provider",
|
|
497
|
+
"ngrok",
|
|
498
|
+
];
|
|
499
|
+
|
|
500
|
+
await runTunnelCapturingLogs();
|
|
501
|
+
|
|
502
|
+
expect(ensureTunnelEdgeMock).toHaveBeenCalledWith({
|
|
503
|
+
assistantId: "apple-1",
|
|
504
|
+
workspaceDir,
|
|
505
|
+
gatewayPort: 8030,
|
|
506
|
+
});
|
|
507
|
+
expect(runNgrokTunnelMock).toHaveBeenCalledWith({
|
|
508
|
+
port: EDGE_PORT,
|
|
509
|
+
assistantId: "apple-1",
|
|
510
|
+
workspaceDir,
|
|
511
|
+
});
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
test("an active cloud assistant falls back to a sole apple-container entry with a note", async () => {
|
|
515
|
+
const workspaceDir = useTempDefaultWorkspaceDir();
|
|
516
|
+
const cloud = makeCloudEntry();
|
|
517
|
+
writeLockfile([cloud, makeAppleContainerEntry()], cloud.assistantId);
|
|
518
|
+
process.argv = ["bun", "vellum", "tunnel", "--provider", "ngrok"];
|
|
519
|
+
|
|
520
|
+
const logs = await runTunnelCapturingLogs();
|
|
521
|
+
|
|
522
|
+
expect(logs).toContain(
|
|
523
|
+
"Assistant 'cloud-1' runs on Vellum Cloud and needs no tunnel. " +
|
|
524
|
+
"Tunneling the local assistant 'apple-1' instead.",
|
|
145
525
|
);
|
|
146
|
-
expect(
|
|
526
|
+
expect(ensureTunnelEdgeMock).toHaveBeenCalledWith({
|
|
527
|
+
assistantId: "apple-1",
|
|
528
|
+
workspaceDir,
|
|
529
|
+
gatewayPort: 8030,
|
|
530
|
+
});
|
|
147
531
|
expect(runNgrokTunnelMock).toHaveBeenCalledWith({
|
|
148
|
-
port:
|
|
532
|
+
port: EDGE_PORT,
|
|
533
|
+
assistantId: "apple-1",
|
|
534
|
+
workspaceDir,
|
|
535
|
+
});
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
test("an exact assistant ID wins over a colliding display name", async () => {
|
|
539
|
+
const decoy = makeLocalEntry("assistant-a");
|
|
540
|
+
decoy.name = "assistant-b";
|
|
541
|
+
const target = makeLocalEntry("assistant-b");
|
|
542
|
+
writeLockfile([decoy, target], "assistant-a");
|
|
543
|
+
process.argv = [
|
|
544
|
+
"bun",
|
|
545
|
+
"vellum",
|
|
546
|
+
"tunnel",
|
|
547
|
+
"assistant-b",
|
|
548
|
+
"--provider",
|
|
549
|
+
"ngrok",
|
|
550
|
+
];
|
|
551
|
+
|
|
552
|
+
await runTunnelCapturingLogs();
|
|
553
|
+
|
|
554
|
+
expect(runNgrokTunnelMock).toHaveBeenCalledWith(
|
|
555
|
+
expect.objectContaining({ assistantId: "assistant-b" }),
|
|
556
|
+
);
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
test("a positional display name of a cloud assistant errors without auto-fallback", async () => {
|
|
560
|
+
const cloud = makeCloudEntry();
|
|
561
|
+
cloud.name = "Cloudy";
|
|
562
|
+
const local = makeLocalEntry();
|
|
563
|
+
writeLockfile([cloud, local], local.assistantId);
|
|
564
|
+
process.argv = ["bun", "vellum", "tunnel", "Cloudy", "--provider", "ngrok"];
|
|
565
|
+
|
|
566
|
+
const { exited, errors } = await runTunnelExpectingExit1();
|
|
567
|
+
|
|
568
|
+
expect(exited).toBe(true);
|
|
569
|
+
expect(errors).toContain(
|
|
570
|
+
"Assistant 'Cloudy (cloud-1)' runs on Vellum Cloud and needs no tunnel.",
|
|
571
|
+
);
|
|
572
|
+
expect(errors).toContain(
|
|
573
|
+
"Pass a local assistant as the name argument: assistant-1.",
|
|
574
|
+
);
|
|
575
|
+
expect(ensureTunnelEdgeMock).not.toHaveBeenCalled();
|
|
576
|
+
expect(runNgrokTunnelMock).not.toHaveBeenCalled();
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
test("an ambiguous positional display name errors listing the candidates", async () => {
|
|
580
|
+
const first = makeLocalEntry("assistant-a");
|
|
581
|
+
first.name = "Ada";
|
|
582
|
+
const second = makeLocalEntry("assistant-b");
|
|
583
|
+
second.name = "Ada";
|
|
584
|
+
writeLockfile([first, second], "assistant-a");
|
|
585
|
+
process.argv = ["bun", "vellum", "tunnel", "Ada", "--provider", "ngrok"];
|
|
586
|
+
|
|
587
|
+
const { exited, errors } = await runTunnelExpectingExit1();
|
|
588
|
+
|
|
589
|
+
expect(exited).toBe(true);
|
|
590
|
+
expect(errors).toContain(
|
|
591
|
+
"Multiple assistants match 'Ada': Ada (assistant-a), Ada (assistant-b).",
|
|
592
|
+
);
|
|
593
|
+
expect(ensureTunnelEdgeMock).not.toHaveBeenCalled();
|
|
594
|
+
expect(runNgrokTunnelMock).not.toHaveBeenCalled();
|
|
595
|
+
});
|
|
596
|
+
|
|
597
|
+
test("an unknown positional name errors", async () => {
|
|
598
|
+
process.argv = ["bun", "vellum", "tunnel", "nope", "--provider", "ngrok"];
|
|
599
|
+
|
|
600
|
+
const { exited, errors } = await runTunnelExpectingExit1();
|
|
601
|
+
|
|
602
|
+
expect(exited).toBe(true);
|
|
603
|
+
expect(errors).toContain("No assistant found with name or ID 'nope'.");
|
|
604
|
+
expect(ensureTunnelEdgeMock).not.toHaveBeenCalled();
|
|
605
|
+
expect(runNgrokTunnelMock).not.toHaveBeenCalled();
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
test("targets the edge port for cloudflare", async () => {
|
|
609
|
+
const entry = makeLocalEntry();
|
|
610
|
+
writeLockfile(entry);
|
|
611
|
+
process.argv = ["bun", "vellum", "tunnel", "--provider", "cloudflare"];
|
|
612
|
+
|
|
613
|
+
await runTunnelCapturingLogs();
|
|
614
|
+
|
|
615
|
+
expect(runCloudflareTunnelMock).toHaveBeenCalledWith({
|
|
616
|
+
port: EDGE_PORT,
|
|
617
|
+
assistantId: "assistant-1",
|
|
618
|
+
workspaceDir: join(entry.resources!.instanceDir, ".vellum", "workspace"),
|
|
619
|
+
});
|
|
620
|
+
expect(runNgrokTunnelMock).not.toHaveBeenCalled();
|
|
621
|
+
});
|
|
622
|
+
|
|
623
|
+
test("targets the edge port for tailscale and notes a reused webhooks-only edge", async () => {
|
|
624
|
+
const entry = makeLocalEntry();
|
|
625
|
+
writeLockfile(entry);
|
|
626
|
+
process.argv = ["bun", "vellum", "tunnel", "--provider", "tailscale"];
|
|
627
|
+
ensureTunnelEdgeMock.mockResolvedValue({
|
|
628
|
+
port: EDGE_PORT,
|
|
629
|
+
started: false,
|
|
630
|
+
includesWebApp: false,
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
const logs = await runTunnelCapturingLogs();
|
|
634
|
+
|
|
635
|
+
expect(runTailscaleTunnelMock).toHaveBeenCalledWith({
|
|
636
|
+
port: EDGE_PORT,
|
|
149
637
|
assistantId: "assistant-1",
|
|
150
638
|
workspaceDir: join(entry.resources!.instanceDir, ".vellum", "workspace"),
|
|
151
|
-
preferNginxIngress: true,
|
|
152
639
|
});
|
|
640
|
+
expect(logs).toContain(`Reusing the nginx edge on 127.0.0.1:${EDGE_PORT}`);
|
|
641
|
+
expect(logs).toContain("serves webhooks only");
|
|
642
|
+
});
|
|
643
|
+
|
|
644
|
+
test("missing nginx aborts before any provider spawn", async () => {
|
|
645
|
+
process.argv = ["bun", "vellum", "tunnel", "--provider", "ngrok"];
|
|
646
|
+
ensureTunnelEdgeMock.mockRejectedValue(
|
|
647
|
+
new Error(
|
|
648
|
+
"nginx is not installed, so the tunnel edge cannot start. " +
|
|
649
|
+
"Install it (macOS: `brew install nginx`, Linux: `sudo apt install nginx`) " +
|
|
650
|
+
"or point NGINX_BIN at an existing binary.",
|
|
651
|
+
),
|
|
652
|
+
);
|
|
653
|
+
|
|
654
|
+
const { exited, errors } = await runTunnelExpectingExit1();
|
|
655
|
+
|
|
656
|
+
expect(exited).toBe(true);
|
|
657
|
+
expect(errors).toContain("brew install nginx");
|
|
658
|
+
expect(runNgrokTunnelMock).not.toHaveBeenCalled();
|
|
153
659
|
expect(runCloudflareTunnelMock).not.toHaveBeenCalled();
|
|
660
|
+
expect(runTailscaleTunnelMock).not.toHaveBeenCalled();
|
|
154
661
|
});
|
|
155
662
|
|
|
156
|
-
test("does not start cloudflared when the flag lookup fails", async () => {
|
|
663
|
+
test("does not start cloudflared when the edge flag lookup fails", async () => {
|
|
157
664
|
process.argv = ["bun", "vellum", "tunnel", "--provider", "cloudflare"];
|
|
665
|
+
ensureTunnelEdgeMock.mockImplementation(realNginxIngress.ensureTunnelEdge);
|
|
666
|
+
|
|
667
|
+
const { exited, errors } = await runTunnelExpectingExit1();
|
|
158
668
|
|
|
159
|
-
|
|
669
|
+
expect(exited).toBe(true);
|
|
670
|
+
expect(errors).toContain(
|
|
160
671
|
"Could not verify the `web-remote-ingress` feature flag",
|
|
161
672
|
);
|
|
162
|
-
|
|
163
673
|
expect(runNgrokTunnelMock).not.toHaveBeenCalled();
|
|
164
674
|
expect(runCloudflareTunnelMock).not.toHaveBeenCalled();
|
|
165
675
|
});
|
|
166
676
|
|
|
167
677
|
test("rejects an unknown --provider with a stale-CLI hint", async () => {
|
|
168
678
|
process.argv = ["bun", "vellum", "tunnel", "--provider", "bogus"];
|
|
169
|
-
|
|
170
|
-
const
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
679
|
+
|
|
680
|
+
const { exited, errors } = await runTunnelExpectingExit1();
|
|
681
|
+
|
|
682
|
+
expect(exited).toBe(true);
|
|
683
|
+
expect(errors).toContain("unknown tunnel provider 'bogus'");
|
|
684
|
+
expect(errors).toContain("your CLI may be out of date");
|
|
685
|
+
expect(errors).toContain("bun install -g vellum@latest");
|
|
686
|
+
expect(runNgrokTunnelMock).not.toHaveBeenCalled();
|
|
687
|
+
expect(runCloudflareTunnelMock).not.toHaveBeenCalled();
|
|
688
|
+
});
|
|
689
|
+
|
|
690
|
+
test("threads --domain through to runNgrokTunnel", async () => {
|
|
691
|
+
const entry = makeLocalEntry();
|
|
692
|
+
writeLockfile(entry);
|
|
693
|
+
process.argv = [
|
|
694
|
+
"bun",
|
|
695
|
+
"vellum",
|
|
696
|
+
"tunnel",
|
|
697
|
+
"--provider",
|
|
698
|
+
"ngrok",
|
|
699
|
+
"--domain",
|
|
700
|
+
"foo.ngrok.app",
|
|
701
|
+
];
|
|
702
|
+
|
|
703
|
+
await runTunnelCapturingLogs();
|
|
704
|
+
|
|
705
|
+
expect(runNgrokTunnelMock).toHaveBeenCalledWith({
|
|
706
|
+
port: EDGE_PORT,
|
|
707
|
+
assistantId: "assistant-1",
|
|
708
|
+
workspaceDir: join(entry.resources!.instanceDir, ".vellum", "workspace"),
|
|
709
|
+
domain: "foo.ngrok.app",
|
|
710
|
+
});
|
|
711
|
+
});
|
|
712
|
+
|
|
713
|
+
test("rejects --domain with a non-ngrok provider", async () => {
|
|
714
|
+
process.argv = [
|
|
715
|
+
"bun",
|
|
716
|
+
"vellum",
|
|
717
|
+
"tunnel",
|
|
718
|
+
"--provider",
|
|
719
|
+
"cloudflare",
|
|
720
|
+
"--domain",
|
|
721
|
+
"foo.ngrok.app",
|
|
722
|
+
];
|
|
723
|
+
|
|
724
|
+
const { exited, errors } = await runTunnelExpectingExit1();
|
|
725
|
+
|
|
726
|
+
expect(exited).toBe(true);
|
|
727
|
+
expect(errors).toContain(
|
|
728
|
+
"--domain is only supported with --provider ngrok",
|
|
174
729
|
);
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
throw new Error(`exit:${code}`);
|
|
179
|
-
}) as never);
|
|
730
|
+
expect(runNgrokTunnelMock).not.toHaveBeenCalled();
|
|
731
|
+
expect(runCloudflareTunnelMock).not.toHaveBeenCalled();
|
|
732
|
+
});
|
|
180
733
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
}
|
|
734
|
+
test("--clear-domain drops the saved domain and runs domainless", async () => {
|
|
735
|
+
const entry = makeLocalEntry();
|
|
736
|
+
writeLockfile(entry);
|
|
737
|
+
const workspaceDir = join(
|
|
738
|
+
entry.resources!.instanceDir,
|
|
739
|
+
".vellum",
|
|
740
|
+
"workspace",
|
|
741
|
+
);
|
|
742
|
+
mkdirSync(workspaceDir, { recursive: true });
|
|
743
|
+
writeFileSync(
|
|
744
|
+
join(workspaceDir, "config.json"),
|
|
745
|
+
JSON.stringify({ ingress: { ngrok: { domain: "dead.ngrok.app" } } }),
|
|
746
|
+
);
|
|
747
|
+
process.argv = [
|
|
748
|
+
"bun",
|
|
749
|
+
"vellum",
|
|
750
|
+
"tunnel",
|
|
751
|
+
"--provider",
|
|
752
|
+
"ngrok",
|
|
753
|
+
"--clear-domain",
|
|
754
|
+
];
|
|
755
|
+
|
|
756
|
+
const logs = await runTunnelCapturingLogs();
|
|
757
|
+
|
|
758
|
+
expect(logs).toContain("Cleared the saved ngrok domain");
|
|
759
|
+
const config = JSON.parse(
|
|
760
|
+
readFileSync(join(workspaceDir, "config.json"), "utf-8"),
|
|
761
|
+
) as { ingress?: { ngrok?: { domain?: string } } };
|
|
762
|
+
expect(config.ingress?.ngrok).toBeUndefined();
|
|
763
|
+
// The run proceeds domainless: no `domain` key reaches runNgrokTunnel.
|
|
764
|
+
expect(runNgrokTunnelMock).toHaveBeenCalledWith({
|
|
765
|
+
port: EDGE_PORT,
|
|
766
|
+
assistantId: "assistant-1",
|
|
767
|
+
workspaceDir,
|
|
768
|
+
});
|
|
769
|
+
});
|
|
770
|
+
|
|
771
|
+
test("rejects --clear-domain combined with --domain", async () => {
|
|
772
|
+
process.argv = [
|
|
773
|
+
"bun",
|
|
774
|
+
"vellum",
|
|
775
|
+
"tunnel",
|
|
776
|
+
"--provider",
|
|
777
|
+
"ngrok",
|
|
778
|
+
"--domain",
|
|
779
|
+
"foo.ngrok.app",
|
|
780
|
+
"--clear-domain",
|
|
781
|
+
];
|
|
782
|
+
|
|
783
|
+
const { exited, errors } = await runTunnelExpectingExit1();
|
|
784
|
+
|
|
785
|
+
expect(exited).toBe(true);
|
|
786
|
+
expect(errors).toContain("--clear-domain cannot be combined with --domain");
|
|
787
|
+
expect(runNgrokTunnelMock).not.toHaveBeenCalled();
|
|
788
|
+
});
|
|
789
|
+
|
|
790
|
+
test("rejects --clear-domain with a non-ngrok provider", async () => {
|
|
791
|
+
process.argv = [
|
|
792
|
+
"bun",
|
|
793
|
+
"vellum",
|
|
794
|
+
"tunnel",
|
|
795
|
+
"--provider",
|
|
796
|
+
"cloudflare",
|
|
797
|
+
"--clear-domain",
|
|
798
|
+
];
|
|
799
|
+
|
|
800
|
+
const { exited, errors } = await runTunnelExpectingExit1();
|
|
190
801
|
|
|
191
802
|
expect(exited).toBe(true);
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
expect(joined).toContain("bun install -g vellum@latest");
|
|
803
|
+
expect(errors).toContain(
|
|
804
|
+
"--clear-domain is only supported with --provider ngrok",
|
|
805
|
+
);
|
|
196
806
|
expect(runNgrokTunnelMock).not.toHaveBeenCalled();
|
|
197
807
|
expect(runCloudflareTunnelMock).not.toHaveBeenCalled();
|
|
198
808
|
});
|
|
199
809
|
|
|
810
|
+
test("errors when --domain is missing its value", async () => {
|
|
811
|
+
process.argv = [
|
|
812
|
+
"bun",
|
|
813
|
+
"vellum",
|
|
814
|
+
"tunnel",
|
|
815
|
+
"--provider",
|
|
816
|
+
"ngrok",
|
|
817
|
+
"--domain",
|
|
818
|
+
];
|
|
819
|
+
|
|
820
|
+
const { exited, errors } = await runTunnelExpectingExit1();
|
|
821
|
+
|
|
822
|
+
expect(exited).toBe(true);
|
|
823
|
+
expect(errors).toContain("--domain requires a value");
|
|
824
|
+
expect(runNgrokTunnelMock).not.toHaveBeenCalled();
|
|
825
|
+
});
|
|
826
|
+
|
|
200
827
|
test("a not-yet-implemented provider error carries the stale-CLI hint", async () => {
|
|
201
828
|
// The default `vellum` provider has no runtime yet, so it exercises the
|
|
202
829
|
// not-yet-implemented path without any network call.
|
|
@@ -212,7 +839,424 @@ describe("tunnel nginx ingress feature flag", () => {
|
|
|
212
839
|
expect(err?.message).toContain("is not yet implemented");
|
|
213
840
|
expect(err?.message).toContain("your CLI may be out of date");
|
|
214
841
|
expect(err?.message).toContain("bun install -g vellum@latest");
|
|
842
|
+
expect(ensureTunnelEdgeMock).not.toHaveBeenCalled();
|
|
215
843
|
expect(runNgrokTunnelMock).not.toHaveBeenCalled();
|
|
216
844
|
expect(runCloudflareTunnelMock).not.toHaveBeenCalled();
|
|
217
845
|
});
|
|
218
846
|
});
|
|
847
|
+
|
|
848
|
+
describe("ngrok --domain spawn args", () => {
|
|
849
|
+
const originalContainerized = process.env.IS_CONTAINERIZED;
|
|
850
|
+
let lastChild: EventEmitter | null = null;
|
|
851
|
+
|
|
852
|
+
const spawnMock = mock((..._args: unknown[]) => {
|
|
853
|
+
const emitter = new EventEmitter();
|
|
854
|
+
lastChild = Object.assign(emitter, {
|
|
855
|
+
stdout: null,
|
|
856
|
+
stderr: null,
|
|
857
|
+
killed: false,
|
|
858
|
+
kill: () => true,
|
|
859
|
+
unref: () => {},
|
|
860
|
+
pid: 4242,
|
|
861
|
+
});
|
|
862
|
+
return lastChild as unknown as ChildProcess;
|
|
863
|
+
});
|
|
864
|
+
const execFileSyncMock = mock(() => "ngrok version 3.9.0");
|
|
865
|
+
|
|
866
|
+
beforeAll(() => {
|
|
867
|
+
mock.module("node:child_process", () => ({
|
|
868
|
+
...realChildProcess,
|
|
869
|
+
spawn: spawnMock,
|
|
870
|
+
execFileSync: execFileSyncMock,
|
|
871
|
+
}));
|
|
872
|
+
});
|
|
873
|
+
|
|
874
|
+
afterAll(() => {
|
|
875
|
+
mock.module("node:child_process", () => realChildProcess);
|
|
876
|
+
});
|
|
877
|
+
|
|
878
|
+
beforeEach(() => {
|
|
879
|
+
spawnMock.mockClear();
|
|
880
|
+
lastChild = null;
|
|
881
|
+
delete process.env.IS_CONTAINERIZED;
|
|
882
|
+
mockNgrokApiFetch([{ tunnels: [] }]);
|
|
883
|
+
});
|
|
884
|
+
|
|
885
|
+
afterEach(() => {
|
|
886
|
+
globalThis.fetch = originalFetch;
|
|
887
|
+
if (originalContainerized === undefined) {
|
|
888
|
+
delete process.env.IS_CONTAINERIZED;
|
|
889
|
+
} else {
|
|
890
|
+
process.env.IS_CONTAINERIZED = originalContainerized;
|
|
891
|
+
}
|
|
892
|
+
for (const dir of tempDirs.splice(0)) {
|
|
893
|
+
rmSync(dir, { recursive: true, force: true });
|
|
894
|
+
}
|
|
895
|
+
});
|
|
896
|
+
|
|
897
|
+
function makeWorkspace(config: Record<string, unknown>): string {
|
|
898
|
+
const ws = mkdtempSync(join(tmpdir(), "vellum-ngrok-domain-test-"));
|
|
899
|
+
tempDirs.push(ws);
|
|
900
|
+
writeFileSync(join(ws, "config.json"), JSON.stringify(config, null, 2));
|
|
901
|
+
return ws;
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
interface StubTunnel {
|
|
905
|
+
public_url: string;
|
|
906
|
+
config: { addr: string };
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
/** ngrok local API stub replaying one response per call, last one repeated. */
|
|
910
|
+
function mockNgrokApiFetch(responses: { tunnels: StubTunnel[] }[]): void {
|
|
911
|
+
let call = 0;
|
|
912
|
+
globalThis.fetch = (async () => {
|
|
913
|
+
const body = responses[Math.min(call, responses.length - 1)];
|
|
914
|
+
call++;
|
|
915
|
+
return new Response(JSON.stringify(body), {
|
|
916
|
+
status: 200,
|
|
917
|
+
headers: { "Content-Type": "application/json" },
|
|
918
|
+
});
|
|
919
|
+
}) as unknown as typeof globalThis.fetch;
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
const unrelatedPortTunnel: StubTunnel = {
|
|
923
|
+
public_url: "https://unrelated.ngrok.app",
|
|
924
|
+
config: { addr: "localhost:65500" },
|
|
925
|
+
};
|
|
926
|
+
|
|
927
|
+
test("runNgrokTunnel spawns ngrok with --domain and persists the domain", async () => {
|
|
928
|
+
const ws = makeWorkspace({});
|
|
929
|
+
// The pre-spawn listing is empty (any running tunnel would abort the run);
|
|
930
|
+
// post-spawn, only the tunnel matching the target port and domain may be
|
|
931
|
+
// saved even with an unrelated-port tunnel listed first.
|
|
932
|
+
mockNgrokApiFetch([
|
|
933
|
+
{ tunnels: [] },
|
|
934
|
+
{
|
|
935
|
+
tunnels: [
|
|
936
|
+
unrelatedPortTunnel,
|
|
937
|
+
{
|
|
938
|
+
public_url: "https://foo.ngrok.app",
|
|
939
|
+
config: { addr: "localhost:7831" },
|
|
940
|
+
},
|
|
941
|
+
],
|
|
942
|
+
},
|
|
943
|
+
]);
|
|
944
|
+
|
|
945
|
+
const run = realNgrok.runNgrokTunnel({
|
|
946
|
+
port: 7831,
|
|
947
|
+
workspaceDir: ws,
|
|
948
|
+
domain: "foo.ngrok.app",
|
|
949
|
+
});
|
|
950
|
+
// runNgrokTunnel blocks until the ngrok process exits; pump exit events
|
|
951
|
+
// until its final exit listener is registered and the promise settles.
|
|
952
|
+
const pump = setInterval(() => lastChild?.emit("exit", 0), 10);
|
|
953
|
+
try {
|
|
954
|
+
await run;
|
|
955
|
+
} finally {
|
|
956
|
+
clearInterval(pump);
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
expect(spawnMock).toHaveBeenCalledTimes(1);
|
|
960
|
+
const [cmd, args] = spawnMock.mock.calls[0] as unknown as [
|
|
961
|
+
string,
|
|
962
|
+
string[],
|
|
963
|
+
];
|
|
964
|
+
expect(cmd).toBe("ngrok");
|
|
965
|
+
expect(args).toEqual([
|
|
966
|
+
"http",
|
|
967
|
+
"127.0.0.1:7831",
|
|
968
|
+
"--log=stdout",
|
|
969
|
+
"--domain=foo.ngrok.app",
|
|
970
|
+
]);
|
|
971
|
+
|
|
972
|
+
const config = JSON.parse(
|
|
973
|
+
readFileSync(join(ws, "config.json"), "utf-8"),
|
|
974
|
+
) as { ingress: { publicBaseUrl?: string; ngrok?: { domain?: string } } };
|
|
975
|
+
expect(config.ingress.publicBaseUrl).toBe("https://foo.ngrok.app");
|
|
976
|
+
expect(config.ingress.ngrok?.domain).toBe("foo.ngrok.app");
|
|
977
|
+
});
|
|
978
|
+
|
|
979
|
+
function mockTunnelListFetch(publicUrl: string, addr: string): void {
|
|
980
|
+
globalThis.fetch = (async () =>
|
|
981
|
+
new Response(
|
|
982
|
+
JSON.stringify({
|
|
983
|
+
tunnels: [{ public_url: publicUrl, config: { addr } }],
|
|
984
|
+
}),
|
|
985
|
+
{ status: 200, headers: { "Content-Type": "application/json" } },
|
|
986
|
+
)) as unknown as typeof globalThis.fetch;
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
test("runNgrokTunnel rejects an existing tunnel that mismatches the requested domain", async () => {
|
|
990
|
+
const ws = makeWorkspace({});
|
|
991
|
+
mockTunnelListFetch("https://other.ngrok-free.app", "localhost:7831");
|
|
992
|
+
|
|
993
|
+
const errors: string[] = [];
|
|
994
|
+
const errSpy = spyOn(console, "error").mockImplementation(
|
|
995
|
+
(...a: unknown[]) => {
|
|
996
|
+
errors.push(a.join(" "));
|
|
997
|
+
},
|
|
998
|
+
);
|
|
999
|
+
const exitSpy = spyOn(process, "exit").mockImplementation(((
|
|
1000
|
+
code?: number,
|
|
1001
|
+
) => {
|
|
1002
|
+
throw new Error(`exit:${code}`);
|
|
1003
|
+
}) as never);
|
|
1004
|
+
|
|
1005
|
+
try {
|
|
1006
|
+
await expect(
|
|
1007
|
+
realNgrok.runNgrokTunnel({
|
|
1008
|
+
port: 7831,
|
|
1009
|
+
workspaceDir: ws,
|
|
1010
|
+
domain: "foo.ngrok.app",
|
|
1011
|
+
}),
|
|
1012
|
+
).rejects.toThrow("exit:1");
|
|
1013
|
+
} finally {
|
|
1014
|
+
errSpy.mockRestore();
|
|
1015
|
+
exitSpy.mockRestore();
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
const combined = errors.join("\n");
|
|
1019
|
+
expect(combined).toContain("https://other.ngrok-free.app");
|
|
1020
|
+
expect(combined).toContain(
|
|
1021
|
+
"does not match the requested domain 'foo.ngrok.app'",
|
|
1022
|
+
);
|
|
1023
|
+
expect(combined).toContain("Stop the existing ngrok agent");
|
|
1024
|
+
expect(spawnMock).not.toHaveBeenCalled();
|
|
1025
|
+
|
|
1026
|
+
const config = JSON.parse(
|
|
1027
|
+
readFileSync(join(ws, "config.json"), "utf-8"),
|
|
1028
|
+
) as { ingress?: { publicBaseUrl?: string; ngrok?: { domain?: string } } };
|
|
1029
|
+
expect(config.ingress?.publicBaseUrl).toBeUndefined();
|
|
1030
|
+
expect(config.ingress?.ngrok).toBeUndefined();
|
|
1031
|
+
});
|
|
1032
|
+
|
|
1033
|
+
test("runNgrokTunnel adopts an existing tunnel that matches the requested domain", async () => {
|
|
1034
|
+
const ws = makeWorkspace({});
|
|
1035
|
+
mockTunnelListFetch("https://foo.ngrok.app", "localhost:7831");
|
|
1036
|
+
|
|
1037
|
+
const run = realNgrok.runNgrokTunnel({
|
|
1038
|
+
port: 7831,
|
|
1039
|
+
workspaceDir: ws,
|
|
1040
|
+
domain: "foo.ngrok.app",
|
|
1041
|
+
});
|
|
1042
|
+
// The adopt path blocks until SIGINT/SIGTERM; pump SIGINT until the
|
|
1043
|
+
// listener is registered and the promise settles. Earlier tests leak
|
|
1044
|
+
// SIGINT handlers that call process.exit, so no-op it while pumping.
|
|
1045
|
+
const exitSpy = spyOn(process, "exit").mockImplementation(
|
|
1046
|
+
(() => undefined) as never,
|
|
1047
|
+
);
|
|
1048
|
+
const pump = setInterval(() => process.emit("SIGINT"), 10);
|
|
1049
|
+
try {
|
|
1050
|
+
await run;
|
|
1051
|
+
} finally {
|
|
1052
|
+
clearInterval(pump);
|
|
1053
|
+
exitSpy.mockRestore();
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
expect(spawnMock).not.toHaveBeenCalled();
|
|
1057
|
+
const config = JSON.parse(
|
|
1058
|
+
readFileSync(join(ws, "config.json"), "utf-8"),
|
|
1059
|
+
) as { ingress: { publicBaseUrl?: string; ngrok?: { domain?: string } } };
|
|
1060
|
+
expect(config.ingress.publicBaseUrl).toBe("https://foo.ngrok.app");
|
|
1061
|
+
expect(config.ingress.ngrok?.domain).toBe("foo.ngrok.app");
|
|
1062
|
+
});
|
|
1063
|
+
|
|
1064
|
+
test("maybeStartNgrokTunnel rejects a mismatched existing tunnel without adopting or spawning", async () => {
|
|
1065
|
+
const ws = makeWorkspace({
|
|
1066
|
+
telegram: { botUsername: "example_bot" },
|
|
1067
|
+
ingress: { ngrok: { domain: "foo.ngrok.app" } },
|
|
1068
|
+
});
|
|
1069
|
+
mockTunnelListFetch("https://other.ngrok-free.app", "localhost:7830");
|
|
1070
|
+
|
|
1071
|
+
const warnings: string[] = [];
|
|
1072
|
+
const warnSpy = spyOn(console, "warn").mockImplementation(
|
|
1073
|
+
(...a: unknown[]) => {
|
|
1074
|
+
warnings.push(a.join(" "));
|
|
1075
|
+
},
|
|
1076
|
+
);
|
|
1077
|
+
|
|
1078
|
+
let child: unknown;
|
|
1079
|
+
try {
|
|
1080
|
+
child = await realNgrok.maybeStartNgrokTunnel(7830, ws);
|
|
1081
|
+
} finally {
|
|
1082
|
+
warnSpy.mockRestore();
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
expect(child).toBeNull();
|
|
1086
|
+
expect(spawnMock).not.toHaveBeenCalled();
|
|
1087
|
+
const combined = warnings.join("\n");
|
|
1088
|
+
expect(combined).toContain("https://other.ngrok-free.app");
|
|
1089
|
+
expect(combined).toContain(
|
|
1090
|
+
"does not match the reserved domain 'foo.ngrok.app'",
|
|
1091
|
+
);
|
|
1092
|
+
expect(combined).toContain(
|
|
1093
|
+
"vellum tunnel --provider ngrok --domain foo.ngrok.app",
|
|
1094
|
+
);
|
|
1095
|
+
|
|
1096
|
+
const config = JSON.parse(
|
|
1097
|
+
readFileSync(join(ws, "config.json"), "utf-8"),
|
|
1098
|
+
) as { ingress: { publicBaseUrl?: string; ngrok?: { domain?: string } } };
|
|
1099
|
+
// The mismatched tunnel is not blessed in config…
|
|
1100
|
+
expect(config.ingress.publicBaseUrl).toBeUndefined();
|
|
1101
|
+
// …and the reserved domain stays saved as standing intent.
|
|
1102
|
+
expect(config.ingress.ngrok?.domain).toBe("foo.ngrok.app");
|
|
1103
|
+
});
|
|
1104
|
+
|
|
1105
|
+
test("maybeStartNgrokTunnel skips when an existing agent tunnels a different port", async () => {
|
|
1106
|
+
const ws = makeWorkspace({
|
|
1107
|
+
telegram: { botUsername: "example_bot" },
|
|
1108
|
+
});
|
|
1109
|
+
// A stale pre-upgrade agent still tunnels the raw gateway port; the edge
|
|
1110
|
+
// port (18080) has no tunnel.
|
|
1111
|
+
mockTunnelListFetch("https://stale.ngrok-free.app", "localhost:7830");
|
|
1112
|
+
|
|
1113
|
+
const warnings: string[] = [];
|
|
1114
|
+
const warnSpy = spyOn(console, "warn").mockImplementation(
|
|
1115
|
+
(...a: unknown[]) => {
|
|
1116
|
+
warnings.push(a.join(" "));
|
|
1117
|
+
},
|
|
1118
|
+
);
|
|
1119
|
+
|
|
1120
|
+
let child: unknown;
|
|
1121
|
+
try {
|
|
1122
|
+
child = await realNgrok.maybeStartNgrokTunnel(18080, ws);
|
|
1123
|
+
} finally {
|
|
1124
|
+
warnSpy.mockRestore();
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
expect(child).toBeNull();
|
|
1128
|
+
expect(spawnMock).not.toHaveBeenCalled();
|
|
1129
|
+
const combined = warnings.join("\n");
|
|
1130
|
+
expect(combined).toContain("different local port");
|
|
1131
|
+
expect(combined).toContain(
|
|
1132
|
+
"https://stale.ngrok-free.app -> localhost:7830",
|
|
1133
|
+
);
|
|
1134
|
+
expect(combined).toContain("not 18080");
|
|
1135
|
+
expect(combined).toContain("vellum tunnel --provider ngrok");
|
|
1136
|
+
|
|
1137
|
+
const config = JSON.parse(
|
|
1138
|
+
readFileSync(join(ws, "config.json"), "utf-8"),
|
|
1139
|
+
) as { ingress?: { publicBaseUrl?: string } };
|
|
1140
|
+
expect(config.ingress?.publicBaseUrl).toBeUndefined();
|
|
1141
|
+
});
|
|
1142
|
+
|
|
1143
|
+
test("runNgrokTunnel fails loudly when an existing agent tunnels a different port", async () => {
|
|
1144
|
+
const ws = makeWorkspace({});
|
|
1145
|
+
mockTunnelListFetch("https://stale.ngrok-free.app", "localhost:7830");
|
|
1146
|
+
|
|
1147
|
+
const errors: string[] = [];
|
|
1148
|
+
const errSpy = spyOn(console, "error").mockImplementation(
|
|
1149
|
+
(...a: unknown[]) => {
|
|
1150
|
+
errors.push(a.join(" "));
|
|
1151
|
+
},
|
|
1152
|
+
);
|
|
1153
|
+
const exitSpy = spyOn(process, "exit").mockImplementation(((
|
|
1154
|
+
code?: number,
|
|
1155
|
+
) => {
|
|
1156
|
+
throw new Error(`exit:${code}`);
|
|
1157
|
+
}) as never);
|
|
1158
|
+
|
|
1159
|
+
try {
|
|
1160
|
+
await expect(
|
|
1161
|
+
realNgrok.runNgrokTunnel({ port: 18080, workspaceDir: ws }),
|
|
1162
|
+
).rejects.toThrow("exit:1");
|
|
1163
|
+
} finally {
|
|
1164
|
+
errSpy.mockRestore();
|
|
1165
|
+
exitSpy.mockRestore();
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
const combined = errors.join("\n");
|
|
1169
|
+
expect(combined).toContain("different local port");
|
|
1170
|
+
expect(combined).toContain(
|
|
1171
|
+
"https://stale.ngrok-free.app -> localhost:7830",
|
|
1172
|
+
);
|
|
1173
|
+
expect(combined).toContain("not 18080");
|
|
1174
|
+
expect(combined).toContain("Stop the existing ngrok agent");
|
|
1175
|
+
expect(spawnMock).not.toHaveBeenCalled();
|
|
1176
|
+
|
|
1177
|
+
const config = JSON.parse(
|
|
1178
|
+
readFileSync(join(ws, "config.json"), "utf-8"),
|
|
1179
|
+
) as { ingress?: { publicBaseUrl?: string } };
|
|
1180
|
+
expect(config.ingress?.publicBaseUrl).toBeUndefined();
|
|
1181
|
+
});
|
|
1182
|
+
|
|
1183
|
+
test("maybeStartNgrokTunnel passes the saved domain to the spawn args", async () => {
|
|
1184
|
+
const ws = makeWorkspace({
|
|
1185
|
+
telegram: { botUsername: "example_bot" },
|
|
1186
|
+
ingress: { ngrok: { domain: "foo.ngrok.app" } },
|
|
1187
|
+
});
|
|
1188
|
+
mockNgrokApiFetch([
|
|
1189
|
+
{ tunnels: [] },
|
|
1190
|
+
{
|
|
1191
|
+
tunnels: [
|
|
1192
|
+
unrelatedPortTunnel,
|
|
1193
|
+
{
|
|
1194
|
+
public_url: "https://foo.ngrok.app",
|
|
1195
|
+
config: { addr: "localhost:7830" },
|
|
1196
|
+
},
|
|
1197
|
+
],
|
|
1198
|
+
},
|
|
1199
|
+
]);
|
|
1200
|
+
|
|
1201
|
+
const child = await realNgrok.maybeStartNgrokTunnel(7830, ws);
|
|
1202
|
+
|
|
1203
|
+
expect(child).not.toBeNull();
|
|
1204
|
+
expect(spawnMock).toHaveBeenCalledTimes(1);
|
|
1205
|
+
const [cmd, args] = spawnMock.mock.calls[0] as unknown as [
|
|
1206
|
+
string,
|
|
1207
|
+
string[],
|
|
1208
|
+
];
|
|
1209
|
+
expect(cmd).toBe("ngrok");
|
|
1210
|
+
expect(args).toEqual([
|
|
1211
|
+
"http",
|
|
1212
|
+
"127.0.0.1:7830",
|
|
1213
|
+
"--log=stdout",
|
|
1214
|
+
"--domain=foo.ngrok.app",
|
|
1215
|
+
]);
|
|
1216
|
+
|
|
1217
|
+
const config = JSON.parse(
|
|
1218
|
+
readFileSync(join(ws, "config.json"), "utf-8"),
|
|
1219
|
+
) as { ingress: { publicBaseUrl?: string } };
|
|
1220
|
+
expect(config.ingress.publicBaseUrl).toBe("https://foo.ngrok.app");
|
|
1221
|
+
});
|
|
1222
|
+
|
|
1223
|
+
test("runNgrokTunnel without --domain reuses the saved domain and does not delete it", async () => {
|
|
1224
|
+
const ws = makeWorkspace({
|
|
1225
|
+
ingress: { ngrok: { domain: "foo.ngrok.app" } },
|
|
1226
|
+
});
|
|
1227
|
+
mockNgrokApiFetch([
|
|
1228
|
+
{ tunnels: [] },
|
|
1229
|
+
{
|
|
1230
|
+
tunnels: [
|
|
1231
|
+
{
|
|
1232
|
+
public_url: "https://foo.ngrok.app",
|
|
1233
|
+
config: { addr: "localhost:7831" },
|
|
1234
|
+
},
|
|
1235
|
+
],
|
|
1236
|
+
},
|
|
1237
|
+
]);
|
|
1238
|
+
|
|
1239
|
+
const run = realNgrok.runNgrokTunnel({ port: 7831, workspaceDir: ws });
|
|
1240
|
+
const pump = setInterval(() => lastChild?.emit("exit", 0), 10);
|
|
1241
|
+
try {
|
|
1242
|
+
await run;
|
|
1243
|
+
} finally {
|
|
1244
|
+
clearInterval(pump);
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
expect(spawnMock).toHaveBeenCalledTimes(1);
|
|
1248
|
+
const [, args] = spawnMock.mock.calls[0] as unknown as [string, string[]];
|
|
1249
|
+
expect(args).toEqual([
|
|
1250
|
+
"http",
|
|
1251
|
+
"127.0.0.1:7831",
|
|
1252
|
+
"--log=stdout",
|
|
1253
|
+
"--domain=foo.ngrok.app",
|
|
1254
|
+
]);
|
|
1255
|
+
|
|
1256
|
+
const config = JSON.parse(
|
|
1257
|
+
readFileSync(join(ws, "config.json"), "utf-8"),
|
|
1258
|
+
) as { ingress: { publicBaseUrl?: string; ngrok?: { domain?: string } } };
|
|
1259
|
+
expect(config.ingress.publicBaseUrl).toBe("https://foo.ngrok.app");
|
|
1260
|
+
expect(config.ingress.ngrok?.domain).toBe("foo.ngrok.app");
|
|
1261
|
+
});
|
|
1262
|
+
});
|