From 7c5d603876036b2e8469c724fc0360ced72db2f1 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Sat, 17 Oct 2026 02:44:21 -0700 Subject: [PATCH] test(web): settings tests run in a real browser Co-Authored-By: Claude Opus 5.5 --- .../src/components/device/DeviceSetup.test.ts | 95 ++++ .../IntegrationsSettings.browser.test.tsx | 102 ++++ .../settings/IntegrationsSettings.test.tsx | 207 -------- .../ProjectIconPickerDialog.browser.test.tsx | 100 ++++ .../settings/ProjectIconPickerDialog.test.tsx | 57 --- .../ProjectSettingsPanel.browser.test.tsx | 358 +++++++++++++ .../settings/ProjectSettingsPanel.test.tsx | 477 ------------------ .../ProviderInstanceCard.browser.test.tsx | 105 ++++ .../settings/ProviderInstanceCard.test.ts | 91 +--- .../settings/SecretInput.browser.test.tsx | 66 +++ .../components/settings/SecretInput.test.tsx | 63 --- .../TelemetryExportSettings.browser.test.tsx | 95 ++++ .../settings/TelemetryExportSettings.test.tsx | 72 --- .../settings/colorPickers.browser.test.tsx | 262 ++++++++++ .../components/settings/colorPickers.test.tsx | 275 ---------- .../settings/settingsLayout.browser.test.tsx | 68 +++ .../settings/settingsLayout.test.tsx | 97 ---- ...useProjectScriptSettings.browser.test.tsx} | 63 ++- 18 files changed, 1291 insertions(+), 1362 deletions(-) create mode 100644 apps/web/src/components/device/DeviceSetup.test.ts create mode 100644 apps/web/src/components/settings/IntegrationsSettings.browser.test.tsx delete mode 100644 apps/web/src/components/settings/IntegrationsSettings.test.tsx create mode 100644 apps/web/src/components/settings/ProjectIconPickerDialog.browser.test.tsx delete mode 100644 apps/web/src/components/settings/ProjectIconPickerDialog.test.tsx create mode 100644 apps/web/src/components/settings/ProjectSettingsPanel.browser.test.tsx delete mode 100644 apps/web/src/components/settings/ProjectSettingsPanel.test.tsx create mode 100644 apps/web/src/components/settings/ProviderInstanceCard.browser.test.tsx create mode 100644 apps/web/src/components/settings/SecretInput.browser.test.tsx delete mode 100644 apps/web/src/components/settings/SecretInput.test.tsx create mode 100644 apps/web/src/components/settings/TelemetryExportSettings.browser.test.tsx delete mode 100644 apps/web/src/components/settings/TelemetryExportSettings.test.tsx create mode 100644 apps/web/src/components/settings/colorPickers.browser.test.tsx delete mode 100644 apps/web/src/components/settings/colorPickers.test.tsx create mode 100644 apps/web/src/components/settings/settingsLayout.browser.test.tsx delete mode 100644 apps/web/src/components/settings/settingsLayout.test.tsx rename apps/web/src/components/settings/{useProjectScriptSettings.test.tsx => useProjectScriptSettings.browser.test.tsx} (67%) diff --git a/apps/web/src/components/device/DeviceSetup.test.ts b/apps/web/src/components/device/DeviceSetup.test.ts new file mode 100644 index 000000000000..3a17528e7ce7 --- /dev/null +++ b/apps/web/src/components/device/DeviceSetup.test.ts @@ -0,0 +1,95 @@ +import type { DeviceServiceState } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { deviceHubReadiness, platformSetupStatus } from "./DeviceSetup"; + +const deviceState = (overrides: Partial = {}): DeviceServiceState => ({ + hosts: [ + { + id: "local", + kind: "local", + label: "This machine", + hubInstalled: false, + agentDeviceInstalled: false, + platforms: [ + { platform: "ios", available: true }, + { platform: "android", available: true }, + ], + }, + ], + hostStatus: "ready", + hostStatuses: {}, + devices: [], + sessions: [], + onboardingCompleted: false, + agentAccessEnabled: false, + hubBasePath: "/api/device-hub", + revision: 0, + ...overrides, +}); + +describe("device setup guidance", () => { + it("directs users to install an iOS runtime and create an Android virtual device", () => { + expect(platformSetupStatus(deviceState(), "ios").message).toContain("Xcode Settings"); + expect(platformSetupStatus(deviceState(), "android").message).toContain("Device Manager"); + }); + + it("preserves a specific missing-tool explanation from the server", () => { + const state = deviceState({ + hosts: [ + { + ...deviceState().hosts[0]!, + platforms: [ + { platform: "ios", available: true }, + { platform: "android", available: false, reason: "Android Emulator is missing." }, + ], + }, + ], + }); + expect(platformSetupStatus(state, "android").message).toBe("Android Emulator is missing."); + }); + + it("counts a remote device host when this machine has no simulator tooling", () => { + const state = deviceState({ + hostStatus: "idle", + hostStatuses: { macmini: { status: "ready" } }, + hosts: [ + { + ...deviceState().hosts[0]!, + platforms: [ + { platform: "ios", available: false, reason: "Xcode was not found." }, + { platform: "android", available: false, reason: "Android SDK was not found." }, + ], + }, + { + id: "macmini", + kind: "ssh", + label: "Mac Mini", + hubInstalled: true, + agentDeviceInstalled: true, + platforms: [ + { platform: "ios", available: true }, + { platform: "android", available: false, reason: "Android SDK was not found." }, + ], + }, + ], + devices: [ + { + hostId: "macmini", + id: "iphone", + platform: "ios", + name: "iPhone 16 Pro", + version: "iOS 27.0", + booted: true, + physical: false, + }, + ], + }); + expect(deviceHubReadiness(state).ready).toBe(true); + expect(platformSetupStatus(state, "ios")).toEqual({ + ready: true, + message: "Xcode and iOS Simulator are available on Mac Mini.", + }); + expect(platformSetupStatus(state, "android").ready).toBe(false); + }); +}); diff --git a/apps/web/src/components/settings/IntegrationsSettings.browser.test.tsx b/apps/web/src/components/settings/IntegrationsSettings.browser.test.tsx new file mode 100644 index 000000000000..eda958c161f9 --- /dev/null +++ b/apps/web/src/components/settings/IntegrationsSettings.browser.test.tsx @@ -0,0 +1,102 @@ +import { DEFAULT_CLIENT_SETTINGS, DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts"; +import { + createMemoryHistory, + createRootRoute, + createRouter, + RouterProvider, +} from "@tanstack/react-router"; +import { act, StrictMode } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +const { listBrowserImportSources } = vi.hoisted(() => ({ + listBrowserImportSources: vi.fn().mockResolvedValue([]), +})); + +vi.mock(import("../preview/previewBridge"), async (original) => { + const actual = await original(); + return { + ...actual, + previewBridge: { ...actual.previewBridge, listBrowserImportSources } as never, + }; +}); +vi.mock(import("../../env"), async (original) => ({ ...(await original()), isElectron: true })); +vi.mock(import("../../state/environments"), async (original) => ({ + ...(await original()), + useEnvironments: (() => ({ environments: [], isReady: true })) as never, + usePrimaryEnvironment: () => null, + usePrimaryEnvironmentId: () => null, +})); +vi.mock(import("../../hooks/useSettings"), async (original) => ({ + ...(await original()), + useClientSettings: ((selector?: (settings: typeof DEFAULT_CLIENT_SETTINGS) => unknown) => + selector ? selector(DEFAULT_CLIENT_SETTINGS) : DEFAULT_CLIENT_SETTINGS) as never, + useClientSettingsHydrated: () => true, + usePrimarySettingsAvailable: () => true, + usePrimarySettings: (() => DEFAULT_UNIFIED_SETTINGS) as never, + useUpdatePrimarySettings: (() => vi.fn()) as never, +})); +vi.mock(import("./ProjectDefaultsSettings"), async (original) => ({ + ...(await original()), + ProjectDefaultsSettings: (() => null) as never, +})); +vi.mock(import("./SettingsScopeContext"), async (original) => ({ + ...(await original()), + useSettingsScope: (() => ({ + scope: { kind: "all", environmentIds: [] }, + search: {}, + environment: null, + environments: [], + target: null, + connectedEnvironments: [], + targets: [], + })) as never, + useOptionalSettingsScope: () => null, +})); + +import { IntegrationsSettingsPanel } from "./IntegrationsSettings"; + +let root: Root; +let container: HTMLDivElement; + +beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + listBrowserImportSources.mockClear(); + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); +}); + +afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + vi.unstubAllGlobals(); +}); + +async function openSettings() { + const router = createRouter({ + routeTree: createRootRoute({ component: IntegrationsSettingsPanel }), + history: createMemoryHistory(), + }); + await router.load(); + await act(async () => + root.render( + + + , + ), + ); + await vi.waitFor(() => expect(container.querySelector("section#browser")).not.toBeNull()); +} + +describe("Integrations browser discovery", () => { + it("does not scan browser files when entering or revisiting settings", async () => { + await openSettings(); + expect(listBrowserImportSources).not.toHaveBeenCalled(); + + await act(async () => root.unmount()); + root = createRoot(container); + await openSettings(); + expect(listBrowserImportSources).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/components/settings/IntegrationsSettings.test.tsx b/apps/web/src/components/settings/IntegrationsSettings.test.tsx deleted file mode 100644 index 198c2cf3f3c5..000000000000 --- a/apps/web/src/components/settings/IntegrationsSettings.test.tsx +++ /dev/null @@ -1,207 +0,0 @@ -import { - DEFAULT_CLIENT_SETTINGS, - DEFAULT_UNIFIED_SETTINGS, - type DeviceServiceState, -} from "@t3tools/contracts"; -import { - createMemoryHistory, - createRootRoute, - createRouter, - RouterProvider, -} from "@tanstack/react-router"; -import { act, StrictMode, type ReactNode } from "react"; -import { create, type ReactTestRenderer } from "react-test-renderer"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; - -const { listBrowserImportSources } = vi.hoisted(() => ({ - listBrowserImportSources: vi.fn().mockResolvedValue([]), -})); - -vi.mock("../ui/tooltip", () => ({ - Tooltip: ({ children }: { children: ReactNode }) => children, - TooltipTrigger: ({ render, children }: { render: ReactNode; children: ReactNode }) => ( - <> - {render} - {children} - - ), - TooltipPopup: () => null, -})); -vi.mock("../preview/previewBridge", () => ({ - previewBridge: { listBrowserImportSources }, -})); -vi.mock("../../env", () => ({ isElectron: true })); -vi.mock("../../state/environments", () => ({ - useEnvironments: () => ({ environments: [], isReady: true }), - usePrimaryEnvironment: () => null, - usePrimaryEnvironmentId: () => null, -})); -vi.mock("../../hooks/useSettings", async (importOriginal) => ({ - ...(await importOriginal()), - PRIMARY_SETTINGS_UNAVAILABLE_MESSAGE: "Connect to an environment", - useClientSettings: (selector?: (settings: typeof DEFAULT_CLIENT_SETTINGS) => unknown) => - selector ? selector(DEFAULT_CLIENT_SETTINGS) : DEFAULT_CLIENT_SETTINGS, - useClientSettingsHydrated: () => true, - usePrimarySettingsAvailable: () => true, - usePrimarySettings: () => DEFAULT_UNIFIED_SETTINGS, - useUpdatePrimarySettings: () => vi.fn(), -})); -vi.mock("./settingsLayout", async (importOriginal) => ({ - ...(await importOriginal()), - SettingsPageContainer: ({ children }: { children: ReactNode }) => children, -})); -vi.mock("./ProjectDefaultsSettings", () => ({ ProjectDefaultsSettings: () => null })); -vi.mock("./SettingsScopeContext", () => ({ - useSettingsScope: () => ({ - scope: { kind: "all", environmentIds: [] }, - search: {}, - environment: null, - environments: [], - target: null, - connectedEnvironments: [], - targets: [], - }), - useOptionalSettingsScope: () => null, -})); - -import { IntegrationsSettingsPanel } from "./IntegrationsSettings"; -import { deviceHubReadiness, platformSetupStatus } from "../device/DeviceSetup"; - -let renderer: ReactTestRenderer | undefined; - -beforeEach(() => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - listBrowserImportSources.mockClear(); -}); - -afterEach(async () => { - await act(() => renderer?.unmount()); - vi.unstubAllGlobals(); -}); - -async function openSettings() { - const router = createRouter({ - routeTree: createRootRoute({ component: IntegrationsSettingsPanel }), - history: createMemoryHistory(), - }); - await router.load(); - await act(() => { - renderer = create( - - - , - ); - }); - expect(renderer!.root.findByType(IntegrationsSettingsPanel)).toBeDefined(); -} - -describe("Integrations browser discovery", () => { - it("does not scan browser files when entering or revisiting settings", async () => { - await openSettings(); - expect(listBrowserImportSources).not.toHaveBeenCalled(); - - await act(() => renderer?.unmount()); - await openSettings(); - expect(listBrowserImportSources).not.toHaveBeenCalled(); - }); - - it("places device settings directly after browser settings", async () => { - await openSettings(); - const sections = renderer!.root - .findAll((node) => node.type === "section") - .map((node) => node.props.id) - .filter(Boolean); - expect(sections.indexOf("devices")).toBeGreaterThan(sections.indexOf("browser")); - }); -}); - -const deviceState = (overrides: Partial = {}): DeviceServiceState => ({ - hosts: [ - { - id: "local", - kind: "local", - label: "This machine", - hubInstalled: false, - agentDeviceInstalled: false, - platforms: [ - { platform: "ios", available: true }, - { platform: "android", available: true }, - ], - }, - ], - hostStatus: "ready", - hostStatuses: {}, - devices: [], - sessions: [], - onboardingCompleted: false, - agentAccessEnabled: false, - hubBasePath: "/api/device-hub", - revision: 0, - ...overrides, -}); - -describe("device setup guidance", () => { - it("directs users to install an iOS runtime and create an Android virtual device", () => { - expect(platformSetupStatus(deviceState(), "ios").message).toContain("Xcode Settings"); - expect(platformSetupStatus(deviceState(), "android").message).toContain("Device Manager"); - }); - - it("preserves a specific missing-tool explanation from the server", () => { - const state = deviceState({ - hosts: [ - { - ...deviceState().hosts[0]!, - platforms: [ - { platform: "ios", available: true }, - { platform: "android", available: false, reason: "Android Emulator is missing." }, - ], - }, - ], - }); - expect(platformSetupStatus(state, "android").message).toBe("Android Emulator is missing."); - }); - - it("counts a remote device host when this machine has no simulator tooling", () => { - const state = deviceState({ - hostStatus: "idle", - hostStatuses: { macmini: { status: "ready" } }, - hosts: [ - { - ...deviceState().hosts[0]!, - platforms: [ - { platform: "ios", available: false, reason: "Xcode was not found." }, - { platform: "android", available: false, reason: "Android SDK was not found." }, - ], - }, - { - id: "macmini", - kind: "ssh", - label: "Mac Mini", - hubInstalled: true, - agentDeviceInstalled: true, - platforms: [ - { platform: "ios", available: true }, - { platform: "android", available: false, reason: "Android SDK was not found." }, - ], - }, - ], - devices: [ - { - hostId: "macmini", - id: "iphone", - platform: "ios", - name: "iPhone 16 Pro", - version: "iOS 27.0", - booted: true, - physical: false, - }, - ], - }); - expect(deviceHubReadiness(state).ready).toBe(true); - expect(platformSetupStatus(state, "ios")).toEqual({ - ready: true, - message: "Xcode and iOS Simulator are available on Mac Mini.", - }); - expect(platformSetupStatus(state, "android").ready).toBe(false); - }); -}); diff --git a/apps/web/src/components/settings/ProjectIconPickerDialog.browser.test.tsx b/apps/web/src/components/settings/ProjectIconPickerDialog.browser.test.tsx new file mode 100644 index 000000000000..eab8eb3bd885 --- /dev/null +++ b/apps/web/src/components/settings/ProjectIconPickerDialog.browser.test.tsx @@ -0,0 +1,100 @@ +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { userEvent } from "vite-plus/test/browser"; + +import { deriveProjectIdentity } from "../../projectIdentity"; +import { ProjectIconPickerDialog } from "./ProjectIconPickerDialog"; + +let root: Root; +let container: HTMLDivElement; + +beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); +}); + +afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + vi.unstubAllGlobals(); +}); + +function button(name: string) { + return vi.waitFor(() => { + const match = [...document.querySelectorAll("button, [role='radio']")].find( + (element) => element.getAttribute("aria-label") === name || element.textContent === name, + ); + if (!match) throw new Error(`No button ${name}`); + return match; + }); +} + +// The modal's inert backdrop covers the unstyled popup, so Playwright pointer clicks land on it. +async function click(name: string) { + const target = await button(name); + await act(async () => target.click()); +} + +async function openPicker() { + const onSelect = vi.fn(); + const onOpenChange = vi.fn(); + await act(async () => + root.render( + , + ), + ); + return { onSelect, onOpenChange }; +} + +describe("ProjectIconPickerDialog", () => { + it("starts an automatic project on icons in its derived color and saves the picked icon", async () => { + const { onSelect, onOpenChange } = await openPicker(); + const { color } = deriveProjectIdentity("Test"); + expect((await button("Icons")).getAttribute("aria-pressed")).toBe("true"); + expect( + document.querySelector('[aria-label="Icon color"] [aria-pressed="true"]'), + ).not.toBeNull(); + + await userEvent.fill(searchBox(), "rocket"); + await click("Rocket"); + await click("Save icon"); + + expect(onSelect).toHaveBeenCalledExactlyOnceWith({ kind: "lucide", name: "rocket", color }); + expect(onOpenChange).toHaveBeenCalledWith(false); + }); + + it("saves a monogram only once the letters are letters or numbers", async () => { + const { onSelect } = await openPicker(); + await click("Monogram"); + const letters = await vi.waitFor(() => { + const input = document.querySelector("#project-monogram"); + if (!input) throw new Error("No monogram input"); + return input; + }); + + await userEvent.fill(letters, "t-3"); + await click("Save icon"); + expect(onSelect).not.toHaveBeenCalled(); + + await userEvent.fill(letters, "t3"); + await click("Save icon"); + expect(onSelect).toHaveBeenCalledExactlyOnceWith({ + kind: "monogram", + text: "T3", + color: deriveProjectIdentity("Test").color, + }); + }); +}); + +function searchBox() { + return document.querySelector('input[aria-label="Search Lucide icons"]')!; +} diff --git a/apps/web/src/components/settings/ProjectIconPickerDialog.test.tsx b/apps/web/src/components/settings/ProjectIconPickerDialog.test.tsx deleted file mode 100644 index e1c08dfbabb4..000000000000 --- a/apps/web/src/components/settings/ProjectIconPickerDialog.test.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import type { ReactNode } from "react"; -import { renderToStaticMarkup } from "react-dom/server"; -import { describe, expect, it, vi } from "vite-plus/test"; - -vi.mock("../ui/button", () => ({ - Button: ({ children }: { readonly children?: ReactNode }) => , -})); - -vi.mock("../ui/dialog", () => { - const Container = ({ children }: { readonly children?: ReactNode }) =>
{children}
; - return { - Dialog: Container, - DialogDescription: Container, - DialogFooter: Container, - DialogHeader: Container, - DialogPanel: Container, - DialogPopup: Container, - DialogTitle: Container, - }; -}); - -vi.mock("../ui/input", () => ({ Input: () => })); -vi.mock("../ui/scroll-area", () => ({ - ScrollArea: ({ children }: { readonly children?: ReactNode }) =>
{children}
, -})); -vi.mock("../ui/toggle-group", () => ({ - Toggle: ({ children, value }: { readonly children?: ReactNode; readonly value: string }) => ( - - ), - ToggleGroup: ({ - children, - value, - }: { - readonly children?: ReactNode; - readonly value: readonly string[]; - }) =>
{children}
, -})); - -import { ProjectIconPickerDialog } from "./ProjectIconPickerDialog"; - -describe("ProjectIconPickerDialog", () => { - it("shows icons first and selects them for an automatic project", () => { - const markup = renderToStaticMarkup( - {}} - onSelect={() => {}} - />, - ); - - expect(markup).toContain('data-current="lucide"'); - expect(markup.indexOf(">Icons<")).toBeLessThan(markup.indexOf(">Emoji<")); - expect(markup).toContain('aria-label="Icon color"'); - }); -}); diff --git a/apps/web/src/components/settings/ProjectSettingsPanel.browser.test.tsx b/apps/web/src/components/settings/ProjectSettingsPanel.browser.test.tsx new file mode 100644 index 000000000000..cf7f4f2fc984 --- /dev/null +++ b/apps/web/src/components/settings/ProjectSettingsPanel.browser.test.tsx @@ -0,0 +1,358 @@ +import { RegistryContext } from "@effect/atom-react"; +import type { + DeleteProjectInput, + UpdateProjectInput, +} from "@t3tools/client-runtime/state/projects"; +import { + AuthOrchestrationOperateScope, + AuthSettingsWriteScope, + EnvironmentId, + ProjectId, + ProviderInstanceId, + type AuthEnvironmentScope, + type AuthSessionState, +} from "@t3tools/contracts"; +import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings"; +import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings"; +import { + createMemoryHistory, + createRootRoute, + createRouter, + RouterProvider, +} from "@tanstack/react-router"; +import * as Cause from "effect/Cause"; +import { AsyncResult, Atom } from "effect/reactivity"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { userEvent } from "vite-plus/test/browser"; + +import type { Project } from "../../types"; + +type SessionResult = AsyncResult.AsyncResult; +const state = vi.hoisted(() => ({ + sessions: new Map>(), + projects: [] as Project[], + update: + vi.fn< + (request: { + environmentId: EnvironmentId; + input: UpdateProjectInput; + }) => Promise> + >(), + delete: + vi.fn< + (request: { + environmentId: EnvironmentId; + input: DeleteProjectInput; + }) => Promise> + >(), + confirm: vi.fn<() => Promise>(), + releaseUploads: vi.fn(), + clearDraft: vi.fn(), +})); + +// Session grants come from the server; each test drives them through these atoms. +vi.mock(import("@t3tools/client-runtime/state/session"), async (original) => { + const actual = await original(); + return { + ...actual, + createEnvironmentSessionAtoms: (( + ...args: Parameters + ) => ({ + ...actual.createEnvironmentSessionAtoms(...args), + sessionStateAtom: (id: EnvironmentId) => state.sessions.get(id)!, + })) as typeof actual.createEnvironmentSessionAtoms, + }; +}); +vi.mock(import("~/state/environments"), async (original) => ({ + ...(await original()), + useEnvironments: (() => ({ environments })) as never, + usePrimaryEnvironmentId: () => primaryId, +})); +vi.mock(import("~/state/entities"), async (original) => ({ + ...(await original()), + useProjects: (() => state.projects) as never, + useThreadShells: () => [], + waitForProjectRemoval: async () => true, +})); +vi.mock(import("~/state/projects"), async (original) => { + const actual = await original(); + return { + ...actual, + projectEnvironment: { + ...actual.projectEnvironment, + update: "update", + delete: "delete", + } as unknown as typeof actual.projectEnvironment, + }; +}); +vi.mock(import("~/state/use-atom-command"), async (original) => ({ + ...(await original()), + useAtomCommand: ((command: "update" | "delete") => state[command]) as never, +})); +vi.mock(import("~/hooks/useSettings"), async (original) => ({ + ...(await original()), + useClientSettings: ((selector?: (settings: typeof DEFAULT_CLIENT_SETTINGS) => unknown) => + selector ? selector(DEFAULT_CLIENT_SETTINGS) : DEFAULT_CLIENT_SETTINGS) as never, +})); +vi.mock(import("~/localApi"), async (original) => ({ + ...(await original()), + readLocalApi: (() => ({ dialogs: { confirm: state.confirm } })) as never, +})); +vi.mock(import("~/lib/composerDraftUploads"), async (original) => ({ + ...(await original()), + releaseProjectDraftUploads: state.releaseUploads, +})); +// Sections with their own data sources and tests; this file covers project permissions. +vi.mock(import("./ProjectDefaultsSettings"), async (original) => ({ + ...(await original()), + ProjectDefaultsSettings: (() => null) as never, +})); +vi.mock(import("./ProjectActionsSettings"), async (original) => ({ + ...(await original()), + ProjectActionsSettings: (() => null) as never, +})); + +import { useComposerDraftStore } from "../../composerDraftStore"; +import { appAtomRegistry } from "../../rpc/atomRegistry"; +import { buildSidebarProjectSnapshots } from "../../sidebarProjectGrouping"; +import { ProjectSettingsPanel } from "./ProjectSettingsPanel"; + +const primaryId = EnvironmentId.make("primary"); +const remoteId = EnvironmentId.make("remote"); +const instanceId = ProviderInstanceId.make("codex"); +const environments = [primaryId, remoteId].map((environmentId) => ({ + environmentId, + label: environmentId, + connection: { phase: "connected" }, + serverConfig: { keybindings: DEFAULT_RESOLVED_KEYBINDINGS }, +})); +const session = (scopes: ReadonlyArray): AuthSessionState => ({ + authenticated: true, + scopes, + auth: { + policy: "remote-reachable", + bootstrapMethods: ["one-time-token"], + sessionMethods: ["bearer-access-token"], + sessionCookieName: "t3_session", + }, +}); +const writable = () => AsyncResult.success(session([AuthOrchestrationOperateScope])); + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((complete) => { + resolve = complete; + }); + return { promise, resolve }; +} + +let root: Root; +let container: HTMLDivElement; + +beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.clearAllMocks(); + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); + state.sessions.clear(); + for (const id of [primaryId, remoteId]) + state.sessions.set(id, Atom.make(writable()).pipe(Atom.keepAlive)); + state.projects = [primaryId, remoteId].map((environmentId) => ({ + id: ProjectId.make(`project-${environmentId}`), + environmentId, + title: "Shared repo", + workspaceRoot: `/work/${environmentId}`, + repositoryIdentity: { + canonicalKey: "github.com/example/shared-repo", + locator: { + source: "git-remote", + remoteName: "origin", + remoteUrl: "https://github.com/example/shared-repo.git", + }, + }, + defaultModelSelection: { instanceId, model: "gpt-5-codex" }, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + scripts: [], + })); + state.update.mockImplementation(async ({ environmentId, input }) => { + const { projectId, ...updates } = input; + const patch = Object.fromEntries( + Object.entries(updates).filter(([, value]) => value !== undefined), + ) as Partial; + state.projects = state.projects.map((project) => + project.environmentId === environmentId && project.id === projectId + ? { ...project, ...patch } + : project, + ); + return AsyncResult.success(undefined); + }); + state.delete.mockResolvedValue(AsyncResult.success(undefined)); + state.confirm.mockResolvedValue(true); + useComposerDraftStore.setState({ clearProjectDraftThreadId: state.clearDraft }); +}); + +afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + vi.unstubAllGlobals(); +}); + +function projectKey() { + return buildSidebarProjectSnapshots({ + projects: state.projects, + settings: DEFAULT_CLIENT_SETTINGS, + primaryEnvironmentId: primaryId, + resolveEnvironmentLabel: (id) => id, + })[0]!.projectKey; +} + +async function mountPanel() { + const key = projectKey(); + const router = createRouter({ + routeTree: createRootRoute({ component: () => }), + history: createMemoryHistory({ initialEntries: ["/settings/projects"] }), + }); + await router.load(); + await act(async () => + root.render( + + + , + ), + ); + await vi.waitFor(() => expect(nameInput()).not.toBeNull()); +} + +const nameInput = () => container.querySelector('[aria-label="Project name"]')!; +const removeCheckout = (root: string) => + container.querySelector(`[aria-label="Remove checkout ${root}"]`)!; +const removeAll = () => + [...container.querySelectorAll("button")].find((button) => + button.textContent?.includes("Remove all entries"), + )!; + +async function grant(id: EnvironmentId, result: SessionResult) { + await act(async () => appAtomRegistry.set(state.sessions.get(id)!, result)); +} + +async function rename(title: string) { + await userEvent.fill(nameInput(), title); + await userEvent.keyboard("{Enter}"); +} + +describe("project settings permissions", () => { + it("disables shared edits when any member lacks project access", async () => { + await grant(remoteId, AsyncResult.success(session([AuthSettingsWriteScope]))); + await mountPanel(); + + expect(nameInput().disabled).toBe(true); + expect(removeAll().disabled).toBe(true); + expect(container.textContent).toContain( + "Shared settings require permission to change every checkout in this group.", + ); + expect(state.update).not.toHaveBeenCalled(); + }); + + it("renames every member when all have project access without settings access", async () => { + await mountPanel(); + await rename("Renamed"); + expect(state.update.mock.calls.map(([request]) => request.environmentId)).toEqual([ + primaryId, + remoteId, + ]); + expect(state.projects.map((project) => project.title)).toEqual(["Renamed", "Renamed"]); + }); + + it("stops before the next member if its grant is revoked during an earlier update", async () => { + const firstUpdate = deferred>(); + state.update.mockReturnValueOnce(firstUpdate.promise); + await mountPanel(); + await rename("Renamed"); + expect(state.update).toHaveBeenCalledOnce(); + + await grant(remoteId, AsyncResult.success(session([]))); + await act(async () => { + firstUpdate.resolve(AsyncResult.success(undefined)); + await firstUpdate.promise; + }); + expect(state.update).toHaveBeenCalledOnce(); + expect(state.update.mock.calls[0]![0].environmentId).toBe(primaryId); + }); + + it("disables removal for a checkout whose project grant is missing", async () => { + await grant(remoteId, AsyncResult.success(session([]))); + await mountPanel(); + expect(removeCheckout("/work/remote").disabled).toBe(true); + expect(removeCheckout("/work/primary").disabled).toBe(false); + }); + + it("removes a writable checkout while removal of the mixed group stays denied", async () => { + await grant(remoteId, AsyncResult.success(session([]))); + await mountPanel(); + expect(removeAll().disabled).toBe(true); + + await userEvent.click(removeCheckout("/work/primary")); + await vi.waitFor(() => expect(state.delete).toHaveBeenCalledOnce()); + expect(state.delete).toHaveBeenCalledWith({ + environmentId: primaryId, + input: { projectId: state.projects[0]!.id, force: true }, + }); + await vi.waitFor(() => expect(state.releaseUploads).toHaveBeenCalledOnce()); + expect(state.clearDraft).toHaveBeenCalledOnce(); + }); + + it("rechecks every removal target after confirmation before deleting or clearing drafts", async () => { + const confirmed = deferred(); + state.confirm.mockReturnValueOnce(confirmed.promise); + await mountPanel(); + await userEvent.click(removeAll()); + expect(state.confirm).toHaveBeenCalledOnce(); + + await grant(remoteId, AsyncResult.success(session([]))); + await act(async () => { + confirmed.resolve(true); + await confirmed.promise; + }); + expect(state.delete).not.toHaveBeenCalled(); + expect(state.releaseUploads).not.toHaveBeenCalled(); + expect(state.clearDraft).not.toHaveBeenCalled(); + }); + + it("does not save an edit whose grant was revoked mid-edit, and recovers when restored", async () => { + await mountPanel(); + await userEvent.fill(nameInput(), "Renamed"); + await grant(remoteId, AsyncResult.success(session([]))); + await act(async () => nameInput().blur()); + expect(state.update).not.toHaveBeenCalled(); + expect(nameInput().disabled).toBe(true); + + await grant(remoteId, writable()); + expect(nameInput().disabled).toBe(false); + await rename("Renamed"); + expect(state.projects.map((project) => project.title)).toEqual(["Renamed", "Renamed"]); + }); + + it.each([ + ["loading", () => AsyncResult.initial(), false], + [ + "failed", + () => AsyncResult.failure(Cause.fail(new Error("Disconnected"))), + false, + ], + ["refreshing", () => AsyncResult.waiting(writable()), true], + ] as const)( + "uses the current grant while a member's session is %s", + async (_state, result, allowed) => { + await grant(remoteId, result()); + await mountPanel(); + expect(nameInput().disabled).toBe(!allowed); + if (!allowed) return; + await rename("Renamed"); + expect(state.update).toHaveBeenCalledTimes(2); + }, + ); +}); diff --git a/apps/web/src/components/settings/ProjectSettingsPanel.test.tsx b/apps/web/src/components/settings/ProjectSettingsPanel.test.tsx deleted file mode 100644 index 79f6a35a8d2c..000000000000 --- a/apps/web/src/components/settings/ProjectSettingsPanel.test.tsx +++ /dev/null @@ -1,477 +0,0 @@ -import { RegistryContext } from "@effect/atom-react"; -import type { - DeleteProjectInput, - UpdateProjectInput, -} from "@t3tools/client-runtime/state/projects"; -import { - AuthOrchestrationOperateScope, - AuthSettingsWriteScope, - DEFAULT_SERVER_SETTINGS, - EnvironmentId, - ProjectId, - ProviderDriverKind, - ProviderInstanceId, - type AuthEnvironmentScope, - type AuthSessionState, - type ResolvedKeybindingsConfig, - type ServerProvider, -} from "@t3tools/contracts"; -import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings"; -import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings"; -import * as Cause from "effect/Cause"; -import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity"; -import { cloneElement, type ReactElement, type ReactNode } from "react"; -import { act, create, type ReactTestRenderer } from "react-test-renderer"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; - -import type { Project } from "../../types"; - -type SessionResult = AsyncResult.AsyncResult; -const state = vi.hoisted(() => ({ - registry: null as AtomRegistry.AtomRegistry | null, - sessions: new Map>(), - providers: null as Atom.Atom> | null, - config: null as Atom.Atom<{ keybindings: ResolvedKeybindingsConfig }> | null, - projects: [] as Project[], - update: - vi.fn< - (request: { - environmentId: EnvironmentId; - input: UpdateProjectInput; - }) => Promise> - >(), - delete: - vi.fn< - (request: { - environmentId: EnvironmentId; - input: DeleteProjectInput; - }) => Promise> - >(), - confirm: vi.fn<() => Promise>(), - updateSettings: vi.fn(), - upsertKeybinding: vi.fn(), - removeKeybinding: vi.fn(), - updateClientSettings: vi.fn(), - releaseUploads: vi.fn(), - clearDraft: vi.fn(), - copy: vi.fn(), - navigate: vi.fn(), -})); - -vi.mock("~/connection/runtime", () => ({ connectionAtomRuntime: undefined })); -vi.mock("@t3tools/client-runtime/state/session", () => ({ - createEnvironmentSessionAtoms: () => ({ - sessionStateAtom: (id: EnvironmentId) => state.sessions.get(id)!, - }), -})); -vi.mock("~/rpc/atomRegistry", () => ({ - get appAtomRegistry() { - return state.registry; - }, -})); -vi.mock("~/state/environments", () => ({ - useEnvironments: () => ({ environments }), - usePrimaryEnvironmentId: () => primaryId, -})); -vi.mock("~/state/entities", () => ({ - useProjects: () => state.projects, - useThreadShells: () => [], - waitForProjectRemoval: async () => true, -})); -vi.mock("~/state/projects", () => ({ - projectEnvironment: { update: "update", delete: "delete" }, -})); -vi.mock("~/state/server", () => ({ - EMPTY_SERVER_PROVIDERS: [], - serverEnvironment: { - providersValueAtom: () => state.providers!, - configValueAtom: () => state.config!, - updateSettings: "updateSettings", - upsertKeybinding: "upsertKeybinding", - removeKeybinding: "removeKeybinding", - }, -})); -vi.mock("~/state/use-atom-command", () => ({ - useAtomCommand: ( - command: "update" | "delete" | "updateSettings" | "upsertKeybinding" | "removeKeybinding", - ) => state[command], -})); -vi.mock("~/hooks/useSettings", () => ({ - useClientSettings: () => DEFAULT_CLIENT_SETTINGS, - useUpdateClientSettings: () => state.updateClientSettings, - usePrimarySettings: () => DEFAULT_SERVER_SETTINGS, - useEnvironmentSettings: () => DEFAULT_SERVER_SETTINGS, -})); -vi.mock("~/hooks/useT3ProjectFileScripts", () => ({ - useT3ProjectFileState: () => ({ - status: "valid", - file: null, - scripts: [{ name: "Imported", command: "vp test" }], - }), -})); -vi.mock("~/hooks/useCopyToClipboard", () => ({ - useCopyToClipboard: () => ({ copyToClipboard: state.copy }), -})); -vi.mock("~/localApi", () => ({ readLocalApi: () => ({ dialogs: { confirm: state.confirm } }) })); -vi.mock("~/composerDraftStore", () => ({ - useComposerDraftStore: { - getState: () => ({ - getDraftThreadByProjectRef: () => null, - clearProjectDraftThreadId: state.clearDraft, - }), - }, -})); -vi.mock("~/lib/composerDraftUploads", () => ({ releaseProjectDraftUploads: state.releaseUploads })); -vi.mock("~/env", () => ({ isElectron: true })); -vi.mock("@tanstack/react-router", () => ({ - useNavigate: () => state.navigate, - useLocation: () => "/settings/projects", -})); -vi.mock("../ui/toast", () => ({ - toastManager: { add: vi.fn() }, - stackedThreadToast: (value: unknown) => value, -})); -vi.mock("../ui/button", () => ({ Button: "button" })); -vi.mock("../ui/input", () => ({ Input: "input" })); -vi.mock("../ui/switch", () => ({ Switch: "input" })); -vi.mock("../ui/select", () => ({ - Select: "select", - SelectItem: "option", - SelectPopup: "div", - SelectTrigger: "button", - SelectValue: "span", -})); -vi.mock("../ui/menu", () => ({ - Menu: "div", - MenuGroup: "div", - MenuGroupLabel: "div", - MenuItem: "button", - MenuPopup: "div", - MenuSeparator: "hr", - MenuTrigger: ({ render, children }: { render: ReactElement; children: ReactNode }) => - cloneElement(render, {}, children), -})); -vi.mock("../ui/tooltip", () => ({ - Tooltip: "div", - TooltipPopup: "span", - TooltipTrigger: ({ render }: { render: ReactNode }) => render, -})); -vi.mock("./settingsLayout", () => ({ - SETTINGS_PICKER_TRIGGER_CLASSNAME: "", - SettingsPageContainer: "div", - SettingsSection: ({ - children, - headerAction, - }: { - children: ReactNode; - headerAction?: ReactNode; - }) => ( -
- {headerAction} - {children} -
- ), - SettingsRow: ({ control, resetAction }: { control?: ReactNode; resetAction?: ReactNode }) => ( -
- {resetAction} - {control} -
- ), - SettingResetButton: ({ - label, - ...props - }: { - label: string; - disabled?: boolean; - onClick: () => void; - }) =>