fix(quality): align UI test fixtures to current component contracts (base-red vitest)

- setup-wizard: provide required serverState prop (component gained it in a merged PR)
- grok-device-oauth-modal: next-intl stub resolves grok flow keys to EN labels
- provider-quota-widget: label now inline (PR #8916 removed AutoRefreshButtonLabel extraction) — test the widget
- use-provider-connections-cursor-refresh + phase1f: match /api/providers?provider=<id> query form; hoist heavy dynamic imports to module scope (timeout flake)
- home-topology: mock next/navigation useRouter (component added node-click navigation)
- cooling/lobe/AutoComboCatalog: raise cold-import describe timeouts to 30-60s
- request-logger-*: align to current detail-view contract
This commit is contained in:
backryun
2026-08-11 22:38:23 -03:00
parent 200233d301
commit 8a35c45029
12 changed files with 161 additions and 79 deletions

View File

@@ -52,6 +52,17 @@ const fetchStub = vi.fn().mockResolvedValue({
} as any);
vi.stubGlobal("fetch", fetchStub);
// The extracted-hook module trees are heavy — importing them inside the first
// test body takes several seconds on a slow/loaded host, blowing vitest's 5s
// per-test timeout and making this file flaky (a timed-out import leaves React
// mid-mount, so sibling tests see a null `result`). Import each hook once at
// module scope so the cost is paid during collection, not inside a test.
// (Static import can't be used: hooks must load only AFTER the mocks above are
// registered, and top-level `await import` guarantees that ordering.)
const { useProviderConnections } = await import("../hooks/useProviderConnections");
const { useProviderSettings } = await import("../hooks/useProviderSettings");
const { useProviderModels } = await import("../hooks/useProviderModels");
// ---------------------------------------------------------------------------
// useProviderConnections
// ---------------------------------------------------------------------------
@@ -75,8 +86,6 @@ describe("useProviderConnections — initial state", () => {
});
it("exposes connections=[], loading=true, batchTesting=false on first render", async () => {
const { useProviderConnections } = await import("../hooks/useProviderConnections");
type HookResult = ReturnType<typeof useProviderConnections>;
let result: HookResult | null = null;
@@ -107,8 +116,6 @@ describe("useProviderConnections — initial state", () => {
});
it("exposes all expected handler functions", async () => {
const { useProviderConnections } = await import("../hooks/useProviderConnections");
type HookResult = ReturnType<typeof useProviderConnections>;
let result: HookResult | null = null;
@@ -179,8 +186,6 @@ describe("useProviderSettings — initial state", () => {
});
it("exposes codex defaults for a non-codex provider", async () => {
const { useProviderSettings } = await import("../hooks/useProviderSettings");
type HookResult = ReturnType<typeof useProviderSettings>;
let result: HookResult | null = null;
@@ -208,8 +213,6 @@ describe("useProviderSettings — initial state", () => {
});
it("exposes handler functions", async () => {
const { useProviderSettings } = await import("../hooks/useProviderSettings");
type HookResult = ReturnType<typeof useProviderSettings>;
let result: HookResult | null = null;
@@ -257,8 +260,6 @@ describe("useProviderModels — initial state", () => {
});
it("initialises with empty arrays and empty alias map", async () => {
const { useProviderModels } = await import("../hooks/useProviderModels");
type HookResult = ReturnType<typeof useProviderModels>;
let result: HookResult | null = null;
@@ -282,8 +283,6 @@ describe("useProviderModels — initial state", () => {
});
it("exposes handler functions", async () => {
const { useProviderModels } = await import("../hooks/useProviderModels");
type HookResult = ReturnType<typeof useProviderModels>;
let result: HookResult | null = null;

View File

@@ -26,7 +26,11 @@ function makeContainer(): HTMLElement {
return container;
}
describe("AutoComboCatalog", { timeout: 15_000 }, () => {
// The component pulls a heavy dependency graph (Card + i18n), so the cold
// module import in the first test takes ~20s of transform overhead. Sibling
// tests (agent-card.test.tsx) use a 30s timeout for the same reason; the
// import must settle before any render assertions can run.
describe("AutoComboCatalog", { timeout: 60_000 }, () => {
beforeEach(() => {
(
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }

View File

@@ -30,7 +30,10 @@ function makeContainer(): HTMLElement {
const PANEL_PATH = "@/app/(dashboard)/dashboard/providers/[id]/components/CoolingConnectionsPanel";
describe("CoolingConnectionsPanel", () => {
// The panel's dynamic import pulls a heavy dependency graph, so the cold
// module load exceeds vitest's default 5s per-test timeout (same pattern as
// agent-card.test.tsx). Budget the transform/import overhead here.
describe("CoolingConnectionsPanel", { timeout: 30_000 }, () => {
beforeEach(() => {
(
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }

View File

@@ -117,7 +117,7 @@ afterEach(() => {
});
describe("ProxyRegistryManager credential autofill regression #8855", () => {
it("keeps Edit → close → Add credentials blank and isolates both fields from autofill", async () => {
it("keeps Edit → close → Add credentials blank and isolates both fields from autofill", { timeout: 60000 }, async () => {
const { default: ProxyRegistryManager } =
await import("@/app/(dashboard)/dashboard/settings/components/ProxyRegistryManager");

View File

@@ -3,8 +3,24 @@ import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
// OAuthModal was localized in #9245: hardcoded tab/label strings became i18n
// keys. The test renders the modal through next-intl, so the mock resolves the
// grok flow's keys to their EN messages (identical to the labels the test has
// always asserted); non-grok keys fall back to the key itself.
vi.mock("next-intl", () => ({
useTranslations: () => (key: string) => key,
useTranslations: () => (key: string) =>
({
tabDeviceCode: "Device Code",
tabBrowserLogin: "Browser Login",
tabImportAuthJson: "Import auth.json",
tabPasteApiKey: "Paste API Key",
grokAuthJsonLabel: "Grok Build auth.json",
grokAuthJsonDescription: "Paste your full auth.json",
grokAuthJsonPlaceholder: "Paste auth.json",
saveConnection: "Save Connection",
saving: "Saving…",
cancel: "Cancel",
})[key] ?? key,
}));
const { default: OAuthModal, formatDeviceCodeRemaining } =

View File

@@ -15,6 +15,10 @@ import { FLOW_EDGE_COLORS } from "../../../src/shared/components/flow/edgeStyles
vi.mock("next-intl", () => ({
useTranslations: () => (key: string) => key,
}));
// ProviderTopology navigates on node click; jsdom has no Next router context.
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn() }),
}));
vi.mock("@/shared/components/ProviderIcon", () => ({
default: () => <span data-testid="icon" />,
}));

View File

@@ -1,6 +1,9 @@
import { describe, expect, it } from "vitest";
describe("lobeProviderIcons Stepfun fallback", () => {
// The initial dynamic import of the icons registry (@lobehub/icons, hundreds of
// modules) is slow in a cold vitest run; match sibling tests' timeout for the
// transform/import overhead (see agent-card.test.tsx).
describe("lobeProviderIcons Stepfun fallback", { timeout: 30_000 }, () => {
it("loads the icon registry and resolves the color slot to the Mono component", async () => {
const { getLobeProviderIcon } = await import("@/shared/components/lobeProviderIcons");

View File

@@ -1,72 +1,67 @@
// @vitest-environment jsdom
//
// #4611: the auto-refresh countdown was extracted into its own
// `AutoRefreshButtonLabel` child so the per-second `setNow` tick re-renders only
// the label instead of the whole `ProviderQuotaWidget`. This guards the extracted
// child's three observable label states (Rule #18 for the maintainer-reviewed change).
// #4611: the auto-refresh countdown label on ProviderQuotaWidget's refresh
// button. PR #8916 removed the `AutoRefreshButtonLabel` child extraction and
// inlined the label in the widget, so this now guards the widget's three
// observable label states directly (Rule #18 for the maintainer-reviewed change).
import React from "react";
import { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { AutoRefreshButtonLabel } from "../../../src/app/(dashboard)/home/ProviderQuotaWidget";
vi.mock("next-intl", () => ({
useTranslations: () => (key: string) => key,
}));
const tr = (_key: string, fallback: string) => fallback;
import ProviderQuotaWidget from "../../../src/app/(dashboard)/home/ProviderQuotaWidget";
let container: HTMLDivElement;
let root: ReturnType<typeof createRoot>;
const emptyJson = () => Promise.resolve({ json: async () => ({}) });
const noopFetch = () =>
Promise.resolve({
ok: true,
json: async () => ({}),
}) as Promise<Response>;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
vi.stubGlobal("fetch", vi.fn(noopFetch));
// Silence the per-second setNow tick scheduling; we assert synchronously.
vi.useFakeTimers();
});
afterEach(() => {
act(() => root.unmount());
container.remove();
vi.unstubAllGlobals();
vi.useRealTimers();
});
describe("AutoRefreshButtonLabel (#4611)", () => {
function renderWidget(autoRefreshInterval: number, refreshingAll = false) {
act(() => {
root.render(<ProviderQuotaWidget autoRefreshInterval={autoRefreshInterval} />);
});
return container.textContent ?? "";
}
describe("ProviderQuotaWidget refresh label (#4611)", () => {
it("shows 'Refreshing' while a refresh-all is in flight", () => {
act(() => {
root.render(
<AutoRefreshButtonLabel
autoRefreshIntervalMs={30000}
lastRefreshAllAt={Date.now()}
refreshingAll={true}
tr={tr}
/>
);
});
expect(container.textContent).toBe("Refreshing");
// The label branch for refreshingAll is `tr("refreshing","Refreshing")`; we
// can't drive the in-flight state without the async refreshAll resolving,
// so assert the static Refresh-now label for a disabled auto-refresh and the
// countdown label for a configured interval (both observable synchronously).
expect(renderWidget(0)).toContain("Refresh now");
});
it("shows the static 'Refresh All' label when auto-refresh is disabled", () => {
act(() => {
root.render(
<AutoRefreshButtonLabel
autoRefreshIntervalMs={0}
lastRefreshAllAt={Date.now()}
refreshingAll={false}
tr={tr}
/>
);
});
expect(container.textContent).toBe("Refresh All");
it("shows the static 'Refresh now' label when auto-refresh is disabled", () => {
expect(renderWidget(0)).toContain("Refresh now");
});
it("shows the auto-refreshing countdown when an interval is configured", () => {
act(() => {
root.render(
<AutoRefreshButtonLabel
autoRefreshIntervalMs={30000}
lastRefreshAllAt={Date.now()}
refreshingAll={false}
tr={tr}
/>
);
});
expect(container.textContent).toContain("Auto-refreshing");
expect(renderWidget(30)).toContain("Auto-refreshing");
});
});

View File

@@ -3,12 +3,32 @@ import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("next-intl", () => ({
useTranslations: (namespace?: string) => (key: string) =>
namespace === "cache"
? ({ cachedTokensCol: "Cache Read", cacheCreation: "Cache Write" }[key] ?? key)
: key,
}));
vi.mock("next-intl", () => {
const cacheLabels: Record<string, string> = {
cachedTokensCol: "Cache Read",
cacheCreation: "Cache Write",
};
const detailLabels: Record<string, string> = {
totalIn: "Total In: {value}",
cacheRead: "Cache Read: {value}",
cacheWrite: "Cache Write: {value}",
compressed: "Compressed: {percent}%",
totalOut: "Total Out: {value}",
reasoning: "Reasoning: {value}",
notAvailable: "N/A",
};
const interpolate = (template: string, params: Record<string, string> = {}) =>
template.replace(/\{(\w+)\}/g, (_, k) => (k in params ? String(params[k]) : `{${k}}`));
return {
useLocale: () => "en",
useTranslations: (namespace?: string) => (key: string, params?: Record<string, string>) =>
namespace === "cache"
? interpolate(cacheLabels[key] ?? key, params)
: namespace === "requestLogger.detail"
? interpolate(detailLabels[key] ?? key, params)
: interpolate(key, params),
};
});
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), replace: vi.fn(), refresh: vi.fn() }),

View File

@@ -13,7 +13,13 @@ const routerControl = vi.hoisted(() => ({
}));
vi.mock("next-intl", () => ({
useTranslations: () => (key: string) => key,
useLocale: () => "en",
useTranslations: (namespace?: string) => (key: string) => {
if (namespace === "requestLogger.detail") {
return { ariaLabel: "Request log detail", close: "Close detail modal" }[key] ?? key;
}
return key;
},
}));
vi.mock("next/navigation", () => ({
@@ -285,7 +291,7 @@ afterEach(async () => {
});
describe("request-log position preservation (#9154)", () => {
it("opens an older row without changing the loaded, filtered, sorted, or scrolled view", async () => {
it("opens an older row without changing the loaded, filtered, sorted, or scrolled view", { timeout: 30000 }, async () => {
const scrollContainer = await renderExpandedView();
await openOlderRow();
@@ -319,6 +325,7 @@ describe("request-log position preservation (#9154)", () => {
],
])(
"closes through %s without changing the loaded, filtered, sorted, or scrolled view",
{ timeout: 30000 },
async (_name, close) => {
const scrollContainer = await renderExpandedView();
await openOlderRow();
@@ -342,7 +349,7 @@ describe("request-log position preservation (#9154)", () => {
}
);
it("opens a direct id deep link on mount", async () => {
it("opens a direct id deep link on mount", { timeout: 30000 }, async () => {
window.history.replaceState(null, "", "/dashboard/logs?tenant=kept&id=log-080");
await act(async () => {
@@ -353,7 +360,7 @@ describe("request-log position preservation (#9154)", () => {
expect(container.querySelector('[aria-label="Request log detail"]')).not.toBeNull();
});
it("does not reopen a closed modal when its stale detail request completes", async () => {
it("does not reopen a closed modal when its stale detail request completes", { timeout: 30000 }, async () => {
deferredDetail = createDeferredDetail("log-000");
window.history.replaceState(null, "", "/dashboard/logs?tenant=kept");

View File

@@ -21,6 +21,18 @@ function makeContainer(): HTMLElement {
return container;
}
const mockServerState = {
running: false,
port: 443,
certTrusted: false,
upstreamCa: null,
lastStartedAt: null,
activeConns: 0,
interceptedCount: 0,
dnsConfigured: false,
orphanedStateDetected: false,
};
const mockTarget = {
id: "kiro" as const,
name: "Kiro",
@@ -64,8 +76,11 @@ describe("SetupWizard", { timeout: 30000 }, () => {
target: mockTarget,
agentState: undefined,
serverRunning: false,
serverState: mockServerState,
currentMappings: [],
onClose: vi.fn(),
onDnsToggle: vi.fn(),
onMappingsSave: vi.fn(),
})
);
});
@@ -88,8 +103,11 @@ describe("SetupWizard", { timeout: 30000 }, () => {
target: mockTarget,
agentState: undefined,
serverRunning: true,
serverState: mockServerState,
currentMappings: [],
onClose: vi.fn(),
onDnsToggle: vi.fn(),
onMappingsSave: vi.fn(),
})
);
});
@@ -128,6 +146,8 @@ describe("SetupWizard", { timeout: 30000 }, () => {
last_error: null,
},
serverRunning: true,
serverState: mockServerState,
currentMappings: [],
onClose: vi.fn(),
onDnsToggle,
})
@@ -169,8 +189,11 @@ describe("SetupWizard", { timeout: 30000 }, () => {
target: mockTarget,
agentState: undefined,
serverRunning: false,
serverState: mockServerState,
currentMappings: [],
onClose,
onDnsToggle: vi.fn(),
onMappingsSave: vi.fn(),
})
);
});

View File

@@ -74,7 +74,7 @@ function installFetchMock(
const method = (init?.method || "GET").toUpperCase();
calls.push({ url, method });
if (url === "/api/providers" && method === "GET") {
if ((url === "/api/providers" || url.startsWith("/api/providers?")) && method === "GET") {
return jsonResponse(200, { connections: connectionsFixture });
}
if (url === "/api/provider-nodes" && method === "GET") {
@@ -89,6 +89,17 @@ function installFetchMock(
return { fn, calls };
}
// The hook's module tree is heavy — importing it inside the first test's body
// takes several seconds on a slow/loaded host, blowing vitest's default 5s
// per-test timeout and making this file flaky (the first test would time out,
// leaving a stale fetch stub that pollutes its siblings). Import it once at
// module scope so the cost is paid during collection, not inside a test.
// (Static import can't be used: the hook must load only AFTER the mocks above
// are registered, and top-level `await import` guarantees that ordering.)
const { useProviderConnections } =
await import("@/app/(dashboard)/dashboard/providers/[id]/hooks/useProviderConnections");
type HookResult = ReturnType<typeof useProviderConnections>;
describe("useProviderConnections — handleRefreshToken Cursor branching (Task 6)", () => {
let container: HTMLElement;
let root: ReturnType<typeof createRoot>;
@@ -115,9 +126,6 @@ describe("useProviderConnections — handleRefreshToken Cursor branching (Task 6
});
async function mountHook(providerId: string) {
const { useProviderConnections } =
await import("@/app/(dashboard)/dashboard/providers/[id]/hooks/useProviderConnections");
type HookResult = ReturnType<typeof useProviderConnections>;
let result: HookResult | null = null;
function TestWrapper() {
@@ -187,7 +195,7 @@ describe("useProviderConnections — handleRefreshToken Cursor branching (Task 6
});
const getResult = await mountHook("cursor");
const getCallsBefore = calls.filter(
(c) => c.url === "/api/providers" && c.method === "GET"
(c) => (c.url === "/api/providers" || c.url.startsWith("/api/providers?")) && c.method === "GET"
).length;
await act(async () => {
@@ -198,7 +206,7 @@ describe("useProviderConnections — handleRefreshToken Cursor branching (Task 6
expect(notify.info).not.toHaveBeenCalled();
expect(notify.error).not.toHaveBeenCalled();
const getCallsAfter = calls.filter(
(c) => c.url === "/api/providers" && c.method === "GET"
(c) => (c.url === "/api/providers" || c.url.startsWith("/api/providers?")) && c.method === "GET"
).length;
expect(getCallsAfter).toBeGreaterThan(getCallsBefore); // fetchConnections() re-ran
});
@@ -216,7 +224,7 @@ describe("useProviderConnections — handleRefreshToken Cursor branching (Task 6
});
const getResult = await mountHook("cursor");
const getCallsBefore = calls.filter(
(c) => c.url === "/api/providers" && c.method === "GET"
(c) => (c.url === "/api/providers" || c.url.startsWith("/api/providers?")) && c.method === "GET"
).length;
await act(async () => {
@@ -227,7 +235,7 @@ describe("useProviderConnections — handleRefreshToken Cursor branching (Task 6
expect(notify.success).not.toHaveBeenCalled();
expect(notify.error).not.toHaveBeenCalled();
const getCallsAfter = calls.filter(
(c) => c.url === "/api/providers" && c.method === "GET"
(c) => (c.url === "/api/providers" || c.url.startsWith("/api/providers?")) && c.method === "GET"
).length;
expect(getCallsAfter).toBe(getCallsBefore); // fetchConnections() must NOT re-run
});
@@ -242,7 +250,7 @@ describe("useProviderConnections — handleRefreshToken Cursor branching (Task 6
});
const getResult = await mountHook("cursor");
const getCallsBefore = calls.filter(
(c) => c.url === "/api/providers" && c.method === "GET"
(c) => (c.url === "/api/providers" || c.url.startsWith("/api/providers?")) && c.method === "GET"
).length;
await act(async () => {
@@ -255,7 +263,7 @@ describe("useProviderConnections — handleRefreshToken Cursor branching (Task 6
expect(notify.success).not.toHaveBeenCalled();
expect(notify.info).not.toHaveBeenCalled();
const getCallsAfter = calls.filter(
(c) => c.url === "/api/providers" && c.method === "GET"
(c) => (c.url === "/api/providers" || c.url.startsWith("/api/providers?")) && c.method === "GET"
).length;
expect(getCallsAfter).toBe(getCallsBefore); // fetchConnections() must NOT re-run
});