diff --git a/Makefile b/Makefile index 0d9a8c661a..f4b9cf7ca3 100644 --- a/Makefile +++ b/Makefile @@ -406,7 +406,7 @@ test-integration: node_modules/.installed build-main ## Run all tests (unit + in test-unit: node_modules/.installed build-main ## Run unit tests @bun test src - @bun test ./tests/ui/storybook/ + @bun test ./tests/ui/storybook/ ./tests/ui/domIsolation.test.ts test: test-unit ## Alias for test-unit diff --git a/jest.config.js b/jest.config.js index aa01f2d591..e006aa666c 100644 --- a/jest.config.js +++ b/jest.config.js @@ -29,8 +29,9 @@ module.exports = { "\\.txt$": "/tests/__mocks__/textMock.js", "\\.svg$": "/tests/__mocks__/svgMock.js", }, - // Storybook UI tests use bun:test and are run via `bun test`, so Jest must skip them. - testPathIgnorePatterns: ["/tests/ui/storybook/"], + // Storybook UI tests and the DOM-harness isolation guards use bun:test and + // are run via `bun test`, so Jest must skip them. + testPathIgnorePatterns: ["/tests/ui/storybook/", "/tests/ui/domIsolation\\.test\\.ts"], // Avoid haste module collision with vscode extension modulePathIgnorePatterns: ["/vscode/"], transform: { diff --git a/src/browser/components/ProjectDeleteConfirmationModal/__tests__/ProjectDeleteConfirmationModal.test.tsx b/src/browser/components/ProjectDeleteConfirmationModal/__tests__/ProjectDeleteConfirmationModal.test.tsx index 15b958d493..1479f3dda7 100644 --- a/src/browser/components/ProjectDeleteConfirmationModal/__tests__/ProjectDeleteConfirmationModal.test.tsx +++ b/src/browser/components/ProjectDeleteConfirmationModal/__tests__/ProjectDeleteConfirmationModal.test.tsx @@ -5,6 +5,10 @@ import { cleanup, fireEvent, render } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import type { ComponentProps, KeyboardEvent, ReactNode } from "react"; import { installDom } from "../../../../../tests/ui/dom"; +import { restoreModulesAfterSuite } from "../../../../../tests/ui/moduleMocks"; +import * as RealDialogModule from "@/browser/components/Dialog/Dialog"; + +restoreModulesAfterSuite([["@/browser/components/Dialog/Dialog", { ...RealDialogModule }]]); void mock.module("@/browser/components/Dialog/Dialog", () => ({ Dialog: (props: { diff --git a/src/browser/components/ProjectPage/ProjectPage.autofocus.test.tsx b/src/browser/components/ProjectPage/ProjectPage.autofocus.test.tsx index f9ee1534c8..7f89ac3dba 100644 --- a/src/browser/components/ProjectPage/ProjectPage.autofocus.test.tsx +++ b/src/browser/components/ProjectPage/ProjectPage.autofocus.test.tsx @@ -5,6 +5,13 @@ import { RouterProvider } from "@/browser/contexts/RouterContext"; import { SettingsProvider } from "@/browser/contexts/SettingsContext"; import { cleanup, render, waitFor } from "@testing-library/react"; import { installDom } from "../../../../tests/ui/dom"; +import { restoreModulesAfterSuite } from "../../../../tests/ui/moduleMocks"; +import * as RealLottieModule from "lottie-react"; +import * as RealAPIModule from "@/browser/contexts/API"; +import * as RealProvidersConfigModule from "@/browser/hooks/useProvidersConfig"; +import * as RealConfiguredProvidersBarModule from "@/browser/components/ConfiguredProvidersBar/ConfiguredProvidersBar"; +import * as RealProjectContextModule from "@/browser/contexts/ProjectContext"; +import * as RealChatInputModule from "@/browser/features/ChatInput/index"; import type * as ProjectPageModule from "@/browser/components/ProjectPage/ProjectPage"; import type * as WorkspaceContextModule from "@/browser/contexts/WorkspaceContext"; @@ -12,12 +19,23 @@ let cleanupDom: (() => void) | null = null; let focusMock: ReturnType | null = null; let readyCalls = 0; +restoreModulesAfterSuite([ + ["lottie-react", { ...RealLottieModule }], + ["@/browser/contexts/API", { ...RealAPIModule }], + ["@/browser/hooks/useProvidersConfig", { ...RealProvidersConfigModule }], + [ + "@/browser/components/ConfiguredProvidersBar/ConfiguredProvidersBar", + { ...RealConfiguredProvidersBarModule }, + ], + ["@/browser/contexts/ProjectContext", { ...RealProjectContextModule }], + ["@/browser/features/ChatInput/index", { ...RealChatInputModule }], +]); + function registerProjectPageMocks() { // Re-register mocks before each test because afterEach restores them and this // file should not depend on top-level module mock state leaking across tests. - // Mock lottie-react so CreationCenterContent/WorkspaceShell imports don't execute - // lottie-web canvas initialization in happy-dom (which causes unhandled errors). + // Mock lottie-react so tests don't run lottie-web animation internals in happy-dom. void mock.module("lottie-react", () => ({ __esModule: true, default: () =>
, diff --git a/src/browser/components/ProjectSidebar/TaskGroupListItem.test.tsx b/src/browser/components/ProjectSidebar/TaskGroupListItem.test.tsx index 4da999fbbe..829b085047 100644 --- a/src/browser/components/ProjectSidebar/TaskGroupListItem.test.tsx +++ b/src/browser/components/ProjectSidebar/TaskGroupListItem.test.tsx @@ -3,7 +3,34 @@ import "../../../../tests/ui/dom"; import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; import { cleanup, fireEvent, render } from "@testing-library/react"; import { installDom } from "../../../../tests/ui/dom"; -import { TaskGroupListItem } from "./TaskGroupListItem"; +import { restoreModulesAfterSuite } from "../../../../tests/ui/moduleMocks"; +import * as RealPositionedMenuModule from "../PositionedMenu/PositionedMenu"; +import type { TaskGroupListItem as TaskGroupListItemComponent } from "./TaskGroupListItem"; + +// Radix portal content is unreliable in happy-dom (see AGENTS.md), so render +// the menu inline. The row's shortcut handling under test only needs menu-item +// events to bubble through the React tree, which the inline stub preserves. +void mock.module("@/browser/components/PositionedMenu/PositionedMenu", () => ({ + PositionedMenu: (props: { open: boolean; children: React.ReactNode }) => + props.open ?
{props.children}
: null, + PositionedMenuItem: (props: { + label: string; + onClick: (event: React.MouseEvent) => void; + }) => ( + + ), +})); +restoreModulesAfterSuite([ + ["@/browser/components/PositionedMenu/PositionedMenu", { ...RealPositionedMenuModule }], +]); + +/* eslint-disable @typescript-eslint/no-require-imports */ +const { TaskGroupListItem } = require("./TaskGroupListItem") as { + TaskGroupListItem: typeof TaskGroupListItemComponent; +}; +/* eslint-enable @typescript-eslint/no-require-imports */ function renderTaskGroup(overrides: Partial> = {}) { return render( diff --git a/src/browser/components/ScratchPage/ScratchPage.stories.tsx b/src/browser/components/ScratchPage/ScratchPage.stories.tsx new file mode 100644 index 0000000000..782ccc63ba --- /dev/null +++ b/src/browser/components/ScratchPage/ScratchPage.stories.tsx @@ -0,0 +1,87 @@ +import { userEvent, waitFor } from "@storybook/test"; + +import { appMeta, AppWithMocks, type AppStory } from "@/browser/stories/meta.js"; +import { createMockORPCClient } from "@/browser/stories/mocks/orpc"; +import { updatePersistedState } from "@/browser/hooks/usePersistedState"; +import { LEFT_SIDEBAR_COLLAPSED_KEY } from "@/common/constants/storage"; + +// Integration: stories render the full app so the sidebar's "New scratch chat" +// entry can navigate to the real scratch creation route. +export default { + ...appMeta, + title: "Components/ScratchPage", +}; + +/** + * With multiple projects configured, the scratch header must show "Scratch" + * as the current scope and offer the project switcher: on mobile the sidebar + * auto-collapses after navigation and is otherwise the only way to reach a + * project's creation page. + */ +export const ScratchCreationWithProjects: AppStory = { + // Mirrors the Pixel phone variant so local viewing reproduces the mobile flow + // the story covers; the test-runner ignores globals and plays at desktop width. + globals: { + viewport: { value: "mobile1", isRotated: false }, + }, + parameters: { + pixel: { + matrix: { themes: ["dark", "light"], viewports: ["laptop", "phone"] }, + }, + }, + render: () => ( + { + // Start expanded so the play function can click the sidebar entry + // even in mobile viewport modes. + updatePersistedState(LEFT_SIDEBAR_COLLAPSED_KEY, false); + return createMockORPCClient({ + projects: new Map([ + ["/Users/dev/frontend-app", { workspaces: [] }], + ["/Users/dev/backend-api", { workspaces: [] }], + ]), + workspaces: [], + }); + }} + /> + ), + play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { + const storyRoot = document.getElementById("storybook-root") ?? canvasElement; + + try { + const newScratchButton = await waitFor( + () => { + // Guard: a previous story's play-function may have left the sidebar + // collapsed via localStorage. + if (document.documentElement.dataset.leftSidebarCollapsed === "true") { + const expandBtn = storyRoot.querySelector("[aria-label='Expand sidebar']"); + if (expandBtn) expandBtn.click(); + throw new Error("Sidebar collapsed: expanding"); + } + const el = storyRoot.querySelector("[aria-label='New scratch chat']"); + if (!el) throw new Error("New scratch chat button not found"); + return el; + }, + { timeout: 10_000 } + ); + await userEvent.click(newScratchButton); + + await waitFor( + () => { + const group = storyRoot.querySelector("[data-component='ScratchProjectGroup']"); + if (!group) throw new Error("Scratch project header not found"); + const selector = group.querySelector("[data-testid='project-selector']"); + if (!selector) throw new Error("Project switcher not found on scratch page"); + if (!(selector.textContent ?? "").includes("Scratch")) { + throw new Error("Project switcher does not show the Scratch scope"); + } + }, + { timeout: 10_000 } + ); + } finally { + // Remove the sidebar-state override so later stories start from the + // default expanded-on-desktop state even if assertions fail. + updatePersistedState(LEFT_SIDEBAR_COLLAPSED_KEY, null); + } + }, +}; diff --git a/src/browser/components/SshPromptDialog/SshPromptDialog.test.tsx b/src/browser/components/SshPromptDialog/SshPromptDialog.test.tsx index eb94320112..376233179e 100644 --- a/src/browser/components/SshPromptDialog/SshPromptDialog.test.tsx +++ b/src/browser/components/SshPromptDialog/SshPromptDialog.test.tsx @@ -9,6 +9,10 @@ import { } from "@/browser/testUtils"; import type { ReactNode } from "react"; import { installDom } from "../../../../tests/ui/dom"; +import { restoreModulesAfterSuite } from "../../../../tests/ui/moduleMocks"; +import * as RealDialogModule from "@/browser/components/Dialog/Dialog"; + +restoreModulesAfterSuite([["@/browser/components/Dialog/Dialog", { ...RealDialogModule }]]); // Self-contained dialog stub — bun's mock.module is process-global, so other // test files may register incomplete Dialog stubs that omit diff --git a/src/browser/features/Analytics/SavedQuerySqlDialog.test.tsx b/src/browser/features/Analytics/SavedQuerySqlDialog.test.tsx index fba134193c..25d20bbc36 100644 --- a/src/browser/features/Analytics/SavedQuerySqlDialog.test.tsx +++ b/src/browser/features/Analytics/SavedQuerySqlDialog.test.tsx @@ -2,6 +2,10 @@ import type { ReactNode } from "react"; import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; import { GlobalWindow } from "happy-dom"; import { cleanup, fireEvent, render } from "@testing-library/react"; +import { restoreModulesAfterSuite } from "../../../../tests/ui/moduleMocks"; +import * as RealDialogModule from "@/browser/components/Dialog/Dialog"; + +restoreModulesAfterSuite([["@/browser/components/Dialog/Dialog", { ...RealDialogModule }]]); void mock.module("@/browser/components/Dialog/Dialog", () => ({ Dialog: (props: { open: boolean; children: ReactNode }) => diff --git a/src/browser/features/ChatInput/CreationControls.tsx b/src/browser/features/ChatInput/CreationControls.tsx index dafa0b1ed7..3b51bfc706 100644 --- a/src/browser/features/ChatInput/CreationControls.tsx +++ b/src/browser/features/ChatInput/CreationControls.tsx @@ -23,6 +23,7 @@ import { import { GitBranch, Loader2, Wand2 } from "lucide-react"; import type { ProjectConfig } from "@/common/types/project"; import { formatProjectHierarchyLabel } from "@/common/utils/subProjects"; +import { CreationProjectSelect } from "./CreationProjectSelect"; import { RuntimeConfigInput } from "@/browser/components/RuntimeConfigInput/RuntimeConfigInput"; import { usePerfRenderMarker } from "@/browser/utils/perf/PerfRenderMarker"; import { cn } from "@/common/lib/utils"; @@ -741,43 +742,16 @@ function CreationControlsContent(props: CreationControlsProps) { // selecting "gbot/bbot" would show "gbot" because props.projectPath // is normalized to the owning parent for runtime/config scoping. const selected = props.selectedProjectPath ?? props.projectPath; - const selectedLabel = formatProjectHierarchyLabel(selected, props.userProjects); - return props.userProjects.size > 1 ? ( - - - - - {/* - * Render the hierarchy label as the explicit child instead of - * relying on Radix's mirror of the matched - * text. This keeps the trigger label in sync - * with the SelectItem labels (which also use the hierarchy - * label) and avoids fallbacks to bare basenames. - */} - {selectedLabel} - - - {selected} - - - {Array.from(props.userProjects.keys()).map((path) => ( - - {formatProjectHierarchyLabel(path, props.userProjects)} - - ))} - - - ) : ( - - -

{selectedLabel}

-
- {selected} -
+ return ( + ({ + value: path, + label: formatProjectHierarchyLabel(path, props.userProjects), + }))} + onChange={props.onSelectedProjectPathChange} + /> ); })()} / diff --git a/src/browser/features/ChatInput/CreationProjectSelect.tsx b/src/browser/features/ChatInput/CreationProjectSelect.tsx new file mode 100644 index 0000000000..89359a01aa --- /dev/null +++ b/src/browser/features/ChatInput/CreationProjectSelect.tsx @@ -0,0 +1,61 @@ +import { + Select as RadixSelect, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/browser/components/SelectPrimitive/SelectPrimitive"; +import { Tooltip, TooltipTrigger, TooltipContent } from "@/browser/components/Tooltip/Tooltip"; + +interface CreationProjectSelectProps { + selected: string; + selectedLabel: string; + tooltip?: string; + options: Array<{ value: string; label: string }>; + onChange: (value: string) => void; +} + +/** + * Current-scope heading for creation composers: a project switcher when there + * is more than one choice, otherwise a static heading. Shared by the project + * creation header (CreationControls) and the scratch creation header. + */ +export function CreationProjectSelect(props: CreationProjectSelectProps) { + const tooltip = props.tooltip ?? props.selected; + if (props.options.length <= 1) { + return ( + + +

{props.selectedLabel}

+
+ {tooltip} +
+ ); + } + return ( + + + + + {/* Explicit child instead of Radix's mirror of the + matched text, so unmatched values still render + the caller's label rather than falling back to nothing. */} + {props.selectedLabel} + + + {tooltip} + + + {props.options.map((option) => ( + + {option.label} + + ))} + + + ); +} diff --git a/src/browser/features/ChatInput/index.tsx b/src/browser/features/ChatInput/index.tsx index fb79e86ece..6073cf69ec 100644 --- a/src/browser/features/ChatInput/index.tsx +++ b/src/browser/features/ChatInput/index.tsx @@ -97,7 +97,12 @@ import { getInlineSkillSuggestions, shouldRefreshInlineSkillSuggestions, } from "@/browser/utils/agentSkills/inlineSkillSuggestions"; -import { resolveWorkspaceCreationScope } from "@/common/utils/subProjects"; +import { + formatProjectHierarchyLabel, + resolveWorkspaceCreationScope, +} from "@/common/utils/subProjects"; +import { SCRATCH_PROJECT_CONFIG_KEY, SCRATCH_PROJECT_NAME } from "@/common/constants/scratch"; +import { CreationProjectSelect } from "./CreationProjectSelect"; import { getCommandGhostHint } from "@/browser/utils/slashCommands/registry"; import { getSlashCommandSuggestions, @@ -3301,6 +3306,32 @@ const ChatInputInner: React.FC = (props) => { {/* Creation header controls - shown above textarea for creation variant */} {creationControlsProps && } + {/* Scratch chats have no CreationControls, but mobile users still + need the current scope visible and a way to reach a project's + creation page: on narrow viewports the sidebar auto-collapses + after opening the scratch page from it. */} + {variant === "creation" && props.kind === "scratch" && ( +
+ ({ + value: path, + label: formatProjectHierarchyLabel(path, userProjects), + })), + ]} + onChange={(path) => { + if (path !== SCRATCH_PROJECT_CONFIG_KEY) { + beginWorkspaceCreation(path); + } + }} + /> +
+ )} + | null = null; +restoreModulesAfterSuite([ + ["@/browser/contexts/API", { ...RealAPIModule }], + ["@/browser/hooks/useExperiments", { ...RealExperimentsModule }], + ["@/browser/components/Dialog/Dialog", { ...RealDialogModule }], +]); + void mock.module("@/browser/contexts/API", () => ({ APIContext: createContext(null), useAPI: () => ({ diff --git a/src/browser/features/RightSidebar/PlanFileDialog.test.tsx b/src/browser/features/RightSidebar/PlanFileDialog.test.tsx index 2aec74e286..ef8705eb3e 100644 --- a/src/browser/features/RightSidebar/PlanFileDialog.test.tsx +++ b/src/browser/features/RightSidebar/PlanFileDialog.test.tsx @@ -2,6 +2,8 @@ import type { ReactNode } from "react"; import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; import { GlobalWindow } from "happy-dom"; import { cleanup, render, waitFor } from "@testing-library/react"; +import { restoreModulesAfterSuite } from "../../../../tests/ui/moduleMocks"; +import * as RealDialogModule from "@/browser/components/Dialog/Dialog"; type GetPlanContentResult = | { success: true; data: { content: string; path: string } } @@ -15,6 +17,8 @@ interface MockApiClient { let mockApi: MockApiClient | null = null; +restoreModulesAfterSuite([["@/browser/components/Dialog/Dialog", { ...RealDialogModule }]]); + void mock.module("@/browser/components/Dialog/Dialog", () => ({ Dialog: (props: { open: boolean; children: ReactNode }) => props.open ?
{props.children}
: null, diff --git a/src/browser/features/Tools/WorkflowRunToolCall.test.tsx b/src/browser/features/Tools/WorkflowRunToolCall.test.tsx index 434f3b9842..a3f2668943 100644 --- a/src/browser/features/Tools/WorkflowRunToolCall.test.tsx +++ b/src/browser/features/Tools/WorkflowRunToolCall.test.tsx @@ -2,6 +2,8 @@ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; import { GlobalWindow } from "happy-dom"; import { cleanup, fireEvent, render, waitFor } from "@testing-library/react"; +import { restoreModulesAfterSuite } from "../../../../tests/ui/moduleMocks"; +import * as RealDialogModule from "@/browser/components/Dialog/Dialog"; import { cloneElement, createContext, @@ -55,6 +57,8 @@ interface MockDialogTriggerChildProps { "aria-haspopup"?: "dialog"; } +restoreModulesAfterSuite([["@/browser/components/Dialog/Dialog", { ...RealDialogModule }]]); + void mock.module("@/browser/components/Dialog/Dialog", () => ({ Dialog: (props: { open: boolean; diff --git a/tests/ui/dom.ts b/tests/ui/dom.ts index 60a8f05b5b..ca287596c6 100644 --- a/tests/ui/dom.ts +++ b/tests/ui/dom.ts @@ -225,6 +225,13 @@ export function installDom(): () => void { previous.IntersectionObserver; (globalThis as unknown as { ResizeObserver?: unknown }).ResizeObserver = previous.ResizeObserver; + + // Self-heal: snapshots taken after a `document = undefined` teardown would + // restore that poisoned state here, breaking modules that detect DOM + // support at eval time (e.g. @react-dnd/asap). Keep a baseline DOM alive. + if (typeof globalThis.document === "undefined" || typeof globalThis.window === "undefined") { + installDom(); + } }; } @@ -243,3 +250,9 @@ export function installDom(): () => void { if (typeof globalThis.document === "undefined") { installDom(); } + +// Require (not statically import) react-dnd after the DOM bootstrap because +// @react-dnd/asap reads `document` while constructing its scheduler during +// module evaluation. +// eslint-disable-next-line @typescript-eslint/no-require-imports +require("react-dnd"); diff --git a/tests/ui/domIsolation.test.ts b/tests/ui/domIsolation.test.ts new file mode 100644 index 0000000000..15113fd064 --- /dev/null +++ b/tests/ui/domIsolation.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, test } from "bun:test"; + +import { installDom } from "./dom"; + +// Pins the harness invariant that a poisoned teardown (a test setting +// globalThis.document = undefined) cannot propagate past a file boundary. +describe("dom harness file-boundary isolation", () => { + test("uninstall never leaves document undefined once a baseline exists", () => { + globalThis.document = undefined as unknown as Document; + globalThis.window = undefined as unknown as Window & typeof globalThis; + + const uninstall = installDom(); + uninstall(); + + expect(typeof globalThis.document).not.toBe("undefined"); + expect(typeof globalThis.window).not.toBe("undefined"); + }); + + test("preloads react-dnd before a poisoned boundary", () => { + const savedDocument = globalThis.document; + globalThis.document = undefined as unknown as Document; + try { + // Guards the eager preload in dom.ts: without it, this require would be + // @react-dnd/asap's first evaluation and would crash on document access. + // eslint-disable-next-line @typescript-eslint/no-require-imports + expect(() => require("react-dnd")).not.toThrow(); + } finally { + globalThis.document = savedDocument; + } + }); +}); diff --git a/tests/ui/moduleMocks.ts b/tests/ui/moduleMocks.ts new file mode 100644 index 0000000000..36e09f8896 --- /dev/null +++ b/tests/ui/moduleMocks.ts @@ -0,0 +1,28 @@ +import { afterAll, mock } from "bun:test"; + +/** + * bun test shares mock.module registrations across suites in the same run, so + * a file-scope stub leaks into every later test file (a leaked closed-Dialog + * stub renders nothing and empties later suites that mount dialog triggers). + * Call at file scope with the captured real exports to restore them once the + * registering suite finishes. + */ +export function restoreModulesAfterSuite( + entries: Array<[modulePath: string, realExports: Record]> +): void { + for (const [modulePath] of entries) { + // Relative paths resolve against THIS file, not the caller, so the restore + // would silently register a bogus virtual module and leave the real module + // mocked (which poisons later suites' mock.module registrations). + if (modulePath.startsWith(".")) { + throw new Error( + `restoreModulesAfterSuite: use an alias or package path instead of relative "${modulePath}"` + ); + } + } + afterAll(() => { + for (const [modulePath, exports] of entries) { + void mock.module(modulePath, () => exports); + } + }); +}