* feat(providers): remove custom providers from settings Add a destructive removal flow so mistaken custom providers can be deleted from config.json instead of only disabled. * test(app): cover provider removal with e2e Move provider removal coverage out of mocked component tests and into the real Settings flow. * fix(providers): keep removal live after config updates
461 lines
14 KiB
TypeScript
461 lines
14 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 "@getpaseo/protocol/agent-types";
|
|
import type { MutableDaemonConfig } from "@getpaseo/protocol/messages";
|
|
|
|
const { theme, snapshotState, configState, patchConfigMock, openProviderSettingsMock } = 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),
|
|
openProviderSettingsMock: vi.fn(),
|
|
}),
|
|
);
|
|
|
|
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"),
|
|
MoreHorizontal: icon("MoreHorizontal"),
|
|
Trash2: icon("Trash2"),
|
|
};
|
|
});
|
|
|
|
vi.mock("react-i18next", () => ({
|
|
useTranslation: () => ({
|
|
t: (key: string, values?: Record<string, string | number>) =>
|
|
(
|
|
({
|
|
"settings.providers.providerDetails": "{{name}} provider details",
|
|
"settings.providers.enableProvider": "Enable {{name}}",
|
|
"settings.providers.statuses.disabled": "Disabled",
|
|
"settings.providers.statuses.available": "Available",
|
|
"settings.providers.statuses.loading": "Loading",
|
|
"settings.providers.statuses.error": "Error",
|
|
"settings.providers.statuses.notInstalled": "Not installed",
|
|
"settings.providers.models.one": "1 model",
|
|
"settings.providers.models.many": "{{count}} models",
|
|
"settings.providers.addErrorTitle": "Unable to add provider",
|
|
"settings.providers.updateErrorTitle": "Unable to update provider",
|
|
"settings.providers.actions.menu": "{{name}} actions",
|
|
"settings.providers.actions.remove": "Remove provider",
|
|
"settings.providers.actions.removing": "Removing...",
|
|
"settings.providers.remove.confirmTitle": "Remove {{name}}?",
|
|
"settings.providers.remove.confirmMessage":
|
|
"This deletes the provider entry from config.json. It cannot be undone.",
|
|
"settings.providers.remove.confirm": "Remove",
|
|
"settings.providers.remove.errorTitle": "Unable to remove provider",
|
|
})[key] ?? key
|
|
)
|
|
.replaceAll("{{name}}", String(values?.name ?? ""))
|
|
.replaceAll("{{count}}", String(values?.count ?? "")),
|
|
}),
|
|
}));
|
|
|
|
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/ui/dropdown-menu", () => ({
|
|
DropdownMenu: ({ children }: { children?: React.ReactNode }) =>
|
|
React.createElement("div", null, children),
|
|
DropdownMenuTrigger: ({
|
|
children,
|
|
onPressIn,
|
|
accessibilityRole,
|
|
accessibilityLabel,
|
|
testID,
|
|
}: {
|
|
children?:
|
|
| React.ReactNode
|
|
| ((state: { pressed: boolean; hovered: boolean; open: boolean }) => React.ReactNode);
|
|
onPressIn?: (event: { stopPropagation: () => void }) => void;
|
|
accessibilityRole?: string;
|
|
accessibilityLabel?: string;
|
|
testID?: string;
|
|
}) =>
|
|
React.createElement(
|
|
"button",
|
|
{
|
|
type: "button",
|
|
role: accessibilityRole,
|
|
"aria-label": accessibilityLabel,
|
|
"data-testid": testID,
|
|
onMouseDown: (event: React.MouseEvent) => onPressIn?.(event),
|
|
onClick: (event: React.MouseEvent) => event.stopPropagation(),
|
|
},
|
|
typeof children === "function"
|
|
? children({ pressed: false, hovered: false, open: false })
|
|
: children,
|
|
),
|
|
DropdownMenuContent: ({ children }: { children?: React.ReactNode }) =>
|
|
React.createElement("div", null, children),
|
|
DropdownMenuItem: ({
|
|
children,
|
|
onSelect,
|
|
status,
|
|
pendingLabel,
|
|
testID,
|
|
}: {
|
|
children?: React.ReactNode;
|
|
onSelect?: () => void;
|
|
status?: "idle" | "pending" | "success";
|
|
pendingLabel?: string;
|
|
testID?: string;
|
|
}) =>
|
|
React.createElement(
|
|
"button",
|
|
{
|
|
type: "button",
|
|
"data-testid": testID,
|
|
disabled: status === "pending" || status === "success",
|
|
onClick: (event: React.MouseEvent) => {
|
|
event.stopPropagation();
|
|
onSelect?.();
|
|
},
|
|
},
|
|
status === "pending" ? pendingLabel : children,
|
|
),
|
|
}));
|
|
|
|
vi.mock("@/components/provider-icons", () => ({
|
|
getProviderIcon: (provider: string) => () =>
|
|
React.createElement("span", { "data-icon": `provider-${provider}` }),
|
|
}));
|
|
|
|
vi.mock("@/stores/provider-settings-store", () => ({
|
|
useProviderSettingsStore: (selector: (state: unknown) => unknown) =>
|
|
selector({ open: openProviderSettingsMock }),
|
|
}));
|
|
|
|
vi.mock("@/components/provider-catalog-list", () => ({
|
|
ProviderCatalogList: () => null,
|
|
}));
|
|
|
|
vi.mock("@/hooks/use-providers-snapshot", () => ({
|
|
useProvidersSnapshot: () => ({
|
|
entries: snapshotState.entries,
|
|
isLoading: snapshotState.isLoading,
|
|
isFetching: false,
|
|
isRefreshing: snapshotState.isRefreshing,
|
|
error: null,
|
|
supportsSnapshot: true,
|
|
refresh: vi.fn(async () => {}),
|
|
refetchIfStale: vi.fn(),
|
|
}),
|
|
}));
|
|
|
|
vi.mock("@/hooks/use-daemon-config", () => ({
|
|
useDaemonConfig: () => ({
|
|
config: configState.config,
|
|
isLoading: false,
|
|
patchConfig: patchConfigMock,
|
|
}),
|
|
}));
|
|
|
|
vi.mock("@/runtime/host-runtime", () => ({
|
|
useHostRuntimeIsConnected: () => true,
|
|
}));
|
|
|
|
vi.mock("@/runtime/host-features", () => ({
|
|
useHostFeature: () => false,
|
|
}));
|
|
|
|
vi.mock("@/utils/confirm-dialog", () => ({
|
|
confirmDialog: vi.fn(async () => 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 },
|
|
browserTools: { enabled: false },
|
|
providers,
|
|
metadataGeneration: { providers: [] },
|
|
autoArchiveAfterMerge: false,
|
|
enableTerminalAgentHooks: false,
|
|
appendSystemPrompt: "",
|
|
};
|
|
}
|
|
|
|
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);
|
|
openProviderSettingsMock.mockReset();
|
|
});
|
|
|
|
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(openProviderSettingsMock).not.toHaveBeenCalled();
|
|
|
|
const row = findRow("Codex provider details");
|
|
act(() => {
|
|
row.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
|
|
});
|
|
|
|
expect(openProviderSettingsMock).toHaveBeenCalledTimes(1);
|
|
expect(openProviderSettingsMock).toHaveBeenCalledWith({
|
|
serverId: "server-1",
|
|
provider: "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 } },
|
|
});
|
|
});
|
|
});
|