Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
166 changes: 101 additions & 65 deletions src/browser/features/Tools/ProposePlanToolCall.test.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,15 @@
import type { ComponentProps } from "react";
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
import { afterAll, afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
import { installDom } from "../../../../tests/ui/dom";
import { cleanup, fireEvent, render, waitFor } from "@testing-library/react";

import * as APIModule from "@/browser/contexts/API";
import * as WorkspaceContextModule from "@/browser/contexts/WorkspaceContext";
import * as UseOpenInEditorModule from "@/browser/hooks/useOpenInEditor";
import * as UseReviewsModule from "@/browser/hooks/useReviews";
import * as UseStartHereModule from "@/browser/hooks/useStartHere";
import * as DiffRendererModule from "@/browser/features/Shared/DiffRenderer";
import * as ReviewTypesModule from "@/common/types/review";
import type { SendMessageOptions } from "@/common/orpc/types";
import type { AgentDefinitionDescriptor } from "@/common/types/agentDefinition";
import { AgentProvider } from "@/browser/contexts/AgentContext";
Expand Down Expand Up @@ -87,70 +94,93 @@ const useStartHereMock = mock(
}
);

void mock.module("@/browser/hooks/useStartHere", () => ({
useStartHere: useStartHereMock,
}));

void mock.module("@/browser/contexts/API", () => ({
useAPI: () => ({ api: mockApi, status: "connected" as const, error: null }),
}));

void mock.module("@/browser/hooks/useOpenInEditor", () => ({
useOpenInEditor: () => () => Promise.resolve({ success: true } as const),
}));

void mock.module("@/browser/contexts/WorkspaceContext", () => ({
useWorkspaceContext: () => ({
workspaceMetadata: new Map<string, { runtimeConfig?: unknown }>(),
}),
}));

