Skip to content

Use of debugger incompatible with console testing framework #396

@cefn

Description

@cefn

Launching the tests within a VSCode Javascript Debug Terminal or probably any debug process fails all CLI tests. The node process uncontrollably writes the following lines for every attached VM, breaking the strategy of console snapshot assertions...

Debugger attached.␊
Waiting for the debugger to disconnect...␊

See upstream nodejs/node#34799

A strategy that allows narrower test assertions (rather than the whole snapshot of the whole CLI output) would enable people to interactively debug tests at the same time as observing actual test failures. However, I don't know if that is compatible with the testing framework used here.

A strategy that worked for us for CLI testing in jest/vitest was not to rely on a console testing framework, but explicitly capture stderr and stdout and use e.g. expect.stringMatching() or other more 'forgiving' tests of console output, that allows there to be variable output which doesn't break the test assertion. This also makes tests more maintainable generally since unrelated changes (e.g. changing some greeting text) won't break every test - only some actual test of the greeting text.

Interception example

// using test utility
    test("Exits with error when invoking unknown command", async () => {
      const { stdout, stderr } = process;
      using callsByStream = interceptStreams({ stdout, stderr });

      // mock the CLI command
      using processMock = mockCommandLineProcess(
        `npx @roku-web-starter/cli this-is-not-a-command`,
      );

      // run the CLI tool
      await executeCommand();

      expect(
        [...callsByStream.stdout, ...callsByStream.stderr].some((message) =>
          message.includes(`Unknown command`),
        ),
      ).toBe(true);

      const [firstExitArgs] = processMock.exit.mock.calls;
      expect(firstExitArgs).toMatchObject([1]);
    });
// test utility
type WriteMethod = NodeJS.WriteStream["write"];

export function interceptStreams<StreamName extends string>(
  streams: Record<StreamName, NodeJS.WriteStream>,
  passthru = false,
) {
  const streamEntries = Object.entries(streams) as [
    StreamName,
    NodeJS.WriteStream,
  ][];

  const writeByStream = {} as Record<StreamName, WriteMethod>;
  const callsByStream = Object.fromEntries(
    Object.keys(streams).map((name) => [name, []]),
  ) as unknown as Record<StreamName, string[]>;

  // substitute stream write method
  for (const [name, stream] of streamEntries) {
    // prepare interception - deliberately stores unbound method
    // eslint-disable-next-line @typescript-eslint/unbound-method
    const { write } = stream;
    const calls: string[] = [];

    // keep per-stream records
    writeByStream[name] = write;
    callsByStream[name] = calls;

    // intercept
    const interceptor = ((...args: Parameters<typeof write>) => {
      const [bufferOrString] = args;
      calls.push(bufferOrString.toString());
      if (passthru) {
        return write.call(stream, ...args);
      }
      return true;
    }) as WriteMethod;
    stream.write = interceptor;
  }

  const stopInterception = () => {
    // restore stream write methods
    for (const [name, stream] of streamEntries) {
      stream.write = writeByStream[name];
    }
  };

  return {
    ...callsByStream,
    [Symbol.dispose]: stopInterception,
  };
}

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions