import type { Dispatch, ReactElement, SetStateAction } from "react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; import * as Cause from "effect/Cause"; import { AsyncResult } from "effect/unstable/reactivity"; import type { EnvironmentId } from "@t3tools/contracts"; const testState = vi.hoisted(() => ({ updateServer: vi.fn(), toast: vi.fn(), })); const hooks = vi.hoisted(() => { let cursor = 0; let slots: unknown[] = []; const nextIndex = () => cursor++; return { beginRender() { cursor = 0; }, reset() { cursor = 0; slots = []; }, useEffect() { nextIndex(); }, useMemoCache(size: number): unknown[] { const index = nextIndex(); if (!slots[index]) { slots[index] = Array.from({ length: size }, () => Symbol.for("react.memo_cache_sentinel")); } return slots[index] as unknown[]; }, useRef(initialValue: T): { current: T } { const index = nextIndex(); if (!slots[index]) { slots[index] = { current: initialValue }; } return slots[index] as { current: T }; }, useState(initialValue: T | (() => T)): [T, Dispatch>] { const index = nextIndex(); if (index >= slots.length) { slots[index] = typeof initialValue === "function" ? (initialValue as () => T)() : initialValue; } const setValue: Dispatch> = (nextValue) => { const previous = slots[index] as T; slots[index] = typeof nextValue === "function" ? (nextValue as (value: T) => T)(previous) : nextValue; }; return [slots[index] as T, setValue]; }, }; }); vi.mock("react", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, useEffect: hooks.useEffect, useRef: hooks.useRef, useState: hooks.useState, }; }); vi.mock("react/compiler-runtime", () => ({ c: hooks.useMemoCache })); vi.mock("~/hooks/useCopyToClipboard", () => ({ useCopyToClipboard: () => ({ copyToClipboard: vi.fn() }), })); 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 { ServerUpdateAction } from "./ServerUpdateAction"; type ActionElement = ReactElement<{ readonly disabled?: boolean; readonly onClick?: () => void; }>; function renderAction(): ActionElement { hooks.beginRender(); return ServerUpdateAction({ environmentId: "env-test" as EnvironmentId, serverLabel: "Test server", selfUpdate: "boot-service", targetVersion: "0.0.29", }) as ActionElement; } function deferred() { let resolve!: (value: T) => void; const promise = new Promise((complete) => { resolve = complete; }); return { promise, resolve }; } async function flushPromises(): Promise { await Promise.resolve(); await Promise.resolve(); await Promise.resolve(); await Promise.resolve(); await Promise.resolve(); } describe("ServerUpdateAction", () => { beforeEach(() => { vi.useFakeTimers(); hooks.reset(); testState.updateServer.mockReset(); testState.toast.mockReset(); }); afterEach(() => { vi.useRealTimers(); }); it("starts a fresh reconnect timeout after a long install succeeds", async () => { const update = deferred< ReturnType> >(); testState.updateServer.mockReturnValue(update.promise); renderAction().props.onClick?.(); expect(renderAction().props.disabled).toBe(true); await vi.advanceTimersByTimeAsync(11 * 60_000); update.resolve( AsyncResult.success({ targetVersion: "0.0.29", method: "boot-service", }), ); await flushPromises(); // The click-based deadline would have fired by now. Success gets a fresh // twelve-minute reconnect window, so the action remains disabled. await vi.advanceTimersByTimeAsync(2 * 60_000); expect(renderAction().props.disabled).toBe(true); expect(testState.toast).not.toHaveBeenCalledWith( expect.objectContaining({ title: "Server update timed out" }), ); await vi.advanceTimersByTimeAsync(10 * 60_000); expect(renderAction().props.disabled).not.toBe(true); expect(testState.toast).toHaveBeenCalledWith( expect.objectContaining({ title: "Server update timed out" }), ); }); it("does not let an expired request clear a newer retry", async () => { const first = deferred>(); const retry = deferred< ReturnType> >(); testState.updateServer.mockReturnValueOnce(first.promise).mockReturnValueOnce(retry.promise); renderAction().props.onClick?.(); await vi.advanceTimersByTimeAsync(12 * 60_000); expect(renderAction().props.disabled).not.toBe(true); renderAction().props.onClick?.(); expect(renderAction().props.disabled).toBe(true); first.resolve(AsyncResult.failure(Cause.fail(new Error("first request failed late")))); await flushPromises(); expect(renderAction().props.disabled).toBe(true); expect(testState.updateServer).toHaveBeenCalledTimes(2); retry.resolve(AsyncResult.success({ targetVersion: "0.0.29", method: "boot-service" })); await flushPromises(); expect(renderAction().props.disabled).toBe(true); }); it("quietly releases the action when a restart RPC is interrupted", async () => { testState.updateServer.mockResolvedValue(AsyncResult.failure(Cause.interrupt())); renderAction().props.onClick?.(); await flushPromises(); expect(renderAction().props.disabled).not.toBe(true); expect(testState.toast).not.toHaveBeenCalledWith( expect.objectContaining({ title: "Server update failed" }), ); }); });