/** @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 { formatTerminalServerTime } from "../utils/logTime"; 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 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 live current-session output 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 live output")); await emitReady(); expect(container?.textContent).toContain("generation A live output"); expect(container?.textContent).toContain("当前受管进程会话 · SSE 实时推送"); await emitSession("session-a"); await emitLog("stdout-a", "session-a", "process.stdout", logEntry(1, "generation A live output")); expect(container?.textContent).toContain("generation A live output"); expect(Array.from(container?.querySelectorAll(".terminal-text") ?? []).filter((node) => node.textContent === "generation A live output")).toHaveLength(1); expect(apiMocks.openServerLogEvents).toHaveBeenCalledTimes(1); expect(apiMocks.listLogStreams).not.toHaveBeenCalled(); expect(apiMocks.dispatchSourceRCONCommand).not.toHaveBeenCalled(); }); it("uses the server-provided clock for terminal system lines", async () => { const serverTime = "2001-02-03T04:05:06Z"; await renderDrawer(); expect(container?.textContent).toContain("时间同步中"); await emitSession("session-server-clock", serverTime); const systemLine = Array.from(container?.querySelectorAll(".terminal-line") ?? []).find((line) => line.textContent?.includes("已跟随当前受管进程输出会话")); expect(systemLine?.querySelector("time")?.textContent).toBe(formatTerminalServerTime(serverTime)); }); 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("keeps visible output 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).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("keeps visible output through a stopped boundary 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).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).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('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('.terminal-command-form input'); const form = container?.querySelector('.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( undefined} />); }); expect(apiMocks.openServerLogEvents).toHaveBeenCalledWith("server-1"); } async function emitSession(logSessionId?: string, serverTime = "2026-08-14T00:00:00Z") { await act(async () => eventStream.emit("session", { serverInstanceId: "server-1", logSessionId, streamCount: logSessionId ? 1 : 0, serverTime })); } 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("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" }; }