void mock.module("@/browser/hooks/useReviews", () => ({
useReviews: () => ({
reviews: [],
pendingCount: 0,
attachedCount: 0,
checkedCount: 0,
attachedReviews: [],
addReview: (data: unknown) => ({
id: "test-review",
data,
status: "attached" as const,
createdAt: Date.now(),
const actualUseStartHereModule = { ...UseStartHereModule };
const actualAPIModule = { ...APIModule };
const actualUseOpenInEditorModule = { ...UseOpenInEditorModule };
const actualWorkspaceContextModule = { ...WorkspaceContextModule };
const actualUseReviewsModule = { ...UseReviewsModule };
const actualDiffRendererModule = { ...DiffRendererModule };
const actualReviewTypesModule = { ...ReviewTypesModule };

async function installProposePlanModuleMocks() {
await mock.module("@/browser/hooks/useStartHere", () => ({
...actualUseStartHereModule,
useStartHere: useStartHereMock,
}));
await mock.module("@/browser/contexts/API", () => ({
...actualAPIModule,
useAPI: () => ({ api: mockApi, status: "connected" as const, error: null }),
}));
await mock.module("@/browser/hooks/useOpenInEditor", () => ({
...actualUseOpenInEditorModule,
useOpenInEditor: () => () => Promise.resolve({ success: true } as const),
}));
await mock.module("@/browser/contexts/WorkspaceContext", () => ({
...actualWorkspaceContextModule,
useWorkspaceContext: () => ({
workspaceMetadata: new Map<string, { runtimeConfig?: unknown }>(),
}),
attachReview: () => undefined,
detachReview: () => undefined,
attachAllPending: () => undefined,
detachAllAttached: () => undefined,
checkReview: () => undefined,
uncheckReview: () => undefined,
removeReview: () => undefined,
updateReviewNote: () => undefined,
clearChecked: () => undefined,
clearAll: () => undefined,
getReview: () => undefined,
}),
}));

void mock.module("@/browser/features/Shared/DiffRenderer", () => ({
SelectableDiffRenderer: (props: { filePath?: string }) => {
selectableDiffRendererCalls.push({ filePath: props.filePath });
return <div data-testid="selectable-diff-renderer" data-filepath={props.filePath ?? ""} />;
},
}));

void mock.module("@/common/types/review", () => ({
isPlanFilePath: (filePath: string) => /[/\\]plans[/\\]/.test(filePath),
normalizePlanFilePath: (filePath: string) => {
const normalizedPath = filePath.replace(/\\/g, "/");
const tildeMuxMatch = /^~\/\.mux\/plans\/(.+)$/.exec(normalizedPath);
if (tildeMuxMatch?.[1]) {
return `.mux/plans/${tildeMuxMatch[1]}`;
}
}));
await mock.module("@/browser/hooks/useReviews", () => ({
...actualUseReviewsModule,
useReviews: () => ({
reviews: [],
pendingCount: 0,
attachedCount: 0,
checkedCount: 0,
attachedReviews: [],
addReview: (data: unknown) => ({
id: "test-review",
data,
status: "attached" as const,
createdAt: Date.now(),
}),
attachReview: () => undefined,
detachReview: () => undefined,
attachAllPending: () => undefined,
detachAllAttached: () => undefined,
checkReview: () => undefined,
uncheckReview: () => undefined,
removeReview: () => undefined,
updateReviewNote: () => undefined,
clearChecked: () => undefined,
clearAll: () => undefined,
getReview: () => undefined,
}),
}));
await mock.module("@/browser/features/Shared/DiffRenderer", () => ({
...actualDiffRendererModule,
SelectableDiffRenderer: (props: { filePath?: string }) => {
selectableDiffRendererCalls.push({ filePath: props.filePath });
return <div data-testid="selectable-diff-renderer" data-filepath={props.filePath ?? ""} />;
},
}));
await mock.module("@/common/types/review", () => ({
...actualReviewTypesModule,
isPlanFilePath: (filePath: string) => /[/\\]plans[/\\]/.test(filePath),
normalizePlanFilePath: (filePath: string) => {
const normalizedPath = filePath.replace(/\\/g, "/");
const tildeMuxMatch = /^~\/\.mux\/plans\/(.+)$/.exec(normalizedPath);
if (tildeMuxMatch?.[1]) {
return `.mux/plans/${tildeMuxMatch[1]}`;
}

return normalizedPath;
},
}));
}

return normalizedPath;
},
}));
async function restoreProposePlanModuleMocks() {
// Bun's mock.module() has no disposer, and mock.restore() does not undo module mocks.
// Restore real exports so this test's renderer stubs do not leak into review suites.
await mock.module("@/browser/hooks/useStartHere", () => actualUseStartHereModule);
await mock.module("@/browser/contexts/API", () => actualAPIModule);
await mock.module("@/browser/hooks/useOpenInEditor", () => actualUseOpenInEditorModule);
await mock.module("@/browser/contexts/WorkspaceContext", () => actualWorkspaceContextModule);
await mock.module("@/browser/hooks/useReviews", () => actualUseReviewsModule);
await mock.module("@/browser/features/Shared/DiffRenderer", () => actualDiffRendererModule);
await mock.module("@/common/types/review", () => actualReviewTypesModule);
}

const WORKSPACE_ID = "ws-123";
const PLAN_PATH = "~/.mux/plans/demo/ws-123.md";
Expand Down Expand Up @@ -278,15 +308,21 @@ function expectSingleQuoteRoot(view: { container: HTMLElement }, text: string) {
describe("ProposePlanToolCall", () => {
let cleanupDom: (() => void) | null = null;

beforeEach(() => {
afterAll(async () => {
await restoreProposePlanModuleMocks();
});

beforeEach(async () => {
startHereCalls = [];
selectableDiffRendererCalls = [];
mockApi = null;
cleanupDom = installDom();
await installProposePlanModuleMocks();
});

afterEach(() => {
afterEach(async () => {
cleanup();
await restoreProposePlanModuleMocks();
mock.restore();
cleanupDom?.();
cleanupDom = null;
Expand Down
130 changes: 69 additions & 61 deletions src/browser/hooks/useModelsFromSettings.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
import { afterAll, afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
import { act, cleanup, renderHook, waitFor } from "@testing-library/react";
import { GlobalWindow } from "happy-dom";
import * as APIModule from "@/browser/contexts/API";
import * as PolicyContextModule from "@/browser/contexts/PolicyContext";
import * as ProvidersConfigModule from "@/browser/hooks/useProvidersConfig";
import * as RoutingModule from "@/browser/hooks/useRouting";
import { installDom } from "../../../tests/ui/dom";
import {
filterHiddenModels,
getDefaultModel,
Expand Down Expand Up @@ -78,24 +82,66 @@ const useRoutingMock = mock(() => ({
},
}));

void mock.module("@/browser/hooks/useProvidersConfig", () => ({
useProvidersConfig: useProvidersConfigMock,
}));
const actualProvidersConfigModule = { ...ProvidersConfigModule };
const actualRoutingModule = { ...RoutingModule };
const actualAPIModule = { ...APIModule };
const actualPolicyContextModule = { ...PolicyContextModule };

async function installUseModelsModuleMocks() {
await mock.module("@/browser/hooks/useProvidersConfig", () => ({
...actualProvidersConfigModule,
useProvidersConfig: useProvidersConfigMock,
}));
await mock.module("@/browser/hooks/useRouting", () => ({
...actualRoutingModule,
useRouting: useRoutingMock,
}));
await mock.module("@/browser/contexts/API", () => ({
...actualAPIModule,
useAPI: () => ({ api: apiMock }),
}));
await mock.module("@/browser/contexts/PolicyContext", () => ({
...actualPolicyContextModule,
usePolicy: () => ({
status: { state: "disabled" as const },
policy: null,
}),
}));
}

void mock.module("@/browser/hooks/useRouting", () => ({
useRouting: useRoutingMock,
}));
async function restoreUseModelsModuleMocks() {
// Bun's mock.module() has no disposer, and mock.restore() does not undo module mocks.
// Restore the real modules so this file cannot poison later tests in the same process.
await mock.module("@/browser/hooks/useProvidersConfig", () => actualProvidersConfigModule);
await mock.module("@/browser/hooks/useRouting", () => actualRoutingModule);
await mock.module("@/browser/contexts/API", () => actualAPIModule);
await mock.module("@/browser/contexts/PolicyContext", () => actualPolicyContextModule);
}

void mock.module("@/browser/contexts/API", () => ({
useAPI: () => ({ api: apiMock }),
}));
let cleanupDom: (() => void) | null = null;

void mock.module("@/browser/contexts/PolicyContext", () => ({
usePolicy: () => ({
status: { state: "disabled" as const },
policy: null,
}),
}));
async function setupUseModelsHookTest() {
cleanupDom = installDom();
window.localStorage.clear();
providersConfig = null;
routePriority = ["direct"];
routeOverrides = {};
apiMock = null;
await installUseModelsModuleMocks();
}

async function cleanupUseModelsHookTest() {
cleanup();
await restoreUseModelsModuleMocks();
mock.restore();
cleanupDom?.();
cleanupDom = null;
apiMock = null;
}

afterAll(async () => {
await restoreUseModelsModuleMocks();
});

describe("getSuggestedModels", () => {
test("returns custom models first, then built-ins (deduped)", () => {
Expand Down Expand Up @@ -169,22 +215,8 @@ describe("filterHiddenModels", () => {
});

describe("useModelsFromSettings selected model preservation", () => {
beforeEach(() => {
globalThis.window = new GlobalWindow() as unknown as Window & typeof globalThis;
globalThis.document = globalThis.window.document;
globalThis.window.localStorage.clear();
providersConfig = null;
routePriority = ["direct"];
routeOverrides = {};
apiMock = null;
});

afterEach(() => {
cleanup();
globalThis.window = undefined as unknown as Window & typeof globalThis;
globalThis.document = undefined as unknown as Document;
apiMock = null;
});
beforeEach(setupUseModelsHookTest);
afterEach(cleanupUseModelsHookTest);

test("getDefaultModel preserves explicit gateway-scoped defaults", () => {
const gatewayModel = "openrouter:openai/gpt-5";
Expand Down Expand Up @@ -312,20 +344,8 @@ describe("useModelsFromSettings selected model preservation", () => {
});

describe("useModelsFromSettings OpenAI Codex OAuth gating", () => {
beforeEach(() => {
globalThis.window = new GlobalWindow() as unknown as Window & typeof globalThis;
globalThis.document = globalThis.window.document;
globalThis.window.localStorage.clear();
providersConfig = null;
routePriority = ["direct"];
routeOverrides = {};
});

afterEach(() => {
cleanup();
globalThis.window = undefined as unknown as Window & typeof globalThis;
globalThis.document = undefined as unknown as Document;
});
beforeEach(setupUseModelsHookTest);
afterEach(cleanupUseModelsHookTest);

test("codex oauth only: shows OAuth-routable OpenAI models and hides API-key-only ones", () => {
providersConfig = {
Expand Down Expand Up @@ -442,20 +462,8 @@ describe("useModelsFromSettings OpenAI Codex OAuth gating", () => {
});

describe("useModelsFromSettings provider availability gating", () => {
beforeEach(() => {
globalThis.window = new GlobalWindow() as unknown as Window & typeof globalThis;
globalThis.document = globalThis.window.document;
globalThis.window.localStorage.clear();
providersConfig = null;
routePriority = ["direct"];
routeOverrides = {};
});

afterEach(() => {
cleanup();
globalThis.window = undefined as unknown as Window & typeof globalThis;
globalThis.document = undefined as unknown as Document;
});
beforeEach(setupUseModelsHookTest);
afterEach(cleanupUseModelsHookTest);

test("hides models from unconfigured providers", () => {
providersConfig = {
Expand Down
14 changes: 12 additions & 2 deletions src/browser/hooks/useRouting.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,15 @@ const wrapper: React.FC<{ children: React.ReactNode }> = (props) =>
);

describe("useRouting", () => {
let previousWindow: typeof globalThis.window;
let previousDocument: typeof globalThis.document;
let testWindow: GlobalWindow | null = null;

beforeEach(() => {
globalThis.window = new GlobalWindow({ url: "https://mux.example.com/" }) as unknown as Window &
typeof globalThis;
previousWindow = globalThis.window;
previousDocument = globalThis.document;
testWindow = new GlobalWindow({ url: "https://mux.example.com/" });
globalThis.window = testWindow as unknown as Window & typeof globalThis;
globalThis.document = globalThis.window.document;
providersConfig = null;
routePriority = ["direct"];
Expand All @@ -62,6 +68,10 @@ describe("useRouting", () => {
afterEach(() => {
cleanup();
getProvidersConfigStore().setClient(null);
testWindow?.close();
testWindow = null;
globalThis.window = previousWindow;
globalThis.document = previousDocument;
});

test("resolveRoute and availableRoutes honor gateway model accessibility", async () => {
Expand Down
Loading
Loading