Files
roboco/panel/src/components/settings/__tests__/feature-flags-card.test.tsx
T
8cb233c1e8 fix(panel): forms catch up with the backend (#614)
New Project claimed GitLab/Gitea were 'planned' while both providers are
fully shipped, showed a hardcoded GitHub badge, and never sent git_provider
at all — a non-GitHub project could not be created without an immediate
edit. It now carries the same forge Select the edit dialog has; the edit
dialog's own 'GitLab support is planned' tooltip is corrected too.

Also: the git actions panel's hardcoded 'main' (wrong PR-eligibility and
target label for master-default and env-ladder projects) is replaced by the
project's resolved head branch; acceptance criteria become editable in the
edit-task dialog; three feature flags get their missing descriptions; and
three forms swap raw-UUID text inputs for the existing Task/Agent selectors.

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-07-20 20:38:36 +02:00

180 lines
6.8 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ReactNode } from "react";
// Deferred mutationFn: the test holds every pending resolver in a queue so it
// can freeze multiple toggles mid-flight and release them one at a time. This
// exercises the REAL useMutation isPending/variables state rather than a
// stubbed hook. `vi.hoisted` keeps the mock fns initialized before the hoisted
// vi.mock factory runs.
const { resolveQueue } = vi.hoisted(() => ({
resolveQueue: { current: [] as Array<(v: unknown) => void> },
}));
const { setFeatureFlag, getFeatureFlags } = vi.hoisted(() => ({
setFeatureFlag: vi.fn(
() =>
new Promise((r) => {
resolveQueue.current.push(r as (v: unknown) => void);
}),
),
getFeatureFlags: vi.fn(async () => ({
flags: [
{ key: "alpha", label: "Alpha", enabled: true },
{ key: "beta", label: "Beta", enabled: false },
],
note: "Changes take effect on the next backend restart.",
})),
}));
vi.mock("@/lib/api", () => ({
settingsApi: { getFeatureFlags, setFeatureFlag },
}));
import { FeatureFlagsCard } from "../feature-flags-card";
function withQueryClient(ui: ReactNode) {
const client = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
return <QueryClientProvider client={client}>{ui}</QueryClientProvider>;
}
// M42: off-transitions (true→false) must open a confirm AlertDialog and only
// fire the mutation on confirm; on-transitions (false→true) fire immediately.
// pendingKeys tracks every in-flight toggle so each row locks independently.
describe("FeatureFlagsCard — M42 off-transition confirm + pending-keys Set", () => {
beforeEach(() => {
setFeatureFlag.mockClear();
getFeatureFlags.mockClear();
resolveQueue.current = [];
});
afterEach(() => {
vi.clearAllMocks();
});
it("off-transition opens a confirm dialog and defers the mutation until confirmed", async () => {
render(withQueryClient(<FeatureFlagsCard />));
const alpha = await screen.findByRole("switch", { name: "Alpha" });
expect(alpha).toBeChecked();
// Click the ON switch → off-transition → confirm dialog opens.
fireEvent.click(alpha);
const dialog = await screen.findByRole("alertdialog");
expect(dialog).toBeInTheDocument();
// Mutation is NOT fired until the operator confirms.
expect(setFeatureFlag).not.toHaveBeenCalled();
// Confirm → mutation fires with enabled=false.
fireEvent.click(screen.getByRole("button", { name: "Disable" }));
await waitFor(() =>
expect(setFeatureFlag).toHaveBeenCalledWith("alpha", false),
);
});
it("on-transition fires immediately without a confirm dialog", async () => {
render(withQueryClient(<FeatureFlagsCard />));
const beta = await screen.findByRole("switch", { name: "Beta" });
expect(beta).not.toBeChecked();
// Click the OFF switch → on-transition → fires immediately, no dialog.
fireEvent.click(beta);
await waitFor(() =>
expect(setFeatureFlag).toHaveBeenCalledWith("beta", true),
);
expect(screen.queryByRole("alertdialog")).not.toBeInTheDocument();
});
it("pendingKeys tracks every in-flight toggle so each row stays locked", async () => {
render(withQueryClient(<FeatureFlagsCard />));
const alpha = await screen.findByRole("switch", { name: "Alpha" });
const beta = await screen.findByRole("switch", { name: "Beta" });
// Alpha: off-transition → confirm → mutation in flight (deferred).
fireEvent.click(alpha);
fireEvent.click(await screen.findByRole("button", { name: "Disable" }));
await waitFor(() =>
expect(setFeatureFlag).toHaveBeenCalledWith("alpha", false),
);
await waitFor(() => expect(alpha).toBeDisabled());
// Beta: on-transition → fires immediately, mutation in flight (deferred).
fireEvent.click(beta);
await waitFor(() =>
expect(setFeatureFlag).toHaveBeenCalledWith("beta", true),
);
// Both switches are locked while their toggles are in flight. Pre-fix only
// the latest toggle's key was tracked, so Alpha would unlock when Beta
// started.
await waitFor(() => expect(beta).toBeDisabled());
expect(alpha).toBeDisabled();
// Resolve Alpha (FIFO) → Alpha unlocks; Beta stays locked until its own
// resolves.
resolveQueue.current.shift()?.(undefined);
await waitFor(() => expect(alpha).not.toBeDisabled());
expect(beta).toBeDisabled();
// Resolve Beta → Beta unlocks.
resolveQueue.current.shift()?.(undefined);
await waitFor(() => expect(beta).not.toBeDisabled());
});
// W9-5 follow-up: every real flag key gets a one-line hover tip on its
// label (FLAG_TOOLTIPS in feature-flags-card.tsx); an unmapped key (the
// "alpha"/"beta" fixtures above) renders bare per HelpTip's falsy short-
// circuit. TooltipTrigger always stamps data-state ("closed" while
// unopened) onto its asChild target, so its presence/absence is a reliable
// proxy for "is this label tooltip-wrapped" without simulating hover.
it("attaches the mapped tooltip to a real flag key and leaves unmapped keys bare", async () => {
getFeatureFlags.mockResolvedValueOnce({
flags: [
{ key: "alpha", label: "Alpha", enabled: true },
{
key: "external_pr_enabled",
label: "External PR Review",
enabled: true,
},
],
note: "Changes take effect on the next backend restart.",
});
render(withQueryClient(<FeatureFlagsCard />));
const mapped = await screen.findByText("External PR Review");
expect(mapped.getAttribute("data-state")).toBe("closed");
const unmapped = screen.getByText("Alpha");
expect(unmapped.getAttribute("data-state")).toBeNull();
});
// FLAG_DESCRIPTIONS previously lagged FLAG_TOOLTIPS for three vault/docs
// flags — the always-visible paragraph silently rendered empty for them.
it("renders an always-visible description for every vault/docs-sync flag", async () => {
getFeatureFlags.mockResolvedValueOnce({
flags: [
{ key: "docs_sync_enabled", label: "Docs Sync", enabled: false },
{
key: "obsidian_vault_enabled",
label: "Obsidian Vault",
enabled: false,
},
{ key: "vault_intake_enabled", label: "Vault Intake", enabled: false },
],
note: "Changes take effect on the next backend restart.",
});
render(withQueryClient(<FeatureFlagsCard />));
expect(await screen.findByText(/docs-update task/i)).toBeInTheDocument();
expect(screen.getByText(/wikilinked Obsidian vault/i)).toBeInTheDocument();
expect(screen.getByText(/board-review drafts/i)).toBeInTheDocument();
});
});