Skip to content

Commit 7c78ce1

Browse files
authored
Merge pull request #378 from dodok8/dodok8-test-cli-tunnel
2 parents adc018b + 2d32c0a commit 7c78ce1

File tree

3 files changed

+212
-21
lines changed

3 files changed

+212
-21
lines changed

packages/cli/deno.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
"@poppanator/http-constants": "npm:@poppanator/http-constants@^1.1.1",
1717
"@std/assert": "jsr:@std/assert@^1.0.13",
1818
"@std/fmt/colors": "jsr:@std/fmt@^0.224.0/colors",
19+
"@std/testing": "jsr:@std/testing@^1.0.8",
1920
"@std/dotenv": "jsr:@std/dotenv@^0.225.2",
2021
"@std/semver": "jsr:@std/semver@^1.0.5",
2122
"cli-highlight": "npm:cli-highlight@^2.1.11",

packages/cli/src/tunnel.test.ts

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
import type { Tunnel, TunnelOptions } from "@hongminhee/localtunnel";
2+
import { assert, assertEquals, assertFalse, assertRejects } from "@std/assert";
3+
import { assertSpyCall, stub } from "@std/testing/mock";
4+
import type { Ora } from "ora";
5+
import { command, tunnelAction } from "./tunnel.ts";
6+
7+
Deno.test("tunnel description", () => {
8+
// Test that the command is properly configured
9+
assert(
10+
command.getDescription().includes(
11+
"Expose a local HTTP server to the public internet using a secure tunnel.\n\n" +
12+
"Note that the HTTP requests through the tunnel have X-Forwarded-* headers.",
13+
),
14+
);
15+
});
16+
17+
Deno.test("tunnel command validates port argument", async () => {
18+
const exitStub = stub(Deno, "exit", () => {
19+
throw new Error("Process would exit");
20+
});
21+
22+
try {
23+
await assertRejects(
24+
() => command.parse(["invalid-port"]),
25+
Error,
26+
"Process would exit",
27+
);
28+
assertSpyCall(exitStub, 0, { args: [2] });
29+
} finally {
30+
exitStub.restore();
31+
}
32+
});
33+
34+
Deno.test("tunnel successfully creates and manages tunnel", async () => {
35+
// Track function calls
36+
let openTunnelCalled = false;
37+
let openTunnelArgs: TunnelOptions[] = [];
38+
let startCalled = false;
39+
let succeedCalled = false;
40+
let succeedArgs: string[] = [];
41+
let logArgs: string[] = [];
42+
let errorArgs: string[] = [];
43+
let addSignalListenerCalled = false;
44+
let exitCalled = false;
45+
46+
// Create a mock tunnel object
47+
const mockTunnel = {
48+
url: new URL("https://abc123.localhost.run"),
49+
localPort: 3000,
50+
pid: 12345,
51+
close: () => Promise.resolve(),
52+
};
53+
54+
// Create mock dependencies
55+
const mockDeps = {
56+
openTunnel: (args: TunnelOptions) => {
57+
openTunnelCalled = true;
58+
openTunnelArgs = [args];
59+
return Promise.resolve(mockTunnel as Tunnel);
60+
},
61+
ora: () => ({
62+
start() {
63+
startCalled = true;
64+
return this;
65+
},
66+
succeed(...args: string[]) {
67+
succeedCalled = true;
68+
succeedArgs = args;
69+
return this;
70+
},
71+
fail() {
72+
return this;
73+
},
74+
} as unknown as Ora),
75+
console: {
76+
log: (...args: string[]) => {
77+
logArgs = args;
78+
},
79+
error: (...args: string[]) => {
80+
errorArgs = args;
81+
},
82+
} as Console,
83+
addSignalListener: (() => {
84+
addSignalListenerCalled = true;
85+
}) as typeof Deno.addSignalListener,
86+
exit: (() => {
87+
exitCalled = true;
88+
}) as typeof Deno.exit,
89+
};
90+
91+
await tunnelAction({ service: undefined }, 3000, mockDeps);
92+
93+
// Verify all the expected interactions occurred
94+
assert(openTunnelCalled);
95+
assertEquals(openTunnelArgs, [{ port: 3000, service: undefined }]);
96+
assert(startCalled);
97+
assert(succeedCalled);
98+
assertEquals(succeedArgs, [
99+
"Your local server at 3000 is now publicly accessible:\n",
100+
]);
101+
assertEquals(logArgs, ["https://abc123.localhost.run/"]);
102+
assertEquals(errorArgs, ["\nPress ^C to close the tunnel."]);
103+
assert(addSignalListenerCalled);
104+
assertFalse(exitCalled);
105+
});
106+
107+
Deno.test("tunnel fails to create a secure tunnel and handles error", async () => {
108+
const exitStub = stub(Deno, "exit", () => {
109+
throw new Error("Process would exit");
110+
});
111+
112+
// Track function calls
113+
let openTunnelCalled = false;
114+
let openTunnelArgs: TunnelOptions[] = [];
115+
let startCalled = false;
116+
let failCalled = false;
117+
let failArgs: string[] = [];
118+
let addSignalListenerCalled = false;
119+
120+
const tunnelError = new Error("Failed to create a secure tunnel.");
121+
122+
// Create mock dependencies that simulate failure
123+
const mockDeps = {
124+
openTunnel: (args: TunnelOptions) => {
125+
openTunnelCalled = true;
126+
openTunnelArgs = [args];
127+
return Promise.reject(tunnelError);
128+
},
129+
ora: () => ({
130+
start() {
131+
startCalled = true;
132+
return this;
133+
},
134+
succeed() {
135+
return this;
136+
},
137+
fail(...args: string[]) {
138+
failCalled = true;
139+
failArgs = args;
140+
return this;
141+
},
142+
} as unknown as Ora),
143+
console: {
144+
log: () => {},
145+
error: () => {},
146+
} as Console,
147+
addSignalListener: (() => {
148+
addSignalListenerCalled = true;
149+
}) as typeof Deno.addSignalListener,
150+
exit: (() => {
151+
throw new Error("Process would exit");
152+
}) as typeof Deno.exit,
153+
};
154+
155+
try {
156+
await assertRejects(
157+
() => tunnelAction({ service: undefined }, 3000, mockDeps),
158+
Error,
159+
"Process would exit",
160+
);
161+
} finally {
162+
exitStub.restore();
163+
}
164+
165+
// Verify error handling interactions
166+
assert(openTunnelCalled);
167+
assertEquals(openTunnelArgs, [{ port: 3000, service: undefined }]);
168+
assert(startCalled);
169+
assert(failCalled);
170+
assertEquals(failArgs, ["Failed to create a secure tunnel."]);
171+
assertFalse(addSignalListenerCalled);
172+
});

