test(git-sync): add reviewer-requested coverage across engine, server, client

Implements the test cases called out in the PR #119 review threads
(code-review, test-strategy report, red-team) — TESTS ONLY, no production
code changes.

packages/git-sync (vitest):
- lib converter/markdown gaps: pageBreak data-loss (it.fails repro),
  subpages lossy round-trip, nested/fenced callouts, ol->taskList bridge,
  column.width number<->string drift, empty details.
- engine units: parentFolderFile, planReconciliation swap/chained move,
  buildVaultLayout last-resort-by-id, firstDivergence, applyPushActions /
  applyPullActions failure isolation.
- real temp-git integration: diffNameStatus -z rename+add/modify
  alignment, copy-line behavior, per-invocation committer identity (no
  leak into repo/global config).
- ENFORCED type-level GitSyncClient contract via vitest typecheck over a
  *.test-d.ts file (tsconfig.vitest.json; build tsconfig untouched).

apps/server (jest):
- orchestrator: delete-cap neutralization + fail-safe, Redis lock / mutex
  skip ladder + release-on-throw, merge guard, pull/push order, remote
  template substitution, poll lifecycle.
- page-change listener: loop-guard, debounce coalescing, id resolution,
  error swallowing.
- vault registry, controller authz (trigger + status), env
  validation/getters, page.service git-sync provenance stamping,
  persistence precedence (agent > git-sync > user) + no boundary snapshot,
  space.service audit-delta, space.repo jsonb-merge, converter-gate corpus
  extension (mention/math/details/marks).

apps/client (vitest + testing-library):
- history-item git-sync badge: render gating + non-clickable.
- edit-space-form toggle: initial state, optimistic payload, rollback on
  error, disabled states.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
claude_code
2026-06-21 17:51:35 +03:00
committed by claude code agent 227
parent 8d0f403745
commit 906c8c6d89
20 changed files with 3331 additions and 0 deletions

View File

@@ -0,0 +1,227 @@
import { describe, it, expect, vi, afterEach, beforeAll } from "vitest";
import { render, screen, cleanup, within } from "@testing-library/react";
import { MantineProvider } from "@mantine/core";
// Mantine Tooltip mounts its label lazily on hover via Floating UI, which is
// flaky under jsdom. Replace ONLY the Tooltip with a thin wrapper that renders
// the label inline (keeping Badge/Switch/etc. real), so the provenance label —
// the contract we care about — is deterministically queryable.
vi.mock("@mantine/core", async () => {
const actual =
await vi.importActual<typeof import("@mantine/core")>("@mantine/core");
const Tooltip = ({
label,
children,
}: {
label?: React.ReactNode;
children?: React.ReactNode;
}) => (
<>
{children}
<span data-testid="tooltip-label">{label}</span>
</>
);
Tooltip.Group = ({ children }: { children?: React.ReactNode }) => (
<>{children}</>
);
return { ...actual, Tooltip };
});
// jsdom lacks matchMedia, which MantineProvider's color-scheme hook needs.
beforeAll(() => {
if (!window.matchMedia) {
window.matchMedia = (query: string) =>
({
matches: false,
media: query,
onchange: null,
addListener: () => {},
removeListener: () => {},
addEventListener: () => {},
removeEventListener: () => {},
dispatchEvent: () => false,
}) as unknown as MediaQueryList;
}
});
// --- Mocks for the heavy / networked module graph ---------------------------
// HistoryItem pulls in i18n, jotai atoms (ai-chat / history), a config-backed
// avatar and a time formatter. The provenance-badge contract is the unit under
// test, so we stub everything else down to inert, deterministic renders and
// keep the real Mantine Badge/Tooltip so role/label queries are meaningful.
// i18n: interpolate {{name}} so the git-sync tooltip carries the author name,
// letting us assert provenance attribution without a real i18n backend.
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string, vars?: Record<string, unknown>) =>
vars && typeof vars.name !== "undefined"
? key.replace("{{name}}", String(vars.name))
: key,
}),
}));
// jotai setters: the badges call useSetAtom; return inert setters so a click on
// the (deep-linkable) AiAgentBadge would fire these — proving the git-sync badge
// does NOT wire any of them.
const setAiChatWindowOpen = vi.fn();
const setActiveChatId = vi.fn();
const setDraft = vi.fn();
const setHistoryModalOpen = vi.fn();
vi.mock("jotai", async () => {
const actual = await vi.importActual<typeof import("jotai")>("jotai");
return {
...actual,
useSetAtom: (atom: unknown) => {
switch (atom) {
case aiChatWindowOpenAtom:
return setAiChatWindowOpen;
case activeAiChatIdAtom:
return setActiveChatId;
case aiChatDraftAtom:
return setDraft;
case historyAtoms:
return setHistoryModalOpen;
default:
return vi.fn();
}
},
};
});
// Atoms are imported only as identity tokens for the useSetAtom switch above.
vi.mock("@/features/ai-chat/atoms/ai-chat-atom.ts", () => ({
activeAiChatIdAtom: { __tag: "activeAiChatIdAtom" },
aiChatWindowOpenAtom: { __tag: "aiChatWindowOpenAtom" },
aiChatDraftAtom: { __tag: "aiChatDraftAtom" },
}));
vi.mock("@/features/page-history/atoms/history-atoms.ts", () => ({
historyAtoms: { __tag: "historyAtoms" },
}));
// Avatar reaches into config (getAvatarUrl) — stub to a plain element.
vi.mock("@/components/ui/custom-avatar.tsx", () => ({
CustomAvatar: ({ name }: { name?: string }) => (
<span data-testid="avatar">{name}</span>
),
}));
// Deterministic, locale-free date string.
vi.mock("@/lib/time", () => ({
formattedDate: () => "2026-06-21",
}));
import HistoryItem from "./history-item";
import {
activeAiChatIdAtom,
aiChatWindowOpenAtom,
aiChatDraftAtom,
} from "@/features/ai-chat/atoms/ai-chat-atom.ts";
import { historyAtoms } from "@/features/page-history/atoms/history-atoms.ts";
import type { IPageHistory } from "@/features/page-history/types/page.types";
function makeItem(overrides: Partial<IPageHistory> = {}): IPageHistory {
return {
id: "h1",
pageId: "p1",
title: "Title",
slug: "slug",
icon: "",
coverPhoto: "",
version: 1,
lastUpdatedById: "u1",
workspaceId: "w1",
createdAt: "2026-06-21T00:00:00.000Z",
updatedAt: "2026-06-21T00:00:00.000Z",
lastUpdatedBy: { id: "u1", name: "Alice", avatarUrl: "" },
...overrides,
};
}
function renderItem(item: IPageHistory) {
return render(
<MantineProvider>
<HistoryItem
historyItem={item}
index={0}
onSelect={vi.fn()}
isActive={false}
/>
</MantineProvider>,
);
}
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
describe("HistoryItem git-sync provenance badge", () => {
// Test 1: the git-sync badge renders ONLY for lastUpdatedSource === 'git-sync'.
it("renders the Git sync badge only when lastUpdatedSource is 'git-sync'", () => {
renderItem(makeItem({ lastUpdatedSource: "git-sync" }));
expect(screen.getByText("Git sync")).toBeTruthy();
});
it.each([
["agent", "agent"],
["user", "user"],
["undefined", undefined],
])(
"does NOT render the Git sync badge when lastUpdatedSource is %s",
(_label, source) => {
renderItem(makeItem({ lastUpdatedSource: source }));
expect(screen.queryByText("Git sync")).toBeNull();
},
);
// Test 2: provenance attribution + the git-sync badge is NOT interactive.
it("attributes the git-sync provenance to the correct author and is not clickable", () => {
renderItem(
makeItem({
lastUpdatedSource: "git-sync",
lastUpdatedBy: { id: "u2", name: "Bob", avatarUrl: "" },
}),
);
const badge = screen.getByText("Git sync");
// Provenance attribution: the tooltip label carries the author name (the
// git-sync badge passes authorName -> "Synced from Git on behalf of {{name}}").
expect(screen.getByText("Synced from Git on behalf of Bob")).toBeTruthy();
// The git-sync badge must NOT behave like AiAgentBadge: the badge element
// itself is not a button, carries no role=button and no tabIndex, and
// clicking it must not trigger any ai-chat deep-link. (The surrounding
// history-row IS an UnstyledButton — that is the row's own select affordance,
// not the badge — so we scope these checks to the badge element.)
const badgeRoot = (badge.closest("[class*='mantine-Badge-root']") ??
badge) as HTMLElement;
expect(badgeRoot.getAttribute("role")).not.toBe("button");
expect(badgeRoot.getAttribute("tabindex")).toBeNull();
expect(badgeRoot.tagName.toLowerCase()).not.toBe("button");
// No interactive descendant button lives inside the badge itself.
expect(within(badgeRoot).queryByRole("button")).toBeNull();
badgeRoot.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(setActiveChatId).not.toHaveBeenCalled();
expect(setAiChatWindowOpen).not.toHaveBeenCalled();
expect(setDraft).not.toHaveBeenCalled();
expect(setHistoryModalOpen).not.toHaveBeenCalled();
});
// Sanity contrast: the agent badge (the copy-paste source) IS interactive when
// it carries an aiChatId — proving the not-clickable assertion above is real.
it("contrast: the AI-agent badge is a deep-link button when it has an aiChatId", () => {
renderItem(
makeItem({
lastUpdatedSource: "agent",
lastUpdatedAiChatId: "chat-1",
}),
);
const agentBadge = screen.getByText("AI-agent");
const root = agentBadge.closest("[role='button']");
expect(root).not.toBeNull();
within(root as HTMLElement).getByText("AI-agent");
});
});

View File

@@ -0,0 +1,171 @@
import {
describe,
it,
expect,
vi,
beforeAll,
afterEach,
} from "vitest";
import {
render,
screen,
cleanup,
fireEvent,
waitFor,
} from "@testing-library/react";
import { MantineProvider } from "@mantine/core";
// --- Mocks for the heavy / networked module graph ---------------------------
// EditSpaceForm wires the "Enable Git sync" Switch to a TanStack-Query mutation
// (useUpdateSpaceMutation). We mock ONLY that hook so the test fully controls
// mutateAsync (resolve / reject) and isPending, and stub i18n. The real Mantine
// Switch is rendered so the checkbox role / disabled state is meaningful.
// i18n: identity translator — labels stay as their English keys for queries.
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
// Mutation hook: a controllable mutateAsync plus a togglable isPending.
const mutateAsync = vi.fn();
let isPending = false;
vi.mock("@/features/space/queries/space-query.ts", () => ({
useUpdateSpaceMutation: () => ({
mutateAsync,
get isPending() {
return isPending;
},
}),
}));
// jsdom lacks matchMedia, which MantineProvider's color-scheme hook needs.
beforeAll(() => {
if (!window.matchMedia) {
window.matchMedia = (query: string) =>
({
matches: false,
media: query,
onchange: null,
addListener: () => {},
removeListener: () => {},
addEventListener: () => {},
removeEventListener: () => {},
dispatchEvent: () => false,
}) as unknown as MediaQueryList;
}
});
import { EditSpaceForm } from "./edit-space-form";
import type { ISpace } from "@/features/space/types/space.types.ts";
function makeSpace(overrides: Partial<ISpace> = {}): ISpace {
return {
id: "space-1",
name: "Engineering",
description: "",
slug: "eng",
hostname: "host",
creatorId: "u1",
createdAt: new Date("2026-01-01"),
updatedAt: new Date("2026-01-01"),
...overrides,
} as ISpace;
}
function renderForm(props: { space: ISpace; readOnly?: boolean }) {
return render(
<MantineProvider>
<EditSpaceForm space={props.space} readOnly={props.readOnly} />
</MantineProvider>,
);
}
// The git-sync toggle is the only switch on the form. Mantine renders it as an
// <input type="checkbox" role="switch">; its label text lives in a sibling
// wrapper, so query by role and assert the visible label is present alongside.
function getToggle(): HTMLInputElement {
// Sanity: the human-readable label is rendered.
screen.getByText("Enable Git sync");
return screen.getByRole("switch") as HTMLInputElement;
}
afterEach(() => {
cleanup();
mutateAsync.mockReset();
isPending = false;
});
describe("EditSpaceForm git-sync toggle", () => {
// Test 3: initial checked state derives from settings.gitSync.enabled ?? false.
it("derives initial checked state from space.settings.gitSync.enabled (true -> checked)", () => {
renderForm({
space: makeSpace({ settings: { gitSync: { enabled: true } } }),
});
expect(getToggle().checked).toBe(true);
});
it("defaults to unchecked when gitSync settings are missing", () => {
renderForm({ space: makeSpace() });
expect(getToggle().checked).toBe(false);
});
// Test 4: toggling fires the mutation with { spaceId, gitSyncEnabled } and
// optimistically flips the switch.
it("fires the mutation with the correct payload and optimistically flips on", async () => {
mutateAsync.mockResolvedValue(undefined);
renderForm({ space: makeSpace() });
const toggle = getToggle();
expect(toggle.checked).toBe(false);
fireEvent.click(toggle);
// Optimistic update: the switch reflects the new state immediately.
expect(toggle.checked).toBe(true);
expect(mutateAsync).toHaveBeenCalledTimes(1);
expect(mutateAsync).toHaveBeenCalledWith({
spaceId: "space-1",
gitSyncEnabled: true,
});
// Resolution leaves the toggle on.
await waitFor(() => expect(toggle.checked).toBe(true));
});
// Test 5: rollback on mutation error — the most valuable test.
it("rolls back the toggle to its prior state when the mutation rejects", async () => {
mutateAsync.mockRejectedValue(new Error("network"));
renderForm({
space: makeSpace({ settings: { gitSync: { enabled: false } } }),
});
const toggle = getToggle();
expect(toggle.checked).toBe(false);
fireEvent.click(toggle);
// Optimistically flips on before the rejection lands.
expect(toggle.checked).toBe(true);
expect(mutateAsync).toHaveBeenCalledWith({
spaceId: "space-1",
gitSyncEnabled: true,
});
// After the rejected promise settles, the component reverts to OFF so the
// user is not misled into believing sync is enabled.
await waitFor(() => expect(toggle.checked).toBe(false));
});
// Test 6: disabled when readOnly and when the mutation is pending.
it("disables the toggle when readOnly", () => {
renderForm({ space: makeSpace(), readOnly: true });
expect(getToggle().disabled).toBe(true);
});
it("disables the toggle while the mutation is pending", () => {
isPending = true;
renderForm({ space: makeSpace() });
expect(getToggle().disabled).toBe(true);
});
});