Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

What is the recommended way to test cliffy cli ap? #778

Open
maxkoretskyi opened this issue Dec 31, 2024 · 1 comment
Open

What is the recommended way to test cliffy cli ap? #778

maxkoretskyi opened this issue Dec 31, 2024 · 1 comment

Comments

@maxkoretskyi
Copy link

maxkoretskyi commented Dec 31, 2024

Let's say I have this very basic command:

async function setupSMPT() {
  let smtpUsername = "NA";
  let smtpPassword = "NA";

  const confirm = await Confirm.prompt(
    "Do you want to setup a connection with a SMTP server?",
  );

  if (confirm) {
    smtpUsername = await Input.prompt(
      "Please specify the username to connect to your SMTP server:",
    );
    smtpPassword = await Input.prompt(
      "Please specify the password to connect to your SMTP server:",
    );
  }

  return { smtpUsername, smtpPassword };
}

const smtp = new Command()
  .name("smtp")
  .option(
    "--interactive [interactive:boolean]",
    "Enable or disable interactive mode.",
    { default: true },
  )
  .action(setupSMPT)

await new Command()
  .name("root")
  .command("smtp", smtp)
  .parse(Deno.args);

How can I test it? Basically, it's 2 sub-tasks I need to do:

  1. Provide a stub for promps used in the action function. Is there a helper for this?
  2. Run the command either as a subprocess with Deno.run/spawnChild or directly calling an action function setupSMPT({...})

Any help is appreciated!

@maxkoretskyi
Copy link
Author

maxkoretskyi commented Jan 7, 2025

I did like this:

import { assert, assertEquals } from "@std/assert";
import { join } from "@std/path";
import { afterEach, beforeEach, describe, it } from "@std/testing/bdd";

import {
  getSendEncodedMsgFn,
  getWaitForMessageFn,
  resolveRootDir,
} from "../utils.ts";

const rootDir = await resolveRootDir();
const targetCmd = join(rootDir, "commands/smtp.ts");

describe("SMTP setup interaction", () => {
  let outputEnvFile = null;

  beforeEach(async () => {
    outputEnvFile = join(rootDir, ".env.test.smtp.interactive.true");
    await Deno.writeTextFile(outputEnvFile, "");
  });

  afterEach(async () => {
    await Deno.remove(outputEnvFile);
  });

  it("interactive=true", async () => {
    const cliOptions = [
      "--interactive",
      "--outputEnvFile",
      outputEnvFile,
    ];

    const command = new Deno.Command("deno", {
      args: ["run", "--allow-all", targetCmd, ...cliOptions],
      stdin: "piped",
      stdout: "piped",
    });

    const process = command.spawn();

    const reader = process.stdout.getReader();
    const writer = process.stdin.getWriter();

    const encoder = new TextEncoder();
    const decoder = new TextDecoder();

    const waitForMessage = getWaitForMessageFn(reader, decoder);
    const sendEncodedMsg = getSendEncodedMsgFn(writer, encoder);

    const username = "someone";
    const password = "password";

    let response = "";

    response = await waitForMessage();

    assert(
      response?.includes(
        "Do you want to setup a connection with a SMTP server?",
      ),
      "Expected to receive `Do you want to setup a connection with a SMTP server?`",
    );

    await sendEncodedMsg("y");

    response = await waitForMessage();

    assert(
      response?.includes(
        "Please specify the username to connect to your SMTP server",
      ),
      "Expected to receive `Please specify the username to connect to your SMTP server`",
    );

    await sendEncodedMsg(username);

    response = await waitForMessage();
    assert(
      response?.includes(
        "Please specify the password to connect to your SMTP server",
      ),
      "Expected to receive `Please specify the password to connect to your SMTP server`",
    );

    await sendEncodedMsg(password);

    await waitForMessage();

    await writer.close();

    const file = await Deno.readTextFile(outputEnvFile);

    assert(
      file.match(RegExp(`^SMTP_USERNAME=${username}$`, "m")),
      "Expected SMTP_USERNAME to be `someone`",
    );

    assert(
      file.match(RegExp(`^SMTP_PASSWORD=${password}$`, "m")),
      "Expected SMTP_PASSWORD to be `password`",
    );

    console.log(
      "--- No more questions expected. Closing stream. Waiting for worker to exit...",
    );

    const status = await process.status;
    assertEquals(status.code, 0);
  });
});

where waitForMessage is implemented like this:

export const getWaitForMessageFn = (
  reader: ReadableStreamDefaultReader<Uint8Array>,
  decoder: TextDecoder,
  hideAnsi = true,
) =>
  async function waitForMessage(
    readTimeout = 1000,
  ) {
    console.log(`... Waiting for message from worker`);

    const { messages, timeout, done } = await readStream(
      reader,
      decoder,
      readTimeout
    );

    if (messages.length > 0) {
      const buffer = messages.join("");

      // stringify ANSI escape codes
      const raw = JSON.stringify(buffer);
      const ansiRegex = /(\\u001b\[\??[0-9;]*[A-Za-z])/g;

      const merged = hideAnsi
        ? raw.replace(ansiRegex, () => `  [ANSI]  `).trim().replace(
          /(\[ANSI]\s*)+/g,
          " [ANSI] ",
        ).trim()
        : raw.replace(ansiRegex, (match) => `  ${match}  `).trim();

      console.log(`<<< Worker sent: ${merged}`);

      return merged;
    } else {
      console.log(`<<< Worker timed out without sending a message.`);

      return null;
    }
  };

type ReadResult = {
  timedOut: boolean;
  value: Uint8Array | null;
  done: boolean;
};

async function readWithTimeout(reader, timeout = 2000): Promise<ReadResult> {
  let timeoutId = null;

  const timeoutPromise = new Promise(resolve => {
    timeoutId = setTimeout(() => resolve({ timedOut: true, value: null, done: false }), timeout);
  });

  const readPromise = reader.read().then(value => ({
    timedOut: false,
    ...value,
  }));

  return await Promise.race([readPromise, timeoutPromise]).finally(() => {
    clearTimeout(timeoutId);
  });
}

export async function readStream(
  reader: ReadableStreamDefaultReader<Uint8Array>,
  decoder: TextDecoder,
  minIntervalBetweenReads = 1000
) {
  const startTime = Date.now();
  const messages = [];
  let lastReadTime = startTime;

  while (true) {
    const timeSinceLastRead = Date.now() - lastReadTime;

    // wait for other messages to be sent within the minIntervalBetweenReads,
    // otherwise return the current message
    if (timeSinceLastRead > minIntervalBetweenReads) {
      return {
        messages,
        timeout: false,
        done: false,
      };
    }

    const { timedOut, value, done } = await readWithTimeout(reader, minIntervalBetweenReads);

    // if there's no message within the minIntervalBetweenReads, return the current message
    if (timedOut) {
      return {
        messages,
        timeout: true,
        done: false,
      };
    }

    if (value) {
      const chunk = decoder.decode(value);
      messages.push(chunk);
    }

    lastReadTime = Date.now();

    // if the stream has been closed, return the current message
    if (done) {
      return {
        messages,
        timeout: false,
        done: true,
      };
    }
  }
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant