Unverified Commit fda14e9e authored by Lemon-miaow's avatar Lemon-miaow
Browse files

fix(panel): 控制台、玩家页、权限页、文件页和仪表盘自动刷新状态,唤醒或停止后立即显示进行中

parent d65cf3af
Loading
Loading
Loading
Loading
+32 −1
Changes for panel/src/components/PhaseBadge.test.ts: 32 added lines, 1 removed line.
Original line number Diff line number Diff line
import { describe, it, expect } from "vitest";
import { PHASE_COLOR, phaseColor, phaseVariant, startFailure } from "@/components/PhaseBadge";
import { PHASE_COLOR, pendingPower, phaseColor, phaseVariant, shownPhase, startFailure } from "@/components/PhaseBadge";
import type { Phase } from "@/lib/types";

// The closed lifecycle set felis-api can emit — the Go source of truth is
@@ -59,3 +59,34 @@ describe("start failure", () => {
    expect(startFailure({ phase: "Failed", desiredState: "Running", startGaveUp: true })).toBe("gaveUp");
  });
});

describe("a server asked to move", () => {
  // desiredState flips the moment a wake or stop is accepted; the phase follows
  // once the operator acts. Every pair the two can be in:
  const cases: [Phase, "Running" | "Stopped" | undefined, "start" | "stop" | null, Phase][] = [
    ["Stopped", "Running", "start", "Starting"],
    ["Unknown", "Running", "start", "Starting"],
    // Woken while going down: it comes back up once it is down.
    ["Stopping", "Running", "start", "Starting"],
    ["Starting", "Running", null, "Starting"],
    ["Running", "Running", null, "Running"],
    // A failed start keeps its own badge and retry.
    ["Failed", "Running", null, "Failed"],
    ["Running", "Stopped", "stop", "Stopping"],
    ["Starting", "Stopped", "stop", "Stopping"],
    ["Stopping", "Stopped", null, "Stopping"],
    ["Stopped", "Stopped", null, "Stopped"],
    ["Failed", "Stopped", null, "Failed"],
    ["Unknown", "Stopped", null, "Unknown"],
    // A view without desiredState shows the phase as it is.
    ["Stopped", undefined, null, "Stopped"],
    ["Running", undefined, null, "Running"],
  ];

  for (const [phase, desiredState, pending, shown] of cases) {
    it(`${phase} asked ${desiredState ?? "(unknown)"} is pending ${pending} and shows ${shown}`, () => {
      expect(pendingPower({ phase, desiredState })).toBe(pending);
      expect(shownPhase({ phase, desiredState })).toBe(shown);
    });
  }
});
+23 −0
Changes for panel/src/components/PhaseBadge.tsx: 23 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -72,6 +72,29 @@ export function startFailure(s: {
  return s.startGaveUp ? "gaveUp" : "retrying";
}

/** PendingPower is a server asked to move that has not got there yet: asked to
 *  run with no pod up ("start"), or asked to stop while still up ("stop"). */
export type PendingPower = "start" | "stop";

/** pendingPower compares the phase with desiredState, the lever a wake or stop
 *  sets at once; the phase follows once the operator acts. Without desiredState
 *  (a public view) nothing reads as pending. */
export function pendingPower(s: { phase: Phase; desiredState?: string }): PendingPower | null {
  if (s.desiredState === "Running" && (s.phase === "Stopped" || s.phase === "Stopping" || s.phase === "Unknown")) {
    return "start";
  }
  if (s.desiredState === "Stopped" && (s.phase === "Running" || s.phase === "Starting")) return "stop";
  return null;
}

/** shownPhase is the phase a person should see: one already asked to start reads
 *  Starting, one asked to stop reads Stopping, so a wake or stop shows at once
 *  instead of the old phase lingering until the operator gets to it. */
export function shownPhase(s: { phase: Phase; desiredState?: string }): Phase {
  const pending = pendingPower(s);
  return pending === "start" ? "Starting" : pending === "stop" ? "Stopping" : s.phase;
}

export function PhaseBadge({
  phase,
  failure = null,
+134 −12
Changes for panel/src/components/PowerButton.test.tsx: 134 added lines, 12 removed lines.
Original line number Diff line number Diff line
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { act, fireEvent, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import i18next from "i18next";
import { PowerButton } from "./PowerButton";
import type { Phase } from "@/lib/types";
import { PowerButton, SUBMITTED_HOLD_MS, SUBMITTED_RECHECK_MS } from "./PowerButton";

const { wake, stop } = vi.hoisted(() => ({ wake: vi.fn(), stop: vi.fn() }));
vi.mock("@/lib/api", async (importOriginal) => {
@@ -18,11 +19,16 @@ beforeEach(() => {
  stop.mockReset();
});

// A fake-timer test that fails must not leave the clock frozen for the rest.
afterEach(() => {
  vi.useRealTimers();
});

describe("PowerButton", () => {
  it("wakes a stopped server and tells the parent", async () => {
    wake.mockResolvedValue(undefined);
    const onChanged = vi.fn();
    render(<PowerButton name="lobby" live={false} onChanged={onChanged} />);
    render(<PowerButton name="lobby" phase="Stopped" desiredState="Stopped" onChanged={onChanged} />);

    await userEvent.click(screen.getByRole("button", { name: t("servers:wake") }));

@@ -33,7 +39,7 @@ describe("PowerButton", () => {
  it("stays on the page with the reason when the wake is refused", async () => {
    wake.mockRejectedValue({ status: 429, code: "quota_exceeded", message: "raw" });
    const onChanged = vi.fn();
    render(<PowerButton name="lobby" live={false} onChanged={onChanged} />);
    render(<PowerButton name="lobby" phase="Stopped" desiredState="Stopped" onChanged={onChanged} />);

    await userEvent.click(screen.getByRole("button", { name: t("servers:wake") }));

@@ -45,7 +51,7 @@ describe("PowerButton", () => {
  it("stops an empty server without asking", async () => {
    stop.mockResolvedValue(undefined);
    const onChanged = vi.fn();
    render(<PowerButton name="lobby" live playersOnline={0} onChanged={onChanged} />);
    render(<PowerButton name="lobby" phase="Running" desiredState="Running" playersOnline={0} onChanged={onChanged} />);

    await userEvent.click(screen.getByRole("button", { name: t("servers:stop") }));

@@ -54,7 +60,7 @@ describe("PowerButton", () => {
  });

  it("asks before disconnecting players, and cancel sends nothing", async () => {
    render(<PowerButton name="lobby" live playersOnline={3} onChanged={vi.fn()} />);
    render(<PowerButton name="lobby" phase="Running" desiredState="Running" playersOnline={3} onChanged={vi.fn()} />);

    await userEvent.click(screen.getByRole("button", { name: t("servers:stop") }));
    expect(screen.getByText(t("servers:stop_confirm_players", { count: 3 }))).toBeTruthy();
@@ -67,7 +73,7 @@ describe("PowerButton", () => {
  it("asks when the player count cannot be read", async () => {
    stop.mockResolvedValue(undefined);
    const onChanged = vi.fn();
    render(<PowerButton name="lobby" live playersOnline={0} playerCountUnknown onChanged={onChanged} />);
    render(<PowerButton name="lobby" phase="Running" desiredState="Running" playersOnline={0} playerCountUnknown onChanged={onChanged} />);

    await userEvent.click(screen.getByRole("button", { name: t("servers:stop") }));
    expect(stop).not.toHaveBeenCalled();
@@ -81,7 +87,7 @@ describe("PowerButton", () => {
  it("keeps the confirmation open with the reason when the stop fails", async () => {
    stop.mockRejectedValue({ status: 409, code: "cooldown", message: "raw" });
    const onChanged = vi.fn();
    render(<PowerButton name="lobby" live playersOnline={2} onChanged={onChanged} />);
    render(<PowerButton name="lobby" phase="Running" desiredState="Running" playersOnline={2} onChanged={onChanged} />);

    await userEvent.click(screen.getByRole("button", { name: t("servers:stop") }));
    await userEvent.click(screen.getByRole("button", { name: t("servers:stop") }));
@@ -94,7 +100,7 @@ describe("PowerButton", () => {
  it("sends one call however fast it is clicked", async () => {
    let resolve!: () => void;
    wake.mockReturnValue(new Promise<void>((r) => (resolve = r)));
    render(<PowerButton name="lobby" live={false} onChanged={vi.fn()} />);
    render(<PowerButton name="lobby" phase="Stopped" desiredState="Stopped" onChanged={vi.fn()} />);

    const button = screen.getByRole("button", { name: t("servers:wake") });
    await userEvent.click(button);
@@ -107,7 +113,7 @@ describe("PowerButton", () => {
  it("offers a failed server a retry, which is the wake", async () => {
    wake.mockResolvedValue(undefined);
    const onChanged = vi.fn();
    render(<PowerButton name="lobby" live={false} failed onChanged={onChanged} />);
    render(<PowerButton name="lobby" phase="Failed" desiredState="Running" failed onChanged={onChanged} />);

    expect(screen.queryByRole("button", { name: t("servers:wake") })).toBeNull();
    await userEvent.click(screen.getByRole("button", { name: t("servers:retry_start") }));
@@ -121,7 +127,7 @@ describe("PowerButton", () => {
    let resolve!: () => void;
    stop.mockReturnValue(new Promise<void>((r) => (resolve = r)));
    const onChanged = vi.fn();
    render(<PowerButton name="lobby" live={false} failed playerCountUnknown onChanged={onChanged} />);
    render(<PowerButton name="lobby" phase="Failed" desiredState="Running" failed playerCountUnknown onChanged={onChanged} />);

    await userEvent.click(screen.getByRole("button", { name: t("servers:stop") }));

@@ -133,3 +139,119 @@ describe("PowerButton", () => {
    await vi.waitFor(() => expect(onChanged).toHaveBeenCalledOnce());
  });
});

describe("PowerButton after an accepted call", () => {
  const ok = () => wake.mockResolvedValue(undefined);

  it("keeps the wake in progress until the view shows it, then offers the stop", async () => {
    ok();
    const { rerender } = render(<PowerButton name="lobby" phase="Stopped" desiredState="Stopped" onChanged={vi.fn()} />);
    await userEvent.click(screen.getByRole("button", { name: t("servers:wake") }));

    // The parent's first reread can still carry the old status.
    rerender(<PowerButton name="lobby" phase="Stopped" desiredState="Stopped" onChanged={vi.fn()} />);
    expect(screen.getByRole("button", { name: t("servers:waking") })).toHaveProperty("disabled", true);
    expect(screen.queryByRole("button", { name: t("servers:wake") })).toBeNull();

    rerender(<PowerButton name="lobby" phase="Stopped" desiredState="Running" onChanged={vi.fn()} />);
    expect(screen.getByRole("button", { name: t("servers:stop") })).toHaveProperty("disabled", false);
  });

  it("asks the parent to reread until the view catches up, and no longer after", async () => {
    vi.useFakeTimers();
    ok();
    const onChanged = vi.fn();
    const { rerender } = render(
      <PowerButton name="lobby" phase="Stopped" desiredState="Stopped" onChanged={onChanged} />,
    );
    await act(async () => {
      fireEvent.click(screen.getByRole("button", { name: t("servers:wake") }));
    });
    const afterClick = onChanged.mock.calls.length;

    await act(() => vi.advanceTimersByTimeAsync(SUBMITTED_RECHECK_MS * 2));
    expect(onChanged.mock.calls.length).toBe(afterClick + 2);

    rerender(<PowerButton name="lobby" phase="Starting" desiredState="Running" onChanged={onChanged} />);
    const caughtUp = onChanged.mock.calls.length;
    await act(() => vi.advanceTimersByTimeAsync(SUBMITTED_RECHECK_MS * 3));
    expect(onChanged.mock.calls.length).toBe(caughtUp);
  });

  it("gives the button back when the view never catches up", async () => {
    vi.useFakeTimers();
    ok();
    render(<PowerButton name="lobby" phase="Stopped" desiredState="Stopped" onChanged={vi.fn()} />);
    await act(async () => {
      fireEvent.click(screen.getByRole("button", { name: t("servers:wake") }));
    });
    expect(screen.getByRole("button", { name: t("servers:waking") })).toBeTruthy();

    await act(() => vi.advanceTimersByTimeAsync(SUBMITTED_HOLD_MS - 1));
    expect(screen.getByRole("button", { name: t("servers:waking") })).toBeTruthy();
    await act(() => vi.advanceTimersByTimeAsync(1));
    expect(screen.getByRole("button", { name: t("servers:wake") })).toHaveProperty("disabled", false);
  });

  it("keeps a stop in progress until the server is down", async () => {
    stop.mockResolvedValue(undefined);
    const { rerender } = render(
      <PowerButton name="lobby" phase="Running" desiredState="Running" playersOnline={0} onChanged={vi.fn()} />,
    );
    await userEvent.click(screen.getByRole("button", { name: t("servers:stop") }));
    expect(screen.getByRole("button", { name: t("servers:stopping") })).toHaveProperty("disabled", true);

    rerender(<PowerButton name="lobby" phase="Running" desiredState="Stopped" onChanged={vi.fn()} />);
    expect(screen.getByRole("button", { name: t("servers:stopping") })).toHaveProperty("disabled", true);
    rerender(<PowerButton name="lobby" phase="Stopped" desiredState="Stopped" onChanged={vi.fn()} />);
    expect(screen.getByRole("button", { name: t("servers:wake") })).toHaveProperty("disabled", false);
  });

  it("keeps a retry in progress while the view still shows the failure", async () => {
    ok();
    const { rerender } = render(
      <PowerButton name="lobby" phase="Failed" desiredState="Running" failed onChanged={vi.fn()} />,
    );
    await userEvent.click(screen.getByRole("button", { name: t("servers:retry_start") }));

    rerender(<PowerButton name="lobby" phase="Failed" desiredState="Running" failed onChanged={vi.fn()} />);
    expect(screen.getByRole("button", { name: t("servers:waking") })).toHaveProperty("disabled", true);
    expect(screen.queryByRole("button", { name: t("servers:retry_start") })).toBeNull();

    rerender(<PowerButton name="lobby" phase="Starting" desiredState="Running" onChanged={vi.fn()} />);
    expect(screen.getByRole("button", { name: t("servers:stop") })).toBeTruthy();
  });
});

describe("PowerButton on a server already moving", () => {
  const cases: {
    phase: Phase;
    desiredState?: "Running" | "Stopped";
    shows: string;
    disabled: boolean;
  }[] = [
    { phase: "Stopping", shows: "servers:stopping", disabled: true },
    { phase: "Stopping", desiredState: "Stopped", shows: "servers:stopping", disabled: true },
    { phase: "Running", desiredState: "Stopped", shows: "servers:stopping", disabled: true },
    { phase: "Starting", desiredState: "Stopped", shows: "servers:stopping", disabled: true },
    // Woken while going down: it comes back up once it is down.
    { phase: "Stopping", desiredState: "Running", shows: "servers:waking", disabled: true },
    // Asked to run, no pod yet: Stop is the way out if it never comes up.
    { phase: "Stopped", desiredState: "Running", shows: "servers:stop", disabled: false },
    { phase: "Unknown", desiredState: "Running", shows: "servers:stop", disabled: false },
    { phase: "Starting", desiredState: "Running", shows: "servers:stop", disabled: false },
    // Without desiredState the phase alone decides.
    { phase: "Starting", shows: "servers:stop", disabled: false },
    { phase: "Unknown", shows: "servers:wake", disabled: false },
  ];

  for (const c of cases) {
    it(`${c.phase} asked ${c.desiredState ?? "(unknown)"} offers ${c.shows}`, () => {
      render(<PowerButton name="lobby" phase={c.phase} desiredState={c.desiredState} onChanged={vi.fn()} />);
      const buttons = screen.getAllByRole("button");
      expect(buttons).toHaveLength(1);
      expect(buttons[0].textContent).toBe(t(c.shows));
      expect(buttons[0]).toHaveProperty("disabled", c.disabled);
    });
  }
});
+70 −6
Changes for panel/src/components/PowerButton.tsx: 70 added lines, 6 removed lines.
Original line number Diff line number Diff line
import { useState } from "react";
import { useEffect, useRef, useState } from "react";
import { Loader2, Play, RotateCcw, Square } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { pendingPower } from "@/components/PhaseBadge";
import { api, humanizeError } from "@/lib/api";
import type { Phase } from "@/lib/types";
import { cn } from "@/lib/utils";

/** How long a sent wake or stop shows as in progress when the view never
 *  reflects it (the parent stopped polling, or someone reversed it at once). */
export const SUBMITTED_HOLD_MS = 20_000;

/** How often a sent call asks the parent to refetch until its view shows it. */
export const SUBMITTED_RECHECK_MS = 2_000;

interface Props {
  name: string;
  /** A pod is up or on its way (Starting/Running/Stopping): offer Stop. */
  live: boolean;
  phase: Phase;
  /** What the server was last asked to be. Absent in a view without it; the
   *  phase alone then decides. */
  desiredState?: "Running" | "Stopped";
  /** Its start Failed while meant to run: offer a retry and a stop. */
  failed?: boolean;
  playersOnline?: number;
@@ -20,6 +31,10 @@ interface Props {
  className?: string;
}

function isUp(phase: Phase): boolean {
  return phase === "Running" || phase === "Starting";
}

// PowerButton starts or stops one server. It is busy while the call runs (no
// double send), shows why a call was refused (quota, cooldown, a phase that
// moved on), and asks before a stop that would disconnect players: the count
@@ -27,9 +42,17 @@ interface Props {
// failed gets both ways out: retry (the wake, which felis-api turns into a fresh
// start) and stop. Nobody is on a server that never came up, so that stop does
// not ask.
//
// An accepted call keeps its spinner, and keeps asking the parent to reread,
// until the parent's view shows the server asked to move (the list and the
// console read a cache that lags the write by a moment), so nobody presses Wake
// again into a 429. A server on its way down offers nothing until it is down;
// one asked to run with no pod yet offers Stop, which is the way out when it
// never comes up.
export function PowerButton({
  name,
  live,
  phase,
  desiredState,
  failed = false,
  playersOnline,
  playerCountUnknown,
@@ -39,8 +62,34 @@ export function PowerButton({
}: Props) {
  const { t } = useTranslation("servers");
  const [busy, setBusy] = useState<"wake" | "stop" | null>(null);
  const [submitted, setSubmitted] = useState<"wake" | "stop" | null>(null);
  const [confirming, setConfirming] = useState(false);
  const [error, setError] = useState<string | null>(null);
  const changed = useRef(onChanged);
  useEffect(() => {
    changed.current = onChanged;
  }, [onChanged]);

  const pending = pendingPower({ phase, desiredState });
  const on = desiredState ? desiredState === "Running" : isUp(phase);

  // A sent call is done once the view shows the lever moved: a wake when the
  // server is meant to run (a retry once it is no longer Failed), a stop when it
  // is meant to stop. The hold ends it anyway if the view never gets there.
  const caughtUp = submitted === "wake" ? on && !failed : submitted === "stop" ? !on : true;
  useEffect(() => {
    if (submitted === null) return;
    if (caughtUp) {
      setSubmitted(null);
      return;
    }
    const recheck = window.setInterval(() => changed.current(), SUBMITTED_RECHECK_MS);
    const hold = window.setTimeout(() => setSubmitted(null), SUBMITTED_HOLD_MS);
    return () => {
      window.clearInterval(recheck);
      window.clearTimeout(hold);
    };
  }, [submitted, caughtUp]);

  async function run(kind: "wake" | "stop") {
    if (busy) return;
@@ -49,6 +98,7 @@ export function PowerButton({
    try {
      await (kind === "wake" ? api.wake(name) : api.stop(name));
      setConfirming(false);
      setSubmitted(kind);
      onChanged();
    } catch (e) {
      setError(humanizeError(e));
@@ -66,9 +116,18 @@ export function PowerButton({
      {busy === "stop" ? t("stopping") : t("stop")}
    </Button>
  );
  // inProgress is a server on its way somewhere with nothing to press meanwhile.
  const inProgress = (kind: "wake" | "stop") => (
    <Button size={size} variant={kind === "stop" ? "outline" : "default"} disabled>
      {spinner}
      {kind === "wake" ? t("waking") : t("stopping")}
    </Button>
  );

  let control;
  if (failed) {
  if (submitted !== null) {
    control = inProgress(submitted);
  } else if (failed) {
    control = (
      <div className="flex flex-wrap items-center justify-end gap-2">
        <Button size={size} onClick={() => void run("wake")} disabled={busy !== null}>
@@ -78,7 +137,12 @@ export function PowerButton({
        {stopButton("outline", () => void run("stop"))}
      </div>
    );
  } else if (!live) {
  } else if (pending === "stop" || (phase === "Stopping" && pending === null)) {
    control = inProgress("stop");
  } else if (pending === "start" && phase === "Stopping") {
    // Woken while stopping: the operator brings it back up once it is down.
    control = inProgress("wake");
  } else if (!on) {
    control = (
      <Button size={size} onClick={() => void run("wake")} disabled={busy !== null}>
        {busy === "wake" ? spinner : <Play />}
+112 −0
Changes for panel/src/components/States.test.tsx: 112 added lines, 0 removed lines.
Original line number Diff line number Diff line
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { NotRunning, RefreshError } from "./States";
import { humanizeError } from "@/lib/api";
import type { Phase } from "@/lib/types";
import type { StartFailure } from "./PhaseBadge";

const { wake, stop } = vi.hoisted(() => ({ wake: vi.fn(), stop: vi.fn() }));
vi.mock("@/lib/api", async (importOriginal) => {
  const actual = await importOriginal<typeof import("@/lib/api")>();
  return { ...actual, api: { ...actual.api, wake, stop } };
});

beforeEach(() => {
  wake.mockReset();
  stop.mockReset();
});

function notRunning(
  phase: Phase,
  desiredState?: "Running" | "Stopped",
  failure: StartFailure | null = null,
  onWoken = vi.fn(),
) {
  return (
    <NotRunning
      title="Server is asleep"
      body="Wake it to manage players."
      serverName="survival"
      phase={phase}
      desiredState={desiredState}
      failure={failure}
      autoRestarts={1}
      onWoken={onWoken}
    />
  );
}

describe("NotRunning", () => {
  it("offers the wake for a server that is down and meant to stay down", () => {
    render(notRunning("Stopped", "Stopped"));
    expect(screen.getByText("Server is asleep")).toBeTruthy();
    expect(screen.getByText("Wake it to manage players.")).toBeTruthy();
    expect(screen.getByRole("button", { name: "Wake" })).toHaveProperty("disabled", false);
  });

  it("says a woken server is starting and offers nothing to press", () => {
    for (const [phase, desired] of [
      ["Stopped", "Running"],
      ["Starting", "Running"],
      ["Starting", undefined],
    ] as const) {
      const { unmount } = render(notRunning(phase, desired));
      expect(screen.getByText("Server is starting"), `${phase}/${desired}`).toBeTruthy();
      expect(screen.getByText(/switches over by itself/), `${phase}/${desired}`).toBeTruthy();
      expect(screen.queryByRole("button"), `${phase}/${desired}`).toBeNull();
      unmount();
    }
  });

  it("says a server on its way down is shutting down", () => {
    for (const [phase, desired] of [
      ["Stopping", undefined],
      ["Stopping", "Stopped"],
      ["Running", "Stopped"],
    ] as const) {
      const { unmount } = render(notRunning(phase, desired));
      expect(screen.getByText("Server is shutting down"), `${phase}/${desired}`).toBeTruthy();
      expect(screen.queryByRole("button"), `${phase}/${desired}`).toBeNull();
      unmount();
    }
  });

  it("offers a failed start its retry and stop", () => {
    render(notRunning("Failed", "Running", "gaveUp"));
    expect(screen.getByText("Server failed to start")).toBeTruthy();
    expect(screen.getByRole("button", { name: "Retry start" })).toBeTruthy();
    expect(screen.getByRole("button", { name: "Stop" })).toBeTruthy();
  });

  it("counts the retries of a start the operator is still retrying", () => {
    render(notRunning("Failed", "Running", "retrying"));
    expect(screen.getByText("Start timed out — retrying")).toBeTruthy();
    expect(screen.getByText(/1 of 3 retries used/)).toBeTruthy();
  });

  it("turns into the starting notice once the page rereads a wake sent from it", async () => {
    wake.mockResolvedValue(undefined);
    const onWoken = vi.fn();
    const { rerender } = render(notRunning("Stopped", "Stopped", null, onWoken));

    await userEvent.click(screen.getByRole("button", { name: "Wake" }));
    expect(wake).toHaveBeenCalledWith("survival");
    expect(onWoken).toHaveBeenCalled();
    expect(screen.getByRole("button", { name: "Waking…" })).toHaveProperty("disabled", true);

    rerender(notRunning("Stopped", "Running", null, onWoken));
    expect(screen.getByRole("status").textContent).toContain("Server is starting");
  });
});

describe("RefreshError", () => {
  it("says the shown status is the last one read, and why the reread failed", () => {
    const err = { status: 429, code: "quota_exceeded", message: "raw" };
    render(<RefreshError error={err} />);
    const alert = screen.getByRole("alert");
    expect(alert.textContent).toContain("Couldn't refresh; what you see is the last status read.");
    expect(alert.textContent).toContain(humanizeError(err));
  });
});
Loading