#!/usr/bin/env node /** * verify-platform-admin-section.mjs —— R2「平台管理」分区的**无浏览器**渲染验收(档案 82) * * 为什么需要:本机 `agent-browser` 的 daemon 起不来(2026-09-13),浏览器截图验收受阻; * 而 `06-工作台UI规范 §7.3` 三段式在 bundle **按需动态加载** 时也拿不到带 rev 的完整 URL。 * 做法:打桩 `react` / `react/jsx-runtime` + 最小 hooks 循环 + **真执行** client.js, * 断言分区**被注册**、**admin 渲染出 6 张卡**、**非 admin 被门禁挡住**。 * 用法: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] = 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: () => ({ setAttribute() {}, remove() {}, textContent: "" }), head: { appendChild() {} } }, }; let ROLE = "admin"; const DATA = { "/api/dsh/status": { running: true, instance: { port: 43095, restarts: 2 }, breaker: null }, "/api/admin/users": { users: [{ role: "admin" }, { role: "active" }, { role: "pending" }, { role: "disabled" }] }, "/api/admin/storage": { generatedAt: 1, users: [{}, {}] }, "/api/plugins/business": { plugins: [{}, {}, {}] }, "/api/admin/runtime": { items: [{}, {}, {}, {}, {}] }, }; const sandbox = { console, setTimeout, clearTimeout, JSON, Object, Promise, Buffer, URL, globalThis: undefined, 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 = {}, regs = []; const ctx = { effect: (fn, name) => { try { fn(); } catch (e) { console.log(" effect 抛错:", name, e.message); } }, locale: { register: (ns, d) => { DICT = Object.assign({}, dICT_zh(d)); }, bind: () => (k) => DICT[k] ?? k }, slots: { inject: (n, fn) => fn(), register: (meta, Comp) => { regs.push({ meta, Comp }); return () => {}; } }, }; function dICT_zh(d) { return d.zh || {}; } 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 > 20) return out; // 表格是「容器>table>tbody>tr>td>文本」6 层,旧上限 12 会把行文本截掉 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) text(n.props.children, out, depth + 1); } return out; } async function render(Comp) { cursor = 0; slots = []; let tree = Comp(); for (let i = 0; i < 6; 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 < 6; i++) { await new Promise(r => setTimeout(r, 30)); if (!dirty) break; dirty = false; cursor = 0; tree = Comp(); } return tree; } /** 收集整棵树上的 className(用来断言「用的是门户那套 class」) */ function classes(n, out = [], depth = 0) { if (n == null || depth > 14) 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; } for (const { meta, Comp } of regs.filter(r => r.meta.id === "platform-admin")) { for (const role of ["admin", "active"]) { ROLE = role; delete globalThis.__OPENED; const tree = await render(Comp); const t = text(tree).join(" | "); console.log("\n ── role=" + role + " ──"); console.log(" 渲染文本:", t.slice(0, 300)); console.log(" 含「平台管理」:", t.includes("平台管理"), "| 含 6 张卡:", ["当前实例","熔断","用户","存储","候选池","运行时"].every(k => t.includes(k))); if (role === "active") console.log(" 含仅管理员说明:", t.includes("仅对管理员显示")); } } // 注:apply 里「仅 admin 注册」是异步判角色后才 inject;本脚本打桩的是同步路径, // 因此这里断言「两个分区都可注册」+ 内容层门禁。注册条件由 R2 的运行时口径另验。 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); ROLE = "admin"; delete globalThis.__OPENED; const tAdmin = text(await render(pa.Comp)).join(" | "); ok("admin:含分区标题「系统管理」", tAdmin.includes("系统管理")); ok("admin:5 个管理项齐全", ["用户管理","候选池","运行时","存储","当前实例"].every(k => tAdmin.includes(k))); ok("admin:不再是只读(提示改为弹窗内管理)", tAdmin.includes("弹窗")); ROLE = "active"; const tUser = text(await render(pa.Comp)).join(" | "); ok("非 admin:被门禁挡住", tUser.includes("仅对管理员显示")); ok("非 admin:不出现任何管理项", !["候选池","运行时"].some(k => tUser.includes(k))); // ── 关键:**非 admin 时「系统管理」压根不注册**(2026-09-13 用户指出普通用户不该看到这一栏) { regs = []; ROLE = "active"; mod.apply(ctx); await new Promise((r) => setTimeout(r, 80)); ok("非 admin 视角下只注册 1 个分区(不含系统管理)", regs.length === 1 && regs[0].meta.id === "business-plugins", "-> " + regs.map(r => r.meta.id).join(", ")); regs = []; ROLE = "admin"; } // ── 视觉层(2026-09-13 第 2 轮):卡片网格 + **5 个弹窗全部**走门户 `portal.html` 的表格样式 ── { ROLE = "admin"; const clsHome = classes(await render(pa.Comp)); ok("首页:卡片网格取门户 .nav-grid 值", clsHome.includes("pa-grid")); ok("首页:卡片取门户 .nav-card 值(.pa-card)", clsHome.includes("pa-card")); ok("首页:计数胶囊取门户 .pg-cnt 值(.pa-cnt)", clsHome.includes("pa-cnt")); ok("首页:标题/副标题取门户 .page-title/.page-sub 值", clsHome.includes("pa-hd") && clsHome.includes("pa-sub")); // 逐个点开 5 个管理项,**每一个**都必须是「门户 .table-wrap + table.tbl」而不是裸 div 列表 const ITEM_IDS = ["users", "plugins", "runtime", "storage", "instance"]; const bad = []; for (const which of ITEM_IDS) { const cls = classes(await renderWith(pa.Comp, () => { slots[2] = which; })); const good = cls.includes("pa-overlay") && cls.includes("pa-modal") && cls.includes("pa-wrap") && cls.includes("pa-tbl") && cls.includes("pa-card-h") && cls.includes("pa-sm"); if (!good) bad.push(which + "(" + cls.filter(c => c.startsWith("pa-")).join("/") + ")"); } ok("5 个弹窗全部 = 门户弹窗外壳 + portal 表格 + .btn-sm", bad.length === 0, bad.length ? "-> " + bad.join(" ; ") : "-> users/plugins/runtime/storage/instance 均通过"); // 用户弹窗:角色徽章 + 危险操作按钮(门户 .badge / .btn-sm.danger) const clsUsers = classes(await renderWith(pa.Comp, () => { slots[2] = "users"; })); ok("用户弹窗:角色用门户 .badge 四色", clsUsers.filter(c => c === "pa-badge").length >= 4, "-> " + clsUsers.filter(c => c === "pa-badge").length + " 枚"); ok("用户弹窗:删除按钮用门户 .btn-sm.danger", clsUsers.includes("danger")); // 实例弹窗:键值对表格,值列不是裸文本 const tInst = text(await renderWith(pa.Comp, () => { slots[2] = "instance"; })).join(" | "); ok("实例弹窗:键值齐全", ["端口", "自动重启", "熔断"].every(k => tInst.includes(k))); slots[2] = null; cursor = 0; dirty = false; } console.log(failed === 0 ? "\n结论:全绿 ✅" : "\n结论:有 " + failed + " 项失败 ❌"); process.exit(failed === 0 ? 0 : 1);