Ctrl K
ComunidadGuías…

test/scripts/precommit.test.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
import { expect } from "@std/expect";
import { describe, it as test } from "@std/testing/bdd";
import { runCommand, runInteractiveCommand } from "#scripts/precommit/git.ts";
import {
  getMergeConflictWarning,
  parseMergeTreeConflictedPaths,
} from "#scripts/precommit/merge-warning.ts";
import {
  formatPushPrompt,
  getPushPromptContext,
  promptToPushCheckedInChanges,
  shouldPushFromAnswer,
} from "#scripts/precommit/push.ts";
import {
  canPrompt,
  canShowProgress,
  currentTerminalState,
} from "#scripts/precommit/terminal.ts";
import type { CapturedOutput } from "#scripts/process.ts";
import { capturedFail, capturedOk } from "#test-utils/captured-output.ts";

const fail = (code: number, stdout = "", stderr = ""): CapturedOutput =>
  capturedFail(code, stderr, stdout);

/** A 40-char SHA stdout line built from a single repeated character. */
const sha = (c: string): CapturedOutput => capturedOk(`${c.repeat(40)}\n`);

/** The six git-metadata responses getMergeConflictWarning reads before
 *  merge-tree: inside-work-tree, the remote URL, then four resolved SHAs. */
const gitMeta = (
  remote = "git@github.com:chobbledotcom/tickets.git",
): CapturedOutput[] => [
  capturedOk("true\n"),
  capturedOk(`${remote}\n`),
  sha("a"),
  sha("b"),
  sha("c"),
  sha("d"),
];

/** A `run` that returns each queued response in turn, then fail(128). */
const runFrom =
  (responses: CapturedOutput[]) =>
  (_cmd: string[]): Promise<CapturedOutput> =>
    Promise.resolve(responses.shift() ?? fail(128));

/** A merge-tree (--name-only) failure listing the given conflicted paths. */
const mergeTreeConflict = (...paths: string[]): CapturedOutput =>
  fail(
    1,
    [
      "e".repeat(40),
      ...paths,
      "",
      `Auto-merging ${paths[0]}`,
      `CONFLICT (content): Merge conflict in ${paths[0]}`,
    ].join("\n"),
  );

/** Like runFrom, but records every command for later assertion. */
const runRecording = (
  responses: CapturedOutput[],
): { calls: string[][]; run: (cmd: string[]) => Promise<CapturedOutput> } => {
  const calls: string[][] = [];
  return {
    calls,
    run: (cmd: string[]) => {
      calls.push(cmd);
      return Promise.resolve(responses.shift() ?? fail(128));
    },
  };
};

/** Push context responses for a clean branch with one unpushed commit. */
const pushReady = (): CapturedOutput[] => [
  capturedOk("true\n"),
  capturedOk(""),
  capturedOk("Ready\n"),
  capturedOk("origin/feature\n"),
  capturedOk("1\n"),
  capturedOk("git@github.com:chobbledotcom/tickets.git\n"),
  capturedOk("feature\n"),
];

/** A `push` that records its invocations (so a test can assert it ran or not). */
const trackPush = (): {
  calls: string[][];
  push: (cmd: string[]) => Promise<CapturedOutput>;
} => {
  const calls: string[][] = [];
  return {
    calls,
    push: (cmd: string[]) => {
      calls.push(cmd);
      return Promise.resolve(capturedOk());
    },
  };
};

/** Invoke promptToPushCheckedInChanges with happy-path deps; override per test. */
const runPushPrompt = (
  overrides: Partial<Parameters<typeof promptToPushCheckedInChanges>[0]>,
): Promise<boolean> =>
  promptToPushCheckedInChanges({
    confirm: () => Promise.resolve(true),
    isInteractive: () => true,
    push: () => Promise.resolve(capturedOk()),
    run: runFrom([]),
    ...overrides,
  });

