SCUM 停止/重启/更新以前只有“结束进程”这一条路,插件没有声明任何优雅关闭方式,
平台也没有把停止后重新启动串起来。现在插件声明自己的关闭脚本,run 先执行它,
平台在停止或更新成功后再自动拉起服务。
run:
- lifecycle stop 支持插件声明的 gracefulStop(可执行文件、参数、环境、超时、
fallback=report|terminate);关闭命令超时且声明 report 时任务失败,不再默默杀进程。
- 新增 steam.update 依赖探针:调用 steamcmd +app_info_print 获取公开分支 buildid,
与本地 steamapps/appmanifest_<appid>.acf 的 buildid 比较,输出
installed/latest/update=yes|no|unknown。
platform:
- 新增 POST /api/v1/server-instances/{id}/restart 与 /update。
- restart 派发插件 stop 动作(走优雅关闭),终态成功后入队 start 作业。
- update 派发插件 install 动作;插件在更新前必须先优雅关闭 SCUM,关闭失败直接拒绝
SteamCMD 更新,成功后平台再拉起服务。
- 依赖检查输入带上插件声明的服务器安装根目录,供 steam.update 读取 appmanifest。
plugin (SCUM server plugin 0.1.16):
- bin/scum-stop.cmd:解析已声明的可执行文件路径,定位同路径正在运行的 SCUMServer.exe,
通过本地 RCON 公告并发送关闭命令,等待进程自行退出;不再使用 taskkill。
- bin/scum-rcon.ps1:插件自有的 Source RCON 客户端,从 UE4SS mod config.ini 读取
密码/端口,密钥不离开本机。
- actions/stop.json 声明 gracefulStop;actions/install.json 更新前先执行同一关闭脚本。
platform_web:
- 服务器详情新增“重启”按钮和“SCUM 版本更新”面板;点“检查更新”查询公开分支版本,
只有检测到更新时“更新版本”按钮才会置为可用并高亮,点击后先确认再派发更新任务。
225 lines
13 KiB
TypeScript
225 lines
13 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
|
|
import { configDiffViewFromPreview } from "./ServerDetailPage";
|
|
import serversPageSource from "./ServersPage.tsx?raw";
|
|
import serverManagementTerminalSource from "../components/ServerManagementTerminalDrawer.tsx?raw";
|
|
import serverDetailPageSource from "./ServerDetailPage.tsx?raw";
|
|
import serverGameUpdatePanelSource from "../components/ServerGameUpdatePanel.tsx?raw";
|
|
import type { ServerConfigDiffPreviewResponse } from "../api/types";
|
|
|
|
const preview: ServerConfigDiffPreviewResponse = {
|
|
serverInstanceId: "server-1",
|
|
configVersion: 7,
|
|
key: "server.properties",
|
|
currentContent: "max-players=20\npvp=true\n",
|
|
proposedContent: "max-players=40\npvp=true\n",
|
|
diff: [
|
|
{ kind: "removed", oldNumber: 1, content: "max-players=20" },
|
|
{ kind: "added", newNumber: 1, content: "max-players=40" },
|
|
{ kind: "context", oldNumber: 2, newNumber: 2, content: "pvp=true" }
|
|
],
|
|
hasChanges: true,
|
|
source: "platform-review",
|
|
reviewedAt: "2026-07-06T00:00:00Z"
|
|
};
|
|
|
|
describe("ServerDetailPage config write approval", () => {
|
|
it("keeps server overview metrics on the server list instead of the detail header", () => {
|
|
expect(serverDetailPageSource).not.toContain("listServerMetrics");
|
|
expect(serverDetailPageSource).not.toContain("server-detail-stat-strip");
|
|
expect(serverDetailPageSource).not.toContain("server-detail-meter-strip");
|
|
expect(serverDetailPageSource).not.toContain("<UsageMeter");
|
|
expect(serversPageSource).toContain("listServerMetrics");
|
|
expect(serversPageSource).toContain("<UsageMeter");
|
|
});
|
|
|
|
it("keeps the detail header focused on a single shared action row", () => {
|
|
expect(serverDetailPageSource).not.toContain("Run 心跳");
|
|
expect(serverDetailPageSource).not.toContain("已自动附着");
|
|
const headerSource = serverDetailPageSource.split('<header className="server-detail-header">')[1]?.split('</header>')[0] ?? "";
|
|
expect(headerSource).toContain('<span>返回列表</span>');
|
|
expect(headerSource).not.toContain('<span>刷新</span>');
|
|
expect(headerSource).toContain('<span>打开终端</span>');
|
|
expect(headerSource).toContain('<span>启动</span>');
|
|
expect(headerSource).toContain('<span>停止</span>');
|
|
const actionStripSource = headerSource.split('<div className="action-strip">')[1]?.split('</div>')[0] ?? "";
|
|
expect(actionStripSource.indexOf('<span>停止</span>')).toBeLessThan(actionStripSource.indexOf('detailStateText'));
|
|
expect(headerSource).toContain('<span>修改配置</span>');
|
|
});
|
|
|
|
it("opens the plugin user management page by default when available", () => {
|
|
expect(serverDetailPageSource).toContain('page.key === "players"');
|
|
expect(serverDetailPageSource).toContain('setSection(`plugin:${defaultPluginPage.key}`)');
|
|
});
|
|
|
|
it("maps platform diff preview responses into the display diff without losing approval metadata", () => {
|
|
const view = configDiffViewFromPreview(preview);
|
|
|
|
expect(view).toMatchObject({
|
|
serverInstanceId: "server-1",
|
|
configVersion: 7,
|
|
key: "server.properties",
|
|
source: "platform-review",
|
|
summary: "+1 / -1 行变更",
|
|
nextContent: "max-players=40\npvp=true\n"
|
|
});
|
|
expect(view.lines).toEqual([
|
|
{ kind: "removed", text: "max-players=20" },
|
|
{ kind: "added", text: "max-players=40" },
|
|
{ kind: "same", text: "pvp=true" }
|
|
]);
|
|
});
|
|
|
|
it("uses AI config diff approval without exposing raw config workbench APIs", () => {
|
|
expect(serverDetailPageSource).toContain("approveAIConfigDiff");
|
|
expect(serverDetailPageSource).toContain("AI 配置助手");
|
|
expect(serverDetailPageSource).not.toContain("previewServerConfigDiff");
|
|
expect(serverDetailPageSource).not.toContain("approveServerConfigWrite");
|
|
expect(serverDetailPageSource).not.toContain('capability: "config.write"');
|
|
});
|
|
|
|
it("uses typed server metadata APIs and keeps destructive deletion out of detail metadata", () => {
|
|
expect(serverDetailPageSource).toContain("updateServerInstance(instance.id");
|
|
expect(serverDetailPageSource).toContain("serverMetadataUpdateRequestFromForm");
|
|
expect(serverDetailPageSource).not.toContain("deleteServerInstance");
|
|
expect(serverDetailPageSource).not.toContain("serverDeleteConfirmation");
|
|
expect(serverDetailPageSource).not.toContain("canDeleteServer(instance.state)");
|
|
expect(serverDetailPageSource).not.toContain("请输入当前登录密码");
|
|
expect(serverDetailPageSource).not.toContain("fallbackConfig");
|
|
});
|
|
|
|
it("does not locally mutate visible config after dispatching approval jobs", () => {
|
|
expect(serverDetailPageSource).not.toContain("setCurrentConfig(suggestion.diff.nextContent)");
|
|
expect(serverDetailPageSource).not.toContain("content: diff.nextContent");
|
|
});
|
|
|
|
it("keeps artifact transfer and backend internals out of server detail", () => {
|
|
expect(serverDetailPageSource).toContain("ServerFilesSection");
|
|
expect(serverDetailPageSource).toContain("getServerFileWorkspace");
|
|
expect(serverDetailPageSource).toContain("downloadServerFileResult");
|
|
expect(serverDetailPageSource).not.toContain("openArtifactDownload");
|
|
expect(serverDetailPageSource).not.toContain("readArtifactContent");
|
|
expect(serverDetailPageSource).not.toContain("浏览器制品传输");
|
|
expect(serverDetailPageSource).not.toContain("storage://");
|
|
expect(serverDetailPageSource).not.toContain("unix://");
|
|
expect(serverDetailPageSource).not.toContain("Bearer ");
|
|
});
|
|
|
|
it("keeps the server file manager list-first without plugin declaration gates", () => {
|
|
expect(serverDetailPageSource).toContain("server-file-editor-overlay");
|
|
expect(serverDetailPageSource).toContain("browseServerFiles");
|
|
expect(serverDetailPageSource).toContain("browseList({ forceNew: true })");
|
|
expect(serverDetailPageSource).toContain("window.setTimeout(() => void browseList({ silent: true }), 500)");
|
|
expect(serverDetailPageSource).toContain("if (!options.silent) setList({ status: \"loading\" })");
|
|
expect(serverDetailPageSource).toContain("serverFileListPendingLabel");
|
|
expect(serverDetailPageSource).not.toContain("refreshRuntimeList");
|
|
expect(serverDetailPageSource).not.toContain("window.setInterval(() => void loadList");
|
|
expect(serverDetailPageSource).not.toContain("server-file-layout");
|
|
expect(serverDetailPageSource).not.toContain("未声明目录");
|
|
expect(serverDetailPageSource).not.toContain("插件尚未声明");
|
|
expect(serverDetailPageSource).not.toContain("插件声明为可编辑");
|
|
expect(serverDetailPageSource).not.toContain("entry.editable");
|
|
expect(serverDetailPageSource).not.toContain("entry.downloadable");
|
|
expect(serverDetailPageSource).not.toContain("当前目录暂无缓存结果;点击刷新目录读取实时文件。");
|
|
expect(serverDetailPageSource).not.toContain("独立通道");
|
|
expect(serverDetailPageSource).not.toContain("目录已更新");
|
|
});
|
|
|
|
it("keeps run distribution and client-manager workflows out of server detail tabs", () => {
|
|
expect(serverDetailPageSource).not.toContain('id="run-builder"');
|
|
expect(serverDetailPageSource).not.toContain("getServerRuntimeActions");
|
|
expect(serverDetailPageSource).not.toContain("generateRunDistribution");
|
|
expect(serverDetailPageSource).not.toContain("downloadLatestRunDistribution");
|
|
expect(serverDetailPageSource).not.toContain("pushRunUpdate");
|
|
expect(serverDetailPageSource).not.toContain("generateClientManager");
|
|
expect(serverDetailPageSource).not.toContain("ClientManagerLifecyclePanel");
|
|
expect(serverManagementTerminalSource).toContain("openServerLogEvents");
|
|
expect(serverDetailPageSource).not.toContain("authKey");
|
|
expect(serverDetailPageSource).not.toContain("password=");
|
|
expect(serverDetailPageSource).not.toContain("unix://");
|
|
expect(serverDetailPageSource).not.toContain("tcp://");
|
|
expect(serverDetailPageSource).not.toContain("mysql://");
|
|
expect(serverDetailPageSource).not.toContain("sqlite://");
|
|
expect(serverDetailPageSource).not.toContain("SCUM 部署模板");
|
|
expect(serverDetailPageSource).not.toContain("isScumTemplate");
|
|
expect(serverDetailPageSource).toContain("部署验证模板");
|
|
expect(serverDetailPageSource).toContain("平台普通响应不会回显");
|
|
});
|
|
|
|
it("does not expose manual runtime configuration surfaces", () => {
|
|
expect(serverDetailPageSource).not.toContain("getServerRuntimeBinding");
|
|
expect(serverDetailPageSource).not.toContain("updateServerRuntimeBinding");
|
|
expect(serverDetailPageSource).not.toContain("运行配置绑定");
|
|
expect(serverDetailPageSource).not.toContain("RuntimeDLLExtensionsPanel");
|
|
});
|
|
|
|
it("uses the historical terminal drawer without restoring separate raw command panels", () => {
|
|
expect(serverDetailPageSource).not.toContain("SourceRCONCommandPanel");
|
|
expect(serverManagementTerminalSource).toContain("dispatchSourceRCONCommand");
|
|
expect(serverDetailPageSource).not.toContain("scumManagementRCONCommandRequest");
|
|
expect(serverDetailPageSource).toContain("<span>打开终端</span>");
|
|
expect(serverDetailPageSource).toContain("ServerManagementTerminalDrawer");
|
|
expect(serverManagementTerminalSource).toContain("terminal-output-topbar");
|
|
expect(serverManagementTerminalSource).toContain("terminalQuickCommandCatalog");
|
|
expect(serverDetailPageSource).toContain("PluginPageHostPage");
|
|
});
|
|
|
|
it("removes generic plugin controls from server detail", () => {
|
|
expect(serverDetailPageSource).not.toContain("PluginControlsSection");
|
|
expect(serverDetailPageSource).not.toContain("PluginBridgeExecutionPanel");
|
|
expect(serverDetailPageSource).not.toContain("platformApiClient.createJob({");
|
|
expect(serverDetailPageSource).not.toContain("插件控制");
|
|
expect(serverDetailPageSource).toContain("platformApiClient.startServerInstance(current.id");
|
|
expect(serverDetailPageSource).toContain("platformApiClient.stopServerInstance(current.id");
|
|
expect(serverDetailPageSource).toContain("platformApiClient.restartServerInstance(current.id");
|
|
expect(serverDetailPageSource).toContain("serverLifecycleCommandRequest(current, action)");
|
|
expect(serverDetailPageSource).not.toContain('capability: "process.start"');
|
|
expect(serverDetailPageSource).not.toContain('capability: "process.stop"');
|
|
});
|
|
|
|
it("lights the SCUM update action only after the plugin-declared build check reports a new version", () => {
|
|
expect(serverDetailPageSource).toContain("ServerGameUpdatePanel");
|
|
expect(serverDetailPageSource).toContain("canRestartServer(instance.data.state)");
|
|
expect(serverGameUpdatePanelSource).toContain("platformApiClient.getDependencyCatalog");
|
|
expect(serverGameUpdatePanelSource).toContain("platformApiClient.checkDependencies");
|
|
expect(serverGameUpdatePanelSource).toContain("platformApiClient.updateServerGame");
|
|
expect(serverGameUpdatePanelSource).toContain("steamUpdateProbe");
|
|
expect(serverGameUpdatePanelSource).toContain("gameUpdateStatusFromProbe");
|
|
expect(serverGameUpdatePanelSource).toContain('serverLifecycleCommandRequest(instance, "update")');
|
|
expect(serverGameUpdatePanelSource).toContain("优雅关机");
|
|
expect(serverGameUpdatePanelSource).not.toContain("taskkill");
|
|
expect(serverGameUpdatePanelSource).not.toContain('capability: "process.stop"');
|
|
expect(serverGameUpdatePanelSource).not.toContain('capability: "process.install"');
|
|
});
|
|
|
|
it("leaves guided deployment to the generated Run heartbeat workflow", () => {
|
|
expect(serverDetailPageSource).not.toContain("canDeployServer(instance.data.state)");
|
|
expect(serverDetailPageSource).not.toContain("requestDeployment(instance.data)");
|
|
expect(serverDetailPageSource).not.toContain("platformApiClient.deployServerInstance(current.id");
|
|
expect(serverDetailPageSource).not.toContain('capability: "process.install"');
|
|
});
|
|
|
|
it("routes plugin-declared pages into server detail tabs", () => {
|
|
expect(serverDetailPageSource).toContain("serverDetailSectionEntries(readyPlugin)");
|
|
expect(serverDetailPageSource).toContain("plugin:${page.key}");
|
|
expect(serverDetailPageSource).toContain("ServerDetailSectionNav");
|
|
expect(serverDetailPageSource).toContain('aria-label="插件功能"');
|
|
expect(serverDetailPageSource).not.toContain(">游戏运营</span>");
|
|
expect(serverDetailPageSource).toContain("server-plugin-section-tabs");
|
|
expect(serverDetailPageSource).not.toContain("server-plugin-section-picker");
|
|
expect(serverDetailPageSource).toContain('section === "files"');
|
|
expect(serverDetailPageSource).toContain("PluginPageSection");
|
|
expect(serverDetailPageSource).toContain("PluginPageHostPage");
|
|
expect(serverDetailPageSource).not.toContain("ScumFileManagementSection");
|
|
});
|
|
|
|
it("keeps plugin lifecycle output out of raw bridge-visible detail surfaces", () => {
|
|
expect(serverDetailPageSource).not.toContain("platformApiClient.openArtifactDownload(artifact.id)");
|
|
expect(serverDetailPageSource).not.toContain("downloadArtifactReference(reference");
|
|
expect(serverDetailPageSource).not.toContain("storage://bucket");
|
|
expect(serverDetailPageSource).not.toContain("runSocket");
|
|
expect(serverDetailPageSource).not.toContain("rawApiKey");
|
|
expect(serverDetailPageSource).not.toContain("apiKeyRef");
|
|
});
|
|
});
|