463 lines · 13.7 KB · TypeScript
RawDownload
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<T>() {  let resolve!: (value: T) => void;  let reject!: (reason?: unknown) => void;   const promise = new Promise<T>((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<never>() },  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<typeof import("@tanstack/react-query")>("@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<string, unknown> },    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 (    <>      <button        type="button"        onClick={() => setActiveThreadRef(scopeThreadRef(ENVIRONMENT_B, SHARED_THREAD_ID))}      >        Switch environment      </button>      <GitActionsControl gitCwd={GIT_CWD} activeThreadRef={activeThreadRef} />    </>  );} describe("GitActionsControl thread-scoped progress toast", () => {  afterEach(() => {    vi.useRealTimers();    vi.clearAllMocks();    activeRunStackedActionDeferredRef.current = createDeferredPromise<never>();    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(<Harness />, { 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(      <GitActionsControl        gitCwd={GIT_CWD}        activeThreadRef={scopeThreadRef(ENVIRONMENT_A, SHARED_THREAD_ID)}      />,      {        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(      <GitActionsControl        gitCwd={GIT_CWD}        activeThreadRef={scopeThreadRef(ENVIRONMENT_A, SHARED_THREAD_ID)}      />,      {        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(      <GitActionsControl        gitCwd={GIT_CWD}        activeThreadRef={scopeThreadRef(ENVIRONMENT_A, SHARED_THREAD_ID)}      />,      {        container: host,      },    );     try {      await Promise.resolve();       expect(setDraftThreadContextSpy).not.toHaveBeenCalled();      expect(setThreadBranchSpy).not.toHaveBeenCalled();    } finally {      await screen.unmount();      host.remove();    }  });});