378 lines
19 KiB
JavaScript
378 lines
19 KiB
JavaScript
import net from "node:net";
|
||
|
||
const debugPort = Number(process.env.FIREFOX_DEBUG_PORT || 6001);
|
||
const baseUrl = process.env.FIREFOX_BASE_URL || "http://localhost:5174/";
|
||
const backendUrl = (process.env.FIREFOX_BACKEND_URL || "http://localhost:8001").replace(/\/$/, "");
|
||
const username = process.env.FIREFOX_TEST_USERNAME || "admin";
|
||
const password = process.env.FIREFOX_TEST_PASSWORD || "";
|
||
const routeTimeoutMs = Number(process.env.FIREFOX_ROUTE_TIMEOUT_MS || 30_000);
|
||
const fullReload = process.env.FIREFOX_FULL_RELOAD === "1";
|
||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||
|
||
/**
|
||
* Static routes that can be verified without creating or mutating business
|
||
* records. Parameterised workflows need their own test data; this list
|
||
* intentionally rejects unexpected auth redirects.
|
||
*/
|
||
const routes = [
|
||
// Public and standalone shells.
|
||
["登录页鉴权恢复", `#/login/${encodeURIComponent(username)}`, { redirect: "#/page/workspace/chats/new" }],
|
||
["落地页", "#/landing"],
|
||
["公开排行榜", "#/public/leaderboard"],
|
||
["会商大屏", "#/roundtable-dashboard"],
|
||
["Official DeerFlow", "#/official"],
|
||
|
||
// Workspace routes.
|
||
["工作区首页", "#/page/workspace/chats/new"],
|
||
["对话列表", "#/page/workspace/chats"],
|
||
["项目入口", "#/page/workspace/projects", { redirect: "#/page/strategy/qa/projects" }],
|
||
["资料库入口", "#/page/workspace/library"],
|
||
["微信新对话", "#/page/workspace/wx/chats/new"],
|
||
["微信智能体", "#/page/workspace/wx/agents"],
|
||
["微信技能", "#/page/workspace/wx/skills"],
|
||
["智能体列表", "#/page/workspace/agents"],
|
||
["新建智能体", "#/page/workspace/agents/new"],
|
||
["智能体协作网络", "#/page/workspace/agents/orbit"],
|
||
["智能体广场", "#/page/workspace/agents/plaza"],
|
||
["技能列表", "#/page/workspace/skills"],
|
||
["技能进化", "#/page/workspace/skills/evolution"],
|
||
["AI 写作", "#/page/workspace/ai-writing"],
|
||
["深度研究", "#/page/workspace/deep-research"],
|
||
["公文助手", "#/page/workspace/gongwen"],
|
||
["新建报告结构", "#/page/workspace/deep-research/structures/new"],
|
||
["企业研究", "#/page/workspace/enterprise-research"],
|
||
["Notebook 列表", "#/page/workspace/studio/notebooks"],
|
||
["多智能体规划", "#/page/workspace/roundtable/planning"],
|
||
["8BF 会商", "#/page/workspace/roundtable/8bf"],
|
||
["岗位会商", "#/page/workspace/roundtable/position"],
|
||
["业务链编辑", "#/page/workspace/roundtable/chains"],
|
||
["业务链列表", "#/page/workspace/business-chains"],
|
||
["报告协作", "#/page/workspace/report-collaboration"],
|
||
["派活链路预览", "#/page/workspace/roundtable/chain-preview"],
|
||
["知识空间", "#/page/workspace/knowledge"],
|
||
["知识图谱配置回退", "#/page/workspace/knowledge/graph", { redirect: "#/page/workspace/knowledge" }],
|
||
["助手知识库配置回退", "#/page/workspace/knowledge/assistant", { redirect: "#/page/workspace/knowledge" }],
|
||
["知识检索调试配置回退", "#/page/workspace/knowledge/retrieval-debug", { redirect: "#/page/workspace/knowledge" }],
|
||
["Markdown 转 Word", "#/page/workspace/knowledge/markdown-editor"],
|
||
["定时任务", "#/page/workspace/scheduled/tasks"],
|
||
|
||
// Workspace admin/settings surfaces.
|
||
["用户管理中心", "#/page/workspace/admin"],
|
||
["管理员定时任务", "#/page/workspace/admin/scheduled-tasks"],
|
||
["管理员智能体", "#/page/workspace/admin/agents"],
|
||
["管理员技能", "#/page/workspace/admin/skills"],
|
||
["技能定时", "#/page/workspace/admin/skill-timing"],
|
||
["模型交换", "#/page/workspace/admin/model-exchanges"],
|
||
["用户排行榜管理", "#/page/workspace/admin/user-leaderboard"],
|
||
["用户排行榜设置", "#/page/workspace/admin/user-leaderboard/settings"],
|
||
["菜单管理", "#/page/workspace/admin/menu-management"],
|
||
["固定问题", "#/page/workspace/admin/fixed-questions"],
|
||
["标签管理", "#/page/workspace/admin/tags"],
|
||
["插件管理", "#/page/workspace/admin/plugins"],
|
||
["岗位管理", "#/page/workspace/admin/positions"],
|
||
["岗位角色", "#/page/workspace/admin/position-roles"],
|
||
["推荐问题", "#/page/workspace/settings/recommended-questions"],
|
||
["提示词前缀", "#/page/workspace/settings/prompt-prefix"],
|
||
["私有广场", "#/page/workspace/settings/private-square"],
|
||
["模型管理", "#/page/workspace/admin/models"],
|
||
["模型并发", "#/page/workspace/settings/model-concurrency"],
|
||
["广场发布", "#/page/workspace/settings/publish-squares"],
|
||
["技能压缩", "#/page/workspace/settings/skill-compression"],
|
||
["LLM 指标", "#/page/workspace/settings/llm-metrics"],
|
||
["Checkpoint 迁移", "#/page/workspace/settings/checkpoint-migration"],
|
||
|
||
// Strategy routes and legacy/full-layout entry points.
|
||
["通用问答", "#/page/strategy/qa/general"],
|
||
["分析项目", "#/page/strategy/qa/projects"],
|
||
["方案项目", "#/page/strategy/qa/projects/solutions", { redirect: "#/page/strategy/qa/projects?type=solution" }],
|
||
["问答项目", "#/page/strategy/qa/projects/groups", { redirect: "#/page/strategy/qa/projects?type=qa" }],
|
||
["策略选择配置回退", "#/page/strategy/qa/strategy-selection", { redirect: "#/page/strategy/qa/projects" }],
|
||
["专家问答", "#/page/strategy/qa/expert-qa"],
|
||
["策略多智能体会商", "#/page/strategy/qa/multi-agent-roundtable"],
|
||
["策略 8BF 会商", "#/page/strategy/qa/8bf-roundtable"],
|
||
["策略岗位会商", "#/page/strategy/qa/position-roundtable"],
|
||
["策略深度研究", "#/page/strategy/qa/deep-research"],
|
||
["策略公文助手", "#/page/strategy/qa/gongwen"],
|
||
["策略新建报告结构", "#/page/strategy/qa/deep-research/structures/new"],
|
||
["策略企业研究", "#/page/strategy/qa/enterprise-research"],
|
||
["智能体看板", "#/page/strategy/dashboard-agent"],
|
||
["任务管理", "#/page/strategy/task-management"],
|
||
["三情分析任务", "#/page/strategy/task-management/plan-task-sentiment"],
|
||
["任务分析", "#/page/strategy/task-management/plan-task/plan-task-fx"],
|
||
["行动分析", "#/page/strategy/task-management/plan-task/plan-task-action"],
|
||
["任务三情会商", "#/page/strategy/task-management/plan-task-sentiment/roundtable"],
|
||
["任务分析会商", "#/page/strategy/task-management/plan-task/plan-task-fx/roundtable"],
|
||
["行动分析会商", "#/page/strategy/task-management/plan-task/plan-task-action/roundtable"],
|
||
["网络行为体资源", "#/page/strategy/resource-management/resource-network-actor"],
|
||
["知识库资源", "#/page/strategy/resource-management/resource-knowledge-base"],
|
||
["评价指标资源", "#/page/strategy/resource-management/resource-evaluation-index"],
|
||
["轻应用管理", "#/page/strategy/light-app/management"],
|
||
["轻应用列表", "#/page/strategy/light-app/apps"],
|
||
["轻应用新建笔记", "#/page/strategy/light-app/new-note"],
|
||
["轻应用笔记", "#/page/strategy/light-app/notes"],
|
||
["轻应用笔记问答", "#/page/strategy/light-app/note-qa"],
|
||
["策略对话", "#/page/strategy/chats"],
|
||
["策略智能体", "#/page/strategy/agents"],
|
||
["策略智能体广场", "#/page/strategy/agents/plaza"],
|
||
["策略新建智能体", "#/page/strategy/agents/new"],
|
||
["策略技能", "#/page/strategy/skills"],
|
||
["策略技能进化", "#/page/strategy/skills/evolution"],
|
||
["业务链映射", "#/page/strategy/business-chain-mapping"],
|
||
["策略业务链列表", "#/page/strategy/business-chains"],
|
||
["策略业务链编辑", "#/page/strategy/roundtable/chains"],
|
||
["策略定时任务", "#/page/strategy/scheduled/tasks"],
|
||
["登录白名单", "#/page/strategy/admin/login-whitelist"],
|
||
["会商诊断", "#/page/strategy/admin/roundtable-diagnostics"],
|
||
["技能地址", "#/page/strategy/admin/skill-addresses"],
|
||
["敏感词权限回退", "#/page/strategy/admin/sensitive-words", { redirect: "#/page/workspace/chats/new" }],
|
||
["任务按钮", "#/page/strategy/admin/task-buttons"],
|
||
["策略管理员定时任务", "#/page/strategy/admin/scheduled-tasks"],
|
||
["策略排行榜", "#/page/strategy/user-leaderboard"],
|
||
["策略用户活动", "#/page/strategy/admin/user-activity"],
|
||
["策略标签", "#/page/strategy/admin/tags"],
|
||
["策略岗位", "#/page/strategy/admin/positions"],
|
||
|
||
// Parallel canvas/notebook/roundtable route trees.
|
||
["画布首页", "#/page/canvas"],
|
||
["画布 AI 写作", "#/page/canvas/ai-writing"],
|
||
["对话版 AI 写作", "#/page/canvas/ai-writing-chat"],
|
||
["文章类型", "#/page/canvas/article-types"],
|
||
["研究模板", "#/page/canvas/research-templates"],
|
||
["新建研究模板", "#/page/canvas/research-templates/new"],
|
||
["Notebook AI 写作", "#/page/notebook/ai-writing"],
|
||
["独立会商", "#/page/roundtable/planning"],
|
||
// Keep isolated/embed routes last because their sessionStorage contract is
|
||
// intentionally sticky across in-app navigation.
|
||
["iframe 新对话", "#/page/workspace/chats_iframe/new?embed=1&hideJump=1&theme=light", { redirect: "#/page/workspace/chats/new?embed=1&hideJump=1&theme=light&chatMode=full" }],
|
||
];
|
||
|
||
const results = [];
|
||
const browserErrors = [];
|
||
const socket = net.createConnection({ host: "127.0.0.1", port: debugPort });
|
||
let buffer = Buffer.alloc(0);
|
||
let rootActor;
|
||
let consoleActor;
|
||
let pendingEvaluation;
|
||
|
||
function send(packet) {
|
||
const body = Buffer.from(JSON.stringify(packet));
|
||
socket.write(Buffer.concat([Buffer.from(`${body.length}:`), body]));
|
||
}
|
||
|
||
function evaluate(expression) {
|
||
return new Promise((resolve, reject) => {
|
||
pendingEvaluation = { expression, resolve, reject };
|
||
send({ to: consoleActor, type: "evaluateJS", text: expression });
|
||
});
|
||
}
|
||
|
||
function shouldIgnoreConsoleError(message) {
|
||
return (
|
||
message.includes("validateDOMNesting") ||
|
||
message.includes("init intent failed") ||
|
||
message.includes("ResizeObserver loop") ||
|
||
message === "[object Object]"
|
||
);
|
||
}
|
||
|
||
function formatConsoleArgument(argument) {
|
||
if (argument == null || typeof argument !== "object") return String(argument);
|
||
const preview = argument.preview;
|
||
if (preview?.message) {
|
||
return `${preview.name || argument.class || "Error"}: ${preview.message}${preview.stack ? `\n${preview.stack}` : ""}`;
|
||
}
|
||
if (argument.class === "Error" && argument.ownPropertyLength === 0) return "Error";
|
||
if (typeof argument.description === "string") return argument.description;
|
||
try {
|
||
return JSON.stringify(argument);
|
||
} catch {
|
||
return String(argument);
|
||
}
|
||
}
|
||
|
||
function handle(packet) {
|
||
if (packet.applicationType && !rootActor) {
|
||
rootActor = packet.from;
|
||
send({ to: rootActor, type: "listTabs" });
|
||
return;
|
||
}
|
||
if (packet.tabs && !consoleActor) {
|
||
consoleActor = packet.tabs[packet.selected].consoleActor;
|
||
send({
|
||
to: consoleActor,
|
||
type: "startListeners",
|
||
listeners: ["PageError", "ConsoleAPI"],
|
||
});
|
||
return;
|
||
}
|
||
if (
|
||
packet.type === "pageError" &&
|
||
packet.pageError &&
|
||
packet.pageError.category !== "CSS Parser" &&
|
||
packet.pageError.warning !== true
|
||
) {
|
||
const pageError = packet.pageError;
|
||
const errorText = String(pageError.errorMessage || pageError.message || "").trim();
|
||
const usefulErrorText = /^Error:\s*$/.test(errorText) ? "" : errorText;
|
||
// Firefox 52 emits a duplicate empty promise-rejection packet when an XHR
|
||
// owned by the previous document is cancelled during reload. It has no
|
||
// stack and no message; meaningful runtime failures carry one or both.
|
||
if (!usefulErrorText && !pageError.stacktrace) return;
|
||
browserErrors.push(
|
||
`PAGE: ${usefulErrorText || JSON.stringify(pageError)}`,
|
||
);
|
||
return;
|
||
}
|
||
if (packet.type === "consoleAPICall" && packet.message?.level === "error") {
|
||
const message = (packet.message.arguments || [])
|
||
.map(formatConsoleArgument)
|
||
.join(" ") || "error";
|
||
if (!shouldIgnoreConsoleError(message)) browserErrors.push(`CONSOLE: ${message}`);
|
||
return;
|
||
}
|
||
if (pendingEvaluation && packet.input === pendingEvaluation.expression) {
|
||
const pending = pendingEvaluation;
|
||
pendingEvaluation = null;
|
||
if (packet.exception) pending.reject(new Error(String(packet.exception)));
|
||
else pending.resolve(packet.result);
|
||
}
|
||
}
|
||
|
||
function consume() {
|
||
while (true) {
|
||
const colon = buffer.indexOf(58);
|
||
if (colon < 0) return;
|
||
const length = Number(buffer.subarray(0, colon).toString());
|
||
if (buffer.length < colon + 1 + length) return;
|
||
const packet = JSON.parse(buffer.subarray(colon + 1, colon + 1 + length).toString());
|
||
buffer = buffer.subarray(colon + 1 + length);
|
||
handle(packet);
|
||
}
|
||
}
|
||
|
||
socket.on("data", (chunk) => {
|
||
buffer = Buffer.concat([buffer, chunk]);
|
||
consume();
|
||
});
|
||
|
||
await new Promise((resolve, reject) => {
|
||
socket.once("connect", resolve);
|
||
socket.once("error", reject);
|
||
});
|
||
while (!consoleActor) await sleep(50);
|
||
|
||
async function waitFor(expression, timeoutMs = routeTimeoutMs) {
|
||
const deadline = Date.now() + timeoutMs;
|
||
while (Date.now() < deadline) {
|
||
try {
|
||
if (await evaluate(expression)) return true;
|
||
} catch {
|
||
// A full reload can invalidate the previous Firefox console actor for a
|
||
// few milliseconds. Retry against the actor once it resumes.
|
||
}
|
||
await sleep(250);
|
||
}
|
||
return false;
|
||
}
|
||
|
||
async function installAdminSession() {
|
||
const url = new URL(`${backendUrl}/api/v1/auth/login/username`);
|
||
if (password) url.searchParams.set("password", password);
|
||
const response = await fetch(url, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ username }),
|
||
});
|
||
if (!response.ok) {
|
||
throw new Error(`测试账号登录失败:HTTP ${response.status}`);
|
||
}
|
||
const auth = await response.json();
|
||
if (auth.system_role !== "admin") {
|
||
throw new Error(`测试账号 ${username} 不是管理员,无法验证管理路由`);
|
||
}
|
||
await evaluate(`(function(){var auth=${JSON.stringify(JSON.stringify(auth))},info=${JSON.stringify(JSON.stringify({ userName: username }))},settings={};try{settings=JSON.parse(localStorage.getItem('deerflow.local-settings')||'{}')}catch(e){}settings.display=settings.display||{};settings.display.layoutMode='full';localStorage.setItem('deerflow.local-settings',JSON.stringify(settings));localStorage.setItem('deerflow.auth',auth);localStorage.setItem('userInfo',info);sessionStorage.setItem('deerflow.auth',auth);sessionStorage.setItem('userInfo',info);return true})()`);
|
||
}
|
||
|
||
async function exerciseVisibleTabs() {
|
||
const count = Number(await evaluate(`(function(){return [].slice.call(document.querySelectorAll('[role="tab"]')).filter(function(item){var rect=item.getBoundingClientRect(),style=getComputedStyle(item);return !item.disabled&&item.getAttribute('aria-disabled')!=='true'&&rect.width>0&&rect.height>0&&style.display!=='none'&&style.visibility!=='hidden'}).length})()`));
|
||
let clicks = 0;
|
||
for (let index = 0; index < count; index += 1) {
|
||
const clicked = await evaluate(`(function(){var items=[].slice.call(document.querySelectorAll('[role="tab"]')).filter(function(item){var rect=item.getBoundingClientRect(),style=getComputedStyle(item);return !item.disabled&&item.getAttribute('aria-disabled')!=='true'&&rect.width>0&&rect.height>0&&style.display!=='none'&&style.visibility!=='hidden'}),item=items[${index}];if(!item)return false;item.click();return true})()`);
|
||
if (!clicked) continue;
|
||
clicks += 1;
|
||
await sleep(120);
|
||
const healthy = await evaluate(`(function(){var root=document.getElementById('root'),rect=root&&root.getBoundingClientRect(),text=document.body.innerText||'';return !!root&&rect.width>0&&rect.height>0&&!/something went wrong|ChunkLoadError|Loading chunk [^ ]+ failed|SyntaxError:|ReferenceError:|TypeError:/i.test(text)})()`);
|
||
if (!healthy) return { pass: false, clicks, detail: `点击第 ${index + 1} 个标签页后进入错误边界` };
|
||
}
|
||
return { pass: true, clicks };
|
||
}
|
||
|
||
async function navigate(hash, options = {}) {
|
||
const expectedHash = options.redirect || hash;
|
||
const url = new URL(hash, baseUrl).href;
|
||
try {
|
||
await evaluate(`(function(){sessionStorage.removeItem('embed.session');sessionStorage.removeItem('embed.session.nested');try{delete window.__deerflowTopChromeEmbed}catch(e){}var url=${JSON.stringify(url)},reload=${JSON.stringify(fullReload)};if(reload){if(location.href!==url)location.href=url;location.reload()}else if(location.href!==url){location.href=url}return true})()`);
|
||
} catch {
|
||
// Firefox destroys the old console evaluation context during a successful
|
||
// full-page reload and occasionally reports that as an opaque exception.
|
||
// The route/readiness assertion below is the source of truth.
|
||
}
|
||
await sleep(700);
|
||
const ready = await waitFor(`(function(){var root=document.getElementById('root'),rect=root&&root.getBoundingClientRect(),hasSurface=document.body.innerText.trim().length>0||!!document.querySelector('iframe,canvas,svg,.cm-editor');return location.hash.indexOf(${JSON.stringify(expectedHash)})===0&&document.readyState==='complete'&&!!root&&rect.width>0&&rect.height>0&&root.innerHTML.length>100&&hasSurface})()`);
|
||
if (!ready) {
|
||
const state = await evaluate(`(function(){function role(store){try{var value=store.getItem('deerflow.auth');return value?JSON.parse(value).system_role:'none'}catch(e){return 'invalid'}}return location.hash+';local='+role(localStorage)+';session='+role(sessionStorage)+';embed='+sessionStorage.getItem('embed.session')})()`);
|
||
return `页面未在 ${Math.round(routeTimeoutMs / 1000)} 秒内完成渲染(${state})`;
|
||
}
|
||
const healthy = await evaluate(`(function(){var text=document.body.innerText||'';return !/something went wrong|ChunkLoadError|Loading chunk [^ ]+ failed|SyntaxError:|ReferenceError:|TypeError:/i.test(text)})()`);
|
||
return healthy === true ? true : "页面进入错误边界";
|
||
}
|
||
|
||
try {
|
||
await installAdminSession();
|
||
const routeFilter = process.env.FIREFOX_ROUTE_FILTER
|
||
? new RegExp(process.env.FIREFOX_ROUTE_FILTER, "i")
|
||
: null;
|
||
const selectedRoutes = routeFilter
|
||
? routes.filter(([name, hash]) => routeFilter.test(`${name} ${hash}`))
|
||
: routes;
|
||
if (selectedRoutes.length === 0) {
|
||
throw new Error("FIREFOX_ROUTE_FILTER 未匹配到路由");
|
||
}
|
||
for (const [name, hash, options = {}] of selectedRoutes) {
|
||
// Let late callbacks from the previous page settle before assigning
|
||
// errors to the next full-page navigation.
|
||
await sleep(350);
|
||
const errorStart = browserErrors.length;
|
||
let detail;
|
||
try {
|
||
detail = await navigate(hash, options);
|
||
if (detail === true) {
|
||
const tabResult = await exerciseVisibleTabs();
|
||
if (!tabResult.pass) detail = tabResult.detail;
|
||
else results.tabClicks = (results.tabClicks || 0) + tabResult.clicks;
|
||
}
|
||
} catch (error) {
|
||
detail = String(error);
|
||
}
|
||
// Allow asynchronous render errors to arrive before assigning them to the
|
||
// next route. Network/API failures are displayed by the page and do not
|
||
// become PageError events; uncaught runtime errors still fail the route.
|
||
await sleep(150);
|
||
const errors = browserErrors
|
||
.slice(errorStart)
|
||
.filter((message) => !message.startsWith("CONSOLE: Warning:"));
|
||
const pass = detail === true && errors.length === 0;
|
||
results.push({ name, hash, pass, detail, errors });
|
||
console.log(`${pass ? "PASS" : "FAIL"} ${name}${pass ? "" : ` — ${detail}`}`);
|
||
for (const error of errors) console.log(` ${error}`);
|
||
}
|
||
} finally {
|
||
socket.end();
|
||
}
|
||
|
||
const failed = results.filter((result) => !result.pass);
|
||
console.log(`\nFirefox 52 full route smoke: ${results.length - failed.length}/${results.length} passed`);
|
||
console.log(`Non-mutating visible tab interactions: ${results.tabClicks || 0}`);
|
||
console.log(`Navigation mode: ${fullReload ? "full reload" : "SPA"}`);
|
||
if (failed.length > 0) process.exitCode = 1;
|