import type { ReactElement } from "react";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 { 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 { ServerUpdateAction, ServerUpdateProgress } 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("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"); });});
import type { ReactElement } from "react";
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 { 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 { ServerUpdateAction, ServerUpdateProgress } 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("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");
});
});