import { RegistryContext } from "@effect/atom-react"; import type { DeleteProjectInput, UpdateProjectInput, } from "@t3tools/client-runtime/state/projects"; import { AuthOrchestrationOperateScope, AuthSettingsWriteScope, DEFAULT_SERVER_SETTINGS, 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, createRoute, createRouter, RouterProvider, } from "@tanstack/react-router"; import * as Cause from "effect/Cause"; import { AsyncResult, Atom } from "effect/reactivity"; import { describe, expect, it, vi } from "vite-plus/test"; import { page, userEvent } from "vite-plus/test/browser"; import { act, render, type Screen } from "../../../test/render"; 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 | AsyncResult.Failure> >(), delete: vi.fn< (request: { environmentId: EnvironmentId; input: DeleteProjectInput; }) => Promise> >(), confirm: vi.fn<() => Promise>(), releaseUploads: vi.fn(), clearDraft: vi.fn(), toast: 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, readEnvironment: ((id: EnvironmentId) => environments.find((environment) => environment.environmentId === id) ?? null) as never, usePrimaryEnvironmentId: () => primaryId, })); vi.mock(import("~/state/entities"), async (original) => ({ ...(await original()), useProjects: (() => state.projects) as never, useThreadShells: () => [], useServerConfigs: (() => new Map( environments.map((environment) => [environment.environmentId, environment.serverConfig]), )) as never, 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, })); // No toaster is mounted in these tests; the guard's error toast is read at this seam. vi.mock(import("../ui/toast"), async (original) => { const actual = await original(); return { ...actual, toastManager: { ...actual.toastManager, add: state.toast } }; }); // These sections have separate data sources and their own tests. vi.mock(import("./ProjectDefaultsSettings"), async (original) => ({ ...(await original()), ProjectDefaultsSettings: (() => null) as never, })); vi.mock(import("./ProjectActionsSettings"), async (original) => ({ ...(await original()), ProjectActionsSettings: (() => null) as never, })); vi.mock(import("../files/projectFilesQueryState"), async (original) => ({ ...(await original()), getProjectFileQueryAtom: () => missingProjectFile, })); import { useComposerDraftStore } from "../../composerDraftStore"; import { appAtomRegistry } from "../../rpc/atomRegistry"; import { assetEnvironment } from "../../state/assets"; import { buildSidebarProjectSnapshots } from "../../sidebarProjectGrouping"; import { ProjectSettingsPanel } from "./ProjectSettingsPanel"; import { ProjectsSettings } from "./ProjectsSettings"; import { SettingsScopeProvider } from "./SettingsScopeContext"; import { SettingsScopeNotice } from "./SettingsScopeNotice"; import { validateSettingsScopeSearch, type SettingsScopeSearch } from "./settingsScope"; import { retainSettingsScope, validateSettingsRouteSearch } from "./settingsScopeNavigation"; 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, settings: DEFAULT_SERVER_SETTINGS, environment: { platform: { machine: "server" }, capabilities: { projectOrganization: true } }, }, })); 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])); const missingProjectFile = Atom.make(AsyncResult.initial()); function deferred() { let resolve!: (value: T) => void; const promise = new Promise((complete) => { resolve = complete; }); return { promise, resolve }; } function setup() { vi.clearAllMocks(); 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 }); } function projectKey() { return buildSidebarProjectSnapshots({ projects: state.projects, settings: DEFAULT_CLIENT_SETTINGS, primaryEnvironmentId: primaryId, resolveEnvironmentLabel: (id) => id, })[0]!.projectKey; } async function mountPanel(search: Omit = {}) { const key = projectKey(); const rootRoute = createRootRoute(); const settingsRoute = createRoute({ getParentRoute: () => rootRoute, path: "settings", validateSearch: validateSettingsRouteSearch, search: { middlewares: [retainSettingsScope] }, }); const projectsRoute = createRoute({ getParentRoute: () => settingsRoute, path: "projects", component: () => ( ), }); const query = new URLSearchParams({ project: key }); for (const [name, value] of Object.entries(search)) { if (value !== undefined) query.set(name, value); } const router = createRouter({ routeTree: rootRoute.addChildren([settingsRoute.addChildren([projectsRoute])]), history: createMemoryHistory({ initialEntries: [`/settings/projects?${query}`] }), }); await router.load(); await using stack = new AsyncDisposableStack(); const screen = stack.use( // The panel's own route tree validates settings search params, which the default router lacks. await render( , { router: false }, ), ); await vi.waitFor(() => expect(nameInput(screen)).not.toBeNull()); stack.move(); return { ...screen, router }; } async function mountSettings(search: SettingsScopeSearch) { const searchParams = new URLSearchParams( Object.entries(search).flatMap(([key, value]) => (value === undefined ? [] : [[key, value]])), ); const route = createRootRoute({ validateSearch: validateSettingsScopeSearch, component: () => ( void router.navigate({ to: "/settings/projects", search: () => next })} > ), }); const router = createRouter({ routeTree: route, history: createMemoryHistory({ initialEntries: [`/settings/projects?${searchParams.toString()}`], }), }); await router.load(); // The settings route tree above validates scope search params, which the default router lacks. const screen = await render( , { router: false }, ); return { ...screen, router }; } const nameInput = (screen: Screen) => screen.container.querySelector('[aria-label="Project name"]')!; const removeCheckout = (screen: Screen, root: string) => screen.container.querySelector(`[aria-label="Remove checkout ${root}"]`)!; const removeAll = (screen: Screen) => [...screen.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(screen: Screen, title: string) { await userEvent.fill(nameInput(screen), title); await userEvent.keyboard("{Enter}"); } // The recheck reports the denial by toast; seeing it proves the recheck ran. async function expectProjectAccessDeniedToast(title: string) { await vi.waitFor(() => expect(state.toast).toHaveBeenCalledWith( expect.objectContaining({ type: "error", title, description: "This connection cannot change projects in remote.", }), ), ); expect(state.toast).toHaveBeenCalledOnce(); } describe("project organization", () => { it("archives the checkout on every machine while one machine is picked", async () => { setup(); await using _screen = await mountPanel({ machine: primaryId }); await userEvent.click(page.getByRole("button", { name: "Archive" })); await vi.waitFor(() => expect(state.update).toHaveBeenCalledTimes(2)); expect( state.update.mock.calls.map(([request]) => [request.environmentId, request.input.archived]), ).toEqual([ [primaryId, true], [remoteId, true], ]); }); }); describe("project settings permissions", () => { it("disables shared edits when any member lacks project access", async () => { setup(); await grant(remoteId, AsyncResult.success(session([AuthSettingsWriteScope]))); await using screen = await mountPanel(); expect(nameInput(screen).disabled).toBe(true); expect(removeAll(screen).disabled).toBe(true); expect(screen.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 () => { setup(); await using screen = await mountPanel(); await rename(screen, "Renamed"); expect(state.update.mock.calls.map(([request]) => request.environmentId)).toEqual([ primaryId, remoteId, ]); expect(nameInput(screen).value).toBe("Renamed"); }); it("stops before the next member if its grant is revoked during an earlier update", async () => { setup(); const firstUpdate = deferred>(); state.update.mockReturnValueOnce(firstUpdate.promise); await using screen = await mountPanel(); await rename(screen, "Renamed"); expect(state.update).toHaveBeenCalledOnce(); await grant(remoteId, AsyncResult.success(session([]))); await act(async () => { firstUpdate.resolve(AsyncResult.success(undefined)); await firstUpdate.promise; }); await expectProjectAccessDeniedToast("Failed to rename project"); 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 () => { setup(); await grant(remoteId, AsyncResult.success(session([]))); await using screen = await mountPanel(); expect(removeCheckout(screen, "/work/remote").disabled).toBe(true); expect(removeCheckout(screen, "/work/primary").disabled).toBe(false); }); it("removes a writable checkout while removal of the mixed group stays denied", async () => { setup(); await grant(remoteId, AsyncResult.success(session([]))); await using screen = await mountPanel(); const initialHref = screen.router.state.location.href; expect(removeAll(screen).disabled).toBe(true); await userEvent.click(removeCheckout(screen, "/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(); expect(screen.router.state.location.href).toBe(initialHref); }); it("rechecks every removal target after confirmation before deleting or clearing drafts", async () => { setup(); const confirmed = deferred(); state.confirm.mockReturnValueOnce(confirmed.promise); await using screen = await mountPanel(); await userEvent.click(removeAll(screen)); expect(state.confirm).toHaveBeenCalledOnce(); await grant(remoteId, AsyncResult.success(session([]))); await act(async () => { confirmed.resolve(true); await confirmed.promise; }); await expectProjectAccessDeniedToast("Failed to remove project"); 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 () => { setup(); await using screen = await mountPanel(); await userEvent.fill(nameInput(screen), "Renamed"); await grant(remoteId, AsyncResult.success(session([]))); // Chromium takes focus off a newly disabled input without firing blur. Send the // focusout so the save attempt runs and hits the same recheck as a real blur. await act(async () => { nameInput(screen).dispatchEvent(new FocusEvent("focusout", { bubbles: true })); }); await expectProjectAccessDeniedToast("Failed to rename project"); expect(state.update).not.toHaveBeenCalled(); expect(nameInput(screen).disabled).toBe(true); await grant(remoteId, writable()); expect(nameInput(screen).disabled).toBe(false); await rename(screen, "Recovered"); await vi.waitFor(() => expect(state.update).toHaveBeenCalledTimes(2)); expect(nameInput(screen).value).toBe("Recovered"); }); 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) => { setup(); await grant(remoteId, result()); await using screen = await mountPanel(); expect(nameInput(screen).disabled).toBe(!allowed); if (!allowed) return; await rename(screen, "Renamed"); expect(state.update).toHaveBeenCalledTimes(2); }, ); }); describe("project identity reset", () => { const scratchIcon = { kind: "lucide", name: "message-square-dashed", color: "gray" } as const; const resetButton = (screen: Screen, label: string) => screen.container.querySelector(`[aria-label="Reset ${label} to default"]`); const asScratch = (project: Project): Project => ({ ...project, isScratch: true, title: "Notes", workspaceRoot: "/t3/scratch", projectIcon: { kind: "emoji", emoji: "📝" }, }); it("restores a renamed scratch project's built-in name and icon", async () => { setup(); state.projects = [{ ...asScratch(state.projects[0]!), faviconPath: null, projectIcon: null }]; await using screen = await mountPanel(); await userEvent.click(resetButton(screen, "project name")!); await vi.waitFor(() => expect(state.update).toHaveBeenCalledWith({ environmentId: primaryId, input: { projectId: state.projects[0]!.id, title: "No project" }, }), ); await userEvent.click(resetButton(screen, "project icon")!); await vi.waitFor(() => expect(state.update).toHaveBeenLastCalledWith({ environmentId: primaryId, input: { projectId: state.projects[0]!.id, faviconPath: null, projectIcon: scratchIcon }, }), ); }); it("resets each member of a mixed group to its own default icon", async () => { setup(); const [primary, remote] = state.projects; state.projects = [asScratch(primary!), { ...remote!, faviconPath: "logo.png" }]; await using screen = await mountPanel(); expect(resetButton(screen, "project name")).toBeNull(); await userEvent.click(resetButton(screen, "project icon")!); await vi.waitFor(() => expect(state.update).toHaveBeenCalledTimes(2)); expect(state.update.mock.calls.map(([request]) => request)).toEqual([ { environmentId: primaryId, input: { projectId: primary!.id, faviconPath: null, projectIcon: scratchIcon }, }, { environmentId: remoteId, input: { projectId: remote!.id, faviconPath: null, projectIcon: null }, }, ]); }); it("refreshes a saved checkout's icon when a later checkout update fails", async () => { setup(); state.projects = state.projects.map((project) => ({ ...project, faviconPath: "logo.png" })); let iconExists = false; const iconUrl = Atom.make(() => AsyncResult.success({ expiresAt: 4_000_000_000_000, relativeUrl: iconExists ? "/restored-icon.svg" : "/project-favicon-missing", }), ); using _mount = { [Symbol.dispose]: appAtomRegistry.mount(iconUrl) }; const createUrl = assetEnvironment.createUrl; using _createUrl = vi .spyOn(assetEnvironment, "createUrl") .mockImplementation((target) => target.environmentId === primaryId ? iconUrl : createUrl(target), ); state.update .mockResolvedValueOnce(AsyncResult.success(undefined)) .mockResolvedValueOnce(AsyncResult.failure(Cause.fail(new Error("Save failed")))); await using screen = await mountPanel(); expect(appAtomRegistry.get(iconUrl).value.relativeUrl).toBe("/project-favicon-missing"); iconExists = true; await userEvent.click(resetButton(screen, "project icon")!); await vi.waitFor(() => expect(appAtomRegistry.get(iconUrl).value.relativeUrl).toBe("/restored-icon.svg"), ); expect(state.toast).toHaveBeenCalledWith( expect.objectContaining({ title: "Failed to update project icon on remote" }), ); }); }); it("opens the fallback search target across all environments from a checkout", async () => { setup(); const root = createRootRoute(); const settings = createRoute({ getParentRoute: () => root, path: "settings", validateSearch: validateSettingsRouteSearch, search: { middlewares: [retainSettingsScope] }, }); const threads = createRoute({ getParentRoute: () => settings, path: "threads", component: () => ( {}}> Choose all environments to edit the fallback model. ), }); const query = new URLSearchParams({ project: projectKey(), machine: primaryId, checkout: "primary:/work/primary", }); const router = createRouter({ routeTree: root.addChildren([settings.addChildren([threads])]), history: createMemoryHistory({ initialEntries: [`/settings/threads?${query}#fallback-model`] }), }); await router.load(); // This route needs the real scope-retention middleware used by settings search. await using _screen = await render( , { router: false }, ); await userEvent.click(page.getByRole("button", { name: "Open all environments" })); await vi.waitFor(() => expect(router.state.location.search).toEqual({})); expect(router.state.location.hash).toBe("fallback-model"); }); describe("project settings scope", () => { const environmentMenu = () => page.getByRole("button", { name: /^Environment scope:/ }); const primaryOption = () => page.getByRole("menuitemradio", { name: /^primary/ }); const remoteOption = () => page.getByRole("menuitemradio", { name: /^remote/ }); it("offers every environment while no project is selected", async () => { setup(); state.projects = state.projects.filter((project) => project.environmentId === primaryId); await using _screen = await mountSettings({}); await userEvent.click(environmentMenu()); await expect.element(primaryOption()).toBeVisible(); await expect.element(remoteOption()).toBeVisible(); }); it("offers only the selected project's checkout environments", async () => { setup(); state.projects = state.projects.filter((project) => project.environmentId === primaryId); await using _screen = await mountSettings({ project: projectKey() }); await expect.element(page.getByRole("textbox", { name: "Project name" })).toBeVisible(); await userEvent.click(environmentMenu()); await expect.element(primaryOption()).toBeVisible(); await expect.element(remoteOption()).not.toBeInTheDocument(); }); it("keeps an unknown project recoverable from any environment", async () => { setup(); state.projects = state.projects.filter((project) => project.environmentId === primaryId); await using screen = await mountSettings({ project: "removed-project" }); await userEvent.click(environmentMenu()); await expect.element(primaryOption()).toBeVisible(); await expect.element(remoteOption()).toBeVisible(); await userEvent.click(remoteOption()); await vi.waitFor(() => expect(screen.router.state.location.search).toEqual({ project: "removed-project", machine: remoteId, }), ); }); it.each([ [undefined, "This project has no checkout on this environment."], [ "removed-checkout", "This checkout is no longer available in the selected project and environment.", ], ])("recovers a stale environment link with checkout %s", async (checkout, message) => { setup(); state.projects = state.projects.filter((project) => project.environmentId === primaryId); const key = projectKey(); await using screen = await mountSettings({ project: key, machine: remoteId, ...(checkout ? { checkout } : {}), }); await expect.element(page.getByText(message, { exact: true })).toBeVisible(); await expect.element(page.getByRole("button", { name: /^Project scope:/ })).toBeVisible(); await expect .element(page.getByRole("button", { name: "Environment scope: remote" })) .toBeVisible(); await userEvent.click(page.getByRole("button", { name: "Show all environments" })); await expect .element(page.getByRole("textbox", { name: "Project name" })) .toHaveValue("Shared repo"); expect(screen.router.state.location.search).toEqual({ project: key }); await expect .element(page.getByRole("button", { name: "Environment scope: All environments" })) .toBeVisible(); await expect.element(page.getByText(message, { exact: true })).not.toBeInTheDocument(); await expect .element(page.getByRole("button", { name: "Remove project", exact: true })) .toBeEnabled(); }); }); it("returns to the project overview after removal, clearing the selection but keeping the environment", async () => { setup(); state.projects = [state.projects[1]!]; await using screen = await mountPanel({ machine: remoteId, checkout: "remote:/work/remote" }); const removeProject = [...screen.container.querySelectorAll("button")].find( (button) => button.textContent === "Remove project", )!; await userEvent.click(removeProject); await vi.waitFor(() => { expect(screen.router.state.location.pathname).toBe("/settings/projects"); expect(screen.router.state.location.search).toEqual({ machine: remoteId }); }); }); describe("project name composition", () => { it.each([ { isComposing: true, keyCode: 13 }, { isComposing: false, keyCode: 229 }, ])("does not save a partial name for $isComposing/$keyCode", async (composition) => { setup(); await using screen = await mountPanel(); const input = nameInput(screen); await userEvent.fill(input, "日本"); await act(async () => { input.dispatchEvent( new KeyboardEvent("keydown", { key: "Enter", bubbles: true, ...composition }), ); }); expect(state.update).not.toHaveBeenCalled(); await expect.element(input).toHaveFocus(); await userEvent.type(input, "語"); await userEvent.keyboard("{Enter}"); await vi.waitFor(() => expect(state.update).toHaveBeenCalledTimes(2)); expect(state.projects.map((project) => project.title)).toEqual(["日本語", "日本語"]); await expect.element(nameInput(screen)).toHaveValue("日本語"); await userEvent.fill(nameInput(screen), "日本語の名前"); await userEvent.keyboard("{Tab}"); await vi.waitFor(() => expect(state.update).toHaveBeenCalledTimes(4)); expect(state.projects.map((project) => project.title)).toEqual([ "日本語の名前", "日本語の名前", ]); }); });