项目文件夹

文件
2026-07-13 13:39:12 +08:00

376 行
12 KiB
TypeScript

// @vitest-environment jsdom
/**
* Tests for AgentCard — per-agent RiskNoticeModal on first DNS activation.
*
* Covers:
* - First toggle opens modal (does NOT call onDnsToggle immediately)
* - Accept closes modal + calls onDnsToggle with true
* - Second activation does NOT open modal (localStorage flag set)
*/
import React from "react";
import { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("next-intl", () => ({
useTranslations: () => (key: string) => key,
}));
vi.mock("next/link", () => ({
default: ({ children, href }: { children: React.ReactNode; href: string }) =>
React.createElement("a", { href }, children),
}));
// Button.tsx exposes a default export — match the real module shape so
// RiskNoticeModal (which uses `import Button from ...`) resolves correctly.
// Round 3 had this as a named-export mock, which masked the production
// `import { Button }` bug fixed in R4 #1.
vi.mock("@/shared/components/Button", () => ({
default: ({
children,
onClick,
}: {
children: React.ReactNode;
onClick?: () => void;
variant?: string;
}) => React.createElement("button", { type: "button", onClick }, children),
}));
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ data: [] }),
} as unknown as Response);
const RISK_KEY_PREFIX = "omniroute-agentbridge-risk-dismissed-";
const cleanupCallbacks: Array<() => void> = [];
function makeContainer(): HTMLElement {
const container = document.createElement("div");
document.body.appendChild(container);
cleanupCallbacks.push(() => container.remove());
return container;
}
const mockTarget = {
id: "copilot" as const,
name: "GitHub Copilot",
icon: "code",
color: "#10B981",
hosts: ["api.githubcopilot.com"],
port: 443,
endpointPatterns: ["/chat/completions"],
defaultModels: [{ id: "gpt-4o", name: "GPT-4o", alias: "gpt-4o" }],
setupTutorial: {
steps: ["Step 1", "Step 2"],
detection: { command: "which copilot", platform: "all" as const },
},
handler: () => Promise.resolve({ default: class {} as never }),
riskNoticeKey: "oauth",
viability: "supported" as const,
};
const baseAgentState = {
agent_id: "copilot",
dns_enabled: false,
cert_trusted: true,
setup_completed: true,
last_started_at: null,
last_error: null,
};
describe("AgentCard RiskNoticeModal", { timeout: 30000 }, () => {
beforeEach(() => {
(
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = true;
// Clear localStorage risk key before each test
try {
localStorage.removeItem(RISK_KEY_PREFIX + "copilot");
} catch {
// ignore
}
});
afterEach(() => {
while (cleanupCallbacks.length > 0) cleanupCallbacks.pop()?.();
document.body.innerHTML = "";
vi.clearAllMocks();
try {
localStorage.removeItem(RISK_KEY_PREFIX + "copilot");
} catch {
// ignore
}
});
it("first DNS activation opens risk modal (does NOT call onDnsToggle yet)", async () => {
const { AgentCard } = await import(
"../../../src/app/(dashboard)/dashboard/tools/agent-bridge/components/AgentCard"
);
const onDnsToggle = vi.fn().mockResolvedValue(undefined);
const container = makeContainer();
await act(async () => {
const root = createRoot(container);
root.render(
React.createElement(AgentCard, {
target: mockTarget,
agentState: baseAgentState,
serverRunning: true,
mappings: [],
onDnsToggle,
onMappingsSave: vi.fn(),
})
);
});
// Expand card
const header = container.querySelector("button[aria-expanded]");
await act(async () => {
header?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
// Click DNS toggle (Start DNS)
const dnsBtn = Array.from(container.querySelectorAll("button")).find((b) =>
b.textContent?.includes("startDns")
);
expect(dnsBtn).not.toBeNull();
await act(async () => {
dnsBtn?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
// Risk modal should be open (dialog element present)
expect(document.body.querySelector('[role="dialog"]')).not.toBeNull();
// onDnsToggle should NOT have been called yet
expect(onDnsToggle).not.toHaveBeenCalled();
}, 30000);
it("accepting risk modal closes modal and calls onDnsToggle with true", async () => {
const { AgentCard } = await import(
"../../../src/app/(dashboard)/dashboard/tools/agent-bridge/components/AgentCard"
);
const onDnsToggle = vi.fn().mockResolvedValue(undefined);
const container = makeContainer();
await act(async () => {
const root = createRoot(container);
root.render(
React.createElement(AgentCard, {
target: mockTarget,
agentState: baseAgentState,
serverRunning: true,
mappings: [],
onDnsToggle,
onMappingsSave: vi.fn(),
})
);
});
// Expand card
const header = container.querySelector("button[aria-expanded]");
await act(async () => {
header?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
// Click DNS toggle to open modal
const dnsBtn = Array.from(container.querySelectorAll("button")).find((b) =>
b.textContent?.includes("startDns")
);
await act(async () => {
dnsBtn?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
// Modal should be open
expect(document.body.querySelector('[role="dialog"]')).not.toBeNull();
// Click "I understand" (accept) button — uses t("understand") key
const acceptBtn = Array.from(document.querySelectorAll("button")).find((b) =>
b.textContent?.includes("understand")
);
expect(acceptBtn).not.toBeNull();
await act(async () => {
acceptBtn?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
// Modal should be closed
expect(document.body.querySelector('[role="dialog"]')).toBeNull();
// onDnsToggle should have been called with true
expect(onDnsToggle).toHaveBeenCalledWith("copilot", true);
// localStorage flag should be set
const stored = localStorage.getItem(RISK_KEY_PREFIX + "copilot");
expect(stored).toBe("true");
}, 30000);
it("accepting risk writes localStorage exactly once (RiskNoticeModal is sole writer)", async () => {
const { AgentCard } = await import(
"../../../src/app/(dashboard)/dashboard/tools/agent-bridge/components/AgentCard"
);
const setItemSpy = vi.spyOn(Storage.prototype, "setItem");
const onDnsToggle = vi.fn().mockResolvedValue(undefined);
const container = makeContainer();
await act(async () => {
const root = createRoot(container);
root.render(
React.createElement(AgentCard, {
target: mockTarget,
agentState: baseAgentState,
serverRunning: true,
mappings: [],
onDnsToggle,
onMappingsSave: vi.fn(),
})
);
});
// Expand card
const header = container.querySelector("button[aria-expanded]");
await act(async () => {
header?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
// Click DNS toggle to open modal
const dnsBtn = Array.from(container.querySelectorAll("button")).find((b) =>
b.textContent?.includes("startDns")
);
await act(async () => {
dnsBtn?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
// Accept modal
const acceptBtn = Array.from(document.querySelectorAll("button")).find((b) =>
b.textContent?.includes("understand")
);
await act(async () => {
acceptBtn?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
const riskKey = "omniroute-agentbridge-risk-dismissed-copilot";
const riskWrites = setItemSpy.mock.calls.filter(([key]) => key === riskKey);
// Must be exactly ONE write — RiskNoticeModal is the sole persistence owner (D16)
expect(riskWrites).toHaveLength(1);
expect(riskWrites[0][1]).toBe("true");
setItemSpy.mockRestore();
}, 30000);
it("second DNS activation does NOT open modal when localStorage flag is set", async () => {
// Pre-set the localStorage flag (simulates accepted risk on previous session)
try {
localStorage.setItem(RISK_KEY_PREFIX + "copilot", "true");
} catch {
// ignore
}
const { AgentCard } = await import(
"../../../src/app/(dashboard)/dashboard/tools/agent-bridge/components/AgentCard"
);
const onDnsToggle = vi.fn().mockResolvedValue(undefined);
const container = makeContainer();
await act(async () => {
const root = createRoot(container);
root.render(
React.createElement(AgentCard, {
target: mockTarget,
agentState: baseAgentState,
serverRunning: true,
mappings: [],
onDnsToggle,
onMappingsSave: vi.fn(),
})
);
});
// Expand card
const header = container.querySelector("button[aria-expanded]");
await act(async () => {
header?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
// Click DNS toggle
const dnsBtn = Array.from(container.querySelectorAll("button")).find((b) =>
b.textContent?.includes("startDns")
);
await act(async () => {
dnsBtn?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
// Risk modal should NOT appear
expect(document.body.querySelector('[role="dialog"]')).toBeNull();
// onDnsToggle should have been called directly (no modal gate)
expect(onDnsToggle).toHaveBeenCalledWith("copilot", true);
}, 30000);
it("cancelling risk modal keeps modal closed and does NOT call onDnsToggle", async () => {
const { AgentCard } = await import(
"../../../src/app/(dashboard)/dashboard/tools/agent-bridge/components/AgentCard"
);
const onDnsToggle = vi.fn().mockResolvedValue(undefined);
const container = makeContainer();
await act(async () => {
const root = createRoot(container);
root.render(
React.createElement(AgentCard, {
target: mockTarget,
agentState: baseAgentState,
serverRunning: true,
mappings: [],
onDnsToggle,
onMappingsSave: vi.fn(),
})
);
});
// Expand card
const header = container.querySelector("button[aria-expanded]");
await act(async () => {
header?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
// Click DNS toggle to open modal
const dnsBtn = Array.from(container.querySelectorAll("button")).find((b) =>
b.textContent?.includes("startDns")
);
await act(async () => {
dnsBtn?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
// Modal should be open
expect(document.body.querySelector('[role="dialog"]')).not.toBeNull();
// Click Cancel button
const cancelBtn = Array.from(document.querySelectorAll("button")).find((b) =>
b.textContent?.includes("cancel")
);
expect(cancelBtn).not.toBeNull();
await act(async () => {
cancelBtn?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
// Modal should be closed
expect(document.body.querySelector('[role="dialog"]')).toBeNull();
// onDnsToggle should NOT have been called
expect(onDnsToggle).not.toHaveBeenCalled();
// localStorage flag should NOT be set (user cancelled)
const stored = localStorage.getItem(RISK_KEY_PREFIX + "copilot");
expect(stored).toBeNull();
}, 30000);
});