#!/usr/bin/env node "use strict"; const assert = require("node:assert"); const fs = require("node:fs"); const path = require("node:path"); const { chromium } = require("playwright"); const BASE = process.env.MNOTE_AI_ADMIN_BASE || "http://127.0.0.1:3000"; const OUTPUT_DIR = process.env.MNOTE_AI_ADMIN_OUTPUT_DIR || path.join(__dirname, "..", "tmp", "ai-management-browser"); const CHROMIUM_EXECUTABLE = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE || (fs.existsSync("/usr/bin/chromium-browser") ? "/usr/bin/chromium-browser" : "") || (fs.existsSync("/usr/bin/chromium") ? "/usr/bin/chromium" : "") || (fs.existsSync("/usr/bin/google-chrome") ? "/usr/bin/google-chrome" : ""); const E2E_USER_EMAIL = process.env.MNOTE_AI_ADMIN_E2E_EMAIL || "ai-user@example.com"; const E2E_USER_NAME = process.env.MNOTE_AI_ADMIN_E2E_USERNAME || "ai-user"; const E2E_USER_PASSWORD = process.env.MNOTE_AI_ADMIN_E2E_PASSWORD || ["Mnote", "E2E", "123!"].join(""); async function quickLogin(page) { await page.goto(`${BASE}/auth`, { waitUntil: "commit" }); const button = page.getByRole("button", { name: "测试账号快速登录" }); await button.waitFor({ state: "visible" }); await Promise.all([ page.waitForURL((url) => !url.pathname.includes("/auth")), button.click(), ]); } async function ensurePasswordUser(browser, user) { const setupContext = await browser.newContext(); try { const payload = (flow) => ({ action: "auth:signIn", args: { provider: "password", params: { email: user.email, username: user.username, name: user.name, password: user.password, flow, }, }, }); let response = await setupContext.request.post(`${BASE}/api/auth`, { data: payload("signIn"), }); if (!response.ok()) { response = await setupContext.request.post(`${BASE}/api/auth`, { data: payload("signUp"), }); } assert(response.ok(), `测试用户 ${user.username} 准备失败: ${response.status()} ${await response.text()}`); } finally { await setupContext.close(); } } async function assertEventually(readValue, predicate, message, timeoutMs = 10000) { const deadline = Date.now() + timeoutMs; let lastValue; while (Date.now() < deadline) { lastValue = await readValue(); if (predicate(lastValue)) { return; } await new Promise((resolve) => setTimeout(resolve, 100)); } assert.fail(`${message}: ${JSON.stringify(lastValue)}`); } async function main() { fs.mkdirSync(OUTPUT_DIR, { recursive: true }); const runId = `${Date.now()}-${process.pid}`; const skillName = `mnote-browser-smoke-${runId}`; const mcpName = `lightrag-smoke-${runId}`; const browser = await chromium.launch({ headless: process.env.MNOTE_AI_ADMIN_HEADED !== "1", executablePath: CHROMIUM_EXECUTABLE || undefined, }); await ensurePasswordUser(browser, { email: E2E_USER_EMAIL, username: E2E_USER_NAME, name: E2E_USER_NAME, password: E2E_USER_PASSWORD, }); const context = await browser.newContext({ viewport: { width: 1440, height: 960 } }); const page = await context.newPage(); try { await quickLogin(page); const response = await page.goto(`${BASE}/admin/ai#ai-admin-models`, { waitUntil: "commit" }); assert(response && response.status() < 400, `AI 管理页加载失败: ${response && response.status()}`); await page.locator('[data-testid="mnote-ai-admin-page"]').waitFor(); await page.locator("#ai-admin-models.is-active").waitFor(); const openHubLink = page.locator('a[href="/page-ai/openhub/admin"]'); assert( await openHubLink.count() === 0, "OpenHub Admin 独立入口必须从当前 AI 管理页移除", ); await page.getByRole("link", { name: "模型配置" }).click(); await page.locator("#ai-admin-models.is-active").waitFor(); const modelPanel = page.locator("#ai-admin-models"); await modelPanel.locator('[data-field="allowedModels"]').fill( "omniroute/freefirst, omniroute/freefirst-fast", ); await modelPanel.locator('[data-field="secretRef"]').fill("env://OMNIROUTE_API_KEY"); await modelPanel.getByRole("button", { name: "保存配置" }).click(); await modelPanel.locator("[data-ai-admin-models-save-status]").filter({ hasText: /已保存/ }).waitFor(); await page.screenshot({ path: path.join(OUTPUT_DIR, "models.png"), fullPage: false, }); await page.getByRole("link", { name: "工具权限" }).click(); await page.locator("#ai-admin-tools.is-active").waitFor(); const patchPolicy = page.locator('[data-ai-admin-tools-container] select').nth(4); await patchPolicy.selectOption("ask"); await page.getByRole("button", { name: "保存策略" }).click(); await page.locator("[data-ai-admin-tools-save-status]").filter({ hasText: /已保存/ }).waitFor(); await page.getByRole("link", { name: "Skills" }).click(); await page.locator("#ai-admin-skills.is-active").waitFor(); await page.getByRole("button", { name: "+ 添加 Skill" }).click(); const skillRow = page.locator(".mnote-ai-admin-skill-row").last(); await skillRow.locator('[name="skillName"]').fill(skillName); await skillRow.locator('[name="skillDescription"]').fill("浏览器验收技能"); await skillRow.locator('[name="skillEnabled"]').check(); const skillsPanel = page.locator("#ai-admin-skills"); await skillsPanel.getByRole("button", { name: "保存 Skills" }).click(); await assertEventually( async () => (await skillsPanel.locator("[data-ai-admin-skills-save-status]").textContent()) || "", (text) => text.includes("已保存"), "Skills 配置保存状态未变为已保存", ); await page.getByRole("link", { name: "MCP" }).click(); await page.locator("#ai-admin-mcp.is-active").waitFor(); await page.getByRole("button", { name: "+ 添加 MCP" }).click(); const mcpRow = page.locator("[data-mcp-idx]").last(); await mcpRow.locator("summary").click(); await mcpRow.locator('[name="mcpName"]').fill(mcpName); await mcpRow.locator('[name="mcpTransport"]').selectOption("stdio"); await mcpRow.locator('[name="mcpCommand"]').fill("scripts/lightrag-native-mcp.sh"); await mcpRow.locator('[name="mcpSecretRefs"]').fill("env://LIGHTRAG_API_KEY"); await mcpRow.locator('[name="mcpEnabled"]').check(); const mcpPanel = page.locator("#ai-admin-mcp"); await mcpPanel.getByRole("button", { name: "保存 MCP" }).click(); await assertEventually( async () => (await mcpPanel.locator("[data-ai-admin-mcp-save-status]").textContent()) || "", (text) => text.includes("已保存"), "MCP 配置保存状态未变为已保存", ); await page.screenshot({ path: path.join(OUTPUT_DIR, "skills-mcp.png"), fullPage: false, }); await page.getByRole("link", { name: "用户管理" }).click(); await page.locator("#ai-admin-users.is-active").waitFor(); const targetUser = page.locator('[data-ai-admin-users-list] [data-user-action="models"][data-user-id="ai-user"]'); await targetUser.waitFor(); await targetUser.click(); await page.locator('[data-ai-admin-user-settings] h3', { hasText: "ai-user" }).waitFor(); const disabledModelId = await page.locator("[data-user-model]").evaluateAll((inputs) => { const checked = inputs .filter((input) => input.checked) .map((input) => input.getAttribute("data-user-model") || "") .filter(Boolean); return checked.length > 1 ? (checked.find((id) => id !== "omniroute/freefirst") || checked[1]) : ""; }); if (disabledModelId) { await page.locator(`[data-user-model="${disabledModelId}"]`).evaluate((input) => { input.checked = false; input.dispatchEvent(new Event("change", { bubbles: true })); }); const saveModelOverride = await Promise.all([ page.waitForResponse((response) => response.url().includes("/api/ai-admin/users/ai-user/settings") && response.request().method() === "PUT" ), page.locator('[data-action="save-user-settings"]').click(), ]).then(([response]) => response); assert( saveModelOverride.ok(), `模型降权保存失败: ${saveModelOverride.status()} ${await saveModelOverride.text()}`, ); } await page.locator('[data-user-tab="skills"]').click(); const disabledSkillId = await page.locator("[data-user-skill]").evaluateAll((inputs) => { const checked = inputs .filter((input) => input.checked) .map((input) => input.getAttribute("data-user-skill") || "") .filter(Boolean); return checked[0] || ""; }); if (disabledSkillId) { await page.locator(`[data-user-skill="${disabledSkillId}"]`).evaluate((input) => { input.checked = false; input.dispatchEvent(new Event("change", { bubbles: true })); }); const saveSkillOverride = await Promise.all([ page.waitForResponse((response) => response.url().includes("/api/ai-admin/users/ai-user/settings") && response.request().method() === "PUT" ), page.locator('[data-action="save-user-settings"]').click(), ]).then(([response]) => response); assert( saveSkillOverride.ok(), `Skill 降权保存失败: ${saveSkillOverride.status()} ${await saveSkillOverride.text()}`, ); } await page.screenshot({ path: path.join(OUTPUT_DIR, "users.png"), fullPage: false, }); await page.locator('[data-action="close-user-drawer"]').click(); await page.waitForFunction(() => { const drawer = document.querySelector("[data-ai-admin-user-drawer]"); return !drawer || !drawer.classList.contains("is-open"); }); await page.getByRole("link", { name: "Skills" }).click(); await page.reload({ waitUntil: "commit" }); await page.locator("#ai-admin-skills.is-active").waitFor(); await assertEventually( async () => page.locator('[name="skillName"]').evaluateAll((inputs) => inputs.map((input) => input.value || ""), ), (values) => values.includes(skillName), "Skill 保存后刷新必须仍存在", ); await page.getByRole("link", { name: "MCP" }).click(); await page.locator("#ai-admin-mcp.is-active").waitFor(); await assertEventually( async () => page.locator('[name="mcpName"]').evaluateAll((inputs) => inputs.map((input) => input.value || ""), ), (values) => values.includes(mcpName), "MCP 保存后刷新必须仍存在", ); const effective = await page.evaluate(async () => { const response = await fetch("/api/ai-settings/effective", { credentials: "include" }); return response.json(); }); assert.equal(effective.defaultModel, "omniroute/freefirst"); assert( (effective.models || []).some((model) => model.id === "omniroute/freefirst-fast"), "effective models 应包含管理员允许的模型", ); assert( (effective.skills || []).some((skill) => skill.name === skillName), "effective skills 应包含已启用 skill", ); assert( (effective.mcpServers || []).some((server) => server.name === mcpName), "effective MCP 应包含已启用 facade server", ); const userSettings = await page.evaluate(async () => { const response = await fetch("/api/ai-admin/users/ai-user/settings", { credentials: "include" }); return response.json(); }); if (disabledModelId) { assert.equal( (userSettings.allowedModels || []).some((model) => model.id === disabledModelId), false, `ai-user 刷新后不应包含被禁用模型 ${disabledModelId}`, ); } if (disabledSkillId) { assert.equal( (userSettings.skills || []).find((skill) => skill.id === disabledSkillId)?.enabled, false, `ai-user 刷新后应保留 Skill 禁用覆盖 ${disabledSkillId}`, ); } const userContext = await browser.newContext({ viewport: { width: 1280, height: 800 } }); const userResponse = await userContext.request.post(`${BASE}/api/auth`, { data: { action: "auth:signIn", args: { provider: "password", params: { email: E2E_USER_EMAIL, username: E2E_USER_NAME, name: E2E_USER_NAME, password: E2E_USER_PASSWORD, flow: "signIn", }, }, }, }); assert(userResponse.ok(), `ai-user 登录失败: ${userResponse.status()} ${await userResponse.text()}`); const userEffectiveResponse = await userContext.request.get(`${BASE}/api/ai-settings/effective`); assert(userEffectiveResponse.ok(), `ai-user effective 读取失败: ${userEffectiveResponse.status()}`); const userEffective = await userEffectiveResponse.json(); if (disabledModelId) { assert.equal( (userEffective.models || []).some((model) => model.id === disabledModelId), false, `ai-user effective 不应包含被管理员取消的模型 ${disabledModelId}`, ); } if (disabledSkillId) { assert.equal( (userEffective.skills || []).some((skill) => skill.id === disabledSkillId || skill.name === disabledSkillId), false, `ai-user effective 不应包含被禁用 Skill ${disabledSkillId}`, ); } await userContext.close(); console.log(JSON.stringify({ ok: true, screenshots: { models: path.join(OUTPUT_DIR, "models.png"), users: path.join(OUTPUT_DIR, "users.png"), skillsMcp: path.join(OUTPUT_DIR, "skills-mcp.png"), }, effective: { defaultModel: effective.defaultModel, modelCount: (effective.models || []).length, skillCount: (effective.skills || []).length, mcpCount: (effective.mcpServers || []).length, }, }, null, 2)); } finally { await browser.close(); } } main().catch((error) => { console.error(error); process.exitCode = 1; });