Unverified Commit 7c78ce1a authored by Hong Minhee (洪 民憙)'s avatar Hong Minhee (洪 民憙) Committed by GitHub
Browse files

Merge pull request #378 from dodok8/dodok8-test-cli-tunnel

parents adc018b9 2d32c0ac
Loading
Loading
Loading
Loading
+1 −0
Original line number Diff line number Diff line
@@ -16,6 +16,7 @@
    "@poppanator/http-constants": "npm:@poppanator/http-constants@^1.1.1",
    "@std/assert": "jsr:@std/assert@^1.0.13",
    "@std/fmt/colors": "jsr:@std/fmt@^0.224.0/colors",
    "@std/testing": "jsr:@std/testing@^1.0.8",
    "@std/dotenv": "jsr:@std/dotenv@^0.225.2",
    "@std/semver": "jsr:@std/semver@^1.0.5",
    "cli-highlight": "npm:cli-highlight@^2.1.11",
+172 −0
Original line number Diff line number Diff line
import type { Tunnel, TunnelOptions } from "@hongminhee/localtunnel";
import { assert, assertEquals, assertFalse, assertRejects } from "@std/assert";
import { assertSpyCall, stub } from "@std/testing/mock";
import type { Ora } from "ora";
import { command, tunnelAction } from "./tunnel.ts";

Deno.test("tunnel description", () => {
  // Test that the command is properly configured
  assert(
    command.getDescription().includes(
      "Expose a local HTTP server to the public internet using a secure tunnel.\n\n" +
        "Note that the HTTP requests through the tunnel have X-Forwarded-* headers.",
    ),
  );
});

Deno.test("tunnel command validates port argument", async () => {
  const exitStub = stub(Deno, "exit", () => {
    throw new Error("Process would exit");
  });

  try {
    await assertRejects(
      () => command.parse(["invalid-port"]),
      Error,
      "Process would exit",
    );
    assertSpyCall(exitStub, 0, { args: [2] });
  } finally {
    exitStub.restore();
  }
});

Deno.test("tunnel successfully creates and manages tunnel", async () => {
  // Track function calls
  let openTunnelCalled = false;
  let openTunnelArgs: TunnelOptions[] = [];
  let startCalled = false;
  let succeedCalled = false;
  let succeedArgs: string[] = [];
  let logArgs: string[] = [];
  let errorArgs: string[] = [];
  let addSignalListenerCalled = false;
  let exitCalled = false;

  // Create a mock tunnel object
  const mockTunnel = {
    url: new URL("https://abc123.localhost.run"),
    localPort: 3000,
    pid: 12345,
    close: () => Promise.resolve(),
  };

  // Create mock dependencies
  const mockDeps = {
    openTunnel: (args: TunnelOptions) => {
      openTunnelCalled = true;
      openTunnelArgs = [args];
      return Promise.resolve(mockTunnel as Tunnel);
    },
    ora: () => ({
      start() {
        startCalled = true;
        return this;
      },
      succeed(...args: string[]) {
        succeedCalled = true;
        succeedArgs = args;
        return this;
      },
      fail() {
        return this;
      },
    } as unknown as Ora),
    console: {
      log: (...args: string[]) => {
        logArgs = args;
      },
      error: (...args: string[]) => {
        errorArgs = args;
      },
    } as Console,
    addSignalListener: (() => {
      addSignalListenerCalled = true;
    }) as typeof Deno.addSignalListener,
    exit: (() => {
      exitCalled = true;
    }) as typeof Deno.exit,
  };

  await tunnelAction({ service: undefined }, 3000, mockDeps);

  // Verify all the expected interactions occurred
  assert(openTunnelCalled);
  assertEquals(openTunnelArgs, [{ port: 3000, service: undefined }]);
  assert(startCalled);
  assert(succeedCalled);
  assertEquals(succeedArgs, [
    "Your local server at 3000 is now publicly accessible:\n",
  ]);
  assertEquals(logArgs, ["https://abc123.localhost.run/"]);
  assertEquals(errorArgs, ["\nPress ^C to close the tunnel."]);
  assert(addSignalListenerCalled);
  assertFalse(exitCalled);
});