describe("precommit merge conflict warning", () => {
  test("parses conflicted paths from git merge-tree output", () => {
    const paths = parseMergeTreeConflictedPaths(
      [
        "5e89402821f6b187f36361df2f18c6db18d384a3",
        "src/a.ts",
        "src/b.ts",
        "",
        "Auto-merging src/a.ts",
        "CONFLICT (content): Merge conflict in src/a.ts",
      ].join("\n"),
    );

    expect(paths).toEqual(["src/a.ts", "src/b.ts"]);
  });

  test("returns a warning when origin main has merge conflicts", async () => {
    const { calls, run } = runRecording([
      ...gitMeta(),
      mergeTreeConflict("src/a.ts", "src/b.ts"),
    ]);

    const warning = await getMergeConflictWarning(run);

    expect(warning).toBe(
      "Heads up - this branch has 2 merge conflicts against git@github.com:chobbledotcom/tickets.git",
    );
    expect(calls.at(-1)).toEqual([
      "git",
      "merge-tree",
      "--write-tree",
      "--name-only",
      "--merge-base",
      "cccccccccccccccccccccccccccccccccccccccc",
      "dddddddddddddddddddddddddddddddddddddddd",
      "origin/main",
    ]);
  });

  test("uses the requested warning wording for one conflicted path", async () => {
    const responses = [
      ...gitMeta("https://github.com/chobbledotcom/tickets.git"),
      mergeTreeConflict("src/a.ts"),
    ];

    await expect(getMergeConflictWarning(runFrom(responses))).resolves.toBe(
      "Heads up - this branch has 1 merge conflicts against https://github.com/chobbledotcom/tickets.git",
    );
  });

  test("does not warn when origin main merges cleanly", async () => {
    const run = runFrom([...gitMeta(), sha("e")]);
    await expect(getMergeConflictWarning(run)).resolves.toBeUndefined();
  });

  test("does not warn when required git metadata commands fail", async () => {
    const run = runFrom([
      capturedOk("true\n"),
      fail(128, "", "fatal: no such remote 'origin'\n"),
    ]);
    await expect(getMergeConflictWarning(run)).resolves.toBeUndefined();
  });

  test("does not warn when required git metadata is blank", async () => {
    const run = runFrom([
      capturedOk("true\n"),
      capturedOk("\n"),
      sha("a"),
      sha("b"),
      sha("c"),
      sha("d"),
    ]);
    await expect(getMergeConflictWarning(run)).resolves.toBeUndefined();
  });

  test("does not warn when merge-tree cannot complete the check", async () => {
    const run = runFrom([
      ...gitMeta(),
      fail(128, "", "fatal: failure to merge\n"),
    ]);
    await expect(getMergeConflictWarning(run)).resolves.toBeUndefined();
  });

  test("does not warn when merge-tree reports no conflicted paths", async () => {
    const run = runFrom([...gitMeta(), fail(1, `${"e".repeat(40)}\n\n`)]);
    await expect(getMergeConflictWarning(run)).resolves.toBeUndefined();
  });

  test("does not warn outside a git work tree", async () => {
    const run = runFrom([fail(128, "", "fatal: not a git repository\n")]);
    await expect(getMergeConflictWarning(run)).resolves.toBeUndefined();
  });

  test("runs commands and captures output", async () => {
    await expect(
      runCommand([Deno.execPath(), "eval", "console.log('merge-warning')"]),
    ).resolves.toEqual({
      code: 0,
      stderr: "",
      stdout: "merge-warning\n",
      success: true,
    });
  });

  test("rejects an empty command", async () => {
    await expect(runCommand([])).rejects.toThrow("No command configured");
  });

  test("runs interactive commands", async () => {
    await expect(
      runInteractiveCommand([Deno.execPath(), "eval", ""]),
    ).resolves.toEqual({
      code: 0,
      stderr: "",
      stdout: "",
      success: true,
    });
  });

  test("rejects an empty interactive command", async () => {
    await expect(runInteractiveCommand([])).rejects.toThrow(
      "No command configured",
    );
  });
});

