功能修改
This commit is contained in:
@@ -17,7 +17,12 @@ const forbiddenFragments = [
|
||||
{ name: "tcp socket", pattern: /tcp:\/\// },
|
||||
{ name: "bearer token", pattern: /Bearer\s+/ },
|
||||
{ name: "raw api key", pattern: /sk-[A-Za-z0-9_-]+/ },
|
||||
{ name: "password query", pattern: /password=/ },
|
||||
{ name: "password query", pattern: /password\s*=/i },
|
||||
{ name: "credential value", pattern: /credential\s*[:=]\s*\S+/i },
|
||||
{ name: "DSN value", pattern: /\b(?:postgres(?:ql)?|mysql|redis|mongodb(?:\+srv)?):\/\//i },
|
||||
{ name: "RCON value", pattern: /\brcon(?:[_-]?(?:password|token))?\s*[:=]\s*\S+/i },
|
||||
{ name: "host path", pattern: /\/(?:home|var|etc|opt|root|tmp)\/|[A-Za-z]:\\/ },
|
||||
{ name: "PID value", pattern: /\bpid\s*[:=#]?\s*\d+/i },
|
||||
{ name: "raw api key ref field", pattern: /apiKeyRef/ },
|
||||
{ name: "raw api key field", pattern: /rawApiKey/ },
|
||||
{ name: "session token", pattern: /sessionToken|run session token/i },
|
||||
@@ -67,8 +72,17 @@ async function main() {
|
||||
assertIncludes(plugin.requiredRunCapabilities, "process.start", "plugin requires process.start");
|
||||
assertIncludes(plugin.bridgeActions, "logs.query", "plugin exposes logs.query bridge action");
|
||||
assertIncludes(plugin.bridgeActions, "artifacts.open", "plugin exposes artifacts.open bridge action");
|
||||
assertIncludes(plugin.bridgeActions, "plugin-lifecycle.request", "plugin exposes Platform lifecycle bridge action");
|
||||
assertIncludes(plugin.bridgeActions, "ai.invoke", "plugin exposes Platform AI bridge action");
|
||||
assertIncludes(plugin.productionLifecycle?.operations, "rollback", "plugin declares rollback governance");
|
||||
assertIncludes(marketplacePlugin.capabilities, "process.start", "marketplace exposes lifecycle capability");
|
||||
assertSafeRedactedRef(aiProvider.apiKeyRef, "AI provider key reference");
|
||||
assertEqual(aiProvider.apiKeyConfigured, true, "AI provider key presence projection");
|
||||
assertEqual(aiProvider.baseUrlConfigured, true, "AI provider base URL presence projection");
|
||||
if (Object.hasOwn(aiProvider, "baseUrl") || Object.hasOwn(aiProvider, "apiKeyRef")) {
|
||||
throw new Error("AI provider response exposed Platform-owned endpoint or secret reference");
|
||||
}
|
||||
|
||||
const productionSeed = await prepareProductionOperations(authHeaders, server, plugin);
|
||||
|
||||
const chrome = await startChrome();
|
||||
const evidence = {
|
||||
@@ -90,11 +104,12 @@ async function main() {
|
||||
plugin: pick(plugin, ["id", "name", "version", "status", "manifestRef", "requiredRunCapabilities", "bridgeActions", "declaredPermissions"]),
|
||||
marketplacePlugin: pick(marketplacePlugin, ["id", "name", "version", "status", "manifestRef", "capabilities", "bridgeActions", "declaredPermissions"]),
|
||||
operator: pick(operator, ["id", "displayName", "email", "status", "roles"]),
|
||||
aiProvider: pick(aiProvider, ["id", "name", "kind", "relayMode", "status", "apiKeyRef"]),
|
||||
aiProvider: pick(aiProvider, ["id", "name", "kind", "relayMode", "status", "baseUrlConfigured", "apiKeyConfigured"]),
|
||||
jobs: jobs.items.map((job) => pick(job, ["id", "serverInstanceId", "runEndpointId", "capability", "state", "resultRef"])),
|
||||
logStreams: logStreams.items.map((stream) => pick(stream, ["id", "serverInstanceId", "streamKey", "source"])),
|
||||
artifacts: artifacts.items.map((artifact) => pick(artifact, ["id", "ownerKind", "ownerId", "state", "checksum"])),
|
||||
usage: pick(usage, ["cpuPercent", "memoryPercent", "diskPercent", "source"])
|
||||
usage: pick(usage, ["cpuPercent", "memoryPercent", "diskPercent", "source"]),
|
||||
production: productionSeed.apiProof
|
||||
},
|
||||
routes: [],
|
||||
safety: {
|
||||
@@ -112,7 +127,7 @@ async function main() {
|
||||
{
|
||||
name: "首页",
|
||||
hash: "#/home",
|
||||
markers: ["平台概览", "数据已加载", "game.example", "运行节点", "CPU"]
|
||||
markers: ["平台概览", "运营数据已同步", "game.example", "运行节点", "CPU", "生产容量与告警", "运行槽位"]
|
||||
},
|
||||
{
|
||||
name: "服务器管理",
|
||||
@@ -145,7 +160,12 @@ async function main() {
|
||||
{
|
||||
name: "AI 提供商管理",
|
||||
hash: "#/aiProviders",
|
||||
markers: ["AI 提供商管理", "平台 API", aiProvider.name, aiProvider.apiKeyRef, "密钥引用"]
|
||||
markers: ["AI 提供商管理", "平台 API", aiProvider.name, "密钥状态", "已配置", "AI 配置审查", productionSeed.diff.diffSummary, server.id]
|
||||
},
|
||||
{
|
||||
name: "系统维护",
|
||||
hash: "#/maintenance",
|
||||
markers: ["系统维护", "容量治理与告警闭环", "运行槽位", productionSeed.alert.title]
|
||||
},
|
||||
{
|
||||
name: "服务器详情",
|
||||
@@ -181,9 +201,15 @@ async function main() {
|
||||
}
|
||||
}
|
||||
|
||||
const pluginControls = await clickAndVerify(chrome, "插件控制", ["Logs 桥接执行", "server.logs.read", "server.artifacts.read", "读取"]);
|
||||
const pluginControls = await clickAndVerify(chrome, "插件控制", ["生产生命周期", "Logs 桥接执行", "server.logs.read", "server.artifacts.read", "读取"]);
|
||||
evidence.routes.push({ name: "服务器详情 / 插件控制", url: await chrome.url(), ...pluginControls });
|
||||
|
||||
evidence.productionInteractions = {
|
||||
alert: await verifyAlertInteraction(chrome, authHeaders, productionSeed.alert),
|
||||
pluginLifecycle: await verifyPluginLifecycleInteraction(chrome, authHeaders, plugin, server),
|
||||
aiDiffApproval: await verifyAIConfigDiffInteraction(chrome, authHeaders, productionSeed.diff)
|
||||
};
|
||||
|
||||
evidence.walkthroughs = await verifyResponsiveThemeWalkthroughs(chrome, routeChecks, server);
|
||||
|
||||
const operationProof = await verifyLifecycleOperation(authHeaders, server, chrome);
|
||||
@@ -229,12 +255,12 @@ async function loginInBrowser(chrome) {
|
||||
setInputValue(password, "operator-local");
|
||||
submit.click();
|
||||
});
|
||||
await chrome.waitForText(["平台概览", "数据已加载"], "post-login home");
|
||||
await chrome.waitForText(["平台概览", "运营数据已同步"], "post-login home");
|
||||
const visibleText = await chrome.visibleText();
|
||||
scanText(visibleText, "登录后首页");
|
||||
return {
|
||||
url: await chrome.url(),
|
||||
requiredMarkers: ["平台概览", "数据已加载"],
|
||||
requiredMarkers: ["平台概览", "运营数据已同步"],
|
||||
fallbackScan: "passed",
|
||||
forbiddenFragmentScan: "passed"
|
||||
};
|
||||
@@ -294,9 +320,21 @@ async function verifyResponsiveThemeWalkthroughs(chrome, routeChecks, server) {
|
||||
for (const scenario of scenarios) {
|
||||
await chrome.setViewport(scenario.viewport);
|
||||
await applyBrowserTheme(chrome, scenario.paletteId, scenario.backgroundId);
|
||||
const appliedPalette = await chrome.evaluate(() => document.documentElement.dataset.themePalette || "");
|
||||
assertEqual(appliedPalette, scenario.paletteId, `${scenario.name} root theme marker`);
|
||||
const routeEvidence = [];
|
||||
if (scenario.viewport.mobile) {
|
||||
const mobileNavigation = await verifyMobileNavigation(chrome, scenario.name, scenario.themeMarkers);
|
||||
routeEvidence.push({
|
||||
name: "移动导航抽屉",
|
||||
url: await chrome.url(),
|
||||
...mobileNavigation,
|
||||
layout: await chrome.layoutSnapshot()
|
||||
});
|
||||
}
|
||||
for (const route of routeChecks) {
|
||||
const state = await verifyBrowserRoute(chrome, route.hash, [...route.markers, ...scenario.themeMarkers], `${scenario.name} / ${route.name}`);
|
||||
const routeThemeMarkers = scenario.viewport.mobile ? [] : scenario.themeMarkers;
|
||||
const state = await verifyBrowserRoute(chrome, route.hash, [...route.markers, ...routeThemeMarkers], `${scenario.name} / ${route.name}`);
|
||||
const layout = await chrome.layoutSnapshot();
|
||||
assertNoVisibleLayoutIssues(layout, `${scenario.name} / ${route.name}`);
|
||||
routeEvidence.push({
|
||||
@@ -328,7 +366,7 @@ async function verifyResponsiveThemeWalkthroughs(chrome, routeChecks, server) {
|
||||
window.location.hash = "#/servers/server-local-debug";
|
||||
});
|
||||
await chrome.waitForText([server.name, "插件控制"], `${scenario.name} / server detail tabs`);
|
||||
const pluginControls = await clickAndVerify(chrome, "插件控制", ["Logs 桥接执行", "server.logs.read", "server.artifacts.read", "读取"]);
|
||||
const pluginControls = await clickAndVerify(chrome, "插件控制", ["生产生命周期", "Logs 桥接执行", "server.logs.read", "server.artifacts.read", "读取"]);
|
||||
const pluginLayout = await chrome.layoutSnapshot();
|
||||
assertNoVisibleLayoutIssues(pluginLayout, `${scenario.name} / plugin controls`);
|
||||
routeEvidence.push({
|
||||
@@ -402,15 +440,11 @@ async function clickAndVerify(chrome, buttonText, markers) {
|
||||
async function verifyServerQuickRuntimeMenu(chrome, label) {
|
||||
const markers = ["生成 run", "下载 run", "推送更新", "生成客户端", "依赖检查", "依赖安装", "实时日志", "历史日志"];
|
||||
await chrome.evaluate(() => {
|
||||
const summary = Array.from(document.querySelectorAll("summary")).find((item) => item.textContent?.includes("运行操作"));
|
||||
if (!(summary instanceof HTMLElement)) {
|
||||
throw new Error("runtime action menu summary not found");
|
||||
const trigger = Array.from(document.querySelectorAll("button")).find((item) => item.textContent?.includes("运行操作"));
|
||||
if (!(trigger instanceof HTMLButtonElement)) {
|
||||
throw new Error("runtime action menu trigger not found");
|
||||
}
|
||||
const details = summary.closest("details");
|
||||
if (!(details instanceof HTMLDetailsElement)) {
|
||||
throw new Error("runtime action menu container not found");
|
||||
}
|
||||
details.open = true;
|
||||
trigger.click();
|
||||
});
|
||||
await chrome.waitForText(markers, `${label} runtime action menu`);
|
||||
const visibleText = await chrome.visibleText();
|
||||
@@ -424,6 +458,63 @@ async function verifyServerQuickRuntimeMenu(chrome, label) {
|
||||
};
|
||||
}
|
||||
|
||||
async function verifyMobileNavigation(chrome, label, themeMarkers) {
|
||||
const menuLabels = ["平台概览", "服务器管理", "插件市场", "用户管理", "AI 提供商", "系统工具"];
|
||||
const closed = await chrome.evaluate(() => {
|
||||
const handle = document.querySelector(".mobile-sidebar-handle");
|
||||
const sidebar = document.querySelector("#primary-sidebar");
|
||||
return {
|
||||
handle: handle instanceof HTMLButtonElement,
|
||||
expanded: handle?.getAttribute("aria-expanded") === "true",
|
||||
sidebarOpen: sidebar?.classList.contains("app-sidebar-mobile-open") ?? false,
|
||||
mainTop: document.querySelector("main")?.getBoundingClientRect().top ?? null
|
||||
};
|
||||
});
|
||||
if (!closed.handle || closed.expanded || closed.sidebarOpen || closed.mainTop !== 0) {
|
||||
throw new Error(`${label} mobile drawer was not closed and out of page flow: ${JSON.stringify(closed)}`);
|
||||
}
|
||||
await chrome.evaluate(() => {
|
||||
const handle = document.querySelector(".mobile-sidebar-handle");
|
||||
if (!(handle instanceof HTMLButtonElement)) throw new Error("mobile drawer handle not found");
|
||||
handle.click();
|
||||
});
|
||||
await chrome.waitForText([...menuLabels, ...themeMarkers], `${label} mobile drawer open`);
|
||||
const opened = await chrome.evaluate(() => ({
|
||||
expanded: document.querySelector(".mobile-sidebar-handle")?.getAttribute("aria-expanded") === "true",
|
||||
sidebarOpen: document.querySelector("#primary-sidebar")?.classList.contains("app-sidebar-mobile-open") ?? false,
|
||||
width: document.querySelector("#primary-sidebar")?.getBoundingClientRect().width ?? 0
|
||||
}));
|
||||
if (!opened.expanded || !opened.sidebarOpen || opened.width <= 0 || opened.width > 320) {
|
||||
throw new Error(`${label} mobile drawer did not open as a bounded text sidebar: ${JSON.stringify(opened)}`);
|
||||
}
|
||||
const visibleText = await chrome.visibleText();
|
||||
assertMarkers(visibleText, [...menuLabels, ...themeMarkers], `${label} mobile drawer`);
|
||||
scanText(visibleText, `${label} mobile drawer`);
|
||||
await chrome.evaluate(() => {
|
||||
const backdrop = document.querySelector(".mobile-sidebar-backdrop");
|
||||
if (!(backdrop instanceof HTMLButtonElement)) throw new Error("mobile drawer backdrop not found");
|
||||
backdrop.click();
|
||||
});
|
||||
await delay(220);
|
||||
const closedAgain = await chrome.evaluate(() => {
|
||||
const backdrop = document.querySelector(".mobile-sidebar-backdrop");
|
||||
const style = backdrop ? window.getComputedStyle(backdrop) : null;
|
||||
return document.querySelector(".mobile-sidebar-handle")?.getAttribute("aria-expanded") !== "true"
|
||||
&& style?.visibility === "hidden"
|
||||
&& style?.pointerEvents === "none";
|
||||
});
|
||||
if (!closedAgain) throw new Error(`${label} mobile drawer did not close from backdrop`);
|
||||
return {
|
||||
requiredMarkers: [...menuLabels, ...themeMarkers],
|
||||
fallbackScan: "passed",
|
||||
forbiddenFragmentScan: "passed",
|
||||
closed,
|
||||
opened,
|
||||
closedAgain,
|
||||
textSample: visibleText.slice(0, 1200)
|
||||
};
|
||||
}
|
||||
|
||||
async function ensureAiProvider(headers) {
|
||||
const providers = await getJson("/ai-providers", headers);
|
||||
if (providers.items.some((item) => item.id === "ai.openai")) {
|
||||
@@ -447,6 +538,218 @@ async function ensureAiProvider(headers) {
|
||||
);
|
||||
}
|
||||
|
||||
async function prepareProductionOperations(headers, server, plugin) {
|
||||
const stamp = Date.now();
|
||||
const lifecycle = await postJson(
|
||||
`/plugin-lifecycles/${encodeURIComponent(plugin.id)}/actions`,
|
||||
{
|
||||
serverInstanceId: server.id,
|
||||
operation: "install",
|
||||
targetVersion: plugin.version,
|
||||
idempotencyKey: `browser-acceptance-plugin-install-${stamp}`,
|
||||
confirmed: false
|
||||
},
|
||||
headers
|
||||
);
|
||||
if (lifecycle.status !== "queued" || !lifecycle.job?.id || !lifecycle.installation?.id) {
|
||||
throw new Error(`plugin lifecycle seed did not create one durable job: ${JSON.stringify(lifecycle)}`);
|
||||
}
|
||||
|
||||
const aiInvocation = await postJson(
|
||||
"/ai/invocations",
|
||||
{
|
||||
requestId: `browser-acceptance-ai-config-${stamp}`,
|
||||
serverInstanceId: server.id,
|
||||
purpose: "config.suggest",
|
||||
prompt: "Keep existing settings and add a reviewed max players recommendation."
|
||||
},
|
||||
headers
|
||||
);
|
||||
if (aiInvocation.status !== "ok" || !aiInvocation.configRecommendation?.diffId) {
|
||||
throw new Error(`AI invocation did not persist a reviewable diff: ${JSON.stringify(aiInvocation)}`);
|
||||
}
|
||||
|
||||
const admission = await postJson(
|
||||
"/production/capacity/admission",
|
||||
{
|
||||
serverInstanceId: server.id,
|
||||
capability: "process.restart",
|
||||
idempotencyKey: `browser-acceptance-capacity-gap-${stamp}`
|
||||
},
|
||||
headers
|
||||
);
|
||||
if (admission.accepted || admission.state !== "denied" || !admission.alertId || !admission.auditEventId) {
|
||||
throw new Error(`capacity admission did not create durable denied evidence: ${JSON.stringify(admission)}`);
|
||||
}
|
||||
|
||||
const [capacity, alerts, lifecycles, diffs] = await Promise.all([
|
||||
getJson("/production/capacity", headers),
|
||||
getJson("/alerts", headers),
|
||||
getJson(`/plugin-lifecycles?pluginId=${encodeURIComponent(plugin.id)}&serverInstanceId=${encodeURIComponent(server.id)}`, headers),
|
||||
getJson(`/ai/config-diffs?serverInstanceId=${encodeURIComponent(server.id)}`, headers)
|
||||
]);
|
||||
const alert = findRequired(alerts.items, (item) => item.id === admission.alertId && item.state === "active", "active capacity alert");
|
||||
const installation = findRequired(lifecycles.items, (item) => item.id === lifecycle.installation.id && item.jobId === lifecycle.job.id, "durable plugin lifecycle installation");
|
||||
const diff = findRequired(diffs.items, (item) => item.id === aiInvocation.configRecommendation.diffId && item.state === "pending", "pending AI config diff");
|
||||
if (capacity.activeAlerts < 1 || !capacity.endpoints.some((item) => item.runEndpointId === server.runEndpointId)) {
|
||||
throw new Error(`production capacity summary did not include seeded state: ${JSON.stringify(capacity)}`);
|
||||
}
|
||||
|
||||
for (const [label, value] of Object.entries({ admission, capacity, alert, installation, diff, aiInvocation })) {
|
||||
assertNoForbiddenProjection(value, `production seed ${label}`);
|
||||
}
|
||||
return {
|
||||
alert,
|
||||
diff,
|
||||
apiProof: {
|
||||
admission: pick(admission, ["accepted", "state", "reason", "pressureCodes", "alertId", "auditEventId"]),
|
||||
capacity: {
|
||||
...pick(capacity, ["totalMaxJobs", "totalRunningJobs", "totalQueuedJobs", "activeAlerts", "generatedAt"]),
|
||||
endpoints: capacity.endpoints.map((item) => pick(item, ["runEndpointId", "status", "maxJobs", "runningJobs", "queuedJobs", "logBacklogBatches", "artifactBacklogChunks", "pressureCodes"]))
|
||||
},
|
||||
alert: pick(alert, ["id", "sourceKind", "sourceId", "ruleKey", "severity", "state", "occurrenceCount", "lastAuditEventId"]),
|
||||
pluginLifecycle: pick(installation, ["id", "pluginId", "serverInstanceId", "currentVersion", "targetVersion", "desiredState", "currentState", "lastOperation", "compatibility", "dependencyState", "jobId", "auditEventId"]),
|
||||
aiConfigDiff: pick(diff, ["id", "requestId", "serverInstanceId", "pluginId", "providerId", "model", "key", "configVersion", "diffSummary", "state", "expiresAt"])
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function verifyAlertInteraction(chrome, headers, seededAlert) {
|
||||
await chrome.navigate(`${webUrl}/#/home`);
|
||||
await chrome.waitForText(["生产容量与告警", seededAlert.title, "确认"], "production alert interaction");
|
||||
await chrome.evaluate((title) => {
|
||||
const item = Array.from(document.querySelectorAll(".production-alert-list .operation-item")).find((candidate) => candidate.textContent?.includes(title));
|
||||
const button = Array.from(item?.querySelectorAll("button") || []).find((candidate) => candidate.textContent?.trim() === "确认");
|
||||
if (!(button instanceof HTMLButtonElement)) throw new Error("capacity alert acknowledge button not found");
|
||||
button.click();
|
||||
}, seededAlert.title);
|
||||
await chrome.waitForText(["确认告警", seededAlert.id, "取消"], "alert confirmation dialog");
|
||||
await chrome.evaluate(() => {
|
||||
const cancel = document.querySelector(".confirm-panel .confirm-actions button");
|
||||
if (!(cancel instanceof HTMLButtonElement)) throw new Error("alert confirmation cancel button not found");
|
||||
cancel.click();
|
||||
});
|
||||
await delay(100);
|
||||
if (await chrome.evaluate(() => Boolean(document.querySelector(".confirm-panel")))) {
|
||||
throw new Error("alert confirmation dialog did not close after cancel");
|
||||
}
|
||||
const afterCancel = await getJson("/alerts", headers);
|
||||
const stillActive = findRequired(afterCancel.items, (item) => item.id === seededAlert.id, "alert after confirmation cancel");
|
||||
assertEqual(stillActive.state, "active", "cancel keeps durable alert active");
|
||||
|
||||
await chrome.evaluate((title) => {
|
||||
const item = Array.from(document.querySelectorAll(".production-alert-list .operation-item")).find((candidate) => candidate.textContent?.includes(title));
|
||||
const button = Array.from(item?.querySelectorAll("button") || []).find((candidate) => candidate.textContent?.trim() === "确认");
|
||||
if (!(button instanceof HTMLButtonElement)) throw new Error("capacity alert acknowledge button not found after cancel");
|
||||
button.click();
|
||||
}, seededAlert.title);
|
||||
await chrome.waitForText(["确认告警", seededAlert.id], "alert confirmation reopen");
|
||||
await chrome.evaluate(() => {
|
||||
const confirm = document.querySelector(".confirm-panel .confirm-primary");
|
||||
if (!(confirm instanceof HTMLButtonElement)) throw new Error("alert confirmation submit button not found");
|
||||
confirm.click();
|
||||
});
|
||||
await chrome.waitForText(["确认已由 Platform 持久化", "acknowledged"], "durable alert acknowledgement");
|
||||
const alerts = await getJson("/alerts", headers);
|
||||
const acknowledged = findRequired(alerts.items, (item) => item.id === seededAlert.id, "acknowledged capacity alert");
|
||||
assertEqual(acknowledged.state, "acknowledged", "browser alert acknowledgement persisted");
|
||||
assertNoForbiddenProjection(acknowledged, "acknowledged alert response");
|
||||
return {
|
||||
cancelPreservedState: stillActive.state,
|
||||
persisted: pick(acknowledged, ["id", "state", "acknowledgedBy", "acknowledgedAt", "lastAuditEventId"]),
|
||||
forbiddenFragmentScan: "passed",
|
||||
textSample: (await chrome.visibleText()).slice(0, 1200)
|
||||
};
|
||||
}
|
||||
|
||||
async function verifyPluginLifecycleInteraction(chrome, headers, plugin, server) {
|
||||
await chrome.navigate(`${webUrl}/#/plugins`);
|
||||
await chrome.waitForText(["插件市场", plugin.id, "查看详情"], "plugin lifecycle marketplace");
|
||||
await chrome.evaluate((pluginId) => {
|
||||
const card = Array.from(document.querySelectorAll(".catalog-card")).find((candidate) => candidate.textContent?.includes(pluginId));
|
||||
const button = card?.querySelector("button[title='查看插件详情']");
|
||||
if (!(button instanceof HTMLButtonElement)) throw new Error("plugin detail button not found");
|
||||
button.click();
|
||||
}, plugin.id);
|
||||
await chrome.waitForText([plugin.name, "生产生命周期", server.id, "compatible"], "plugin lifecycle detail");
|
||||
await chrome.evaluate(() => {
|
||||
const select = document.querySelector("select[aria-label='生命周期操作']");
|
||||
if (!(select instanceof HTMLSelectElement)) throw new Error("plugin lifecycle operation select not found");
|
||||
const setter = Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, "value")?.set;
|
||||
setter.call(select, "enable");
|
||||
select.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
});
|
||||
await chrome.evaluate(() => {
|
||||
const button = document.querySelector(".plugin-lifecycle-controls .primary-command");
|
||||
if (!(button instanceof HTMLButtonElement)) throw new Error("plugin lifecycle execute button not found");
|
||||
button.click();
|
||||
});
|
||||
await chrome.waitForText(["确认启用", plugin.name, server.id], "plugin lifecycle confirmation");
|
||||
await chrome.evaluate(() => {
|
||||
const confirm = Array.from(document.querySelectorAll(".confirm-panel .confirm-actions button")).find((item) => item.textContent?.trim() === "启用");
|
||||
if (!(confirm instanceof HTMLButtonElement)) throw new Error("plugin lifecycle confirmation submit not found");
|
||||
confirm.click();
|
||||
});
|
||||
await chrome.waitForText(["启用:queued", "任务"], "plugin lifecycle durable dispatch");
|
||||
const response = await getJson(`/plugin-lifecycles?pluginId=${encodeURIComponent(plugin.id)}&serverInstanceId=${encodeURIComponent(server.id)}`, headers);
|
||||
const installation = findRequired(response.items, (item) => item.pluginId === plugin.id && item.serverInstanceId === server.id, "plugin lifecycle after browser dispatch");
|
||||
assertEqual(installation.lastOperation, "enable", "browser lifecycle operation persisted");
|
||||
if (!installation.jobId || !installation.auditEventId) {
|
||||
throw new Error(`browser lifecycle dispatch missed job/audit linkage: ${JSON.stringify(installation)}`);
|
||||
}
|
||||
assertNoForbiddenProjection(installation, "browser plugin lifecycle response");
|
||||
return {
|
||||
persisted: pick(installation, ["id", "pluginId", "serverInstanceId", "currentState", "desiredState", "lastOperation", "dependencyState", "jobId", "auditEventId", "alertId"]),
|
||||
forbiddenFragmentScan: "passed",
|
||||
textSample: (await chrome.visibleText()).slice(0, 1200)
|
||||
};
|
||||
}
|
||||
|
||||
async function verifyAIConfigDiffInteraction(chrome, headers, seededDiff) {
|
||||
await chrome.navigate(`${webUrl}/#/aiProviders`);
|
||||
await chrome.waitForText(["AI 配置审查", seededDiff.serverInstanceId, seededDiff.diffSummary, "审查并批准"], "AI config diff review");
|
||||
await chrome.evaluate(() => {
|
||||
const button = Array.from(document.querySelectorAll(".ai-diff-review-panel button")).find((item) => item.textContent?.includes("审查并批准"));
|
||||
if (!(button instanceof HTMLButtonElement)) throw new Error("AI diff review button not found");
|
||||
button.click();
|
||||
});
|
||||
await chrome.waitForText(["批准 AI 配置差异", seededDiff.id, "取消"], "AI diff approval confirmation");
|
||||
await chrome.evaluate(() => {
|
||||
const cancel = document.querySelector(".confirm-panel .confirm-actions button");
|
||||
if (!(cancel instanceof HTMLButtonElement)) throw new Error("AI diff approval cancel button not found");
|
||||
cancel.click();
|
||||
});
|
||||
const pendingResponse = await getJson(`/ai/config-diffs?serverInstanceId=${encodeURIComponent(seededDiff.serverInstanceId)}`, headers);
|
||||
const pending = findRequired(pendingResponse.items, (item) => item.id === seededDiff.id, "AI diff after approval cancel");
|
||||
assertEqual(pending.state, "pending", "cancel keeps AI diff pending");
|
||||
|
||||
await chrome.evaluate(() => {
|
||||
const button = Array.from(document.querySelectorAll(".ai-diff-review-panel button")).find((item) => item.textContent?.includes("审查并批准"));
|
||||
if (!(button instanceof HTMLButtonElement)) throw new Error("AI diff review button not found after cancel");
|
||||
button.click();
|
||||
});
|
||||
await chrome.waitForText(["批准 AI 配置差异", seededDiff.id], "AI diff approval confirmation reopen");
|
||||
await chrome.evaluate(() => {
|
||||
const confirm = document.querySelector(".confirm-panel .confirm-primary");
|
||||
if (!(confirm instanceof HTMLButtonElement)) throw new Error("AI diff approval submit button not found");
|
||||
confirm.click();
|
||||
});
|
||||
await chrome.waitForText(["已审批", "写入任务"], "AI diff durable approval");
|
||||
const approvedResponse = await getJson(`/ai/config-diffs?serverInstanceId=${encodeURIComponent(seededDiff.serverInstanceId)}`, headers);
|
||||
const approved = findRequired(approvedResponse.items, (item) => item.id === seededDiff.id, "approved AI config diff");
|
||||
assertEqual(approved.state, "approved", "browser AI diff approval persisted");
|
||||
if (!approved.jobId || !approved.approvedBy || !approved.approvedAt) {
|
||||
throw new Error(`approved AI diff missed durable approval linkage: ${JSON.stringify(approved)}`);
|
||||
}
|
||||
assertNoForbiddenProjection(approved, "approved AI diff response");
|
||||
return {
|
||||
cancelPreservedState: pending.state,
|
||||
persisted: pick(approved, ["id", "serverInstanceId", "state", "approvedBy", "approvedAt", "jobId", "configVersion", "currentConfigChecksum"]),
|
||||
forbiddenFragmentScan: "passed",
|
||||
textSample: (await chrome.visibleText()).slice(0, 1200)
|
||||
};
|
||||
}
|
||||
|
||||
async function verifyFrontendEnvironment() {
|
||||
const [html, packageJsonText, viteConfigText] = await Promise.all([
|
||||
fetchText(webUrl),
|
||||
@@ -470,34 +773,42 @@ async function verifyFrontendEnvironment() {
|
||||
}
|
||||
|
||||
async function verifyLifecycleOperation(headers, server, chrome) {
|
||||
const request = { expectedConfigVersion: server.configVersion, idempotencyKey: `browser-acceptance-start-${Date.now()}` };
|
||||
const result = await postJson(`/server-instances/${encodeURIComponent(server.id)}/start`, request, headers);
|
||||
const currentServer = await getJson(`/server-instances/${encodeURIComponent(server.id)}`, headers);
|
||||
if (currentServer.configVersion <= server.configVersion) {
|
||||
throw new Error(`AI config write did not advance the durable server config version: before=${server.configVersion} after=${currentServer.configVersion}`);
|
||||
}
|
||||
const action = currentServer.state === "running" ? "stop" : "start";
|
||||
const expectedCapability = action === "stop" ? "process.stop" : "process.start";
|
||||
const request = { expectedConfigVersion: currentServer.configVersion, idempotencyKey: `browser-acceptance-${action}-${Date.now()}` };
|
||||
const result = await postJson(`/server-instances/${encodeURIComponent(currentServer.id)}/${action}`, request, headers);
|
||||
if (!result.accepted || !result.job?.id) {
|
||||
throw new Error("lifecycle start operation did not return accepted job evidence");
|
||||
}
|
||||
if (result.job.capability !== "process.start") {
|
||||
if (result.job.capability !== expectedCapability) {
|
||||
throw new Error(`lifecycle job used unexpected capability ${result.job.capability}`);
|
||||
}
|
||||
if (result.job.runEndpointId !== "run-local-debug") {
|
||||
throw new Error(`lifecycle job used unexpected run endpoint ${result.job.runEndpointId}`);
|
||||
}
|
||||
|
||||
const job = await waitForJob(headers, server.id, result.job.id);
|
||||
const job = await waitForJob(headers, currentServer.id, result.job.id);
|
||||
|
||||
await chrome.navigate(`${webUrl}/#/servers/server-local-debug`);
|
||||
await chrome.waitForText([server.name, "操作历史"], "server detail after lifecycle operation");
|
||||
await chrome.waitForText([currentServer.name, "操作历史"], "server detail after lifecycle operation");
|
||||
const historyState = await clickAndVerify(chrome, "操作历史", ["操作历史", "平台任务记录", "server-lifecycle", "process."]);
|
||||
|
||||
return {
|
||||
action: "start",
|
||||
action,
|
||||
accepted: result.accepted,
|
||||
request: {
|
||||
expectedConfigVersion: request.expectedConfigVersion,
|
||||
idempotencyKey: request.idempotencyKey
|
||||
idempotencyKey: request.idempotencyKey,
|
||||
configVersionBeforeAIWrite: server.configVersion,
|
||||
configVersionAfterAIWrite: currentServer.configVersion
|
||||
},
|
||||
acceptedJob: pick(result.job, ["id", "serverInstanceId", "runEndpointId", "capability", "state", "resultRef"]),
|
||||
job: pick(job, ["id", "serverInstanceId", "runEndpointId", "capability", "state", "resultRef"]),
|
||||
proof: "platform API accepted process.start and platform-owned jobs endpoint returned the same job; browser verified operation history entry point without direct run access",
|
||||
proof: `platform API accepted ${expectedCapability} and platform-owned jobs endpoint returned the same job; browser verified operation history entry point without direct run access`,
|
||||
browserEvidence: {
|
||||
...historyState,
|
||||
proofMode: "operation-history-entry-point"
|
||||
@@ -894,6 +1205,18 @@ function scanText(text, routeName) {
|
||||
}
|
||||
}
|
||||
|
||||
function assertNoForbiddenProjection(value, label) {
|
||||
const serialized = JSON.stringify(value);
|
||||
const forbidden = forbiddenFragments.find((item) => item.pattern.test(serialized));
|
||||
if (forbidden) {
|
||||
throw new Error(`${label} contains forbidden API fragment: ${forbidden.name}`);
|
||||
}
|
||||
const forbiddenField = /"(?:apiKey|apiKeyRef|rawApiKey|baseUrl|hostPath|pid|socket|dsn|rcon|credential|directRunEndpoint)"\s*:/i.exec(serialized);
|
||||
if (forbiddenField) {
|
||||
throw new Error(`${label} contains forbidden API field ${forbiddenField[0]}`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertEqual(actual, expected, label) {
|
||||
if (actual !== expected) {
|
||||
throw new Error(`${label}: expected ${expected}, got ${actual}`);
|
||||
@@ -906,12 +1229,6 @@ function assertIncludes(values, expected, label) {
|
||||
}
|
||||
}
|
||||
|
||||
function assertSafeRedactedRef(value, label) {
|
||||
if (typeof value !== "string" || (!value.startsWith("secret://") && !value.startsWith("env://"))) {
|
||||
throw new Error(`${label} must be a safe secret/env reference`);
|
||||
}
|
||||
}
|
||||
|
||||
function pick(value, keys) {
|
||||
return Object.fromEntries(keys.map((key) => [key, value?.[key]]));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user