2026-06-29 05:38:21 +02:00
|
|
|
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";
|
|
|
|
|
|
2026-07-07 10:09:23 +02:00
|
|
|
// 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> },
|
2026-06-29 05:38:21 +02:00
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
const { setFeatureFlag, getFeatureFlags } = vi.hoisted(() => ({
|
|
|
|
|
setFeatureFlag: vi.fn(
|
|
|
|
|
() =>
|
|
|
|
|
new Promise((r) => {
|
2026-07-07 10:09:23 +02:00
|
|
|
resolveQueue.current.push(r as (v: unknown) => void);
|
2026-06-29 05:38:21 +02:00
|
|
|
}),
|
|
|
|
|
),
|
|
|
|
|
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>;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-07 10:09:23 +02:00
|
|
|
// 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", () => {
|
2026-06-29 05:38:21 +02:00
|
|
|
beforeEach(() => {
|
|
|
|
|
setFeatureFlag.mockClear();
|
|
|
|
|
getFeatureFlags.mockClear();
|
2026-07-07 10:09:23 +02:00
|
|
|
resolveQueue.current = [];
|
2026-06-29 05:38:21 +02:00
|
|
|
});
|
|
|
|
|
afterEach(() => {
|
|
|
|
|
vi.clearAllMocks();
|
|
|
|
|
});
|
|
|
|
|
|
2026-07-07 10:09:23 +02:00
|
|
|
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 () => {
|
2026-06-29 05:38:21 +02:00
|
|
|
render(withQueryClient(<FeatureFlagsCard />));
|
|
|
|
|
|
|
|
|
|
const alpha = await screen.findByRole("switch", { name: "Alpha" });
|
|
|
|
|
const beta = await screen.findByRole("switch", { name: "Beta" });
|
|
|
|
|
|
2026-07-07 10:09:23 +02:00
|
|
|
// Alpha: off-transition → confirm → mutation in flight (deferred).
|
2026-06-29 05:38:21 +02:00
|
|
|
fireEvent.click(alpha);
|
2026-07-07 10:09:23 +02:00
|
|
|
fireEvent.click(await screen.findByRole("button", { name: "Disable" }));
|
2026-06-29 05:38:21 +02:00
|
|
|
await waitFor(() =>
|
|
|
|
|
expect(setFeatureFlag).toHaveBeenCalledWith("alpha", false),
|
|
|
|
|
);
|
|
|
|
|
await waitFor(() => expect(alpha).toBeDisabled());
|
|
|
|
|
|
2026-07-07 10:09:23 +02:00
|
|
|
// 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);
|
2026-06-29 05:38:21 +02:00
|
|
|
await waitFor(() => expect(alpha).not.toBeDisabled());
|
2026-07-07 10:09:23 +02:00
|
|
|
expect(beta).toBeDisabled();
|
|
|
|
|
|
|
|
|
|
// Resolve Beta → Beta unlocks.
|
|
|
|
|
resolveQueue.current.shift()?.(undefined);
|
|
|
|
|
await waitFor(() => expect(beta).not.toBeDisabled());
|
2026-06-29 05:38:21 +02:00
|
|
|
});
|
2026-07-15 16:33:22 +02:00
|
|
|
|
|
|
|
|
// 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();
|
|
|
|
|
});
|
2026-07-20 20:38:36 +02:00
|
|
|
|
|
|
|
|
// 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();
|
|
|
|
|
});
|
2026-06-29 05:38:21 +02:00
|
|
|
});
|