Files
kandev/apps/web/components/vcs-split-button.test.ts
T
2026-09-14 09:28:23 +01:00

227 lines
8.6 KiB
TypeScript

import { describe, expect, it, vi } from "vitest";
import { t } from "@/lib/i18n";
import {
buildVcsSplitCallbacks,
determinePrimaryAction,
hasOpenChangeRequest,
} from "./vcs-split-button";
import { buildSingleRepoContributionCallbacks } from "./vcs-split-button-parts";
const SELECTED_PR_URL = "https://github.com/acme/widget-a/pull/42";
/**
* The VCS tooltips used to build their plural by hand:
*
* `Create PR (${aheadCount} commit${aheadCount !== 1 ? "s" : ""} ahead)`
*
* That puts the plural rule at the call site where no other locale can reach
* it, so the copy moved to `count` + `_one`/`_other`. These assertions pin the
* English to exactly what the concatenation emitted — E2E specs and the
* accessible-name queries depend on it, and a plural key is the easiest place
* to silently reword a string while every gate stays green.
*/
describe("vcs split-button count-bearing copy", () => {
const legacyCommit = (n: number) => `Commit ${n} changed file${n !== 1 ? "s" : ""}`;
const legacyPr = (n: number) => `Create PR (${n} commit${n !== 1 ? "s" : ""} ahead)`;
const legacyPush = (n: number) => `Push ${n} commit${n !== 1 ? "s" : ""} to remote`;
// 1 is the only value the old ternary treated specially; 0 and 2 both take
// the "s" branch, and 0 is the one a naive `n > 1` port would get wrong.
for (const n of [0, 1, 2, 5]) {
it(`matches the pre-migration English for ${n} commit(s)`, () => {
expect(t("integrations:commitChangedFiles", { count: n })).toBe(legacyCommit(n));
expect(t("integrations:createPrCommitsAhead", { count: n })).toBe(legacyPr(n));
expect(t("integrations:pushCommitsToRemote", { count: n })).toBe(legacyPush(n));
});
}
it("interpolates the git ref rather than translating it", () => {
expect(t("integrations:ontoBranch", { branch: "origin/main" })).toBe("onto origin/main");
expect(t("integrations:fromBranch", { branch: "release/1.2" })).toBe("from release/1.2");
expect(t("integrations:rebaseOntoBranchBehind", { branch: "origin/main", behind: 3 })).toBe(
"Rebase onto origin/main (3 behind)",
);
});
it("keeps the divergence aria-labels count-invariant, as the shipped English was", () => {
expect(t("integrations:commitsAheadAriaLabel", { value: 1 })).toBe("1 commits ahead");
expect(t("integrations:commitsBehindAriaLabel", { value: 2 })).toBe("2 commits behind");
});
});
describe("hasOpenChangeRequest", () => {
it("recognizes a registered provider review as the native task change request", () => {
expect(
hasOpenChangeRequest(undefined, [
{
providerId: "bitbucket",
reviewKey: "workspace/repo#42",
title: "Fix auth",
url: "https://bitbucket.test/pull-requests/42",
connectionScope: "https://bitbucket.test",
repositoryId: "repository-1",
changeRequestNumber: 42,
state: "OPEN",
},
]),
).toBe(true);
});
it("does not treat merged or closed reviews as open", () => {
expect(
hasOpenChangeRequest("closed", [
{
providerId: "bitbucket",
reviewKey: "workspace/repo#42",
title: "Fix auth",
url: "https://bitbucket.test/pull-requests/42",
connectionScope: "https://bitbucket.test",
repositoryId: "repository-1",
changeRequestNumber: 42,
state: "MERGED",
},
]),
).toBe(false);
});
});
describe("vcs split-button remote action semantics", () => {
it("uses the upstream-relative count for a linked PR push", () => {
expect(determinePrimaryAction(0, 1, 7, true)).toBe("push");
});
it("does not select Push when divergence policy removes unsafe push evidence", () => {
expect(determinePrimaryAction(0, 0, 7, true)).toBe("rebase");
});
it("passes the blocked repository to single-repository contribution actions", async () => {
const requestReplace = vi.fn();
const requestUse = vi.fn();
const openExternalLinkFn = vi.fn().mockResolvedValue("browser");
const resolutionTarget = {
expectedRemoteHead: "a".repeat(40),
repo: "widget-a",
repositoryName: "widget-a",
};
const callbacks = buildVcsSplitCallbacks({
openCommitDialog: vi.fn(),
openPRDialog: vi.fn(),
handlePull: vi.fn(),
handlePush: vi.fn(),
handleRebase: vi.fn(),
handleMerge: vi.fn(),
resolution: { requestReplace, requestUse },
resolutionTarget,
selectedPR: { pr_url: SELECTED_PR_URL },
openExternalLinkFn,
});
callbacks.onReplaceContribution("widget-a");
callbacks.onUseContribution("widget-a");
callbacks.onViewContribution("widget-a");
await Promise.resolve();
expect(requestReplace).toHaveBeenCalledWith(resolutionTarget);
expect(requestUse).toHaveBeenCalledWith(resolutionTarget);
expect(openExternalLinkFn).toHaveBeenCalledWith(SELECTED_PR_URL);
});
it("scopes the single-repository menu callbacks to the blocked repository", () => {
const onReplaceContribution = vi.fn();
const onUseContribution = vi.fn();
const onViewContribution = vi.fn();
const onCompareContribution = vi.fn();
const callbacks = buildSingleRepoContributionCallbacks(
{ onReplaceContribution, onUseContribution, onViewContribution, onCompareContribution },
"widget-a",
);
callbacks.onReplaceContribution();
callbacks.onUseContribution();
callbacks.onViewContribution();
callbacks.onCompareContribution();
expect(onReplaceContribution).toHaveBeenCalledWith("widget-a");
expect(onUseContribution).toHaveBeenCalledWith("widget-a");
expect(onViewContribution).toHaveBeenCalledWith("widget-a");
expect(onCompareContribution).toHaveBeenCalledWith("widget-a");
});
it("normalizes a root checkout scope for single-repository menu callbacks", () => {
const onReplaceContribution = vi.fn();
const onUseContribution = vi.fn();
const onViewContribution = vi.fn();
const onCompareContribution = vi.fn();
const callbacks = buildSingleRepoContributionCallbacks(
{ onReplaceContribution, onUseContribution, onViewContribution, onCompareContribution },
undefined,
);
callbacks.onReplaceContribution();
callbacks.onUseContribution();
callbacks.onViewContribution();
callbacks.onCompareContribution();
expect(onReplaceContribution).toHaveBeenCalledWith("");
expect(onUseContribution).toHaveBeenCalledWith("");
expect(onViewContribution).toHaveBeenCalledWith("");
expect(onCompareContribution).toHaveBeenCalledWith("");
});
});
describe("vcs split-button root repository scope", () => {
it("matches the root checkout scope when callbacks receive no repository name", async () => {
const requestReplace = vi.fn();
const requestUse = vi.fn();
const openExternalLinkFn = vi.fn().mockResolvedValue("browser");
const onCompareContribution = vi.fn();
const resolutionTarget = {
expectedRemoteHead: "a".repeat(40),
repo: "",
repositoryName: "testorg/testrepo",
};
const callbacks = buildVcsSplitCallbacks({
openCommitDialog: vi.fn(),
openPRDialog: vi.fn(),
handlePull: vi.fn(),
handlePush: vi.fn(),
handleRebase: vi.fn(),
handleMerge: vi.fn(),
resolution: { requestReplace, requestUse },
resolutionTarget,
selectedPR: { pr_url: SELECTED_PR_URL },
openExternalLinkFn,
onCompareContribution,
});
callbacks.onReplaceContribution();
callbacks.onUseContribution();
callbacks.onViewContribution();
callbacks.onCompareContribution();
await Promise.resolve();
expect(requestReplace).toHaveBeenCalledWith(resolutionTarget);
expect(requestUse).toHaveBeenCalledWith(resolutionTarget);
expect(openExternalLinkFn).toHaveBeenCalledWith(SELECTED_PR_URL);
expect(onCompareContribution).toHaveBeenCalledWith("");
});
});
/**
* The discard dialog used to concatenate a " in your local clone at X" tail
* onto a shared stem, which froze the tail's position in the sentence. Each
* branch is now a whole message; these pin both to the previous rendering.
*/
describe("discard-local-changes description", () => {
it("reads identically to the old stem + tail concatenation", () => {
const stem = "Starting this task will permanently discard the uncommitted changes";
const tail = "Back up anything you want to keep before continuing.";
expect(t("common:discardLocalChangesDescription")).toBe(`${stem} in your local clone. ${tail}`);
expect(t("common:discardLocalChangesAtPathDescription", { repoPath: "/srv/repo" })).toBe(
`${stem} in your local clone at /srv/repo. ${tail}`,
);
});
});