299 lines
14 KiB
TypeScript
299 lines
14 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 { 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<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 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("hydrates recent current-session output when the terminal opens after Run already attached", async () => {
|
|
const currentStream = logStream("stdout-current", "session-current", "process.stdout");
|
|
currentStream.latestSeq = 900;
|
|
apiMocks.queryLogStream.mockResolvedValueOnce({ logStreamId: currentStream.id, entries: [logEntry(899, "existing SCUM output before drawer opened", "2026-08-14T00:14:59Z"), logEntry(900, "latest SCUM output before drawer opened", "2026-08-14T00:15:00Z")], nextSeq: 901, latestSeq: 900 });
|
|
await renderDrawer();
|
|
|
|
await emitSession("session-current");
|
|
await emitStream(currentStream);
|
|
await emitReady();
|
|
await flushPromises();
|
|
|
|
expect(container?.textContent).toContain("existing SCUM output before drawer opened");
|
|
expect(container?.textContent).toContain("latest SCUM output before drawer opened");
|
|
expect(apiMocks.queryLogStream).toHaveBeenCalledWith({ logStreamId: currentStream.id, afterSeq: 400, limit: 500 });
|
|
expect(apiMocks.listLogStreams).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<HTMLDivElement>(".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("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" onClose={() => 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<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 };
|
|
}
|
|
|
|
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"
|
|
};
|
|
}
|