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 { 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( , ); expect(markup).toContain("Update the desktop app on that machine to update this server."); expect(markup).not.toContain(" { 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 = [ { 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(); }); 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( , ); 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( , ); expect(markup).toContain("Downloading…"); expect(markup).not.toContain("Install"); }); it("keeps the failure visible with its retryable error", () => { const markup = renderToStaticMarkup( , ); expect(markup).toContain('role="alert"'); expect(markup).toContain("The package could not be verified."); expect(markup).not.toContain("animate-status-pulse"); }); });