describe("precommit terminal behavior", () => {
  test("shows progress in git hooks where stdout is a terminal but stdin is not", () => {
    const hookTerminalState = { ci: false, stdin: false, stdout: true };

    expect(canShowProgress(hookTerminalState)).toBe(true);
    expect(canPrompt(hookTerminalState)).toBe(false);
  });

  test("does not show progress or prompt in CI", () => {
    const ciTerminalState = { ci: true, stdin: true, stdout: true };

    expect(canShowProgress(ciTerminalState)).toBe(false);
    expect(canPrompt(ciTerminalState)).toBe(false);
  });

  test("reads the current terminal state", () => {
    const state = currentTerminalState();

    expect(typeof state.ci).toBe("boolean");
    expect(typeof state.stdin).toBe("boolean");
    expect(typeof state.stdout).toBe("boolean");
  });
});

describe("precommit push prompt", () => {
  test("builds prompt context for a clean branch with unpushed commits", async () => {
    const { calls, run } = runRecording([
      capturedOk("true\n"),
      capturedOk(""),
      capturedOk("Ship the hook\n\nBody\n"),
      capturedOk("origin/feature\n"),
      capturedOk("2\n"),
      capturedOk("git@github.com:chobbledotcom/tickets.git\n"),
      capturedOk("feature\n"),
    ]);

    await expect(getPushPromptContext(run)).resolves.toEqual({
      branchName: "feature",
      commitMessage: "Ship the hook\n\nBody",
      originUrl: "git@github.com:chobbledotcom/tickets.git",
      unpushedCommits: 2,
    });
    expect(calls).toEqual([
      ["git", "rev-parse", "--is-inside-work-tree"],
      ["git", "status", "--porcelain"],
      ["git", "log", "-1", "--format=%B"],
      [
        "git",
        "rev-parse",
        "--abbrev-ref",
        "--symbolic-full-name",
        "@{upstream}",
      ],
      ["git", "rev-list", "--count", "origin/feature..HEAD"],
      ["git", "remote", "get-url", "origin"],
      ["git", "rev-parse", "--abbrev-ref", "HEAD"],
    ]);
  });

  test("uses origin main as a fallback when no upstream exists", async () => {
    const responses = [
      capturedOk("true\n"),
      capturedOk(""),
      capturedOk("Ship local branch\n"),
      fail(128),
      capturedOk("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\n"),
      capturedOk("1\n"),
      capturedOk("git@github.com:chobbledotcom/tickets.git\n"),
      capturedOk("feature\n"),
    ];
    const run = runFrom(responses);

    await expect(getPushPromptContext(run)).resolves.toEqual({
      branchName: "feature",
      commitMessage: "Ship local branch",
      originUrl: "git@github.com:chobbledotcom/tickets.git",
      unpushedCommits: 1,
    });
  });

  test("assumes one unpushed commit when no comparison ref exists", async () => {
    const responses = [
      capturedOk("true\n"),
      capturedOk(""),
      capturedOk("Initial branch commit\n"),
      fail(128),
      fail(128),
      capturedOk("git@github.com:chobbledotcom/tickets.git\n"),
      capturedOk("feature\n"),
    ];
    const run = runFrom(responses);

    await expect(getPushPromptContext(run)).resolves.toEqual({
      branchName: "feature",
      commitMessage: "Initial branch commit",
      originUrl: "git@github.com:chobbledotcom/tickets.git",
      unpushedCommits: 1,
    });
  });

  test("does not build context outside a git work tree", async () => {
    const run = (_cmd: string[]): Promise<CapturedOutput> =>
      Promise.resolve(fail(128));

    await expect(getPushPromptContext(run)).resolves.toBeUndefined();
  });

  test("does not build context when the worktree is dirty", async () => {
    const responses = [
      capturedOk("true\n"),
      capturedOk(" M scripts/precommit.ts\n"),
    ];
    const run = runFrom(responses);

    await expect(getPushPromptContext(run)).resolves.toBeUndefined();
  });

  test("does not build context when there is no commit message", async () => {
    const responses = [capturedOk("true\n"), capturedOk(""), capturedOk("\n")];
    const run = runFrom(responses);

    await expect(getPushPromptContext(run)).resolves.toBeUndefined();
  });

  test("does not build context when there are no unpushed commits", async () => {
    const responses = [
      capturedOk("true\n"),
      capturedOk(""),
      capturedOk("Already pushed\n"),
      capturedOk("origin/feature\n"),
      capturedOk("0\n"),
    ];
    const run = runFrom(responses);

    await expect(getPushPromptContext(run)).resolves.toBeUndefined();
  });

  test("does not build context when unpushed count is blank", async () => {
    const responses = [
      capturedOk("true\n"),
      capturedOk(""),
      capturedOk("Blank count\n"),
      capturedOk("origin/feature\n"),
      capturedOk("\n"),
    ];
    const run = runFrom(responses);

    await expect(getPushPromptContext(run)).resolves.toBeUndefined();
  });

  test("does not build context when unpushed count is invalid", async () => {
    const responses = [
      capturedOk("true\n"),
      capturedOk(""),
      capturedOk("Bad count\n"),
      capturedOk("origin/feature\n"),
      capturedOk("many\n"),
    ];
    const run = runFrom(responses);

    await expect(getPushPromptContext(run)).resolves.toBeUndefined();
  });

  test("does not build context without an origin remote", async () => {
    const responses = [
      capturedOk("true\n"),
      capturedOk(""),
      capturedOk("No origin\n"),
      capturedOk("origin/feature\n"),
      capturedOk("1\n"),
      fail(128),
      capturedOk("feature\n"),
    ];
    const run = runFrom(responses);

    await expect(getPushPromptContext(run)).resolves.toBeUndefined();
  });

  test("does not build context for a detached head", async () => {
    const responses = [
      capturedOk("true\n"),
      capturedOk(""),
      capturedOk("Detached\n"),
      capturedOk("origin/feature\n"),
      capturedOk("1\n"),
      capturedOk("git@github.com:chobbledotcom/tickets.git\n"),
      capturedOk("HEAD\n"),
    ];
    const run = runFrom(responses);

    await expect(getPushPromptContext(run)).resolves.toBeUndefined();
  });

  test("formats push prompts with commit counts and subjects", () => {
    expect(
      formatPushPrompt({
        branchName: "feature",
        commitMessage: "Ship the hook\n\nBody",
        originUrl: "git@github.com:chobbledotcom/tickets.git",
        unpushedCommits: 2,
      }),
    ).toBe(
      "Push 2 commits from feature to git@github.com:chobbledotcom/tickets.git? (Ship the hook) [y/N] ",
    );
    expect(
      formatPushPrompt({
        branchName: "feature",
        commitMessage: "",
        originUrl: "git@github.com:chobbledotcom/tickets.git",
        unpushedCommits: 1,
      }),
    ).toBe(
      "Push 1 commit from feature to git@github.com:chobbledotcom/tickets.git? [y/N] ",
    );
  });

  test("accepts only explicit yes answers", () => {
    expect(shouldPushFromAnswer("y\n")).toBe(true);
    expect(shouldPushFromAnswer("YES")).toBe(true);
    expect(shouldPushFromAnswer("no")).toBe(false);
    expect(shouldPushFromAnswer("")).toBe(false);
  });

  test("skips pushing when not interactive", async () => {
    const { calls, push } = trackPush();
    await expect(
      runPushPrompt({ isInteractive: () => false, push }),
    ).resolves.toBe(true);
    expect(calls.length).toBe(0);
  });

  test("skips pushing when there is no prompt context", async () => {
    const { calls, push } = trackPush();
    await expect(runPushPrompt({ push })).resolves.toBe(true);
    expect(calls.length).toBe(0);
  });

  test("skips pushing when the user declines", async () => {
    const { calls, push } = trackPush();
    await expect(
      runPushPrompt({
        confirm: () => Promise.resolve(false),
        push,
        run: runFrom(pushReady()),
      }),
    ).resolves.toBe(true);
    expect(calls.length).toBe(0);
  });

  test("pushes when the user confirms", async () => {
    const { calls, push } = trackPush();
    await expect(
      runPushPrompt({ push, run: runFrom(pushReady()) }),
    ).resolves.toBe(true);
    expect(calls).toEqual([["git", "push"]]);
  });

  test("reports push failure", async () => {
    await expect(
      runPushPrompt({
        push: () => Promise.resolve(fail(1)),
        run: runFrom(pushReady()),
      }),
    ).resolves.toBe(false);
  });
});