packages/cli/src/tunnel.ts

Lines changed: 39 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,44 @@ import ora from "ora";
44

55
const service = new EnumType(["localhost.run", "serveo.net"]);
66

7+
export async function tunnelAction(
8+
options: { service?: "localhost.run" | "serveo.net" },
9+
port: number,
10+
deps: {
11+
openTunnel: typeof openTunnel;
12+
ora: typeof ora;
13+
console: typeof console;
14+
addSignalListener: typeof Deno.addSignalListener;
15+
exit: typeof Deno.exit;
16+
} = {
17+
openTunnel,
18+
ora,
19+
console,
20+
addSignalListener: Deno.addSignalListener,
21+
exit: Deno.exit,
22+
},
23+
) {
24+
const spinner = deps.ora({
25+
text: "Creating a secure tunnel...",
26+
discardStdin: false,
27+
}).start();
28+
let tunnel: Tunnel;
29+
try {
30+
tunnel = await deps.openTunnel({ port, service: options.service });
31+
} catch {
32+
spinner.fail("Failed to create a secure tunnel.");
33+
deps.exit(1);
34+
}
35+
spinner.succeed(
36+
`Your local server at ${port} is now publicly accessible:\n`,
37+
);
38+
deps.console.log(tunnel.url.href);
39+
deps.console.error("\nPress ^C to close the tunnel.");
40+
deps.addSignalListener("SIGINT", async () => {
41+
await tunnel.close();
42+
});
43+
}
44+
745
export const command = new Command()
846
.type("service", service)
947
.arguments("<port:integer>")
@@ -12,24 +50,4 @@ export const command = new Command()
1250
"Note that the HTTP requests through the tunnel have X-Forwarded-* headers.",
1351
)
1452
.option("-s, --service <service:service>", "The localtunnel service to use.")
15-
.action(async (options, port: number) => {
16-
const spinner = ora({
17-
text: "Creating a secure tunnel...",
18-
discardStdin: false,
19-
}).start();
20-
let tunnel: Tunnel;
21-
try {
22-
tunnel = await openTunnel({ port, service: options.service });
23-
} catch {
24-
spinner.fail("Failed to create a secure tunnel.");
25-
Deno.exit(1);
26-
}
27-
spinner.succeed(
28-
`Your local server at ${port} is now publicly accessible:\n`,
29-
);
30-
console.log(tunnel.url.href);
31-
console.error("\nPress ^C to close the tunnel.");
32-
Deno.addSignalListener("SIGINT", async () => {
33-
await tunnel.close();
34-
});
35-
});
53+
.action(tunnelAction);

0 commit comments

Comments
 (0)