import { scopeThreadRef } from "@t3tools/client-runtime"; import { ThreadId } from "@t3tools/contracts"; import { useState } from "react"; import { afterEach, describe, expect, it, vi } from "vitest"; import { render } from "vitest-browser-react"; const SHARED_THREAD_ID = ThreadId.make("thread-shared"); const ENVIRONMENT_A = "environment-local" as never; const ENVIRONMENT_B = "environment-remote" as never; const GIT_CWD = "/repo/project"; const BRANCH_NAME = "feature/toast-scope"; function createDeferredPromise() { let resolve!: (value: T) => void; let reject!: (reason?: unknown) => void; const promise = new Promise((nextResolve, nextReject) => { resolve = nextResolve; reject = nextReject; }); return { promise, resolve, reject }; } const { activeRunStackedActionDeferredRef, activeDraftThreadRef, hasServerThreadRef, invalidateGitQueriesSpy, refreshGitStatusSpy, runStackedActionMutateAsyncSpy, setDraftThreadContextSpy, setThreadBranchSpy, toastAddSpy, toastCloseSpy, toastPromiseSpy, toastUpdateSpy, } = vi.hoisted(() => ({ activeRunStackedActionDeferredRef: { current: createDeferredPromise() }, activeDraftThreadRef: { current: null as unknown }, hasServerThreadRef: { current: true }, invalidateGitQueriesSpy: vi.fn(() => Promise.resolve()), refreshGitStatusSpy: vi.fn(() => Promise.resolve(null)), runStackedActionMutateAsyncSpy: vi.fn(() => activeRunStackedActionDeferredRef.current.promise), setDraftThreadContextSpy: vi.fn(), setThreadBranchSpy: vi.fn(), toastAddSpy: vi.fn(() => "toast-1"), toastCloseSpy: vi.fn(), toastPromiseSpy: vi.fn(), toastUpdateSpy: vi.fn(), })); vi.mock("@tanstack/react-query", async () => { const actual = await vi.importActual("@tanstack/react-query"); return { ...actual, useIsMutating: vi.fn(() => 0), useMutation: vi.fn((options: { __kind?: string }) => { if (options.__kind === "run-stacked-action") { return { mutateAsync: runStackedActionMutateAsyncSpy, isPending: false, }; } if (options.__kind === "pull") { return { mutateAsync: vi.fn(), isPending: false, }; } return { mutate: vi.fn(), mutateAsync: vi.fn(), isPending: false, }; }), useQuery: vi.fn(() => ({ data: null, error: null })), useQueryClient: vi.fn(() => ({})), }; }); vi.mock("~/components/ui/toast", () => ({ toastManager: { add: toastAddSpy, close: toastCloseSpy, promise: toastPromiseSpy, update: toastUpdateSpy, }, stackedThreadToast: vi.fn((options: unknown) => options), })); vi.mock("~/editorPreferences", () => ({ openInPreferredEditor: vi.fn(), })); vi.mock("~/lib/gitReactQuery", () => ({ gitInitMutationOptions: vi.fn(() => ({ __kind: "init" })), gitMutationKeys: { pull: vi.fn(() => ["pull"]), runStackedAction: vi.fn(() => ["run-stacked-action"]), }, gitPullMutationOptions: vi.fn(() => ({ __kind: "pull" })), gitRunStackedActionMutationOptions: vi.fn(() => ({ __kind: "run-stacked-action" })), invalidateGitQueries: invalidateGitQueriesSpy, })); vi.mock("~/lib/gitStatusState", () => ({ refreshGitStatus: refreshGitStatusSpy, resetGitStatusStateForTests: () => undefined, useGitStatus: vi.fn(() => ({ data: { branch: BRANCH_NAME, hasWorkingTreeChanges: false, workingTree: { files: [], insertions: 0, deletions: 0 }, hasUpstream: true, aheadCount: 1, behindCount: 0, pr: null, }, error: null, isPending: false, })), })); vi.mock("~/localApi", () => ({ ensureLocalApi: vi.fn(() => { throw new Error("ensureLocalApi not implemented in browser test"); }), readLocalApi: vi.fn(() => null), })); vi.mock("~/composerDraftStore", async () => { const draftStoreState = { getDraftThreadByRef: () => activeDraftThreadRef.current, getDraftSession: () => activeDraftThreadRef.current, getDraftThread: () => activeDraftThreadRef.current, getDraftSessionByLogicalProjectKey: () => null, setDraftThreadContext: setDraftThreadContextSpy, setLogicalProjectDraftThreadId: vi.fn(), setProjectDraftThreadId: vi.fn(), hasDraftThreadsInEnvironment: () => false, clearDraftThread: vi.fn(), }; return { DraftId: { makeUnsafe: (value: string) => value, }, useComposerDraftStore: Object.assign( (selector: (state: unknown) => unknown) => selector(draftStoreState), { getState: () => draftStoreState }, ), markPromotedDraftThread: vi.fn(), markPromotedDraftThreadByRef: vi.fn(), markPromotedDraftThreads: vi.fn(), markPromotedDraftThreadsByRef: vi.fn(), finalizePromotedDraftThreadByRef: vi.fn(), finalizePromotedDraftThreadsByRef: vi.fn(), }; }); vi.mock("~/store", () => ({ selectEnvironmentState: ( state: { environmentStateById: Record }, environmentId: string | null, ) => { if (!environmentId) { throw new Error("Missing environment id"); } const environmentState = state.environmentStateById[environmentId]; if (!environmentState) { throw new Error(`Unknown environment: ${environmentId}`); } return environmentState; }, selectProjectsForEnvironment: () => [], selectProjectsAcrossEnvironments: () => [], selectThreadsForEnvironment: () => [], selectThreadsAcrossEnvironments: () => [], selectThreadShellsAcrossEnvironments: () => [], selectSidebarThreadsAcrossEnvironments: () => [], selectSidebarThreadsForProjectRef: () => [], selectSidebarThreadsForProjectRefs: () => [], selectBootstrapCompleteForActiveEnvironment: () => true, selectProjectByRef: () => null, selectThreadByRef: () => null, selectSidebarThreadSummaryByRef: () => null, selectThreadIdsByProjectRef: () => [], useStore: (selector: (state: unknown) => unknown) => selector({ setThreadBranch: setThreadBranchSpy, environmentStateById: { [ENVIRONMENT_A]: { threadShellById: hasServerThreadRef.current ? { [SHARED_THREAD_ID]: { id: SHARED_THREAD_ID, branch: BRANCH_NAME, worktreePath: null, }, } : {}, threadSessionById: {}, threadTurnStateById: {}, messageIdsByThreadId: {}, messageByThreadId: {}, activityIdsByThreadId: {}, activityByThreadId: {}, proposedPlanIdsByThreadId: {}, proposedPlanByThreadId: {}, turnDiffIdsByThreadId: {}, turnDiffSummaryByThreadId: {}, }, [ENVIRONMENT_B]: { threadShellById: hasServerThreadRef.current ? { [SHARED_THREAD_ID]: { id: SHARED_THREAD_ID, branch: BRANCH_NAME, worktreePath: null, }, } : {}, threadSessionById: {}, threadTurnStateById: {}, messageIdsByThreadId: {}, messageByThreadId: {}, activityIdsByThreadId: {}, activityByThreadId: {}, proposedPlanIdsByThreadId: {}, proposedPlanByThreadId: {}, turnDiffIdsByThreadId: {}, turnDiffSummaryByThreadId: {}, }, }, }), })); vi.mock("~/terminal-links", () => ({ resolvePathLinkTarget: vi.fn(), })); import GitActionsControl from "./GitActionsControl"; function findButtonByText(text: string): HTMLButtonElement | null { return (Array.from(document.querySelectorAll("button")).find((button) => button.textContent?.includes(text), ) ?? null) as HTMLButtonElement | null; } function Harness() { const [activeThreadRef, setActiveThreadRef] = useState( scopeThreadRef(ENVIRONMENT_A, SHARED_THREAD_ID), ); return ( <> ); } describe("GitActionsControl thread-scoped progress toast", () => { afterEach(() => { vi.useRealTimers(); vi.clearAllMocks(); activeRunStackedActionDeferredRef.current = createDeferredPromise(); activeDraftThreadRef.current = null; hasServerThreadRef.current = true; document.body.innerHTML = ""; }); it("keeps an in-flight git action toast pinned to the thread ref that started it", async () => { vi.useFakeTimers(); const host = document.createElement("div"); document.body.append(host); const screen = await render(, { container: host }); try { const quickActionButton = findButtonByText("Push & create PR"); expect(quickActionButton, 'Unable to find button containing "Push & create PR"').toBeTruthy(); if (!(quickActionButton instanceof HTMLButtonElement)) { throw new Error('Unable to find button containing "Push & create PR"'); } quickActionButton.click(); expect(toastAddSpy).toHaveBeenCalledWith( expect.objectContaining({ data: { threadRef: scopeThreadRef(ENVIRONMENT_A, SHARED_THREAD_ID) }, title: "Pushing...", type: "loading", }), ); await vi.advanceTimersByTimeAsync(1_000); expect(toastUpdateSpy).toHaveBeenLastCalledWith( "toast-1", expect.objectContaining({ data: { threadRef: scopeThreadRef(ENVIRONMENT_A, SHARED_THREAD_ID) }, title: "Pushing...", type: "loading", }), ); const switchEnvironmentButton = findButtonByText("Switch environment"); expect( switchEnvironmentButton, 'Unable to find button containing "Switch environment"', ).toBeTruthy(); if (!(switchEnvironmentButton instanceof HTMLButtonElement)) { throw new Error('Unable to find button containing "Switch environment"'); } switchEnvironmentButton.click(); await vi.advanceTimersByTimeAsync(1_000); expect(toastUpdateSpy).toHaveBeenLastCalledWith( "toast-1", expect.objectContaining({ data: { threadRef: scopeThreadRef(ENVIRONMENT_A, SHARED_THREAD_ID) }, title: "Pushing...", type: "loading", }), ); } finally { activeRunStackedActionDeferredRef.current.reject(new Error("test cleanup")); await Promise.resolve(); vi.useRealTimers(); await screen.unmount(); host.remove(); } }); it("debounces focus-driven git status refreshes", async () => { vi.useFakeTimers(); const originalVisibilityState = Object.getOwnPropertyDescriptor(document, "visibilityState"); let visibilityState: DocumentVisibilityState = "hidden"; Object.defineProperty(document, "visibilityState", { configurable: true, get: () => visibilityState, }); const host = document.createElement("div"); document.body.append(host); const screen = await render( , { container: host, }, ); try { window.dispatchEvent(new Event("focus")); visibilityState = "visible"; document.dispatchEvent(new Event("visibilitychange")); expect(refreshGitStatusSpy).not.toHaveBeenCalled(); await vi.advanceTimersByTimeAsync(249); expect(refreshGitStatusSpy).not.toHaveBeenCalled(); await vi.advanceTimersByTimeAsync(1); expect(refreshGitStatusSpy).toHaveBeenCalledTimes(1); expect(refreshGitStatusSpy).toHaveBeenCalledWith({ environmentId: ENVIRONMENT_A, cwd: GIT_CWD, }); } finally { if (originalVisibilityState) { Object.defineProperty(document, "visibilityState", originalVisibilityState); } vi.useRealTimers(); await screen.unmount(); host.remove(); } }); it("syncs the live branch into the active draft thread when no server thread exists", async () => { hasServerThreadRef.current = false; activeDraftThreadRef.current = { threadId: SHARED_THREAD_ID, environmentId: ENVIRONMENT_A, branch: null, worktreePath: null, }; const host = document.createElement("div"); document.body.append(host); const screen = await render( , { container: host, }, ); try { await Promise.resolve(); expect(setDraftThreadContextSpy).toHaveBeenCalledWith( scopeThreadRef(ENVIRONMENT_A, SHARED_THREAD_ID), { branch: BRANCH_NAME, worktreePath: null, }, ); expect(setThreadBranchSpy).not.toHaveBeenCalled(); } finally { await screen.unmount(); host.remove(); } }); it("does not overwrite a selected base branch while a new worktree draft is being configured", async () => { hasServerThreadRef.current = false; activeDraftThreadRef.current = { threadId: SHARED_THREAD_ID, environmentId: ENVIRONMENT_A, branch: "feature/base-branch", worktreePath: null, envMode: "worktree", }; const host = document.createElement("div"); document.body.append(host); const screen = await render( , { container: host, }, ); try { await Promise.resolve(); expect(setDraftThreadContextSpy).not.toHaveBeenCalled(); expect(setThreadBranchSpy).not.toHaveBeenCalled(); } finally { await screen.unmount(); host.remove(); } }); });