Add a daemon setting `autoArchiveAfterMerge` (default off). When on, the daemon's existing GitHub poll triggers archive once the PR is merged, but only if the worktree is clean (not dirty, not ahead of origin). Skips silently otherwise.
373 lines
11 KiB
TypeScript
373 lines
11 KiB
TypeScript
/**
|
|
* @vitest-environment jsdom
|
|
*/
|
|
import React, { act } from "react";
|
|
import { createRoot, type Root } from "react-dom/client";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import type { ProviderSnapshotEntry } from "@server/server/agent/agent-sdk-types";
|
|
import type { MutableDaemonConfig } from "@server/shared/messages";
|
|
|
|
const { theme, snapshotState, configState, patchConfigMock, refreshMock } = vi.hoisted(() => ({
|
|
theme: {
|
|
spacing: { 1: 4, "1.5": 6, 2: 8, 3: 12, 4: 16, 6: 24 },
|
|
iconSize: { sm: 14, md: 20 },
|
|
fontSize: { xs: 11, sm: 13, base: 15 },
|
|
fontWeight: { normal: "400" },
|
|
borderRadius: { lg: 8 },
|
|
opacity: { 50: 0.5 },
|
|
colors: {
|
|
surface1: "#111",
|
|
surface2: "#222",
|
|
surface3: "#333",
|
|
foreground: "#fff",
|
|
foregroundMuted: "#aaa",
|
|
border: "#555",
|
|
accent: "#0a84ff",
|
|
statusSuccess: "#00ff00",
|
|
statusWarning: "#ff9500",
|
|
statusDanger: "#ff0000",
|
|
palette: { red: { 300: "#ff6b6b" }, white: "#fff" },
|
|
},
|
|
},
|
|
snapshotState: {
|
|
entries: undefined as ProviderSnapshotEntry[] | undefined,
|
|
isLoading: false,
|
|
isRefreshing: false,
|
|
},
|
|
configState: {
|
|
config: null as MutableDaemonConfig | null,
|
|
},
|
|
patchConfigMock: vi.fn(async () => undefined),
|
|
refreshMock: vi.fn(async () => {}),
|
|
}));
|
|
|
|
vi.mock("react-native", () => ({
|
|
View: ({ children, testID }: { children?: React.ReactNode; testID?: string }) =>
|
|
React.createElement("div", { "data-testid": testID }, children),
|
|
Text: ({ children }: { children?: React.ReactNode }) =>
|
|
React.createElement("span", null, children),
|
|
Pressable: ({
|
|
children,
|
|
onPress,
|
|
onHoverIn,
|
|
onHoverOut,
|
|
accessibilityRole,
|
|
accessibilityLabel,
|
|
disabled,
|
|
testID,
|
|
}: {
|
|
children?:
|
|
| React.ReactNode
|
|
| ((state: { pressed: boolean; hovered: boolean }) => React.ReactNode);
|
|
onPress?: (event: React.MouseEvent) => void;
|
|
onHoverIn?: () => void;
|
|
onHoverOut?: () => void;
|
|
accessibilityRole?: string;
|
|
accessibilityLabel?: string;
|
|
disabled?: boolean;
|
|
testID?: string;
|
|
}) =>
|
|
React.createElement(
|
|
"div",
|
|
{
|
|
role: accessibilityRole,
|
|
"aria-label": accessibilityLabel,
|
|
"aria-disabled": disabled ? "true" : undefined,
|
|
"data-testid": testID,
|
|
onClick: disabled ? undefined : onPress,
|
|
onMouseEnter: onHoverIn,
|
|
onMouseLeave: onHoverOut,
|
|
},
|
|
typeof children === "function" ? children({ pressed: false, hovered: false }) : children,
|
|
),
|
|
ActivityIndicator: () => React.createElement("span", { "data-testid": "activity-indicator" }),
|
|
}));
|
|
|
|
vi.mock("react-native-unistyles", () => ({
|
|
StyleSheet: {
|
|
create: (factory: unknown) =>
|
|
typeof factory === "function" ? (factory as (t: typeof theme) => unknown)(theme) : factory,
|
|
},
|
|
useUnistyles: () => ({ theme }),
|
|
}));
|
|
|
|
vi.mock("lucide-react-native", () => {
|
|
const icon = (name: string) => () => React.createElement("span", { "data-icon": name });
|
|
return {
|
|
ChevronRight: icon("ChevronRight"),
|
|
Plus: icon("Plus"),
|
|
RotateCw: icon("RotateCw"),
|
|
};
|
|
});
|
|
|
|
vi.mock("@/components/ui/switch", () => ({
|
|
Switch: ({
|
|
value,
|
|
onValueChange,
|
|
disabled,
|
|
accessibilityLabel,
|
|
testID,
|
|
}: {
|
|
value: boolean;
|
|
onValueChange?: (next: boolean) => void;
|
|
disabled?: boolean;
|
|
accessibilityLabel?: string;
|
|
testID?: string;
|
|
}) =>
|
|
React.createElement("div", {
|
|
role: "switch",
|
|
"aria-checked": value ? "true" : "false",
|
|
"aria-disabled": disabled ? "true" : undefined,
|
|
"aria-label": accessibilityLabel,
|
|
"data-testid": testID ?? "provider-switch",
|
|
onClick: (event: React.MouseEvent) => {
|
|
event.stopPropagation();
|
|
if (disabled) return;
|
|
onValueChange?.(!value);
|
|
},
|
|
}),
|
|
}));
|
|
|
|
vi.mock("@/components/ui/loading-spinner", () => ({
|
|
LoadingSpinner: () => React.createElement("span", { "data-testid": "loading-spinner" }),
|
|
}));
|
|
|
|
vi.mock("@/components/provider-icons", () => ({
|
|
getProviderIcon: (provider: string) => () =>
|
|
React.createElement("span", { "data-icon": `provider-${provider}` }),
|
|
}));
|
|
|
|
vi.mock("@/components/provider-diagnostic-sheet", () => ({
|
|
ProviderDiagnosticSheet: ({ provider }: { provider: string }) =>
|
|
React.createElement("div", {
|
|
"data-testid": "provider-diagnostic-sheet",
|
|
"data-provider": provider,
|
|
}),
|
|
}));
|
|
|
|
vi.mock("@/components/add-provider-modal", () => ({
|
|
AddProviderModal: () => null,
|
|
}));
|
|
|
|
vi.mock("@/hooks/use-providers-snapshot", () => ({
|
|
useProvidersSnapshot: () => ({
|
|
entries: snapshotState.entries,
|
|
isLoading: snapshotState.isLoading,
|
|
isFetching: false,
|
|
isRefreshing: snapshotState.isRefreshing,
|
|
error: null,
|
|
supportsSnapshot: true,
|
|
refresh: refreshMock,
|
|
refetchIfStale: vi.fn(),
|
|
}),
|
|
}));
|
|
|
|
vi.mock("@/hooks/use-daemon-config", () => ({
|
|
useDaemonConfig: () => ({
|
|
config: configState.config,
|
|
isLoading: false,
|
|
patchConfig: patchConfigMock,
|
|
}),
|
|
}));
|
|
|
|
vi.mock("@/runtime/host-runtime", () => ({
|
|
useHostRuntimeIsConnected: () => true,
|
|
}));
|
|
|
|
import { ProvidersSection } from "./providers-section";
|
|
|
|
const claudeEntry: ProviderSnapshotEntry = {
|
|
provider: "claude",
|
|
status: "ready",
|
|
enabled: true,
|
|
label: "Claude",
|
|
description: "Claude Code",
|
|
defaultModeId: null,
|
|
modes: [],
|
|
models: [
|
|
{ provider: "claude", id: "claude-opus-4-7", label: "Claude Opus 4.7" },
|
|
{ provider: "claude", id: "claude-sonnet-4-6", label: "Claude Sonnet 4.6" },
|
|
{ provider: "claude", id: "claude-haiku-4-5", label: "Claude Haiku 4.5" },
|
|
],
|
|
};
|
|
|
|
const disabledCodexEntry: ProviderSnapshotEntry = {
|
|
provider: "codex",
|
|
status: "unavailable",
|
|
enabled: false,
|
|
label: "Codex",
|
|
description: "OpenAI Codex",
|
|
defaultModeId: null,
|
|
modes: [],
|
|
};
|
|
|
|
function makeConfig(providers: MutableDaemonConfig["providers"] = {}): MutableDaemonConfig {
|
|
return { mcp: { injectIntoAgents: false }, providers, autoArchiveAfterMerge: false };
|
|
}
|
|
|
|
function descendants(el: HTMLElement): HTMLElement[] {
|
|
return Array.from(el.querySelectorAll<HTMLElement>("*"));
|
|
}
|
|
|
|
function indexOfMatches(nodes: HTMLElement[], selector: string): number {
|
|
return nodes.findIndex((node) => node.matches(selector));
|
|
}
|
|
|
|
function indexOfText(nodes: HTMLElement[], text: string): number {
|
|
return nodes.findIndex((node) => node.textContent?.trim() === text);
|
|
}
|
|
|
|
describe("ProvidersSection", () => {
|
|
let root: Root | null = null;
|
|
let container: HTMLElement | null = null;
|
|
|
|
beforeEach(() => {
|
|
vi.stubGlobal("React", React);
|
|
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
|
|
|
container = document.createElement("div");
|
|
document.body.appendChild(container);
|
|
root = createRoot(container);
|
|
|
|
snapshotState.entries = undefined;
|
|
snapshotState.isLoading = false;
|
|
snapshotState.isRefreshing = false;
|
|
configState.config = null;
|
|
patchConfigMock.mockReset();
|
|
patchConfigMock.mockResolvedValue(undefined);
|
|
refreshMock.mockReset();
|
|
refreshMock.mockResolvedValue(undefined);
|
|
});
|
|
|
|
afterEach(() => {
|
|
if (root) {
|
|
act(() => {
|
|
root?.unmount();
|
|
});
|
|
}
|
|
root = null;
|
|
container?.remove();
|
|
container = null;
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
function render(): void {
|
|
act(() => {
|
|
root?.render(<ProvidersSection serverId="server-1" />);
|
|
});
|
|
}
|
|
|
|
function findRow(accessibilityLabel: string): HTMLElement {
|
|
const row = container?.querySelector<HTMLElement>(
|
|
`[role="button"][aria-label="${accessibilityLabel}"]`,
|
|
);
|
|
if (!row) throw new Error(`Expected row with aria-label "${accessibilityLabel}"`);
|
|
return row;
|
|
}
|
|
|
|
it("renders the disabled provider with its server-provided label in snapshot order", () => {
|
|
snapshotState.entries = [claudeEntry, disabledCodexEntry];
|
|
configState.config = makeConfig({ codex: { enabled: false } });
|
|
|
|
render();
|
|
|
|
const rows = Array.from(
|
|
container?.querySelectorAll<HTMLElement>('[role="button"][aria-label$="provider details"]') ??
|
|
[],
|
|
);
|
|
expect(rows.map((row) => row.getAttribute("aria-label"))).toEqual([
|
|
"Claude provider details",
|
|
"Codex provider details",
|
|
]);
|
|
|
|
const codexRow = findRow("Codex provider details");
|
|
const codexNodes = descendants(codexRow);
|
|
expect(indexOfText(codexNodes, "Codex")).toBeGreaterThanOrEqual(0);
|
|
expect(indexOfText(codexNodes, "codex")).toBe(-1);
|
|
expect(indexOfText(codexNodes, "Disabled")).toBeGreaterThanOrEqual(0);
|
|
});
|
|
|
|
it("composes the row as chevron, icon, label, status, model count, then switch", () => {
|
|
snapshotState.entries = [claudeEntry];
|
|
configState.config = makeConfig();
|
|
|
|
render();
|
|
|
|
const row = findRow("Claude provider details");
|
|
const nodes = descendants(row);
|
|
const chevron = indexOfMatches(nodes, '[data-icon="ChevronRight"]');
|
|
const icon = indexOfMatches(nodes, '[data-icon="provider-claude"]');
|
|
const label = indexOfText(nodes, "Claude");
|
|
const status = indexOfText(nodes, "Available");
|
|
const modelCount = indexOfText(nodes, "3 models");
|
|
const switchEl = indexOfMatches(nodes, '[role="switch"]');
|
|
|
|
expect(chevron).toBeGreaterThanOrEqual(0);
|
|
expect(icon).toBeGreaterThan(chevron);
|
|
expect(label).toBeGreaterThan(icon);
|
|
expect(status).toBeGreaterThan(label);
|
|
expect(modelCount).toBeGreaterThan(status);
|
|
expect(switchEl).toBeGreaterThan(modelCount);
|
|
});
|
|
|
|
it("opens the diagnostic sheet when the outer row is pressed for a disabled provider", () => {
|
|
snapshotState.entries = [disabledCodexEntry];
|
|
configState.config = makeConfig({ codex: { enabled: false } });
|
|
|
|
render();
|
|
|
|
expect(container?.querySelector('[data-testid="provider-diagnostic-sheet"]')).toBeNull();
|
|
|
|
const row = findRow("Codex provider details");
|
|
act(() => {
|
|
row.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
|
|
});
|
|
|
|
const sheet = container?.querySelector('[data-testid="provider-diagnostic-sheet"]');
|
|
expect(sheet).not.toBeNull();
|
|
expect(sheet?.getAttribute("data-provider")).toBe("codex");
|
|
});
|
|
|
|
it("toggles the provider enabled flag through patchConfig when the switch is pressed", async () => {
|
|
snapshotState.entries = [claudeEntry];
|
|
configState.config = makeConfig();
|
|
|
|
render();
|
|
|
|
const row = findRow("Claude provider details");
|
|
const switchEl = row.querySelector<HTMLElement>('[role="switch"]');
|
|
expect(switchEl).not.toBeNull();
|
|
expect(switchEl?.getAttribute("aria-checked")).toBe("true");
|
|
|
|
await act(async () => {
|
|
switchEl?.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
|
|
});
|
|
|
|
expect(patchConfigMock).toHaveBeenCalledTimes(1);
|
|
expect(patchConfigMock).toHaveBeenCalledWith({
|
|
providers: { claude: { enabled: false } },
|
|
});
|
|
expect(refreshMock).not.toHaveBeenCalled();
|
|
expect(container?.querySelector('[data-testid="provider-diagnostic-sheet"]')).toBeNull();
|
|
});
|
|
|
|
it("forces a provider snapshot refresh from the settings refresh action", async () => {
|
|
snapshotState.entries = [claudeEntry];
|
|
configState.config = makeConfig();
|
|
|
|
render();
|
|
|
|
const refreshButton = container?.querySelector<HTMLElement>(
|
|
'[role="button"][aria-label="Refresh providers"]',
|
|
);
|
|
expect(refreshButton).not.toBeNull();
|
|
|
|
await act(async () => {
|
|
refreshButton?.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
|
|
});
|
|
|
|
expect(refreshMock).toHaveBeenCalledTimes(1);
|
|
expect(patchConfigMock).not.toHaveBeenCalled();
|
|
});
|
|
});
|