Files
browser/platform_web/components/ServerManagementTerminalDrawer.test.tsx
T

271 lines
13 KiB
TypeScript

/** @vitest-environment jsdom */
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { JobResponse, LogEntryBody, LogStreamResponse, SourceRCONCommandResponse } from "../api/types";
import { ServerManagementTerminalDrawer } from "./ServerManagementTerminalDrawer";
const apiMocks = vi.hoisted(() => ({
dispatchSourceRCONCommand: vi.fn(),
getJob: vi.fn(),
listLogStreams: vi.fn(),
openServerLogEvents: vi.fn(),
queryLogStream: vi.fn()
}));
vi.mock("../api/client", () => ({ platformApiClient: apiMocks }));
class FakeEventStream {
onerror: ((event: Event) => void) | null = null;
closed = false;
private readonly listeners = new Map<string, Set<(event: MessageEvent) => void>>();
addEventListener(type: string, listener: (event: MessageEvent) => void) {
const listeners = this.listeners.get(type) ?? new Set<(event: MessageEvent) => void>();
listeners.add(listener);
this.listeners.set(type, listeners);
}
removeEventListener(type: string, listener: (event: MessageEvent) => void) {
this.listeners.get(type)?.delete(listener);
}
close() {
this.closed = true;
this.listeners.clear();
}
emit(type: string, payload: unknown) {
const event = new MessageEvent(type, { data: JSON.stringify(payload) });
this.listeners.get(type)?.forEach((listener) => listener(event));
}
}
let root: Root | null = null;
let container: HTMLDivElement | null = null;
let eventStream: FakeEventStream;
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
beforeEach(() => {
eventStream = new FakeEventStream();
apiMocks.openServerLogEvents.mockReturnValue(eventStream);
apiMocks.listLogStreams.mockResolvedValue({ items: [], count: 0 });
apiMocks.queryLogStream.mockResolvedValue({ logStreamId: "", entries: [], nextSeq: 0, latestSeq: 0 });
vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => { callback(0); return 1; });
vi.stubGlobal("cancelAnimationFrame", vi.fn());
});
afterEach(async () => {
if (root) await act(async () => root?.unmount());
container?.remove();
root = null;
container = null;
vi.clearAllMocks();
vi.unstubAllGlobals();
});
describe("ServerManagementTerminalDrawer", () => {
it("shows current-session replay and keeps it on a repeated boundary for the same session", async () => {
await renderDrawer();
await emitSession("session-a");
await emitStream(logStream("stdout-a", "session-a", "process.stdout"));
await emitLog("stdout-a", "session-a", "process.stdout", logEntry(1, "generation A current replay"));
await emitReady();
expect(container?.textContent).toContain("generation A current replay");
expect(container?.textContent).toContain("当前受管进程会话 · SSE 实时推送");
await emitSession("session-a");
await emitLog("stdout-a", "session-a", "process.stdout", logEntry(1, "generation A current replay"));
expect(container?.textContent).toContain("generation A current replay");
expect(Array.from(container?.querySelectorAll(".terminal-text") ?? []).filter((node) => node.textContent === "generation A current replay")).toHaveLength(1);
expect(apiMocks.openServerLogEvents).toHaveBeenCalledTimes(1);
expect(apiMocks.listLogStreams).not.toHaveBeenCalled();
expect(apiMocks.dispatchSourceRCONCommand).not.toHaveBeenCalled();
});
it("renders an empty current session without accepting unrelated or sessionless logs", async () => {
await renderDrawer();
await emitSession();
await emitLog("legacy-job", undefined, "job", logEntry(1, "legacy output must stay historical"));
await emitReady();
expect(container?.textContent).toContain("当前没有可跟随的受管进程输出");
expect(container?.textContent).not.toContain("legacy output must stay historical");
expect(apiMocks.dispatchSourceRCONCommand).not.toHaveBeenCalled();
});
it("clears generation A on a new session and rejects late generation A events", async () => {
await renderDrawer();
await emitSession("session-a");
await emitStream(logStream("stdout-a", "session-a", "process.stdout"));
await emitLog("stdout-a", "session-a", "process.stdout", logEntry(1, "generation A output"));
await emitSession("session-b");
await emitLog("stdout-a", "session-a", "process.stdout", logEntry(2, "late generation A output"));
await emitStream(logStream("stdout-b", "session-b", "process.stdout"));
await emitLog("stdout-b", "session-b", "process.stdout", logEntry(1, "generation B output"));
expect(container?.textContent).not.toContain("generation A output");
expect(container?.textContent).not.toContain("late generation A output");
expect(container?.textContent).toContain("generation B output");
expect(container?.textContent).toContain("Run 已切换到新的受管进程输出会话");
expect(apiMocks.openServerLogEvents).toHaveBeenCalledTimes(1);
});
it("clears a stopped session and restores the running session on the same SSE connection", async () => {
await renderDrawer();
await emitSession("session-a");
await emitStream(logStream("stdout-a", "session-a", "process.stdout"));
await emitLog("stdout-a", "session-a", "process.stdout", logEntry(1, "running output before stop"));
await emitSession();
await emitLog("stdout-a", "session-a", "process.stdout", logEntry(2, "late output after stop"));
expect(container?.textContent).not.toContain("running output before stop");
expect(container?.textContent).not.toContain("late output after stop");
expect(container?.textContent).toContain("当前没有可跟随的受管进程输出");
await emitSession("session-b");
await emitStream(logStream("stdout-b", "session-b", "process.stdout"));
await emitLog("stdout-b", "session-b", "process.stdout", logEntry(1, "running output after recovery"));
expect(container?.textContent).not.toContain("running output before stop");
expect(container?.textContent).toContain("running output after recovery");
expect(apiMocks.openServerLogEvents).toHaveBeenCalledTimes(1);
});
it("keeps selected historical output separate while live output continues in the background", async () => {
const oldStream = logStream("stdout-old", "session-old", "process.stdout", "2026-08-01T00:00:00Z");
oldStream.latestSeq = 900;
apiMocks.listLogStreams.mockResolvedValue({ items: [oldStream], count: 1 });
apiMocks.queryLogStream.mockResolvedValue({ logStreamId: oldStream.id, entries: [logEntry(1, "selected historical output", "2026-08-01T00:00:01Z")], nextSeq: 1, latestSeq: 1 });
await renderDrawer();
await emitSession("session-current");
await emitStream(logStream("stdout-current", "session-current", "process.stdout"));
await emitLog("stdout-current", "session-current", "process.stdout", logEntry(1, "current live output"));
await clickButton("查看历史");
await flushPromises();
const select = container?.querySelector<HTMLSelectElement>('select[aria-label="选择历史日志流"]');
if (!select) throw new Error("history stream selector not found");
await act(async () => setSelectValue(select, oldStream.id));
await flushPromises();
expect(container?.textContent).toContain("selected historical output");
expect(container?.textContent).not.toContain("current live output");
await emitLog("stdout-current", "session-current", "process.stdout", logEntry(2, "new live output while viewing history"));
expect(container?.textContent).not.toContain("new live output while viewing history");
await clickButton("实时输出");
expect(container?.textContent).toContain("current live output");
expect(container?.textContent).toContain("new live output while viewing history");
expect(container?.textContent).not.toContain("selected historical output");
expect(apiMocks.listLogStreams).toHaveBeenCalledWith("server-1");
expect(apiMocks.queryLogStream).toHaveBeenCalledWith({ logStreamId: oldStream.id, afterSeq: 400, limit: 500 });
expect(apiMocks.dispatchSourceRCONCommand).not.toHaveBeenCalled();
});
it("uses RCON only after an operator submits a command", async () => {
const pending = sourceRCONDispatch("queued");
const succeeded = jobResponse("succeeded");
apiMocks.dispatchSourceRCONCommand.mockResolvedValue(pending);
apiMocks.getJob.mockResolvedValue(succeeded);
await renderDrawer();
await emitSession("session-current");
await emitLog("stdout-current", "session-current", "process.stdout", logEntry(1, "ordinary live output"));
expect(apiMocks.dispatchSourceRCONCommand).not.toHaveBeenCalled();
const input = container?.querySelector<HTMLInputElement>('.terminal-command-form input');
const form = container?.querySelector<HTMLFormElement>('.terminal-command-form');
if (!input || !form) throw new Error("command form not found");
await act(async () => setInputValue(input, "#ListPlayers"));
await act(async () => form.dispatchEvent(new SubmitEvent("submit", { bubbles: true, cancelable: true })));
await flushPromises();
expect(apiMocks.dispatchSourceRCONCommand).toHaveBeenCalledTimes(1);
expect(apiMocks.dispatchSourceRCONCommand).toHaveBeenCalledWith("server-1", expect.objectContaining({ kind: "command", command: "#ListPlayers" }));
expect(apiMocks.getJob).toHaveBeenCalledWith(pending.jobId);
expect(container?.textContent).toContain("ordinary live output");
});
});
async function renderDrawer() {
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
await act(async () => {
root?.render(<ServerManagementTerminalDrawer open serverId="server-1" serverName="SCUM Alpha" pluginId="game.scum" canManage onClose={() => undefined} />);
});
expect(apiMocks.openServerLogEvents).toHaveBeenCalledWith("server-1", { historyLimit: 500 });
}
async function emitSession(logSessionId?: string) {
await act(async () => eventStream.emit("session", { serverInstanceId: "server-1", logSessionId, streamCount: logSessionId ? 1 : 0, serverTime: "2026-08-14T00:00:00Z" }));
}
async function emitStream(stream: LogStreamResponse) {
await act(async () => eventStream.emit("stream", stream));
}
async function emitLog(streamId: string, logSessionId: string | undefined, streamKey: string, entry: LogEntryBody) {
await act(async () => eventStream.emit("log", { serverInstanceId: "server-1", streamId, source: "process", streamKey, logSessionId, latestSeq: entry.seq, entry }));
}
async function emitReady() {
await act(async () => eventStream.emit("ready", { serverInstanceId: "server-1", streamCount: 1, serverTime: "2026-08-14T00:00:00Z" }));
}
async function clickButton(label: string) {
const button = Array.from(container?.querySelectorAll<HTMLButtonElement>("button") ?? []).find((item) => item.textContent?.includes(label));
if (!button) throw new Error(`button not found: ${label}`);
await act(async () => button.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })));
}
async function flushPromises() {
await act(async () => { await Promise.resolve(); });
}
function setInputValue(input: HTMLInputElement, value: string) {
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set?.call(input, value);
input.dispatchEvent(new Event("input", { bubbles: true }));
}
function setSelectValue(select: HTMLSelectElement, value: string) {
Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, "value")?.set?.call(select, value);
select.dispatchEvent(new Event("change", { bubbles: true }));
}
function logStream(id: string, logSessionId: string, streamKey: string, updatedAt = "2026-08-14T00:00:00Z"): LogStreamResponse {
return { id, serverInstanceId: "server-1", source: "process", streamKey, logSessionId, sessionStartedAt: updatedAt, latestSeq: 1, storageBackend: "database", retentionPolicy: "default", createdAt: updatedAt, updatedAt };
}
function logEntry(seq: number, line: string, timestamp = `2026-08-14T00:00:0${seq}Z`): LogEntryBody {
return { seq, timestamp, line, redacted: true };
}
function sourceRCONDispatch(status: SourceRCONCommandResponse["status"]): SourceRCONCommandResponse {
return { jobId: "job-rcon-1", serverInstanceId: "server-1", status, message: "queued" };
}
function jobResponse(state: JobResponse["state"]): JobResponse {
return {
id: "job-rcon-1",
serverInstanceId: "server-1",
runEndpointId: "run-1",
capability: "remote.run.rcon.command",
targetKey: "source-rcon/command",
idempotencyKey: "idem-rcon-1",
state,
progress: { percent: state === "succeeded" ? 100 : 50, message: state === "succeeded" ? "command completed" : "running" },
retryPolicy: { maxAttempts: 1, initialBackoffSeconds: 1, maxBackoffSeconds: 1 },
attempt: 1,
reconcileCount: 0,
createdAt: "2026-08-14T00:00:00Z",
updatedAt: "2026-08-14T00:00:03Z"
};
}