Files

360 lines
14 KiB
JavaScript
Raw Permalink Normal View History

2026-07-04 21:47:42 +08:00
#!/usr/bin/env node
"use strict";
const { loginViaAuthForm } = require('./lib/browser-auth-login');
2026-07-04 21:47:42 +08:00
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) {
// 7-76 P0: 标准表单登录(无测试快速登录按钮)
const base =
(typeof BASE_URL !== "undefined" && BASE_URL) ||
(typeof baseUrl !== "undefined" && baseUrl) ||
process.env.MNOTE_UI_BASE_URL ||
"http://127.0.0.1:3000";
const timeout =
(typeof UI_TIMEOUT_MS !== "undefined" && UI_TIMEOUT_MS) ||
(typeof TIMEOUT !== "undefined" && TIMEOUT) ||
30_000;
if (!String(page.url() || "").includes("/auth")) {
await page.goto(String(base).replace(/\/+$/, "") + "/auth", {
waitUntil: "commit",
timeout,
});
}
await loginViaAuthForm(page, {
baseUrl: base,
timeoutMs: timeout,
gotoAuth: false,
});
await page
.waitForURL((url) => !String(url).includes("/auth"), { timeout })
.catch(() => {});
2026-07-04 21:47:42 +08:00
}
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(
2026-07-12 14:18:26 +08:00
await openHubLink.count() === 0,
"OpenHub Admin 独立入口必须从当前 AI 管理页移除",
2026-07-04 21:47:42 +08:00
);
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(
2026-07-13 09:37:03 +08:00
"omniroute/freefirst, omniroute/gpt-5.4-mini",
2026-07-04 21:47:42 +08:00
);
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();
2026-07-12 14:18:26 +08:00
await page.getByRole("link", { name: "Skills" }).click();
2026-07-04 21:47:42 +08:00
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();
2026-07-12 14:18:26 +08:00
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 配置保存状态未变为已保存",
);
2026-07-04 21:47:42 +08:00
2026-07-12 14:18:26 +08:00
await page.getByRole("link", { name: "MCP" }).click();
await page.locator("#ai-admin-mcp.is-active").waitFor();
2026-07-04 21:47:42 +08:00
await page.getByRole("button", { name: "+ 添加 MCP" }).click();
const mcpRow = page.locator("[data-mcp-idx]").last();
2026-07-12 14:18:26 +08:00
await mcpRow.locator("summary").click();
2026-07-04 21:47:42 +08:00
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();
2026-07-12 14:18:26 +08:00
const mcpPanel = page.locator("#ai-admin-mcp");
await mcpPanel.getByRole("button", { name: "保存 MCP" }).click();
2026-07-04 21:47:42 +08:00
await assertEventually(
2026-07-12 14:18:26 +08:00
async () => (await mcpPanel.locator("[data-ai-admin-mcp-save-status]").textContent()) || "",
2026-07-04 21:47:42 +08:00
(text) => text.includes("已保存"),
2026-07-12 14:18:26 +08:00
"MCP 配置保存状态未变为已保存",
2026-07-04 21:47:42 +08:00
);
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();
2026-07-12 14:18:26 +08:00
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]) : "";
2026-07-04 21:47:42 +08:00
});
2026-07-12 14:18:26 +08:00
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()}`,
);
}
2026-07-04 21:47:42 +08:00
await page.locator('[data-user-tab="skills"]').click();
2026-07-12 14:18:26 +08:00
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] || "";
2026-07-04 21:47:42 +08:00
});
2026-07-12 14:18:26 +08:00
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()}`,
);
}
2026-07-04 21:47:42 +08:00
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");
});
2026-07-12 14:18:26 +08:00
await page.getByRole("link", { name: "Skills" }).click();
2026-07-04 21:47:42 +08:00
await page.reload({ waitUntil: "commit" });
await page.locator("#ai-admin-skills.is-active").waitFor();
2026-07-12 14:18:26 +08:00
await assertEventually(
async () => page.locator('[name="skillName"]').evaluateAll((inputs) =>
inputs.map((input) => input.value || ""),
),
(values) => values.includes(skillName),
2026-07-04 21:47:42 +08:00
"Skill 保存后刷新必须仍存在",
);
2026-07-12 14:18:26 +08:00
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),
2026-07-04 21:47:42 +08:00
"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(
2026-07-13 09:37:03 +08:00
(effective.models || []).some((model) => model.id === "omniroute/gpt-5.4-mini"),
2026-07-04 21:47:42 +08:00
"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();
});
2026-07-12 14:18:26 +08:00
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}`,
);
}
2026-07-04 21:47:42 +08:00
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();
2026-07-12 14:18:26 +08:00
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}`,
);
}
2026-07-04 21:47:42 +08:00
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;
});