276 lines · 7.7 KB · TypeScript
RawDownload
import { ThreadId } from "@t3tools/contracts";import { useState } from "react";import { afterEach, describe, expect, it, vi } from "vitest";import { render } from "vitest-browser-react"; const THREAD_A = ThreadId.makeUnsafe("thread-a");const THREAD_B = ThreadId.makeUnsafe("thread-b");const GIT_CWD = "/repo/project";const BRANCH_NAME = "feature/toast-scope"; const {  invalidateGitQueriesSpy,  refreshGitStatusSpy,  runStackedActionMutateAsyncSpy,  setThreadBranchSpy,  toastAddSpy,  toastCloseSpy,  toastPromiseSpy,  toastUpdateSpy,} = vi.hoisted(() => ({  invalidateGitQueriesSpy: vi.fn(() => Promise.resolve()),  refreshGitStatusSpy: vi.fn(() => Promise.resolve(null)),  runStackedActionMutateAsyncSpy: vi.fn(() => new Promise<never>(() => undefined)),  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((options: { queryKey?: string[] }) => {      if (options.queryKey?.[0] === "git-branches") {        return {          data: {            isRepo: true,            hasOriginRemote: true,            branches: [              {                name: BRANCH_NAME,                current: true,                isDefault: false,                worktreePath: null,              },            ],          },          error: null,        };      }       return { data: null, error: null };    }),    useQueryClient: vi.fn(() => ({})),  };}); vi.mock("~/components/ui/toast", () => ({  toastManager: {    add: toastAddSpy,    close: toastCloseSpy,    promise: toastPromiseSpy,    update: toastUpdateSpy,  },})); 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("~/lib/utils", async () => {  const actual = await vi.importActual<typeof import("~/lib/utils")>("~/lib/utils");   return {    ...actual,    newCommandId: vi.fn(() => "command-1"),    randomUUID: vi.fn(() => "action-1"),  };}); vi.mock("~/nativeApi", () => ({  readNativeApi: vi.fn(() => null),})); vi.mock("~/store", () => ({  useStore: (selector: (state: unknown) => unknown) =>    selector({      setThreadBranch: setThreadBranchSpy,      threads: [        { id: THREAD_A, branch: BRANCH_NAME, worktreePath: null },        { id: THREAD_B, branch: BRANCH_NAME, worktreePath: null },      ],    }),})); 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 [activeThreadId, setActiveThreadId] = useState(THREAD_A);   return (    <>      <button type="button" onClick={() => setActiveThreadId(THREAD_B)}>        Switch thread      </button>      <GitActionsControl gitCwd={GIT_CWD} activeThreadId={activeThreadId} />    </>  );} describe("GitActionsControl thread-scoped progress toast", () => {  afterEach(() => {    vi.useRealTimers();    vi.clearAllMocks();    document.body.innerHTML = "";  });   it("keeps an in-flight git action toast pinned to the thread 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: { threadId: THREAD_A },          title: "Pushing...",          type: "loading",        }),      );       await vi.advanceTimersByTimeAsync(1_000);       expect(toastUpdateSpy).toHaveBeenLastCalledWith(        "toast-1",        expect.objectContaining({          data: { threadId: THREAD_A },          title: "Pushing...",          type: "loading",        }),      );       const switchThreadButton = findButtonByText("Switch thread");      expect(switchThreadButton, 'Unable to find button containing "Switch thread"').toBeTruthy();      if (!(switchThreadButton instanceof HTMLButtonElement)) {        throw new Error('Unable to find button containing "Switch thread"');      }      switchThreadButton.click();      await vi.advanceTimersByTimeAsync(1_000);       expect(toastUpdateSpy).toHaveBeenLastCalledWith(        "toast-1",        expect.objectContaining({          data: { threadId: THREAD_A },          title: "Pushing...",          type: "loading",        }),      );    } finally {      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} activeThreadId={THREAD_A} />, {      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(GIT_CWD);    } finally {      if (originalVisibilityState) {        Object.defineProperty(document, "visibilityState", originalVisibilityState);      }      await screen.unmount();      host.remove();    }  });});