313 lines
15 KiB
TypeScript
313 lines
15 KiB
TypeScript
/** @vitest-environment jsdom */
|
|
|
|
import { act } from "react";
|
|
import { createRoot, type Root } from "react-dom/client";
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
|
|
import type { GamePluginResponse } from "../api/types";
|
|
import { defaultServerCreateForm } from "../contracts/serverManagement";
|
|
import { serverCreateRequestFromForm } from "../schemas/serverManagement";
|
|
import { ServerDeploymentWorkflow } from "./ServerDeploymentWorkflow";
|
|
|
|
const plugin: GamePluginResponse = {
|
|
id: "game.runtime",
|
|
name: "Runtime Game",
|
|
version: "1.0.0",
|
|
serverType: "runtime",
|
|
manifestRef: "artifact://runtime-manifest",
|
|
createFormSchemaRef: "schemas/create.json",
|
|
createFields: [{ key: "serverRoot", label: "服务器目录", type: "text", required: true }],
|
|
requiredRunCapabilities: ["process.install"],
|
|
declaredPermissions: ["server.create"],
|
|
permissions: { ai: false, logs: true, files: false, jobs: true, artifacts: false },
|
|
lifecycleActions: { install: "actions/install.json", start: "actions/start.json", stop: "actions/stop.json" },
|
|
bridgeActions: [],
|
|
pages: [],
|
|
tags: [],
|
|
aiPurposes: [],
|
|
productionLifecycle: { operations: ["install"], dependencyPolicy: "optional" },
|
|
status: "installed",
|
|
runtimeProfiles: {
|
|
transportProfiles: [{ key: "rcon", kind: "rcon", targetKey: "rcon.password", capabilities: ["remote.run.rcon.command"] }],
|
|
lifecycleProfiles: [{ key: "local", mode: "local-process", capabilities: ["process.install"], transportKeys: ["rcon"] }]
|
|
}
|
|
};
|
|
|
|
let root: Root | null = null;
|
|
let container: HTMLDivElement | null = null;
|
|
|
|
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
|
|
afterEach(async () => {
|
|
if (root) {
|
|
await act(async () => root?.unmount());
|
|
}
|
|
container?.remove();
|
|
root = null;
|
|
container = null;
|
|
});
|
|
|
|
describe("ServerDeploymentWorkflow", () => {
|
|
it("merges plugin defaults into guided edit inputs", async () => {
|
|
container = document.createElement("div");
|
|
document.body.append(container);
|
|
root = createRoot(container);
|
|
const scumPlugin: GamePluginResponse = { ...plugin, id: "game.scum", name: "SCUM Server", serverType: "scum", serverDisplayName: "SCUM Dedicated Server", createFields: [{ key: "gamePort", label: "游戏端口", type: "port", required: true, defaultValue: "7779" }, { key: "queryPort", label: "查询端口", type: "port", required: true, defaultValue: "27015" }] };
|
|
const initialForm = { ...defaultServerCreateForm([scumPlugin]), name: "Moon Base", createInputs: { gamePort: "28000" } };
|
|
|
|
await act(async () => {
|
|
root?.render(
|
|
<ServerDeploymentWorkflow
|
|
open
|
|
kind="edit"
|
|
plugins={[scumPlugin]}
|
|
initialForm={initialForm}
|
|
deployment={{ serverInstanceId: "server-scum", mode: "guided-install", createInputs: { gamePort: "28000" }, serverRootConfigured: false, workingDirectoryConfigured: false, installCommandConfigured: false, startCommandConfigured: false, stopCommandConfigured: false, statusCommandConfigured: false, revision: 1 }}
|
|
onClose={() => undefined}
|
|
onSubmit={async () => undefined}
|
|
/>
|
|
);
|
|
});
|
|
|
|
expect(container.textContent).toContain("游戏端口");
|
|
expect(container.textContent).toContain("查询端口");
|
|
expect(Array.from(container.querySelectorAll<HTMLInputElement>('input[type="number"]')).map((input) => input.value)).toEqual(["28000", "27015"]);
|
|
});
|
|
|
|
it("shows plugin startup inputs when editing an adopted SCUM server", async () => {
|
|
container = document.createElement("div");
|
|
document.body.append(container);
|
|
root = createRoot(container);
|
|
const scumPlugin: GamePluginResponse = { ...plugin, id: "game.scum", name: "SCUM Server", serverType: "scum", serverDisplayName: "SCUM Dedicated Server", createFields: [{ key: "gamePort", label: "游戏端口", type: "port", required: true, defaultValue: "7779" }, { key: "queryPort", label: "查询端口", type: "port", required: true, defaultValue: "27015" }, { key: "maxPlayers", label: "最大玩家数", type: "number", required: true, defaultValue: "128" }] };
|
|
const initialForm = { ...defaultServerCreateForm([scumPlugin]), name: "Moon Base", deploymentMode: "existing-server" as const, createInputs: { gamePort: "28888" } };
|
|
|
|
await act(async () => {
|
|
root?.render(
|
|
<ServerDeploymentWorkflow
|
|
open
|
|
kind="edit"
|
|
plugins={[scumPlugin]}
|
|
initialForm={initialForm}
|
|
deployment={{ serverInstanceId: "server-scum", mode: "existing-server", createInputs: { gamePort: "28888" }, serverRootConfigured: true, workingDirectoryConfigured: false, installCommandConfigured: false, startCommandConfigured: false, stopCommandConfigured: false, statusCommandConfigured: false, revision: 1 }}
|
|
onClose={() => undefined}
|
|
onSubmit={async () => undefined}
|
|
/>
|
|
);
|
|
});
|
|
|
|
expect(container.textContent).toContain("游戏端口");
|
|
expect(container.textContent).toContain("查询端口");
|
|
expect(container.textContent).toContain("最大玩家数");
|
|
expect(container.textContent).toContain("接管流程不会把这些值写回或覆盖已有游戏配置");
|
|
expect(Array.from(container.querySelectorAll<HTMLInputElement>('input[type="number"]')).map((input) => input.value)).toEqual(["28888", "27015", "128"]);
|
|
});
|
|
|
|
it("normalizes the create wizard to canonical plugins with visible guided inputs", async () => {
|
|
container = document.createElement("div");
|
|
document.body.append(container);
|
|
root = createRoot(container);
|
|
const examplePlugin: GamePluginResponse = { ...plugin, id: "game.example", name: "Example Server", serverType: "example", serverDisplayName: "Example Server", createFields: [], tags: ["development"] };
|
|
const staleScumPlugin: GamePluginResponse = { ...plugin, id: "game.scum.codex.20260804095301", name: "SCUM Server", version: "0.1.4", serverType: "scum", serverDisplayName: "SCUM Dedicated Server", manifestRef: "plugins/examples/scum-server-plugin/game.scum.codex.20260804095301/manifest.json", tags: ["scum"], createFields: [{ key: "gamePort", label: "游戏端口", type: "port", required: true, defaultValue: "27000" }] };
|
|
const scumPlugin: GamePluginResponse = { ...staleScumPlugin, id: "game.scum", version: "0.1.15", manifestRef: "artifact://manifests/game.scum/0.1.15", createFields: [{ key: "gamePort", label: "游戏端口", type: "port", required: true, defaultValue: "7779" }, { key: "queryPort", label: "查询端口", type: "port", required: true, defaultValue: "27015" }] };
|
|
const initialForm = defaultServerCreateForm([examplePlugin, staleScumPlugin, scumPlugin]);
|
|
|
|
await act(async () => {
|
|
root?.render(
|
|
<ServerDeploymentWorkflow
|
|
open
|
|
kind="create"
|
|
plugins={[examplePlugin, staleScumPlugin, scumPlugin]}
|
|
initialForm={initialForm}
|
|
onClose={() => undefined}
|
|
onSubmit={async () => undefined}
|
|
/>
|
|
);
|
|
});
|
|
|
|
const pluginSelect = container.querySelector<HTMLSelectElement>('select[name="pluginId"]');
|
|
expect(pluginSelect?.value).toBe("game.scum");
|
|
expect(Array.from(pluginSelect?.options ?? []).map((option) => option.value)).toEqual(["game.scum", "game.example"]);
|
|
|
|
const nameInput = container.querySelector<HTMLInputElement>('input[name="name"]');
|
|
if (!nameInput) throw new Error("server name input not found");
|
|
await act(async () => {
|
|
setInputValue(nameInput, "Moon Base");
|
|
});
|
|
|
|
await submitWorkflow(container);
|
|
await submitWorkflow(container);
|
|
expect(container.querySelector<HTMLInputElement>('input[value="7779"]')).not.toBeNull();
|
|
expect(container.querySelector<HTMLInputElement>('input[value="27015"]')).not.toBeNull();
|
|
});
|
|
|
|
it("submits deployment mode and matching startup inputs from the create wizard", async () => {
|
|
container = document.createElement("div");
|
|
document.body.append(container);
|
|
root = createRoot(container);
|
|
const initialForm = defaultServerCreateForm([plugin]);
|
|
let submitted: ReturnType<typeof serverCreateRequestFromForm> | undefined;
|
|
const onSubmit = vi.fn(async (form: typeof initialForm) => {
|
|
submitted = serverCreateRequestFromForm(form, 17);
|
|
});
|
|
|
|
await act(async () => {
|
|
root?.render(
|
|
<ServerDeploymentWorkflow
|
|
open
|
|
kind="create"
|
|
plugins={[plugin]}
|
|
initialForm={initialForm}
|
|
onClose={() => undefined}
|
|
onSubmit={onSubmit}
|
|
/>
|
|
);
|
|
});
|
|
|
|
expect(container.querySelector('select[name="pluginId"]')).not.toBeNull();
|
|
expect(container.querySelector('input[name="name"]')).not.toBeNull();
|
|
for (const field of ["deploymentTargetId", "runEndpointId", "profileKey", "serverRoot", "startCommand", "deploymentMode"]) {
|
|
expect(container.querySelector(`[name="${field}"]`)).toBeNull();
|
|
}
|
|
expect(container.textContent).not.toContain("运行连接设置");
|
|
|
|
const nameInput = container.querySelector<HTMLInputElement>('input[name="name"]');
|
|
if (!nameInput) throw new Error("server name input not found");
|
|
await act(async () => {
|
|
setInputValue(nameInput, "Custom Runtime Server");
|
|
});
|
|
|
|
await submitWorkflow(container);
|
|
expect(container.textContent).toContain("选择这台服务器的创建方式");
|
|
expect(container.textContent).toContain("自定义启动方式");
|
|
|
|
await clickButtonByText(container, "自定义启动方式");
|
|
await submitWorkflow(container);
|
|
expect(container.querySelector<HTMLInputElement>('input[name="startCommand"]')).not.toBeNull();
|
|
expect(container.querySelector('[name="deploymentTargetId"]')).toBeNull();
|
|
expect(container.querySelector('[name="runEndpointId"]')).toBeNull();
|
|
expect(container.querySelector('[name="profileKey"]')).toBeNull();
|
|
|
|
const rootInput = container.querySelector<HTMLInputElement>('input[name="serverRoot"]');
|
|
const startInput = container.querySelector<HTMLInputElement>('input[name="startCommand"]');
|
|
if (!rootInput || !startInput) throw new Error("custom startup inputs not found");
|
|
await act(async () => {
|
|
setInputValue(rootInput, "/srv/custom-runtime");
|
|
setInputValue(startInput, "./start-runtime.sh");
|
|
});
|
|
|
|
await submitWorkflow(container);
|
|
expect(container.textContent).toContain("本次保存创建向导配置");
|
|
expect(container.textContent).toContain("自定义启动方式");
|
|
expect(container.textContent).toContain("将替换");
|
|
|
|
await submitWorkflow(container);
|
|
expect(onSubmit).toHaveBeenCalledTimes(1);
|
|
expect(submitted).toMatchObject({
|
|
id: "server-custom-runtime-server-17",
|
|
pluginId: "game.runtime",
|
|
name: "Custom Runtime Server",
|
|
idempotencyKey: "web:create:server-custom-runtime-server-17:17",
|
|
deployment: {
|
|
mode: "custom-command",
|
|
serverRoot: "/srv/custom-runtime",
|
|
startCommand: "./start-runtime.sh"
|
|
}
|
|
});
|
|
expect(submitted && "runEndpointId" in submitted).toBe(false);
|
|
expect(submitted && "profileKey" in submitted).toBe(false);
|
|
expect(submitted && "bindings" in submitted).toBe(false);
|
|
expect(submitted?.deployment && "profileKey" in submitted.deployment).toBe(false);
|
|
expect(submitted?.deployment && "runtimeBindings" in submitted.deployment).toBe(false);
|
|
});
|
|
|
|
it("submits SCUM port and max player inputs from the existing-server create wizard", async () => {
|
|
container = document.createElement("div");
|
|
document.body.append(container);
|
|
root = createRoot(container);
|
|
const scumPlugin: GamePluginResponse = { ...plugin, id: "game.scum", name: "SCUM Server", serverType: "scum", serverDisplayName: "SCUM Dedicated Server", createFields: [{ key: "gamePort", label: "游戏端口", type: "port", required: true, defaultValue: "7779" }, { key: "queryPort", label: "查询端口", type: "port", required: true, defaultValue: "27015" }, { key: "maxPlayers", label: "最大玩家数", type: "number", required: true, defaultValue: "128" }] };
|
|
const initialForm = defaultServerCreateForm([scumPlugin]);
|
|
let submitted: ReturnType<typeof serverCreateRequestFromForm> | undefined;
|
|
const onSubmit = vi.fn(async (form: typeof initialForm) => {
|
|
submitted = serverCreateRequestFromForm(form, 18);
|
|
});
|
|
|
|
await act(async () => {
|
|
root?.render(
|
|
<ServerDeploymentWorkflow
|
|
open
|
|
kind="create"
|
|
plugins={[scumPlugin]}
|
|
initialForm={initialForm}
|
|
onClose={() => undefined}
|
|
onSubmit={onSubmit}
|
|
/>
|
|
);
|
|
});
|
|
|
|
const nameInput = container.querySelector<HTMLInputElement>('input[name="name"]');
|
|
if (!nameInput) throw new Error("server name input not found");
|
|
await act(async () => {
|
|
setInputValue(nameInput, "SCUM Existing Server");
|
|
});
|
|
|
|
await submitWorkflow(container);
|
|
await clickButtonByText(container, "接管已有服务器");
|
|
await submitWorkflow(container);
|
|
|
|
expect(container.textContent).toContain("已有服务器目录");
|
|
expect(container.textContent).toContain("游戏端口");
|
|
expect(container.textContent).toContain("查询端口");
|
|
expect(container.textContent).toContain("最大玩家数");
|
|
expect(Array.from(container.querySelectorAll<HTMLInputElement>('input[type="number"]')).map((input) => input.value)).toEqual(["7779", "27015", "128"]);
|
|
|
|
const rootInput = container.querySelector<HTMLInputElement>('input[name="serverRoot"]');
|
|
const [gamePortInput, , maxPlayersInput] = Array.from(container.querySelectorAll<HTMLInputElement>('input[type="number"]'));
|
|
if (!rootInput || !gamePortInput || !maxPlayersInput) throw new Error("SCUM existing-server inputs not found");
|
|
await act(async () => {
|
|
setInputValue(rootInput, "/srv/scum-existing");
|
|
setInputValue(gamePortInput, "28001");
|
|
setInputValue(maxPlayersInput, "64");
|
|
});
|
|
|
|
await submitWorkflow(container);
|
|
expect(container.textContent).toContain("本次保存创建向导配置");
|
|
expect(container.textContent).toContain("游戏配置");
|
|
expect(container.textContent).toContain("3 项已准备");
|
|
|
|
await submitWorkflow(container);
|
|
expect(onSubmit).toHaveBeenCalledTimes(1);
|
|
expect(submitted).toMatchObject({
|
|
id: "server-scum-existing-server-18",
|
|
pluginId: "game.scum",
|
|
name: "SCUM Existing Server",
|
|
deployment: {
|
|
mode: "existing-server",
|
|
serverRoot: "/srv/scum-existing",
|
|
createInputs: { gamePort: "28001", queryPort: "27015", maxPlayers: "64" }
|
|
}
|
|
});
|
|
});
|
|
});
|
|
|
|
async function submitWorkflow(target: HTMLElement) {
|
|
const form = target.querySelector<HTMLFormElement>('form[aria-label="创建服务器部署向导"]');
|
|
if (!form) throw new Error("create workflow form not found");
|
|
await act(async () => {
|
|
form.dispatchEvent(new SubmitEvent("submit", { bubbles: true, cancelable: true }));
|
|
});
|
|
}
|
|
|
|
async function clickButtonByText(target: HTMLElement, text: string) {
|
|
const button = Array.from(target.querySelectorAll<HTMLButtonElement>("button")).find((item) => item.textContent?.includes(text));
|
|
if (!button) throw new Error(`button not found: ${text}`);
|
|
await act(async () => {
|
|
button.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
|
|
});
|
|
}
|
|
|
|
function setInputValue(input: HTMLInputElement, value: string) {
|
|
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
|
|
setter?.call(input, value);
|
|
input.dispatchEvent(new Event("input", { bubbles: true }));
|
|
}
|