feat(scum): rebuild plugin-owned management data
This commit is contained in:
@@ -244,18 +244,6 @@ describe("PlatformApiClient AI providers", () => {
|
||||
count: 1
|
||||
});
|
||||
}
|
||||
if (url.endsWith("/api/v1/server-instances/server-1/scum/players")) return jsonResponse({ items: [{ id: "scum-player-1", gamePlayerId: "steam-1", displayName: "Prisoner One", online: true }], count: 1 });
|
||||
if (url.endsWith("/api/v1/server-instances/server-1/scum/squads")) return jsonResponse({ items: [{ id: "squad-1", squadId: "squad-1", name: "Alpha" }], count: 1 });
|
||||
if (url.endsWith("/api/v1/server-instances/server-1/scum/squad-members")) return jsonResponse({ items: [{ id: "member-1", squadId: "squad-1", gamePlayerId: "steam-1" }], count: 1 });
|
||||
if (url.endsWith("/api/v1/server-instances/server-1/scum/vehicles")) return jsonResponse({ items: [{ id: "vehicle-1", vehicleId: "vehicle-1", label: "SUV" }], count: 1 });
|
||||
if (url.endsWith("/api/v1/server-instances/server-1/scum/flags")) return jsonResponse({ items: [{ id: "flag-1", flagId: "flag-1", ownerSquadId: "squad-1" }], count: 1 });
|
||||
if (url.endsWith("/api/v1/server-instances/server-1/scum/positions")) return jsonResponse({ items: [{ id: "position-1", subjectType: "player", subjectId: "steam-1", x: 1, y: 2, z: 3 }], count: 1 });
|
||||
if (url.endsWith("/api/v1/server-instances/server-1/scum/operations") && (!init?.method || init.method === "GET")) return jsonResponse({ items: [], count: 0 });
|
||||
if (url.endsWith("/api/v1/server-instances/server-1/scum/operations") && init?.method === "POST") return jsonResponse({ id: "op-1", serverInstanceId: server.id, pluginId: plugin.id, templateKey: "player.fame.set", status: "waiting", approvalLevel: "operator", createdAt: "2026-07-03T00:00:00Z", updatedAt: "2026-07-03T00:00:00Z" });
|
||||
if (url.endsWith("/api/v1/server-instances/server-1/scum/operations/op-1/approve") && init?.method === "POST") return jsonResponse({ id: "op-1", serverInstanceId: server.id, pluginId: plugin.id, templateKey: "player.fame.set", status: "queued", approvalLevel: "operator", createdAt: "2026-07-03T00:00:00Z", updatedAt: "2026-07-03T00:00:00Z" });
|
||||
if (url.endsWith("/api/v1/server-instances/server-1/scum/workflows") && (!init?.method || init.method === "GET")) return jsonResponse({ items: [], count: 0 });
|
||||
if (url.endsWith("/api/v1/server-instances/server-1/scum/workflows") && init?.method === "POST") return jsonResponse({ id: "workflow-1", serverInstanceId: server.id, pluginId: plugin.id, templateKey: "scum.world-refresh", status: "queued", createdAt: "2026-07-03T00:00:00Z", updatedAt: "2026-07-03T00:00:00Z" });
|
||||
if (url.endsWith("/api/v1/server-instances/server-1/scum/workflow-steps?workflowId=workflow-1")) return jsonResponse({ items: [{ id: "step-1", workflowId: "workflow-1", serverInstanceId: server.id, stepKey: "read-positions", status: "queued", createdAt: "2026-07-03T00:00:00Z", updatedAt: "2026-07-03T00:00:00Z" }], count: 1 });
|
||||
if (url.endsWith("/api/v1/file-operations/dispatch") && init?.method === "POST") {
|
||||
expect(JSON.parse(String(init.body))).toEqual({
|
||||
serverInstanceId: server.id,
|
||||
@@ -554,18 +542,6 @@ describe("PlatformApiClient AI providers", () => {
|
||||
await expect(client.deleteServerInstance(server.id, { password: "secret-password", force: true, confirmation: "FORCE DELETE" })).resolves.toBeUndefined();
|
||||
await expect(client.getPlatformResourceUsage()).resolves.toMatchObject({ source: "platform-derived", cpuPercent: 28 });
|
||||
await expect(client.listServerMetrics()).resolves.toMatchObject({ count: 1, items: [{ serverInstanceId: server.id, online: true }] });
|
||||
await expect(client.listSCUMPlayers(server.id)).resolves.toMatchObject({ count: 1, items: [{ gamePlayerId: "steam-1" }] });
|
||||
await expect(client.listSCUMSquads(server.id)).resolves.toMatchObject({ count: 1 });
|
||||
await expect(client.listSCUMSquadMembers(server.id)).resolves.toMatchObject({ count: 1 });
|
||||
await expect(client.listSCUMVehicles(server.id)).resolves.toMatchObject({ count: 1 });
|
||||
await expect(client.listSCUMFlags(server.id)).resolves.toMatchObject({ count: 1 });
|
||||
await expect(client.listSCUMPositions(server.id)).resolves.toMatchObject({ count: 1 });
|
||||
await expect(client.listSCUMOperations(server.id)).resolves.toMatchObject({ count: 0 });
|
||||
await expect(client.createSCUMOperation(server.id, { templateKey: "player.fame.set", playerId: "steam-1", payload: { fame: 100 }, reason: "typed correction", idempotencyKey: "idem-scum-op" })).resolves.toMatchObject({ id: "op-1", status: "waiting" });
|
||||
await expect(client.approveSCUMOperation(server.id, "op-1")).resolves.toMatchObject({ id: "op-1", status: "queued" });
|
||||
await expect(client.listSCUMWorkflows(server.id)).resolves.toMatchObject({ count: 0 });
|
||||
await expect(client.createSCUMWorkflow(server.id, { templateKey: "scum.world-refresh", idempotencyKey: "idem-scum-workflow" })).resolves.toMatchObject({ id: "workflow-1", status: "queued" });
|
||||
await expect(client.listSCUMWorkflowSteps(server.id, "workflow-1")).resolves.toMatchObject({ count: 1, items: [{ stepKey: "read-positions" }] });
|
||||
await expect(client.dispatchFileOperation({ serverInstanceId: server.id, operation: "read", key: "logs/latest.log", idempotencyKey: "idem-file" })).resolves.toMatchObject({
|
||||
status: "queued",
|
||||
job: { capability: "files.read", targetKey: "logs/latest.log" }
|
||||
@@ -624,7 +600,7 @@ describe("PlatformApiClient AI providers", () => {
|
||||
client.invokeAI({ requestId: "ai-1", serverInstanceId: server.id, purpose: "config.suggest", prompt: "Tune PVP safely", currentConfig: "server.name=Example Survival #1\n" })
|
||||
).resolves.toMatchObject({ status: "ok", usage: { mocked: true }, configRecommendation: { diffSummary: "review required" } });
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(48);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(36);
|
||||
});
|
||||
|
||||
it("calls plugin marketplace endpoints with filter and state contracts", async () => {
|
||||
@@ -653,24 +629,6 @@ describe("PlatformApiClient AI providers", () => {
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("surfaces SCUM typed operation failures from the platform", async () => {
|
||||
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = String(input);
|
||||
if (url.endsWith("/api/v1/server-instances/server-1/scum/operations") && init?.method === "POST") {
|
||||
return new Response(JSON.stringify({ code: "validation", message: "SCUM operation template is not declared" }), {
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json" }
|
||||
});
|
||||
}
|
||||
throw new Error(`unexpected request: ${url}`);
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const client = new PlatformApiClient();
|
||||
|
||||
await expect(client.createSCUMOperation(server.id, { templateKey: "raw.sql", reason: "unsafe", idempotencyKey: "bad-scum-op" })).rejects.toThrow("SCUM operation template is not declared");
|
||||
});
|
||||
|
||||
it("keeps raw key and base URL fields out of provider responses", () => {
|
||||
expect("apiKey" in provider).toBe(false);
|
||||
expect("rawApiKey" in provider).toBe(false);
|
||||
|
||||
@@ -101,14 +101,6 @@ import type {
|
||||
RemoteAdapterDeclarationListResponse,
|
||||
RemoteAdapterRequest,
|
||||
RemoteAdapterResponse,
|
||||
SCUMListResponse,
|
||||
SCUMOperationListResponse,
|
||||
SCUMOperationRequest,
|
||||
SCUMOperationResponse,
|
||||
SCUMWorkflowCreateRequest,
|
||||
SCUMWorkflowListResponse,
|
||||
SCUMWorkflowResponse,
|
||||
SCUMWorkflowStepListResponse,
|
||||
ServerRuntimeActionsResponse,
|
||||
UserCreateRequest,
|
||||
UserListResponse,
|
||||
@@ -590,61 +582,19 @@ export class PlatformApiClient {
|
||||
return this.request(`/server-instances/${encodeURIComponent(serverInstanceId)}/plugin-data/${encodeURIComponent(collection)}`, { method: "PUT", body: { key, value } });
|
||||
}
|
||||
|
||||
async deletePluginData(serverInstanceId: string, collection: string, key: string): Promise<void> {
|
||||
const query = new URLSearchParams({ key }).toString();
|
||||
return this.request(`/server-instances/${encodeURIComponent(serverInstanceId)}/plugin-data/${encodeURIComponent(collection)}?${query}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
async transactPluginData(serverInstanceId: string, collection: string, mutations: Array<{ operation: "put" | "delete"; key: string; value?: Record<string, unknown> }>): Promise<{ items: Array<{ key: string; value: Record<string, unknown> }>; count: number }> {
|
||||
return this.request(`/server-instances/${encodeURIComponent(serverInstanceId)}/plugin-data/${encodeURIComponent(collection)}/transaction`, { method: "POST", body: { mutations } });
|
||||
}
|
||||
|
||||
async requestRemoteAdapter(serverInstanceId: string, request: RemoteAdapterRequest): Promise<RemoteAdapterResponse> {
|
||||
return this.request<RemoteAdapterResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/remote-adapters`, { method: "POST", body: request });
|
||||
}
|
||||
|
||||
async listSCUMPlayers(serverInstanceId: string): Promise<SCUMListResponse> {
|
||||
return this.request<SCUMListResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/players`);
|
||||
}
|
||||
|
||||
async listSCUMSquads(serverInstanceId: string): Promise<SCUMListResponse> {
|
||||
return this.request<SCUMListResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/squads`);
|
||||
}
|
||||
|
||||
async listSCUMSquadMembers(serverInstanceId: string): Promise<SCUMListResponse> {
|
||||
return this.request<SCUMListResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/squad-members`);
|
||||
}
|
||||
|
||||
async listSCUMVehicles(serverInstanceId: string): Promise<SCUMListResponse> {
|
||||
return this.request<SCUMListResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/vehicles`);
|
||||
}
|
||||
|
||||
async listSCUMFlags(serverInstanceId: string): Promise<SCUMListResponse> {
|
||||
return this.request<SCUMListResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/flags`);
|
||||
}
|
||||
|
||||
async listSCUMPositions(serverInstanceId: string): Promise<SCUMListResponse> {
|
||||
return this.request<SCUMListResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/positions`);
|
||||
}
|
||||
|
||||
async listSCUMOperations(serverInstanceId: string): Promise<SCUMOperationListResponse> {
|
||||
return this.request<SCUMOperationListResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/operations`);
|
||||
}
|
||||
|
||||
async createSCUMOperation(serverInstanceId: string, request: SCUMOperationRequest): Promise<SCUMOperationResponse> {
|
||||
return this.request<SCUMOperationResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/operations`, { method: "POST", body: request });
|
||||
}
|
||||
|
||||
async approveSCUMOperation(serverInstanceId: string, operationId: string): Promise<SCUMOperationResponse> {
|
||||
return this.request<SCUMOperationResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/operations/${encodeURIComponent(operationId)}/approve`, { method: "POST", body: {} });
|
||||
}
|
||||
|
||||
async listSCUMWorkflows(serverInstanceId: string): Promise<SCUMWorkflowListResponse> {
|
||||
return this.request<SCUMWorkflowListResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/workflows`);
|
||||
}
|
||||
|
||||
async createSCUMWorkflow(serverInstanceId: string, request: SCUMWorkflowCreateRequest): Promise<SCUMWorkflowResponse> {
|
||||
return this.request<SCUMWorkflowResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/workflows`, { method: "POST", body: request });
|
||||
}
|
||||
|
||||
async listSCUMWorkflowSteps(serverInstanceId: string, workflowId?: string): Promise<SCUMWorkflowStepListResponse> {
|
||||
const params = new URLSearchParams();
|
||||
if (workflowId) params.set("workflowId", workflowId);
|
||||
const query = params.toString();
|
||||
return this.request<SCUMWorkflowStepListResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/workflow-steps${query ? `?${query}` : ""}`);
|
||||
}
|
||||
|
||||
async dispatchFileOperation(request: FileOperationDispatchRequest): Promise<FileOperationDispatchResponse> {
|
||||
return this.request<FileOperationDispatchResponse>("/file-operations/dispatch", {
|
||||
method: "POST",
|
||||
|
||||
@@ -6,7 +6,7 @@ API clients and DTO types live here, not inside page components.
|
||||
|
||||
- `users`: user and role APIs.
|
||||
- `serverPlugins`: plugin marketplace and installed plugin APIs.
|
||||
- `serverInstances`: create server, lifecycle, deployment/member/detail APIs, and SCUM typed projection/workflow APIs.
|
||||
- `serverInstances`: create server, lifecycle, deployment/member/detail APIs, and scoped plugin-data collections.
|
||||
- `aiProviders`: provider CRUD, test, and model APIs.
|
||||
- `jobs`: job status and operation APIs.
|
||||
- `runEndpoints`: run endpoint status, lifecycle capabilities, and capacity APIs.
|
||||
@@ -26,7 +26,7 @@ Normal browser login uses the platform's HttpOnly SameSite cookie and `credentia
|
||||
- `getServerRuntimeBinding` reads `/server-instances/{id}/runtime-binding`; `updateServerRuntimeBinding` patches the selected profile and logical refs for internal/advanced logical transports. Server detail must not expose a manual runtime-binding tab or require these fields before normal start/stop when plugin-declared deployment/lifecycle data is sufficient. Responses contain only profile metadata, logical key names, configured/secret-backed flags, missing keys, and safe reasons. They never contain stored refs or secret values.
|
||||
- `startServerInstance` and `stopServerInstance` post `ServerLifecycleCommandRequest` with the current config version and receive the lifecycle job response.
|
||||
- `listServerAdministratorCandidates`, `addServerAdministrator`, and `removeServerAdministrator` call server membership endpoints so server owners can invite or remove active non-platform-admin server administrators.
|
||||
- SCUM projection reads use `listSCUMPlayers`, `listSCUMSquads`, `listSCUMSquadMembers`, `listSCUMVehicles`, `listSCUMFlags`, and `listSCUMPositions`; SCUM writes use `createSCUMOperation`, `approveSCUMOperation`, `createSCUMWorkflow`, and workflow/step list APIs. These APIs expose only projection rows, typed template keys, status, and safe summaries, never SQL text, RCON text, DSNs, host paths, or protected payloads.
|
||||
- Game-specific pages use the scoped `plugin-data` collection API and declared plugin bridge machine actions; Platform does not expose game-specific projection or workflow clients.
|
||||
- `dispatchFileOperation` posts `FileOperationDispatchRequest` to `/file-operations/dispatch` using logical file keys and scoped refs rather than raw host paths; it is not wired into SCUM server-detail/plugin pages as a raw file workbench.
|
||||
- `listArtifacts`, `openArtifactDownload`, and `readArtifactContent` use platform artifact routes for available job/server artifacts. Browser reads are chunked through `/artifacts/{id}/content` and must render only safe filenames, checksums, progress, and platform storage behavior.
|
||||
- `authorizePluginBridge` posts `PluginBridgeAuthorizeRequest` to `/plugin-bridge/authorize` for preflight decisions.
|
||||
|
||||
@@ -51,10 +51,14 @@ export interface GameClientBridgeQueryTemplateDeclarationResponse {
|
||||
targetKey: string;
|
||||
parameterSchemaRef: string;
|
||||
resultSchemaRef: string;
|
||||
sqlRef?: string;
|
||||
maxRows: number;
|
||||
timeoutSeconds: number;
|
||||
rowTarget?: { collection: string; upsertKeys: string[]; columnMappings: Record<string, string> };
|
||||
}
|
||||
|
||||
export interface GameClientBridgeDataPackDeclarationResponse { key: string; databaseUserVersion: number; logParserRefs: string[]; configMapRefs: string[]; }
|
||||
|
||||
export interface GameClientBridgePageContractResponse {
|
||||
pageKey: string;
|
||||
commandTypes?: string[];
|
||||
@@ -85,6 +89,7 @@ export interface GameClientBridgeManifestResponse {
|
||||
commands: GameClientBridgeCommandDeclarationResponse[];
|
||||
snapshots: GameClientBridgeSnapshotDeclarationResponse[];
|
||||
queryTemplates?: GameClientBridgeQueryTemplateDeclarationResponse[];
|
||||
dataPacks?: GameClientBridgeDataPackDeclarationResponse[];
|
||||
commandRetentionSeconds: number;
|
||||
maxCommands: number;
|
||||
pages?: GameClientBridgePageContractResponse[];
|
||||
@@ -1373,17 +1378,6 @@ export interface RemoteAdapterResponse {
|
||||
completedAt?: string;
|
||||
}
|
||||
|
||||
export type SCUMJsonRecord = Record<string, unknown>;
|
||||
export interface SCUMListResponse<T = SCUMJsonRecord> { items: T[]; count: number; }
|
||||
export interface SCUMWorkflowCreateRequest { templateKey: string; idempotencyKey: string; input?: SCUMJsonRecord; }
|
||||
export interface SCUMOperationRequest { templateKey: string; playerId?: string; payload?: SCUMJsonRecord; guard?: SCUMJsonRecord; reason: string; idempotencyKey: string; }
|
||||
export interface SCUMWorkflowResponse { id: string; serverInstanceId: string; pluginId: string; templateKey: string; requestedBy?: string; idempotencyKey?: string; status: string; currentStepKey?: string; input?: SCUMJsonRecord; safeSummary?: SCUMJsonRecord; blockerReason?: string; auditReferences?: string[]; createdAt: string; updatedAt: string; completedAt?: string; }
|
||||
export interface SCUMWorkflowStepResponse { id: string; workflowId: string; serverInstanceId: string; stepKey: string; dependsOn?: string[]; status: string; operationKey?: string; queryTemplateKey?: string; capability?: string; targetKey?: string; jobId?: string; attempt?: number; maxAttempts?: number; mutatesState?: boolean; confirmation?: SCUMJsonRecord; safeSummary?: SCUMJsonRecord; blockerReason?: string; auditReferences?: string[]; createdAt: string; updatedAt: string; completedAt?: string; }
|
||||
export interface SCUMOperationResponse { id: string; serverInstanceId: string; pluginId: string; templateKey: string; playerId?: string; requesterId?: string; approverId?: string; approvalLevel: string; payload?: SCUMJsonRecord; guard?: SCUMJsonRecord; confirmation?: SCUMJsonRecord; status: string; reason?: string; runJobId?: string; safeSummary?: SCUMJsonRecord; auditReferences?: string[]; createdAt: string; approvedAt?: string; completedAt?: string; updatedAt: string; }
|
||||
export type SCUMWorkflowListResponse = SCUMListResponse<SCUMWorkflowResponse>;
|
||||
export type SCUMWorkflowStepListResponse = SCUMListResponse<SCUMWorkflowStepResponse>;
|
||||
export type SCUMOperationListResponse = SCUMListResponse<SCUMOperationResponse>;
|
||||
|
||||
export interface ServerConfigResponse {
|
||||
serverInstanceId: string;
|
||||
configVersion: number;
|
||||
|
||||
@@ -1,6 +1,24 @@
|
||||
import type { PluginBridgeExecuteEnvelope, PluginBridgeExecutionResult } from "./pluginBridge";
|
||||
import type { GameClientBridgeCommandFilterRequest, GameClientBridgeQueueRequest, GameClientBridgeSnapshotQuery } from "../api/types";
|
||||
|
||||
export interface PluginDataMutation {
|
||||
operation: "put" | "delete";
|
||||
key: string;
|
||||
value?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface PluginPageWorkspaceActions {
|
||||
pluginData?: {
|
||||
list: (collection: string, key?: string) => Promise<unknown>;
|
||||
put: (collection: string, key: string, value: Record<string, unknown>) => Promise<unknown>;
|
||||
delete: (collection: string, key: string) => Promise<void>;
|
||||
transact: (collection: string, mutations: PluginDataMutation[]) => Promise<unknown>;
|
||||
};
|
||||
gameClient?: {
|
||||
queue: (request: GameClientBridgeQueueRequest) => Promise<unknown>;
|
||||
get: (commandId: string) => Promise<unknown>;
|
||||
list: (filter?: GameClientBridgeCommandFilterRequest) => Promise<unknown>;
|
||||
snapshots: (query?: GameClientBridgeSnapshotQuery) => Promise<unknown>;
|
||||
};
|
||||
dispatch?: (envelope: PluginBridgeExecuteEnvelope, signal?: AbortSignal) => Promise<PluginBridgeExecutionResult>;
|
||||
}
|
||||
|
||||
@@ -1,13 +1,40 @@
|
||||
/** @vitest-environment jsdom */
|
||||
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it } from "vitest";
|
||||
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(),
|
||||
listPluginData: vi.fn(),
|
||||
putPluginData: vi.fn(),
|
||||
queueGameClientBridgeCommand: 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",
|
||||
@@ -37,7 +64,7 @@ const plugin: GamePluginResponse = {
|
||||
bundleKey: "scum-server-plugin",
|
||||
bundleVersion: "1.0.1",
|
||||
bundleIntegritySha256: "sha256:3488b316d909e597024f8f31c7bc96ab8019f643d74a528dc442d3df0dc3d54e",
|
||||
permissions: ["server.game-client.read", "server.game-client.command"],
|
||||
permissions: ["server.read", "server.game-client.read", "server.game-client.command"],
|
||||
bridgeActions: ["server.instances.read"]
|
||||
}],
|
||||
tags: ["scum"],
|
||||
@@ -53,6 +80,38 @@ const plugin: GamePluginResponse = {
|
||||
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.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",
|
||||
@@ -101,6 +160,11 @@ describe("PluginPageHostPage", () => {
|
||||
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");
|
||||
@@ -109,4 +173,62 @@ describe("PluginPageHostPage", () => {
|
||||
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?.dispatch).toBeDefined();
|
||||
|
||||
const mutations = [
|
||||
{ operation: "put" as const, key: "user-1", value: { name: "Ada" } },
|
||||
{ operation: "delete" as const, key: "user-2" }
|
||||
];
|
||||
const queueRequest = {
|
||||
profileKey: "scum-client",
|
||||
commandType: "reward.deliver",
|
||||
payload: { steamId: "redacted", giftKey: "starter" },
|
||||
idempotencyKey: "gift:user-1:starter",
|
||||
expiresAt: "2026-08-15T12:00:00Z"
|
||||
};
|
||||
const commandFilter = { profileKey: "scum-client", state: "pending" as const, commandType: "reward.deliver" };
|
||||
const snapshotQuery = { profileKey: "scum-client", type: "scum.positions", streamKey: "current", limit: 50 };
|
||||
const dispatchEnvelope = { requestId: "request-1", action: "server.instances.read" as const, payload: { view: "summary" } };
|
||||
|
||||
await actions?.pluginData?.list("scum_users", "user-1");
|
||||
await actions?.pluginData?.put("scum_users", "user-1", { name: "Ada" });
|
||||
await actions?.pluginData?.delete("scum_users", "user-2");
|
||||
await actions?.pluginData?.transact("scum_users", mutations);
|
||||
await actions?.gameClient?.queue(queueRequest);
|
||||
await actions?.gameClient?.get("command-1");
|
||||
await actions?.gameClient?.list(commandFilter);
|
||||
await actions?.gameClient?.snapshots(snapshotQuery);
|
||||
await expect(actions?.dispatch?.(dispatchEnvelope)).resolves.toMatchObject({ status: "ok", result: { state: "ready" } });
|
||||
|
||||
expect(apiMocks.listPluginData).toHaveBeenCalledWith("server-1", "scum_users", "user-1");
|
||||
expect(apiMocks.putPluginData).toHaveBeenCalledWith("server-1", "scum_users", "user-1", { name: "Ada" });
|
||||
expect(apiMocks.deletePluginData).toHaveBeenCalledWith("server-1", "scum_users", "user-2");
|
||||
expect(apiMocks.transactPluginData).toHaveBeenCalledWith("server-1", "scum_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.executePluginBridge).toHaveBeenCalledWith({
|
||||
requestId: "request-1",
|
||||
pluginId: "game.scum",
|
||||
routeKey: "players",
|
||||
serverInstanceId: "server-1",
|
||||
action: "server.instances.read",
|
||||
aiPurpose: undefined,
|
||||
payload: { view: "summary" }
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,7 +9,7 @@ import { EmptyState, ErrorState, LoadingState } from "../components/StateViews";
|
||||
import type { PageComponentProps } from "../contracts/page";
|
||||
import { pluginBridgeManifestContractFromResponse } from "../contracts/pluginBridge";
|
||||
import type { PluginPageWorkspaceActions } from "../contracts/pluginPageHost";
|
||||
import { createPluginBridgeHostContext } from "../utils/pluginBridgeHost";
|
||||
import { createPluginBridgeDispatcher, createPluginBridgeHostContext } from "../utils/pluginBridgeHost";
|
||||
import { loadPluginPageBundle, type PluginPageAvailability } from "../utils/pluginPageBundles";
|
||||
|
||||
type PluginPageState =
|
||||
@@ -71,7 +71,20 @@ export function PluginPageHostPage({ params, onNavigate, initialPlugin, embedded
|
||||
return {
|
||||
pluginData: {
|
||||
list: (collection, key) => platformApiClient.listPluginData(serverId, collection, key),
|
||||
put: (collection, key, value) => platformApiClient.putPluginData(serverId, collection, key, value)
|
||||
put: (collection, key, value) => platformApiClient.putPluginData(serverId, collection, key, value),
|
||||
delete: (collection, key) => platformApiClient.deletePluginData(serverId, collection, key),
|
||||
transact: (collection, mutations) => platformApiClient.transactPluginData(serverId, collection, mutations)
|
||||
},
|
||||
gameClient: {
|
||||
queue: (request) => platformApiClient.queueGameClientBridgeCommand(serverId, request),
|
||||
get: (commandId) => platformApiClient.getGameClientBridgeCommand(serverId, commandId),
|
||||
list: (filter) => platformApiClient.listGameClientBridgeCommands(serverId, filter),
|
||||
snapshots: (query) => platformApiClient.listGameClientBridgeSnapshots(serverId, query)
|
||||
},
|
||||
dispatch: (envelope, signal) => {
|
||||
const context = hostContextRef.current;
|
||||
if (!context) return Promise.resolve({ requestId: envelope.requestId, action: envelope.action, status: "error", error: { code: "host_unavailable", message: "plugin bridge host is unavailable" } });
|
||||
return createPluginBridgeDispatcher(context, platformApiClient)(envelope, signal);
|
||||
}
|
||||
};
|
||||
}, [pluginId, serverId]);
|
||||
|
||||
Reference in New Issue
Block a user