Files
deerflow-code/frontend-web/scripts/smoke-firefox52-full.mjs
T

378 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;