mirror of
https://github.com/millionco/react-doctor.git
synced 2026-09-14 20:00:24 +08:00
103 lines
4.1 KiB
TypeScript
103 lines
4.1 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
|
|
import { setSpinnerSilent, spinner } from "../src/cli/utils/spinner.js";
|
|
|
|
const ESC = String.fromCharCode(0x1b);
|
|
const ANSI_ESCAPE_PATTERN = new RegExp(`${ESC}\\[[0-9;?]*[A-Za-z]`, "g");
|
|
const CURSOR_UP_PATTERN = new RegExp(`${ESC}\\[1A`);
|
|
const ERASE_LINE_PATTERN = new RegExp(`${ESC}\\[0K`);
|
|
|
|
const stripAnsi = (input: string): string => input.replace(ANSI_ESCAPE_PATTERN, "");
|
|
|
|
describe("spinner static (non-interactive) mode", () => {
|
|
let stderrWriteSpy: ReturnType<typeof vi.spyOn>;
|
|
let writtenChunks: string[];
|
|
let previousGitDir: string | undefined;
|
|
|
|
beforeEach(() => {
|
|
// `GIT_DIR` is the canonical "I'm inside a git hook" signal and
|
|
// routes through `isSpinnerInteractive` to demote ora to one-shot
|
|
// succeed/fail lines — same path #293 hits in real lefthook /
|
|
// husky / pre-commit runs.
|
|
previousGitDir = process.env.GIT_DIR;
|
|
process.env.GIT_DIR = "/fake/.git";
|
|
setSpinnerSilent(false);
|
|
writtenChunks = [];
|
|
stderrWriteSpy = vi.spyOn(process.stderr, "write").mockImplementation(((
|
|
chunk: string | Uint8Array,
|
|
) => {
|
|
writtenChunks.push(typeof chunk === "string" ? chunk : chunk.toString());
|
|
return true;
|
|
}) as typeof process.stderr.write);
|
|
});
|
|
|
|
afterEach(() => {
|
|
stderrWriteSpy.mockRestore();
|
|
if (previousGitDir === undefined) {
|
|
delete process.env.GIT_DIR;
|
|
} else {
|
|
process.env.GIT_DIR = previousGitDir;
|
|
}
|
|
});
|
|
|
|
// Regression guard for #293: in non-interactive contexts (Git pre-push
|
|
// hooks, `script(1)`, etc.) the spinner must not emit cursor-up +
|
|
// erase-line escapes, and a synchronous start+succeed pair must finish
|
|
// in a single line so we don't double-print every project-detection
|
|
// step or burn CPU in an unbounded re-render loop.
|
|
it("emits exactly one symbol+text line per start/succeed cycle", () => {
|
|
spinner("Detecting framework. Found Next.js.")
|
|
.start()
|
|
.succeed("Detecting framework. Found Next.js.");
|
|
|
|
const cleanOutput = stripAnsi(writtenChunks.join(""));
|
|
expect(cleanOutput).toContain("Detecting framework. Found Next.js.");
|
|
// Exactly one terminating newline -> exactly one line.
|
|
expect(cleanOutput.match(/\n/g) ?? []).toHaveLength(1);
|
|
expect(writtenChunks.join("")).not.toMatch(CURSOR_UP_PATTERN);
|
|
expect(writtenChunks.join("")).not.toMatch(ERASE_LINE_PATTERN);
|
|
});
|
|
|
|
it("emits exactly one line per fail() finalization", () => {
|
|
spinner("Running lint checks...").start().fail("Lint checks failed.");
|
|
|
|
const cleanOutput = stripAnsi(writtenChunks.join(""));
|
|
expect(cleanOutput).toContain("Lint checks failed.");
|
|
expect(cleanOutput).not.toContain("Running lint checks...");
|
|
expect(cleanOutput.match(/\n/g) ?? []).toHaveLength(1);
|
|
});
|
|
|
|
it("does not animate: multiple sequential start+succeed calls each emit a single line", () => {
|
|
spinner("step one").start().succeed("step one done");
|
|
spinner("step two").start().succeed("step two done");
|
|
spinner("step three").start().succeed("step three done");
|
|
|
|
const cleanOutput = stripAnsi(writtenChunks.join(""));
|
|
expect(cleanOutput).toContain("step one done");
|
|
expect(cleanOutput).toContain("step two done");
|
|
expect(cleanOutput).toContain("step three done");
|
|
expect(cleanOutput.match(/\n/g) ?? []).toHaveLength(3);
|
|
});
|
|
|
|
it("succeed/fail are idempotent (calling twice still emits a single line)", () => {
|
|
const handle = spinner("Installing skill...").start();
|
|
handle.succeed("Installed.");
|
|
handle.succeed("Installed.");
|
|
handle.fail("Should be ignored.");
|
|
|
|
const cleanOutput = stripAnsi(writtenChunks.join(""));
|
|
expect(cleanOutput).toContain("Installed.");
|
|
expect(cleanOutput).not.toContain("Should be ignored.");
|
|
expect(cleanOutput.match(/\n/g) ?? []).toHaveLength(1);
|
|
});
|
|
|
|
it("silent mode suppresses all output, including in static mode", () => {
|
|
setSpinnerSilent(true);
|
|
try {
|
|
spinner("noop").start().succeed("should be hidden");
|
|
} finally {
|
|
setSpinnerSilent(false);
|
|
}
|
|
expect(writtenChunks.join("")).toBe("");
|
|
});
|
|
});
|