Files
browser/platform_web/pages/PluginPageHostPage.test.tsx

258 lines
13 KiB
TypeScript

/** @vitest-environment jsdom */
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { renderToStaticMarkup } from "react-dom/server";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import hostSource from "./PluginPageHostPage.tsx?raw";
import type { GamePluginResponse } from "../api/types";
import type { PageComponentProps } from "../contracts/page";
import type { PluginPageWorkspaceActions } from "../contracts/pluginPageHost";
import { capabilitiesForRoles } from "../contracts/workspace";
import type { OperationTracker } from "../stores/operations";
import { PluginPageHostPage } from "./PluginPageHostPage";
const apiMocks = vi.hoisted(() => ({
deletePluginData: vi.fn(),
executePluginBridge: vi.fn(),
getGameClientBridgeCommand: vi.fn(),
getGameClientBridgeStatus: vi.fn(),
listGameClientBridgeCommands: vi.fn(),
listGameClientBridgeSnapshots: vi.fn(),
listGamePlugins: vi.fn(),
listLogStreams: vi.fn(),
listPluginData: vi.fn(),
listScumUsers: vi.fn(),
listScumUserTrajectories: vi.fn(),
listScumVehicles: vi.fn(),
listScumVehicleTrajectories: vi.fn(),
listScumVehicleLocks: vi.fn(),
putPluginData: vi.fn(),
queueGameClientBridgeCommand: vi.fn(),
queryLogStream: vi.fn(),
transactPluginData: vi.fn()
}));
const bundleMocks = vi.hoisted(() => ({ loadPluginPageBundle: vi.fn() }));
vi.mock("../api/client", () => ({ platformApiClient: apiMocks }));
vi.mock("../utils/pluginPageBundles", async (importOriginal) => ({
...await importOriginal<typeof import("../utils/pluginPageBundles")>(),
loadPluginPageBundle: bundleMocks.loadPluginPageBundle
}));
const operations: OperationTracker = {
operations: [],
begin: () => "operation-test",
update: () => undefined,
succeed: () => undefined,
fail: () => undefined,
isPending: () => false
};
const plugin: GamePluginResponse = {
id: "game.scum",
name: "SCUM Server",
version: "1.0.0",
serverType: "scum",
serverDisplayName: "SCUM Server",
manifestRef: "artifact://manifests/game.scum/1.0.0",
createFormSchemaRef: "schemas/create-form.schema.json",
requiredRunCapabilities: [],
declaredPermissions: ["server.read", "server.logs.read", "server.game-client.read", "server.game-client.command"],
permissions: { ai: true, logs: false, files: false, jobs: true, artifacts: false, remoteAccess: false },
lifecycleActions: {},
bridgeActions: ["server.instances.read"],
pages: [{
key: "players",
title: "用户管理",
path: "/players",
bundleKey: "scum-server-plugin",
bundleVersion: "1.0.3",
bundleIntegritySha256: "sha256:3488b316d909e597024f8f31c7bc96ab8019f643d74a528dc442d3df0dc3d54e",
permissions: ["server.read", "server.logs.read", "server.game-client.read", "server.game-client.command"],
bridgeActions: ["server.instances.read"]
}],
tags: ["scum"],
aiPurposes: [],
productionLifecycle: { operations: ["install", "enable", "disable", "upgrade", "rollback", "retire", "dependency-check"], dependencyPolicy: "required" },
gameClientBridge: {
commands: [{ type: "diagnostic.ping", title: "Diagnostic ping", permission: "server.game-client.command", payloadSchemaRef: "schemas/bridge/diagnostic-ping.json", timeoutSeconds: 30, maxPayloadBytes: 4096 }],
snapshots: [{ type: "bridge.health", schemaVersion: "1", schemaRef: "schemas/bridge/health.json", keepForSeconds: 3600, maxRecords: 24 }],
commandRetentionSeconds: 86400,
maxCommands: 1000,
pages: [{ pageKey: "players", commandTypes: ["diagnostic.ping"], snapshotTypes: ["bridge.health"] }]
},
status: "installed"
};
let root: Root | null = null;
let container: HTMLDivElement | null = null;
let capturedWorkspaceActions: PluginPageWorkspaceActions | undefined;
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
beforeEach(() => {
capturedWorkspaceActions = undefined;
apiMocks.getGameClientBridgeStatus.mockResolvedValue({ available: true, features: [] });
apiMocks.listPluginData.mockResolvedValue({ items: [], count: 0 });
apiMocks.putPluginData.mockResolvedValue({ key: "user-1", value: { name: "Ada" } });
apiMocks.deletePluginData.mockResolvedValue(undefined);
apiMocks.transactPluginData.mockResolvedValue({ items: [], count: 0 });
apiMocks.queueGameClientBridgeCommand.mockResolvedValue({ id: "command-1" });
apiMocks.getGameClientBridgeCommand.mockResolvedValue({ id: "command-1" });
apiMocks.listGameClientBridgeCommands.mockResolvedValue({ items: [], count: 0 });
apiMocks.listGameClientBridgeSnapshots.mockResolvedValue({ items: [], count: 0 });
apiMocks.listScumUsers.mockResolvedValue({ items: [], count: 0 });
apiMocks.listScumUserTrajectories.mockResolvedValue({ items: [], count: 0 });
apiMocks.listScumVehicles.mockResolvedValue({ items: [], count: 0 });
apiMocks.listScumVehicleTrajectories.mockResolvedValue({ items: [], count: 0 });
apiMocks.listScumVehicleLocks.mockResolvedValue({ items: [], count: 0 });
apiMocks.listLogStreams.mockResolvedValue({ items: [{ id: "stream-login", serverInstanceId: "server-1", source: "file", streamKey: "scum.login", latestSeq: 2, storageBackend: "local-segments", retentionPolicy: "default", createdAt: "2026-08-15T12:00:00Z", updatedAt: "2026-08-15T12:00:00Z" }], count: 1 });
apiMocks.queryLogStream.mockResolvedValue({ logStreamId: "stream-login", entries: [], nextSeq: 2, latestSeq: 2 });
apiMocks.executePluginBridge.mockResolvedValue({ requestId: "request-1", action: "server.instances.read", status: "ok", result: { state: "ready" } });
bundleMocks.loadPluginPageBundle.mockResolvedValue(({ workspaceActions }: { workspaceActions?: PluginPageWorkspaceActions }) => {
capturedWorkspaceActions = workspaceActions;
return <div>mock plugin bundle</div>;
});
});
afterEach(async () => {
if (root) await act(async () => root?.unmount());
container?.remove();
root = null;
container = null;
vi.clearAllMocks();
});
function props(serverId = "server-1"): PageComponentProps {
const session = {
id: "operator-1",
displayName: "Operator",
status: "active" as const,
roles: ["platformAdmin" as const],
capabilities: capabilitiesForRoles(["platformAdmin"]),
profile: {},
source: "local" as const
};
return {
session,
params: { pluginId: "game.scum", routeKey: "players", serverId },
operations,
onNavigate: () => undefined,
onLogout: async () => undefined,
onProfileSave: async () => session,
onThemePreferenceSave: async () => ({ userId: session.id, paletteId: "mecha-black", backgroundPresetId: "mecha-grid", persistence: "api", updatedAt: "2026-07-20T00:00:00Z" })
};
}
describe("PluginPageHostPage", () => {
it("renders a generic manifest-owned bundle declaration", () => {
const html = renderToStaticMarkup(<PluginPageHostPage {...props()} initialPlugin={plugin} />);
expect(html).toContain("用户管理");
expect(html).toContain("平台托管上下文");
expect(html).toContain("scum-server-plugin@1.0.3");
expect(html).toContain("完整性");
expect(html).toContain("返回服务器");
expect(html).not.toMatch(/sessionToken|componentKey|hostPath|dsn|runSocket|credential/i);
});
it("does not mount a bundle without client-side availability validation", () => {
const html = renderToStaticMarkup(<PluginPageHostPage {...props("")} initialPlugin={plugin} />);
expect(html).toContain("未绑定服务器");
expect(html).toContain("正在校验并加载插件页面 bundle");
});
it("remains a manifest-driven host without SCUM component imports or game branches", () => {
expect(hostSource).toContain("loadPluginPageBundle");
expect(hostSource).not.toMatch(/ScumFileConfigWorkbench|GamePlayerIntelligencePanel|GameGiftCatalogPanel|ScumMapTrajectoryPanel|game\.scum/);
});
it("keeps generic plugin data callbacks stable across parent operational refreshes", () => {
expect(hostSource).toContain("readyPluginRef.current = readyPlugin");
expect(hostSource).toContain("hostContextRef.current = hostContext");
expect(hostSource).toContain("list: (collection, key) => platformApiClient.listPluginData(serverId, collection, key)");
expect(hostSource).toContain("put: (collection, key, value) => platformApiClient.putPluginData(serverId, collection, key, value)");
expect(hostSource).toContain("delete: (collection, key) => platformApiClient.deletePluginData(serverId, collection, key)");
expect(hostSource).toContain("transact: (collection, mutations) => platformApiClient.transactPluginData(serverId, collection, mutations)");
expect(hostSource).toContain("queue: (request) => platformApiClient.queueGameClientBridgeCommand(serverId, request)");
expect(hostSource).toContain("snapshots: (query) => platformApiClient.listGameClientBridgeSnapshots(serverId, query)");
expect(hostSource).toContain("createPluginBridgeDispatcher(context, platformApiClient)");
expect(hostSource).not.toContain("listSCUMPlayers:");
expect(hostSource).not.toContain("refreshWorkspace");
expect(hostSource).not.toContain("requestFile");
expect(hostSource).not.toContain("writeFile");
expect(hostSource).not.toContain("getDeclaredFileReadSnapshot");
expect(hostSource).toContain("}, [pluginId, serverId]);");
expect(hostSource).not.toContain("}, [hostContext, readyPlugin, serverId]);");
});
it("passes every generic data, dispatch, and game-client operation through the hosted bundle", async () => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root?.render(<PluginPageHostPage {...props()} initialPlugin={plugin} embedded />);
});
const actions = capturedWorkspaceActions;
expect(actions?.pluginData).toBeDefined();
expect(actions?.gameClient).toBeDefined();
expect(actions?.logs).toBeDefined();
expect(actions?.dispatch).toBeDefined();
const mutations = [
{ operation: "put" as const, key: "user-1", value: { name: "Ada" } },
{ operation: "delete" as const, key: "user-2" }
];
const queueRequest = {
profileKey: "example-client",
commandType: "reward.deliver",
payload: { steamId: "steam-1", giftKey: "starter" },
idempotencyKey: "gift:user-1:starter",
expiresAt: "2026-08-15T12:00:00Z"
};
const commandFilter = { profileKey: "example-client", state: "pending" as const, commandType: "reward.deliver" };
const snapshotQuery = { profileKey: "example-client", type: "example.positions", streamKey: "current", limit: 50 };
const dispatchEnvelope = { requestId: "request-1", action: "server.instances.read" as const, payload: { view: "summary" } };
await actions?.pluginData?.list("plugin_users", "user-1");
await actions?.pluginData?.put("plugin_users", "user-1", { name: "Ada" });
await actions?.pluginData?.delete("plugin_users", "user-2");
await actions?.pluginData?.transact("plugin_users", mutations);
await actions?.gameClient?.queue(queueRequest);
await actions?.gameClient?.get("command-1");
await actions?.gameClient?.list(commandFilter);
await actions?.gameClient?.snapshots(snapshotQuery);
await actions?.logs?.listStreams();
await actions?.logs?.query({ logStreamId: "stream-login", afterSeq: 1, limit: 50 });
await actions?.scum?.users({ limit: 10, online: true });
await actions?.scum?.vehicleLocks({ limit: 10, gameVehicleId: "veh-1" });
await expect(actions?.dispatch?.(dispatchEnvelope)).resolves.toMatchObject({ status: "ok", result: { state: "ready" } });
expect(apiMocks.listPluginData).toHaveBeenCalledWith("server-1", "plugin_users", "user-1");
expect(apiMocks.putPluginData).toHaveBeenCalledWith("server-1", "plugin_users", "user-1", { name: "Ada" });
expect(apiMocks.deletePluginData).toHaveBeenCalledWith("server-1", "plugin_users", "user-2");
expect(apiMocks.transactPluginData).toHaveBeenCalledWith("server-1", "plugin_users", mutations);
expect(apiMocks.queueGameClientBridgeCommand).toHaveBeenCalledWith("server-1", queueRequest);
expect(apiMocks.getGameClientBridgeCommand).toHaveBeenCalledWith("server-1", "command-1");
expect(apiMocks.listGameClientBridgeCommands).toHaveBeenCalledWith("server-1", commandFilter);
expect(apiMocks.listGameClientBridgeSnapshots).toHaveBeenCalledWith("server-1", snapshotQuery);
expect(apiMocks.listLogStreams).toHaveBeenCalledWith("server-1");
expect(apiMocks.queryLogStream).toHaveBeenCalledWith({ logStreamId: "stream-login", afterSeq: 1, limit: 50 });
expect(apiMocks.listScumUsers).toHaveBeenCalledWith("server-1", { limit: 10, online: true });
expect(apiMocks.listScumVehicleLocks).toHaveBeenCalledWith("server-1", { limit: 10, gameVehicleId: "veh-1" });
expect(apiMocks.executePluginBridge).toHaveBeenCalledWith({
requestId: "request-1",
pluginId: "game.scum",
routeKey: "players",
serverInstanceId: "server-1",
action: "server.instances.read",
aiPurpose: undefined,
payload: { view: "summary" }
});
});
});