Files
dsh_shenxian/scripts/verify-platform-admin-section.mjs
T

253 lines
14 KiB
JavaScript
Raw Normal View History

#!/usr/bin/env node
/**
* verify-platform-admin-section.mjs —— 「系统管理」分区的**无浏览器**渲染验收(档案 82)
*
* 为什么需要:本机 `agent-browser` 的 daemon 起不来(2026-09-13),浏览器截图验收受阻;
* 而 `06-工作台UI规范 §7.3` 三段式在 bundle **按需动态加载** 时也拿不到带 rev 的完整 URL。
* 做法:打桩 `react` / `react/jsx-runtime` + 最小 hooks 循环 + **真执行** client.js,
* 断言分区被注册、admin 渲染出门户同款 **6 个功能页**、非 admin 被门禁挡住、
* 以及 zh/en 词典**键集一一对应**。
*
* 第 3 轮(2026-09-13):口径从「5 项只读摘要」改为「门户 6 个功能页同名同构」,
* 因此断言同步换成门户的功能名与各页表头。
*
* 用法:node scripts/verify-platform-admin-section.mjs 退出码 0=全绿 / 1=有失败
*/
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
import vm from "node:vm";
const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
const src = readFileSync(join(ROOT, "poc/business-plugins/lib/client.js"), "utf8");
let failed = 0;
const ok = (name, cond, extra = "") => { console.log((cond ? " ✓ " : " ✗ ") + name + (extra ? " " + extra : "")); if (!cond) failed++; };
let slots = [], cursor = 0, dirty = false;
const React = {
useState(init) { const i = cursor++; if (!(i in slots)) slots[i] = typeof init === "function" ? init() : init; return [slots[i], (v) => { slots[i] = typeof v === "function" ? v(slots[i]) : v; dirty = true; }]; },
useEffect(fn) { const i = cursor++; if (!slots[i]) { slots[i] = 1; fn(); } },
};
const jsx = (type, props) => ({ type, props: props || {} });
const jsxRuntime = { jsx, jsxs: jsx, Fragment: "Fragment" };
let def = null;
const win = {
__ModuleLoader__: { load: (d) => { def = d; } },
location: { hostname: "admin.alotbuy.com", protocol: "https:", origin: "https://admin.alotbuy.com" },
open: (u) => { globalThis.__OPENED = u; },
document: {
createElement: () => {
const o = { setAttribute() {}, remove() {} };
Object.defineProperty(o, "textContent", { set(v) { CSS += v; }, get() { return ""; } });
return o;
},
head: { appendChild() {} },
},
};
let ROLE = "admin";
let CSS = "";
const DATA = {
"/api/auth/me": null, // 由 fetch 桩按 ROLE 生成
"/api/dsh/status": { running: true, instance: { port: 43095, restarts: 2 }, breaker: null },
"/api/admin/users": { users: [{ id: 1, username: "a", role: "admin", createdAt: 1 }, { id: 2, username: "b", role: "active", createdAt: 2 }] },
"/api/admin/storage": { generatedAt: 1, users: [] },
"/api/plugins/business": { plugins: [{ id: "p1", name: "x", version: "1.0.0" }] },
"/api/admin/runtime": { items: [{ name: "node", group: "g", kind: "k", version: "22", source: "s", script: "sc", removable: false }], note: "n", drift: [], runtimeDir: { path: "/p", bytes: 1, installedAt: 1 }, manifest: "m", baselineScript: "b" },
// 档案 85 ·「我的密钥」:两层密钥(我自己的 + 平台共享)
"/api/me/keys": { keys: [{ id: "k1", name: "my-key", enabled: true, updatedAt: 1 }], effective: "own", shared: { available: true, name: "shared-key", owner: "admin" } },
};
const sandbox = {
console, setTimeout, clearTimeout,
JSON, Object, Promise, Buffer, URL, globalThis: undefined,
Math, Date, encodeURIComponent, decodeURIComponent,
window: win, document: win.document,
fetch: async (url) => {
const p = String(url).replace("https://alotbuy.com", "");
const body = p === "/api/auth/me" ? { user: { id: "u1", username: ROLE, role: ROLE } } : DATA[p];
return { ok: body !== undefined, status: body === undefined ? 404 : 200, json: async () => body };
},
require: (m) => (m === "react" ? React : m === "react/jsx-runtime" ? jsxRuntime : (() => { throw new Error("require " + m); })()),
};
sandbox.globalThis = sandbox;
vm.createContext(sandbox);
vm.runInContext(src, sandbox);
const mod = def.factory(sandbox.require);
let DICT = {}, ZH = {}, EN = {}, regs = [];
const ctx = {
effect: (fn, name) => { try { fn(); } catch (e) { console.log(" effect 抛错:", name, e.message); } },
locale: {
register: (ns, d) => { ZH = d.zh || {}; EN = d.en || {}; DICT = Object.assign({}, ZH); },
bind: () => (k) => DICT[k] ?? k,
},
slots: { inject: (n, fn) => fn(), register: (meta, Comp) => { regs.push({ meta, Comp }); return () => {}; } },
};
const inj = Array.isArray(mod.inject) ? mod.inject : [];
console.log(" inject 声明:", JSON.stringify(inj));
mod.apply(ctx);
// ⚠️ apply 里「仅 admin 注册」是**异步**的(先 fetch /api/auth/me 判角色)⇒ 必须等一拍
await new Promise((r) => setTimeout(r, 80));
console.log(" 已注册 section:", regs.map(r => r.meta.id + " / order=" + r.meta.order + " / label=" + r.meta.label()).join(" | "));
function text(n, out = [], depth = 0) {
if (n == null || depth > 24) return out;
if (typeof n === "string" || typeof n === "number") { out.push(String(n)); return out; }
if (Array.isArray(n)) { n.forEach(x => text(x, out, depth + 1)); return out; }
if (typeof n === "object") {
if (typeof n.type === "function") { text(n.type(n.props || {}), out, depth + 1); return out; }
if (n.props) {
// 第 3 轮:功能页正文走 `dangerouslySetInnerHTML`(门户原文),必须单独收集
const dsi = n.props.dangerouslySetInnerHTML;
if (dsi && dsi.__html) out.push(dsi.__html);
text(n.props.children, out, depth + 1);
}
}
return out;
}
async function render(Comp) {
cursor = 0; slots = [];
let tree = Comp();
for (let i = 0; i < 8; i++) { await new Promise(r => setTimeout(r, 30)); if (!dirty) break; dirty = false; cursor = 0; tree = Comp(); }
return tree;
}
/** 首帧渲染完成后改一个 state(patch 直接写 slots),再渲染一次 —— 用来把某个功能页「点开」。 */
async function renderWith(Comp, patch) {
let tree = await render(Comp);
patch();
cursor = 0; dirty = false;
tree = Comp();
for (let i = 0; i < 8; i++) { await new Promise(r => setTimeout(r, 30)); if (!dirty) break; dirty = false; cursor = 0; tree = Comp(); }
return tree;
}
function classes(n, out = [], depth = 0) {
if (n == null || depth > 16) return out;
if (Array.isArray(n)) { n.forEach(x => classes(x, out, depth + 1)); return out; }
if (typeof n === "object") {
if (typeof n.type === "function") { classes(n.type(n.props || {}), out, depth + 1); return out; }
if (n.props) {
if (typeof n.props.className === "string") n.props.className.split(/\s+/).forEach(c => { if (c) out.push(c); });
classes(n.props.children, out, depth + 1);
}
}
return out;
}
/**
* 收集整棵树的 class —— **同时扫 React 节点与内联 HTML**。
* 第 3 轮起功能页正文走 `dangerouslySetInnerHTML`(门户原文),class 只存在于字符串里,
* 只看 React 节点会漏掉全部表格 / 工具条 / 页签类名。
*/
function clsAll(tree) {
const out = new Set(classes(tree));
const s = text(tree).join(" ");
const re = /class="([^"]*)"/g;
let m;
while ((m = re.exec(s))) m[1].split(/\s+/).forEach((c) => { if (c) out.add(c); });
return [...out];
}
// ── 词典(第 3 轮新增断言):zh / en 键集必须一一对应 ─────────────────────────────
{
const zk = Object.keys(ZH).sort(), ek = Object.keys(EN).sort();
const onlyZh = zk.filter(k => !(k in EN));
const onlyEn = ek.filter(k => !(k in ZH));
ok("词典:zh/en 键集一一对应", onlyZh.length === 0 && onlyEn.length === 0,
`zh ${zk.length} / en ${ek.length}` + (onlyZh.length ? " | 仅 zh: " + onlyZh.join(",") : "") + (onlyEn.length ? " | 仅 en: " + onlyEn.join(",") : ""));
const paKeys = zk.filter(k => k.startsWith("pa."));
ok("词典:pa.* 词条齐备(门户 6 页文案)", paKeys.length >= 120, "-> " + paKeys.length + " 条");
const ph = Object.entries(ZH).filter(([k, v]) => String(v).includes("{")).map(([k]) => k).sort();
const phEn = Object.entries(EN).filter(([k, v]) => String(v).includes("{")).map(([k]) => k).sort();
ok("词典:占位符键在两个语言里一致", ph.join(",") === phEn.join(","), "-> " + ph.join(","));
}
// ── 注册与门禁 ────────────────────────────────────────────────────────────────
ok("admin 视角下注册了两个 settings.section", regs.length === 2, "-> " + regs.map(r => r.meta.id).join(", "));
const pa = regs.find(r => r.meta.id === "platform-admin");
ok("存在 platform-admin 分区", !!pa);
if (pa) ok("其 order = 102", pa.meta.order === 102);
const PORTAL_ITEMS = ["服务管理", "密钥管理", "用户管理", "技能管理", "插件管理", "运行环境"];
ROLE = "admin";
const tAdmin = text(await render(pa.Comp)).join(" | ");
ok("admin:含分区标题「系统管理」", tAdmin.includes("系统管理"));
ok("admin:6 个功能项与门户同名", PORTAL_ITEMS.every(k => tAdmin.includes(k)),
"-> " + PORTAL_ITEMS.filter(k => !tAdmin.includes(k)).join(",") || "");
ok("admin:首页两组标题(服务 / 管理)", tAdmin.includes("服务") && tAdmin.includes("管理"));
ok("admin:不再出现旧的「候选池 / 运行时 / 当前实例」自造项名",
!["候选池", "当前实例"].some(k => tAdmin.includes(k)));
ROLE = "active";
const tUser = text(await render(pa.Comp)).join(" | ");
ok("非 admin:被门禁挡住", tUser.includes("仅对管理员显示"));
ok("非 admin:不出现任何功能项", !PORTAL_ITEMS.some(k => tUser.includes(k)));
// ── 非 admin:不注册「系统管理」(模型配置走官方「设置 → 模型」页,平台不再自带入口)──
{
regs = []; ROLE = "active";
mod.apply(ctx);
await new Promise((r) => setTimeout(r, 80));
const ids = regs.map(r => r.meta.id).sort();
ok("非 admin 视角下只注册 1 个分区(功能管理;不含系统管理)",
ids.length === 1 && ids[0] === "business-plugins",
"-> " + ids.join(", "));
regs = []; ROLE = "admin";
}
// ── 首页视觉:门户 `.nav-grid` / `.nav-card` 家族 ─────────────────────────────
{
ROLE = "admin";
const clsHome = classes(await render(pa.Comp));
ok("首页:标题/副标题取门户 .page-title/.page-sub 值", clsHome.includes("pa-hd") && clsHome.includes("pa-sub"));
ok("首页:分组标题取门户 .home-section-title 值", clsHome.includes("pa-sec"));
ok("首页:卡片网格取门户 .nav-grid 值", clsHome.includes("pa-grid"));
ok("首页:卡片取门户 .nav-card 值(.pa-card)", clsHome.includes("pa-card"));
// ── 6 个功能页逐个点开:**弹窗外壳 + 门户该页的表格/操作** ──────────────────
const EXPECT = {
files: ["服务管理", "启动 DSH", "新建文件夹", "上传文件", "修改时间", "根"],
keys: ["密钥管理", "全局 API 密钥", "sk-..."],
users: ["用户管理", "注册时间", "操作"],
skills: ["技能管理", "共享技能", "更新时间", "上传 / 替换"],
plugins: ["插件管理", "官方推荐插件", "手动添加 / 管理", "导入到平台", "下载量", "投放时间"],
// runtime 的表格由 init 动态生成(门户同构),静态 html 只有容器 ⇒ 这里只查标题与副标题
runtime: ["运行环境", "实例共享的运行时与工具"],
};
const badShell = [], badBody = [];
for (const [key, wants] of Object.entries(EXPECT)) {
const tree = await renderWith(pa.Comp, () => { slots[1] = key; });
const cls = classes(tree);
const shell = cls.includes("pa-overlay") && cls.includes("pa-modal") && cls.includes("pa-wide")
&& cls.includes("pa-mhead") && cls.includes("pa-mbody") && cls.includes("pa-phead") && cls.includes("pa-sm");
if (!shell) badShell.push(key + "(" + cls.filter(c => c.startsWith("pa-")).join("/") + ")");
const body = text(tree).join(" | ");
const miss = wants.filter(w => !body.includes(w));
if (miss.length) badBody.push(key + " 缺 " + miss.join(","));
}
ok("6 个功能页全部 = 门户弹窗外壳(遮罩/大面板/页头/滚动体)", badShell.length === 0,
badShell.length ? "-> " + badShell.join(" ; ") : "-> files/keys/users/skills/plugins/runtime 均通过");
ok("6 个功能页正文 = 门户该页的表格与操作", badBody.length === 0,
badBody.length ? "-> " + badBody.join(" ; ") : "-> 表头 / 按钮 / 说明文本均在");
// 插件页:双页签(门户 .pg-tabs)+ 门户表格类
const clsPl = clsAll(await renderWith(pa.Comp, () => { slots[1] = "plugins"; }));
ok("插件页:双页签取门户 .pg-tabs/.pg-tab 值", clsPl.includes("pa-tabs") && clsPl.includes("pa-tab"));
ok("插件页:页签计数胶囊取门户 .pg-cnt 值", clsPl.includes("pa-tcnt"));
// 导入方式徽章出现在 init 动态生成的行里(静态 html 无)⇒ 改断言 CSS 已注入门户 .badge 对应值
ok("门户组件 CSS 已注入(.badge / .table-wrap / .btn-sm / .pg-tabs 家族)",
[".pa-badge", ".pa-wrap", ".pa-sm", ".pa-tabs", ".pa-box", ".pa-btn", ".pa-plist"].every(c => CSS.includes(c)));
// 用户页:表格 + 徽章 + 危险按钮
const clsU = clsAll(await renderWith(pa.Comp, () => { slots[1] = "users"; }));
ok("用户页:门户表格(.pa-wrap + .pa-tbl)", clsU.includes("pa-wrap") && clsU.includes("pa-tbl"));
ok("用户页:角色徽章四色文案齐备(词典)",
["pa.role.admin", "pa.role.active", "pa.role.pending", "pa.role.disabled"].every(k => ZH[k]));
// 服务页:门户 .dsh-bar / .pathbar / .table-wrap
const clsF = clsAll(await renderWith(pa.Comp, () => { slots[1] = "files"; }));
ok("服务页:门户 .dsh-bar / .pathbar 取值", clsF.includes("pa-dshbar") && clsF.includes("pa-pathbar"));
ok("服务页:门户 .table-wrap/table.tbl 取值", clsF.includes("pa-wrap") && clsF.includes("pa-tbl"));
slots[1] = null; cursor = 0; dirty = false;
}
console.log(failed === 0 ? "\n结论:全绿 ✅" : "\n结论:有 " + failed + " 项失败 ❌");
process.exit(failed === 0 ? 0 : 1);