409 lines · 12.6 KB · TypeScript
RawDownload
import { act, type ReactElement } from "react";import { create, type ReactTestRenderer } from "react-test-renderer";import { renderToStaticMarkup } from "react-dom/server";import type { EnvironmentId } from "@t3tools/contracts";import * as Cause from "effect/Cause";import { AsyncResult } from "effect/unstable/reactivity";import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; const testState = vi.hoisted(() => ({  updateServer: vi.fn(),  toast: vi.fn(),  continueThreadsAfterServerUpdate: false,})); vi.mock("~/hooks/useCopyToClipboard", () => ({  useCopyToClipboard: () => ({ copyToClipboard: vi.fn() }),}));vi.mock("~/hooks/useSettings", () => ({  useEnvironmentSettings: (    _environmentId: EnvironmentId,    selector: (settings: { continueThreadsAfterServerUpdate: boolean }) => unknown,  ) => selector({ continueThreadsAfterServerUpdate: testState.continueThreadsAfterServerUpdate }),}));vi.mock("~/state/server", () => ({  serverEnvironment: { updateServer: Symbol("updateServer") },}));vi.mock("~/state/use-atom-command", () => ({  useAtomCommand: () => testState.updateServer,}));vi.mock("./ui/toast", () => ({  toastManager: { add: testState.toast },})); import {  readConfirmDialogState,  registerConfirmDialogHost,  resetConfirmDialogForTests,  respondToConfirmDialog,} from "~/confirmDialog";import {  ServerUpdateAction,  ServerUpdateProgress,  ServerUpdatesAction,  type ServerUpdateTarget,} from "./ServerUpdateAction"; type ActionElement = ReactElement<{  readonly onClick?: () => void;}>; function renderAction(): ActionElement {  return ServerUpdateAction({    environmentId: "env-test" as EnvironmentId,    serverLabel: "Test server",    selfUpdate: "boot-service",    targetVersion: "0.0.31",  }) as ActionElement;} async function flushPromises(): Promise<void> {  await Promise.resolve();  await Promise.resolve();} describe("ServerUpdateAction", () => {  beforeEach(() => {    testState.updateServer.mockReset();    testState.toast.mockReset();    testState.continueThreadsAfterServerUpdate = false;  });   it("reports success only after the shared update flow reconnects", async () => {    testState.updateServer.mockResolvedValue(      AsyncResult.success({ targetVersion: "0.0.31", method: "boot-service" as const }),    );     renderAction().props.onClick?.();    await flushPromises();     expect(testState.updateServer).toHaveBeenCalledWith({      environmentId: "env-test",      input: { targetVersion: "0.0.31" },    });    expect(testState.toast).toHaveBeenCalledWith({      type: "success",      title: "Test server updated",      description: "Reconnected on t3@0.0.31.",    });  });   it("reports one result when the update action is double-clicked", async () => {    let finishUpdate: (() => void) | undefined;    testState.updateServer.mockImplementation(      () =>        new Promise((resolve) => {          finishUpdate = () =>            resolve(              AsyncResult.success({ targetVersion: "0.0.31", method: "boot-service" as const }),            );        }),    );     const action = renderAction();    action.props.onClick?.();    action.props.onClick?.();     expect(testState.updateServer).toHaveBeenCalledTimes(1);    finishUpdate?.();    await flushPromises();    expect(testState.toast).toHaveBeenCalledTimes(1);  });   it("quietly releases the action when the operation is interrupted", async () => {    testState.updateServer.mockResolvedValue(AsyncResult.failure(Cause.interrupt()));     renderAction().props.onClick?.();    await flushPromises();     expect(testState.toast).not.toHaveBeenCalled();  });   it("keeps the manual instruction for desktop servers without remote update support", () => {    const markup = renderToStaticMarkup(      <ServerUpdateAction        environmentId={"env-test" as EnvironmentId}        serverLabel="Test server"        selfUpdate="desktop-managed"        targetVersion="0.0.31"      />,    );     expect(markup).toContain("Update the desktop app on that machine to update this server.");    expect(markup).not.toContain("<button");  });   it("updates remote desktop apps through the shared update flow", async () => {    testState.updateServer.mockResolvedValue(      AsyncResult.success({ targetVersion: "0.0.34", method: "desktop-app" as const }),    );     const action = ServerUpdateAction({      environmentId: "env-test" as EnvironmentId,      serverLabel: "Test server",      selfUpdate: "desktop-managed",      desktopAppUpdate: true,      targetVersion: "0.0.31",    }) as ActionElement;     // No confirm-dialog host is mounted in this test, which the component    // treats as consent: the click itself was the request.    action.props.onClick?.();    await flushPromises();     expect(testState.updateServer).toHaveBeenCalledWith({      environmentId: "env-test",      input: { targetVersion: "0.0.31" },    });    expect(testState.toast).toHaveBeenCalledWith({      type: "success",      title: "Test server updated",      description: "Desktop app relaunched on 0.0.34.",    });  });   it("leaves thread continuation off by default", async () => {    testState.updateServer.mockResolvedValue(      AsyncResult.success({ targetVersion: "0.0.31", method: "boot-service" as const }),    );    const action = ServerUpdateAction({      environmentId: "env-test" as EnvironmentId,      serverLabel: "Test server",      selfUpdate: "boot-service",      threadContinuation: true,      targetVersion: "0.0.31",    }) as ActionElement;     action.props.onClick?.();    await flushPromises();     expect(testState.updateServer).toHaveBeenCalledWith({      environmentId: "env-test",      input: { targetVersion: "0.0.31" },    });  });   it("applies the saved thread continuation preference automatically", async () => {    testState.updateServer.mockResolvedValue(      AsyncResult.success({ targetVersion: "0.0.31", method: "boot-service" as const }),    );    testState.continueThreadsAfterServerUpdate = true;    const action = ServerUpdateAction({      environmentId: "env-test" as EnvironmentId,      serverLabel: "Test server",      selfUpdate: "boot-service",      threadContinuation: true,      targetVersion: "0.0.31",    }) as ActionElement;     action.props.onClick?.();    await flushPromises();     expect(testState.updateServer).toHaveBeenCalledWith({      environmentId: "env-test",      input: { targetVersion: "0.0.31", continueRunningThreads: true },    });  });}); describe("ServerUpdatesAction", () => {  let renderer: ReactTestRenderer | undefined;  const targets: ReadonlyArray<ServerUpdateTarget> = [    {      environmentId: "batch-a" as EnvironmentId,      serverLabel: "Laptop",      selfUpdate: "boot-service",      targetVersion: "0.0.31",      threadContinuation: true,      continueThreadsAfterServerUpdate: true,    },    {      environmentId: "batch-b" as EnvironmentId,      serverLabel: "Office",      selfUpdate: "respawn",      targetVersion: "0.0.31",      threadContinuation: true,      continueThreadsAfterServerUpdate: false,    },    {      environmentId: "batch-c" as EnvironmentId,      serverLabel: "Manual",      selfUpdate: null,      targetVersion: "0.0.31",    },  ];  const success = AsyncResult.success({ targetVersion: "0.0.31", method: "boot-service" as const });   async function mount(batch = targets) {    await act(async () => {      renderer = create(<ServerUpdatesAction targets={batch} />);    });    return renderer!.root.findByType("button");  }   beforeEach(() => {    vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);    testState.updateServer.mockReset();    testState.toast.mockReset();    resetConfirmDialogForTests();  });  afterEach(async () => {    await act(async () => {      renderer?.unmount();    });    renderer = undefined;    resetConfirmDialogForTests();    vi.unstubAllGlobals();  });   it("updates both supported machines with their own continuation preference and skips the manual machine", async () => {    testState.updateServer.mockResolvedValue(success);    const button = await mount();    await act(async () => {      button.props.onClick();    });     expect(testState.updateServer.mock.calls.map(([target]) => target)).toEqual([      {        environmentId: "batch-a",        input: { targetVersion: "0.0.31", continueRunningThreads: true },      },      { environmentId: "batch-b", input: { targetVersion: "0.0.31" } },    ]);    expect(testState.toast.mock.calls.map(([toast]) => toast.title)).toEqual([      "Laptop updated",      "Office updated",    ]);  });   it("names a failed machine while letting the other machine complete", async () => {    testState.updateServer      .mockResolvedValueOnce(AsyncResult.failure(Cause.fail(new Error("Download failed"))))      .mockResolvedValueOnce(success);    const button = await mount();    await act(async () => {      button.props.onClick();    });     expect(testState.updateServer).toHaveBeenCalledTimes(2);    expect(testState.toast).toHaveBeenCalledWith({      type: "error",      title: "Laptop update failed",      description: "Download failed",    });    expect(testState.toast).toHaveBeenCalledWith(      expect.objectContaining({ type: "success", title: "Office updated" }),    );    expect(button.props.disabled).toBe(false);  });   it("starts each machine once when double-clicked and disables the action until both finish", async () => {    const completions: Array<() => void> = [];    testState.updateServer.mockImplementation(      () =>        new Promise((resolve) => {          completions.push(() => resolve(success));        }),    );    const button = await mount();    await act(async () => {      button.props.onClick();      button.props.onClick();    });    expect(testState.updateServer).toHaveBeenCalledTimes(2);    expect(button.props.disabled).toBe(true);    await act(async () => {      completions[0]!();    });    expect(button.props.disabled).toBe(true);    await act(async () => {      completions[1]!();    });    expect(button.props.disabled).toBe(false);    expect(testState.toast).toHaveBeenCalledTimes(2);  });   it("asks once for desktop machines and cancels the entire batch", async () => {    registerConfirmDialogHost();    const button = await mount(      targets.map((target, index) =>        index < 2 ? { ...target, selfUpdate: "desktop-managed", desktopAppUpdate: true } : target,      ),    );    await act(async () => {      button.props.onClick();    });    const confirmation = readConfirmDialogState();    expect(confirmation).toEqual(      expect.objectContaining({        status: "confirming",        message: expect.stringContaining("Laptop, Office"),      }),    );    expect(testState.updateServer).not.toHaveBeenCalled();    await act(async () => {      respondToConfirmDialog(false);    });    expect(testState.updateServer).not.toHaveBeenCalled();    expect(button.props.disabled).toBe(false);  });}); describe("ServerUpdateProgress", () => {  it("shows one calm status row for the restart wait", () => {    const markup = renderToStaticMarkup(      <ServerUpdateProgress        state={{          status: "running",          stage: "resuming",          fromVersion: "0.0.30",          targetVersion: "0.0.31",        }}      />,    );     expect(markup).toContain("Restarting…");    // The wait state is monochrome and calm: no versions, no step rail, no    // success/warning colors, one duty-cycled pulse on the dot.    expect(markup).not.toContain("0.0.30");    expect(markup).not.toContain("Resum");    expect(markup).not.toContain("text-success");    expect(markup).not.toContain("text-primary");    expect(markup).toContain("animate-status-pulse");    expect(markup).not.toContain("animate-spin");  });   it("folds the sub-second installing handoff into the download phase", () => {    const markup = renderToStaticMarkup(      <ServerUpdateProgress        state={{          status: "running",          stage: "installing",          fromVersion: "0.0.30",          targetVersion: "0.0.31",        }}      />,    );     expect(markup).toContain("Downloading…");    expect(markup).not.toContain("Install");  });   it("keeps the failure visible with its retryable error", () => {    const markup = renderToStaticMarkup(      <ServerUpdateProgress        state={{          status: "failed",          stage: "installing",          fromVersion: "0.0.30",          targetVersion: "0.0.31",          message: "The package could not be verified.",        }}      />,    );     expect(markup).toContain('role="alert"');    expect(markup).toContain("The package could not be verified.");    expect(markup).not.toContain("animate-status-pulse");  });});