mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-02 13:22:11 +03:00
`Button.tsx` exposes only a default export, but `RiskNoticeModal.tsx` was
importing `{ Button }` (named) — so `Button` resolved to `undefined` and every
render of the modal crashed with React's "Element type is invalid".
The modal opens on first DNS activation for every agent, so this bug
effectively broke DNS interception for every agent in production. It went
undetected through 3 rounds of code review because two test artifacts masked
the failure:
1. `tests/unit/ui/agent-card.test.tsx > calls onDnsToggle when DNS button
clicked` failed since round 3 with the exact error "Element type is
invalid... Check the render method of RiskNoticeModal", but was repeatedly
dismissed as "pre-existing / flaky".
2. `tests/unit/ui/agent-card-risk-modal.test.tsx` (rodada 3) mocked Button
as a named export — which made the test green even though the production
import was broken. Classic "test alignment to broken code" anti-pattern.
This commit:
- switches RiskNoticeModal to `import Button from "@/shared/components/Button"`
- adds a regression-guard test that source-greps for the default-import shape
and asserts Button.tsx remains default-only
- updates the named mock in agent-card-risk-modal.test.tsx to `default:` so
tests now reflect the real module shape (no more masking)
- updates the agent-card.test.tsx DNS-click test to seed the per-agent
risk-accepted localStorage flag, isolating the DNS-toggle path from the
risk-modal path (the modal flow is covered by the risk-modal spec)
After: agent-card.test.tsx 4/4 green; agent-card-risk-modal.test.tsx 5/5 green;
new regression guard prevents recurrence of either pattern.
215 lines
6.5 KiB
TypeScript
215 lines
6.5 KiB
TypeScript
// @vitest-environment jsdom
|
|
/**
|
|
* UI unit tests for AgentCard — DNS toggle + wizard open.
|
|
*
|
|
* Note: AgentCard/SetupWizard import @/mitm/types (zod types only, no DB).
|
|
* We set testTimeout=30000 to handle the initial transform overhead.
|
|
*/
|
|
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),
|
|
}));
|
|
|
|
globalThis.fetch = vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
json: () => Promise.resolve({ data: [] }),
|
|
} as unknown as Response);
|
|
|
|
const cleanupCallbacks: Array<() => void> = [];
|
|
|
|
function makeContainer(): HTMLElement {
|
|
const container = document.createElement("div");
|
|
document.body.appendChild(container);
|
|
cleanupCallbacks.push(() => container.remove());
|
|
return container;
|
|
}
|
|
|
|
// Minimal mock target (matches MitmTarget shape but no heavy imports)
|
|
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: "providers.riskNotice.oauth",
|
|
viability: "supported" as const,
|
|
};
|
|
|
|
describe("AgentCard", { timeout: 30000 }, () => {
|
|
beforeEach(() => {
|
|
(
|
|
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
|
).IS_REACT_ACT_ENVIRONMENT = true;
|
|
});
|
|
|
|
afterEach(() => {
|
|
while (cleanupCallbacks.length > 0) cleanupCallbacks.pop()?.();
|
|
document.body.innerHTML = "";
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it("renders agent name and hosts", async () => {
|
|
const { AgentCard } = await import(
|
|
"../../../src/app/(dashboard)/dashboard/tools/agent-bridge/components/AgentCard"
|
|
);
|
|
|
|
const container = makeContainer();
|
|
await act(async () => {
|
|
const root = createRoot(container);
|
|
root.render(
|
|
React.createElement(AgentCard, {
|
|
target: mockTarget,
|
|
agentState: undefined,
|
|
serverRunning: false,
|
|
mappings: [],
|
|
onDnsToggle: vi.fn(),
|
|
onMappingsSave: vi.fn(),
|
|
})
|
|
);
|
|
});
|
|
|
|
expect(document.body.innerHTML).toContain("GitHub Copilot");
|
|
expect(document.body.innerHTML).toContain("api.githubcopilot.com");
|
|
}, 30000);
|
|
|
|
it("expands on click and shows DNS toggle", async () => {
|
|
const { AgentCard } = await import(
|
|
"../../../src/app/(dashboard)/dashboard/tools/agent-bridge/components/AgentCard"
|
|
);
|
|
|
|
const container = makeContainer();
|
|
await act(async () => {
|
|
const root = createRoot(container);
|
|
root.render(
|
|
React.createElement(AgentCard, {
|
|
target: mockTarget,
|
|
agentState: undefined,
|
|
serverRunning: true,
|
|
mappings: [],
|
|
onDnsToggle: vi.fn(),
|
|
onMappingsSave: vi.fn(),
|
|
})
|
|
);
|
|
});
|
|
|
|
const header = container.querySelector("button[aria-expanded]");
|
|
expect(header).not.toBeNull();
|
|
|
|
await act(async () => {
|
|
header?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
});
|
|
|
|
expect(document.body.innerHTML).toContain("startDns");
|
|
}, 30000);
|
|
|
|
it("calls onDnsToggle when DNS button clicked", async () => {
|
|
const { AgentCard } = await import(
|
|
"../../../src/app/(dashboard)/dashboard/tools/agent-bridge/components/AgentCard"
|
|
);
|
|
|
|
// Simulate that the per-agent RiskNoticeModal (Fix4 M5) has already been
|
|
// accepted for this agent — otherwise the DNS click opens the modal first
|
|
// and onDnsToggle is only called after the user accepts. We test the
|
|
// "already accepted" path here; the modal flow is covered by
|
|
// tests/unit/ui/agent-card-risk-modal.test.tsx.
|
|
localStorage.setItem("omniroute-agentbridge-risk-dismissed-copilot", "true");
|
|
|
|
const onDnsToggle = vi.fn().mockResolvedValue(undefined);
|
|
const container = makeContainer();
|
|
|
|
await act(async () => {
|
|
const root = createRoot(container);
|
|
root.render(
|
|
React.createElement(AgentCard, {
|
|
target: mockTarget,
|
|
agentState: {
|
|
agent_id: "copilot",
|
|
dns_enabled: false,
|
|
cert_trusted: false,
|
|
setup_completed: false,
|
|
last_started_at: null,
|
|
last_error: null,
|
|
},
|
|
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 }));
|
|
});
|
|
|
|
// Find and click DNS button
|
|
const dnsButton = Array.from(container.querySelectorAll("button")).find((b) =>
|
|
b.textContent?.includes("startDns")
|
|
);
|
|
expect(dnsButton).not.toBeNull();
|
|
|
|
await act(async () => {
|
|
dnsButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
});
|
|
|
|
expect(onDnsToggle).toHaveBeenCalledWith("copilot", true);
|
|
}, 30000);
|
|
|
|
it("opens wizard when setup wizard button clicked", async () => {
|
|
const { AgentCard } = await import(
|
|
"../../../src/app/(dashboard)/dashboard/tools/agent-bridge/components/AgentCard"
|
|
);
|
|
|
|
const container = makeContainer();
|
|
await act(async () => {
|
|
const root = createRoot(container);
|
|
root.render(
|
|
React.createElement(AgentCard, {
|
|
target: mockTarget,
|
|
agentState: undefined,
|
|
serverRunning: true,
|
|
mappings: [],
|
|
onDnsToggle: vi.fn(),
|
|
onMappingsSave: vi.fn(),
|
|
})
|
|
);
|
|
});
|
|
|
|
// Expand card first
|
|
const header = container.querySelector("button[aria-expanded]");
|
|
await act(async () => {
|
|
header?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
});
|
|
|
|
// Find setup wizard button
|
|
const wizardBtn = Array.from(document.querySelectorAll("button")).find((b) =>
|
|
b.textContent?.includes("setupWizard")
|
|
);
|
|
|
|
await act(async () => {
|
|
wizardBtn?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
});
|
|
|
|
expect(document.body.querySelector('[role="dialog"]')).not.toBeNull();
|
|
}, 30000);
|
|
});
|