Deno.test("tunnel fails to create a secure tunnel and handles error", async () => {
  const exitStub = stub(Deno, "exit", () => {
    throw new Error("Process would exit");
  });

  // Track function calls
  let openTunnelCalled = false;
  let openTunnelArgs: TunnelOptions[] = [];
  let startCalled = false;
  let failCalled = false;
  let failArgs: string[] = [];
  let addSignalListenerCalled = false;

  const tunnelError = new Error("Failed to create a secure tunnel.");

  // Create mock dependencies that simulate failure
  const mockDeps = {
    openTunnel: (args: TunnelOptions) => {
      openTunnelCalled = true;
      openTunnelArgs = [args];
      return Promise.reject(tunnelError);
    },
    ora: () => ({
      start() {
        startCalled = true;
        return this;
      },
      succeed() {
        return this;
      },
      fail(...args: string[]) {
        failCalled = true;
        failArgs = args;
        return this;
      },
    } as unknown as Ora),
    console: {
      log: () => {},
      error: () => {},
    } as Console,
    addSignalListener: (() => {
      addSignalListenerCalled = true;
    }) as typeof Deno.addSignalListener,
    exit: (() => {
      throw new Error("Process would exit");
    }) as typeof Deno.exit,
  };

  try {
    await assertRejects(
      () => tunnelAction({ service: undefined }, 3000, mockDeps),
      Error,
      "Process would exit",
    );
  } finally {
    exitStub.restore();
  }

  // Verify error handling interactions
  assert(openTunnelCalled);
  assertEquals(openTunnelArgs, [{ port: 3000, service: undefined }]);
  assert(startCalled);
  assert(failCalled);
  assertEquals(failArgs, ["Failed to create a secure tunnel."]);
  assertFalse(addSignalListenerCalled);
});
+39 −21
Original line number Diff line number Diff line
@@ -4,32 +4,50 @@ import ora from "ora";

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

export const command = new Command()
  .type("service", service)
  .arguments("<port:integer>")
  .description(
    "Expose a local HTTP server to the public internet using a secure tunnel.\n\n" +
      "Note that the HTTP requests through the tunnel have X-Forwarded-* headers.",
  )
  .option("-s, --service <service:service>", "The localtunnel service to use.")
  .action(async (options, port: number) => {
    const spinner = ora({
export async function tunnelAction(
  options: { service?: "localhost.run" | "serveo.net" },
  port: number,
  deps: {
    openTunnel: typeof openTunnel;
    ora: typeof ora;
    console: typeof console;
    addSignalListener: typeof Deno.addSignalListener;
    exit: typeof Deno.exit;
  } = {
    openTunnel,
    ora,
    console,
    addSignalListener: Deno.addSignalListener,
    exit: Deno.exit,
  },
) {
  const spinner = deps.ora({
    text: "Creating a secure tunnel...",
    discardStdin: false,
  }).start();
  let tunnel: Tunnel;
  try {
      tunnel = await openTunnel({ port, service: options.service });
    tunnel = await deps.openTunnel({ port, service: options.service });
  } catch {
    spinner.fail("Failed to create a secure tunnel.");
      Deno.exit(1);
    deps.exit(1);
  }
  spinner.succeed(
    `Your local server at ${port} is now publicly accessible:\n`,
  );
    console.log(tunnel.url.href);
    console.error("\nPress ^C to close the tunnel.");
    Deno.addSignalListener("SIGINT", async () => {
  deps.console.log(tunnel.url.href);
  deps.console.error("\nPress ^C to close the tunnel.");
  deps.addSignalListener("SIGINT", async () => {
    await tunnel.close();
  });
  });
}

export const command = new Command()
  .type("service", service)
  .arguments("<port:integer>")
  .description(
    "Expose a local HTTP server to the public internet using a secure tunnel.\n\n" +
      "Note that the HTTP requests through the tunnel have X-Forwarded-* headers.",
  )
  .option("-s, --service <service:service>", "The localtunnel service to use.")
  .action(tunnelAction);