Implement platform management features
This commit is contained in:
@@ -0,0 +1,255 @@
|
||||
/** @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 { GameClientBridgeCommandResponse, LogEntryBody, LogStreamResponse } from "../api/types";
|
||||
import { ServerManagementTerminalDrawer } from "./ServerManagementTerminalDrawer";
|
||||
|
||||
const apiMocks = vi.hoisted(() => ({
|
||||
getGameClientBridgeCommand: vi.fn(),
|
||||
listLogStreams: vi.fn(),
|
||||
openServerLogEvents: vi.fn(),
|
||||
queryLogStream: vi.fn(),
|
||||
queueGameClientBridgeCommand: 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.queueGameClientBridgeCommand).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.queueGameClientBridgeCommand).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.queueGameClientBridgeCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses RCON only after an operator submits a command", async () => {
|
||||
const pending = bridgeCommand("pending");
|
||||
const succeeded = bridgeCommand("succeeded");
|
||||
apiMocks.queueGameClientBridgeCommand.mockResolvedValue(pending);
|
||||
apiMocks.getGameClientBridgeCommand.mockResolvedValue(succeeded);
|
||||
await renderDrawer();
|
||||
await emitSession("session-current");
|
||||
await emitLog("stdout-current", "session-current", "process.stdout", logEntry(1, "ordinary live output"));
|
||||
expect(apiMocks.queueGameClientBridgeCommand).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.queueGameClientBridgeCommand).toHaveBeenCalledTimes(1);
|
||||
expect(apiMocks.getGameClientBridgeCommand).toHaveBeenCalledWith("server-1", pending.id);
|
||||
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 bridgeCommand(state: GameClientBridgeCommandResponse["state"]): GameClientBridgeCommandResponse {
|
||||
return {
|
||||
id: "command-1", serverInstanceId: "server-1", pluginId: "game.scum", profileKey: "scum-rcon", commandType: "management.command", priority: 50, state, approvalState: "not_required",
|
||||
result: state === "succeeded" ? { status: "succeeded", summary: "command completed", completedAt: "2026-08-14T00:00:03Z" } : undefined,
|
||||
expiresAt: "2026-08-14T00:01:00Z", createdAt: "2026-08-14T00:00:00Z", updatedAt: "2026-08-14T00:00:03Z", completedAt: state === "succeeded" ? "2026-08-14T00:00:03Z" : undefined
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user