198 lines · 6.0 KB · TypeScript
RawDownload
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<T>(initialValue: T): { current: T } {      const index = nextIndex();      if (!slots[index]) {        slots[index] = { current: initialValue };      }      return slots[index] as { current: T };    },    useState<T>(initialValue: T | (() => T)): [T, Dispatch<SetStateAction<T>>] {      const index = nextIndex();      if (index >= slots.length) {        slots[index] =          typeof initialValue === "function" ? (initialValue as () => T)() : initialValue;      }      const setValue: Dispatch<SetStateAction<T>> = (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<typeof import("react")>();  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<T>() {  let resolve!: (value: T) => void;  const promise = new Promise<T>((complete) => {    resolve = complete;  });  return { promise, resolve };} async function flushPromises(): Promise<void> {  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<typeof AsyncResult.success<{ targetVersion: string; method: "boot-service" }>>      >();    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<ReturnType<typeof AsyncResult.failure>>();    const retry =      deferred<        ReturnType<typeof AsyncResult.success<{ targetVersion: string; method: "boot-service" }>>      >();    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" }),    );  });});