2026-07-04 21:47:42 +08:00
|
|
|
#!/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(
|
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;
|
|
|
|
|
});
|