mirror of
https://github.com/aiclientproxy/proxycast.git
synced 2026-09-01 15:30:52 +08:00
1726 lines
49 KiB
JavaScript
1726 lines
49 KiB
JavaScript
#!/usr/bin/env node
|
||
|
||
import fs from "node:fs";
|
||
import os from "node:os";
|
||
import path from "node:path";
|
||
import process from "node:process";
|
||
import { chromium } from "playwright";
|
||
|
||
const DEFAULTS = {
|
||
appUrl: "http://127.0.0.1:1420/",
|
||
healthUrl: "http://127.0.0.1:3030/health",
|
||
invokeUrl: "http://127.0.0.1:3030/invoke",
|
||
timeoutMs: 180_000,
|
||
intervalMs: 1_000,
|
||
i18nPatchMetricsOutput: "",
|
||
};
|
||
|
||
const INVOKE_TIMEOUT_CEILING_MS = 180_000;
|
||
const INVOKE_RETRY_COUNT = 10;
|
||
const INVOKE_RETRY_DELAY_MS = 1_000;
|
||
const DEFAULT_ACTION_TIMEOUT_MS = 45_000;
|
||
const DEFAULT_NAVIGATION_TIMEOUT_MS = 120_000;
|
||
const POST_HEALTH_SETTLE_MS = 1_000;
|
||
const ONBOARDING_VERSION = "1.1.0";
|
||
const APP_SERVER_HANDLE_JSON_LINES_COMMAND = "app_server_handle_json_lines";
|
||
|
||
const DEFAULT_PACK = {
|
||
name: "smoke-default",
|
||
title: "Smoke 默认项目资料",
|
||
type: "custom",
|
||
sourceFileName: "default-source.md",
|
||
sourceText: [
|
||
"# Smoke 默认项目资料",
|
||
"",
|
||
"- 事实:该资料用于 GUI smoke 验证默认资料、目录和聊天使用入口。",
|
||
"- 边界:只能用于本地 smoke,不得作为用户真实知识资产。",
|
||
"- 待确认:无。",
|
||
].join("\n"),
|
||
};
|
||
|
||
const SECONDARY_PACK = {
|
||
name: "smoke-secondary",
|
||
title: "Smoke 备用项目资料",
|
||
type: "custom",
|
||
sourceFileName: "secondary-source.md",
|
||
sourceText: [
|
||
"# Smoke 备用项目资料",
|
||
"",
|
||
"- 事实:该资料用于验证同一项目下存在多份资料时目录仍可读。",
|
||
"- 边界:只能用于本地 smoke,不参与默认生成。",
|
||
"- 待确认:无。",
|
||
].join("\n"),
|
||
};
|
||
|
||
const BUILDER_ACCEPTANCE_PACK = {
|
||
title: "content-ops-acceptance",
|
||
sourceText: [
|
||
"# 内容运营验收资料",
|
||
"",
|
||
"- 栏目:每周二发布选题复盘,每周五发布案例拆解。",
|
||
"- SOP:选题必须包含目标人群、表达角度、引用素材和风险边界。",
|
||
"- 边界:没有来源的增长数据必须标记待确认,不能编造成事实。",
|
||
].join("\n"),
|
||
};
|
||
|
||
const PERSONA_PACK = {
|
||
name: "smoke-persona",
|
||
title: "Smoke 人设资料",
|
||
type: "personal-ip",
|
||
sourceFileName: "persona-source.md",
|
||
sourceText: [
|
||
"# Smoke 人设资料",
|
||
"",
|
||
"- 语气:清晰、克制、只说已确认事实。",
|
||
"- 边界:不得把 smoke 数据当作真实用户资料。",
|
||
"- 适用:验证项目资料选择弹层的写作口吻和参考资料协同。",
|
||
].join("\n"),
|
||
};
|
||
|
||
const AGENT_RESULT_MESSAGE = {
|
||
id: "smoke-agent-result-knowledge",
|
||
title: "对话结果资料",
|
||
content: [
|
||
"# 对话结果资料",
|
||
"",
|
||
"- 事实:该结果来自当前 Agent 对话,用于验证生成结果可以保存到项目资料。",
|
||
"- 适用场景:用户拿到一段可复用结论后,可以一键保存,随后在项目资料管理页检查确认。",
|
||
"- 风险提示:保存后仍需确认,避免把临时分析当成长期事实。",
|
||
].join("\n"),
|
||
};
|
||
|
||
const FILE_MANAGER_SOURCE_ID = "default-source";
|
||
const FILE_MANAGER_SOURCE_VISIBLE_TITLE = "default source";
|
||
const USER_FACING_FORBIDDEN_TEXT = [
|
||
".lime/knowledge",
|
||
"KNOWLEDGE.md",
|
||
"knowledge_builder",
|
||
"compiled/brief.md",
|
||
"sources/source.md",
|
||
"frontmatter",
|
||
"<knowledge_pack",
|
||
"working_dir",
|
||
"user-confirmed",
|
||
"runtimeMode",
|
||
"primaryDocument",
|
||
"runtimeBinding",
|
||
"Builder Skill",
|
||
"Knowledge Pack",
|
||
"Resolver",
|
||
"Context Run",
|
||
"runtime",
|
||
"profile",
|
||
"documents",
|
||
"sources",
|
||
"runs",
|
||
"persona",
|
||
"data",
|
||
"wrapper",
|
||
"selected sections",
|
||
"compile",
|
||
"Request failed",
|
||
"Bad request",
|
||
];
|
||
|
||
function rewriteKnowledgeCompileInvokePayload(rawPostData) {
|
||
if (!rawPostData) {
|
||
return null;
|
||
}
|
||
|
||
let payload;
|
||
try {
|
||
payload = JSON.parse(rawPostData);
|
||
} catch {
|
||
return null;
|
||
}
|
||
|
||
if (payload?.cmd !== APP_SERVER_HANDLE_JSON_LINES_COMMAND) {
|
||
return null;
|
||
}
|
||
|
||
const args =
|
||
payload.args && typeof payload.args === "object" ? { ...payload.args } : {};
|
||
const request =
|
||
args.request && typeof args.request === "object" ? { ...args.request } : {};
|
||
const lines = Array.isArray(request.lines) ? request.lines : [];
|
||
let rewritten = false;
|
||
const rewrittenLines = lines.map((line) => {
|
||
let message;
|
||
try {
|
||
message = JSON.parse(String(line));
|
||
} catch {
|
||
return line;
|
||
}
|
||
|
||
if (message?.method !== "knowledgePack/compile") {
|
||
return line;
|
||
}
|
||
|
||
const params =
|
||
message.params && typeof message.params === "object"
|
||
? { ...message.params }
|
||
: {};
|
||
const builderRuntime =
|
||
params.builderRuntime && typeof params.builderRuntime === "object"
|
||
? { ...params.builderRuntime }
|
||
: {};
|
||
|
||
rewritten = true;
|
||
return `${JSON.stringify({
|
||
...message,
|
||
params: {
|
||
...params,
|
||
builderRuntime: {
|
||
...builderRuntime,
|
||
enabled: false,
|
||
},
|
||
},
|
||
})}\n`;
|
||
});
|
||
|
||
if (!rewritten) {
|
||
return null;
|
||
}
|
||
|
||
return JSON.stringify({
|
||
...payload,
|
||
args: {
|
||
...args,
|
||
request: {
|
||
...request,
|
||
lines: rewrittenLines,
|
||
},
|
||
},
|
||
});
|
||
}
|
||
|
||
function printHelp() {
|
||
console.log(`
|
||
Lime Knowledge GUI Smoke
|
||
|
||
用途:
|
||
通过真实 Lime 页面验证项目资料管理页、全部资料列表、
|
||
用于生成视图与补充导入入口。
|
||
|
||
用法:
|
||
npm run smoke:knowledge-gui
|
||
|
||
选项:
|
||
--app-url <url> 前端地址,默认 http://127.0.0.1:1420/
|
||
--health-url <url> DevBridge 健康检查地址,默认 http://127.0.0.1:3030/health
|
||
--invoke-url <url> DevBridge invoke 地址,默认 http://127.0.0.1:3030/invoke
|
||
--timeout-ms <ms> 总超时,默认 180000
|
||
--interval-ms <ms> 轮询间隔,默认 1000
|
||
--i18n-patch-metrics-output <path>
|
||
导出 window.__I18N_METRICS__ JSON,供 i18n:patch-report 消费
|
||
-h, --help 显示帮助
|
||
`);
|
||
}
|
||
|
||
function parseArgs(argv) {
|
||
const options = { ...DEFAULTS };
|
||
|
||
for (let index = 0; index < argv.length; index += 1) {
|
||
const arg = argv[index];
|
||
|
||
if (arg === "--app-url" && argv[index + 1]) {
|
||
options.appUrl = String(argv[index + 1]).trim();
|
||
index += 1;
|
||
continue;
|
||
}
|
||
|
||
if (arg === "--health-url" && argv[index + 1]) {
|
||
options.healthUrl = String(argv[index + 1]).trim();
|
||
index += 1;
|
||
continue;
|
||
}
|
||
|
||
if (arg === "--invoke-url" && argv[index + 1]) {
|
||
options.invokeUrl = String(argv[index + 1]).trim();
|
||
index += 1;
|
||
continue;
|
||
}
|
||
|
||
if (arg === "--timeout-ms" && argv[index + 1]) {
|
||
options.timeoutMs = Number(argv[index + 1]);
|
||
index += 1;
|
||
continue;
|
||
}
|
||
|
||
if (arg === "--interval-ms" && argv[index + 1]) {
|
||
options.intervalMs = Number(argv[index + 1]);
|
||
index += 1;
|
||
continue;
|
||
}
|
||
|
||
if (arg === "--i18n-patch-metrics-output" && argv[index + 1]) {
|
||
options.i18nPatchMetricsOutput = String(argv[index + 1]).trim();
|
||
index += 1;
|
||
continue;
|
||
}
|
||
|
||
if (arg === "--help" || arg === "-h") {
|
||
printHelp();
|
||
process.exit(0);
|
||
}
|
||
}
|
||
|
||
if (!Number.isFinite(options.timeoutMs) || options.timeoutMs < 30_000) {
|
||
throw new Error("--timeout-ms 必须是 >= 30000 的数字");
|
||
}
|
||
if (!Number.isFinite(options.intervalMs) || options.intervalMs < 100) {
|
||
throw new Error("--interval-ms 必须是 >= 100 的数字");
|
||
}
|
||
if (!options.appUrl || !options.healthUrl || !options.invokeUrl) {
|
||
throw new Error("--app-url、--health-url、--invoke-url 均不能为空");
|
||
}
|
||
|
||
return options;
|
||
}
|
||
|
||
function sleep(ms) {
|
||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||
}
|
||
|
||
function logStage(label) {
|
||
console.log(`[smoke:knowledge-gui] stage=${label}`);
|
||
}
|
||
|
||
function navigationTimeoutMs(options) {
|
||
return Math.min(options.timeoutMs, DEFAULT_NAVIGATION_TIMEOUT_MS);
|
||
}
|
||
|
||
async function exportI18nPatchMetrics(page, outputPath) {
|
||
if (!outputPath) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const metrics = await page.evaluate(() => {
|
||
const globalMetrics = window.__I18N_METRICS__;
|
||
if (!globalMetrics) {
|
||
return null;
|
||
}
|
||
|
||
return JSON.parse(JSON.stringify(globalMetrics));
|
||
});
|
||
const resolvedOutputPath = path.resolve(process.cwd(), outputPath);
|
||
fs.mkdirSync(path.dirname(resolvedOutputPath), { recursive: true });
|
||
fs.writeFileSync(
|
||
resolvedOutputPath,
|
||
JSON.stringify(metrics ?? {}, null, 2),
|
||
"utf8",
|
||
);
|
||
console.log(
|
||
`[smoke:knowledge-gui] i18n Patch metrics: ${resolvedOutputPath}`,
|
||
);
|
||
} catch (error) {
|
||
console.warn(
|
||
`[smoke:knowledge-gui] 导出 i18n Patch metrics 失败: ${
|
||
error instanceof Error ? error.message : String(error)
|
||
}`,
|
||
);
|
||
}
|
||
}
|
||
|
||
function isTransientInvokeError(error) {
|
||
return (
|
||
error?.name === "TimeoutError" ||
|
||
(error instanceof TypeError && error.message === "fetch failed")
|
||
);
|
||
}
|
||
|
||
async function invoke(options, cmd, args) {
|
||
const invokeTimeoutMs = Math.min(
|
||
options.timeoutMs,
|
||
INVOKE_TIMEOUT_CEILING_MS,
|
||
);
|
||
|
||
for (let attempt = 1; attempt <= INVOKE_RETRY_COUNT; attempt += 1) {
|
||
try {
|
||
const response = await fetch(options.invokeUrl, {
|
||
method: "POST",
|
||
headers: {
|
||
"content-type": "application/json",
|
||
},
|
||
body: JSON.stringify({ cmd, args }),
|
||
signal: AbortSignal.timeout(invokeTimeoutMs),
|
||
});
|
||
|
||
if (!response.ok) {
|
||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||
}
|
||
|
||
const payload = await response.json();
|
||
if (payload?.error) {
|
||
throw new Error(String(payload.error));
|
||
}
|
||
|
||
return payload?.result;
|
||
} catch (error) {
|
||
const detail = error instanceof Error ? error.message : String(error);
|
||
if (!isTransientInvokeError(error) || attempt >= INVOKE_RETRY_COUNT) {
|
||
if (error?.name === "TimeoutError") {
|
||
throw new Error(
|
||
`[smoke:knowledge-gui] ${cmd} 超时,${invokeTimeoutMs}ms 内未收到 DevBridge 响应`,
|
||
);
|
||
}
|
||
throw new Error(`[smoke:knowledge-gui] ${cmd} 请求失败: ${detail}`);
|
||
}
|
||
console.warn(
|
||
`[smoke:knowledge-gui] ${cmd} 第 ${attempt} 次请求失败,${INVOKE_RETRY_DELAY_MS}ms 后重试: ${detail}`,
|
||
);
|
||
await sleep(INVOKE_RETRY_DELAY_MS);
|
||
}
|
||
}
|
||
|
||
throw new Error(`[smoke:knowledge-gui] ${cmd} 请求失败: unknown error`);
|
||
}
|
||
|
||
let appServerRequestId = 1;
|
||
|
||
async function invokeAppServerMethod(options, method, params = {}) {
|
||
const id = `knowledge-gui-${appServerRequestId++}`;
|
||
const result = await invoke(options, APP_SERVER_HANDLE_JSON_LINES_COMMAND, {
|
||
request: { lines: [`${JSON.stringify({ id, method, params })}\n`] },
|
||
});
|
||
const messages = (Array.isArray(result?.lines) ? result.lines : [])
|
||
.map((line) => {
|
||
try {
|
||
return JSON.parse(String(line));
|
||
} catch {
|
||
return null;
|
||
}
|
||
})
|
||
.filter(Boolean);
|
||
const error = messages.find((message) => message.id === id && message.error);
|
||
if (error) {
|
||
throw new Error(
|
||
`${method}: ${error.error?.message || "App Server JSON-RPC error"}`,
|
||
);
|
||
}
|
||
const response = messages.find(
|
||
(message) => message.id === id && Object.hasOwn(message, "result"),
|
||
);
|
||
if (!response) {
|
||
throw new Error(`${method}: missing App Server response`);
|
||
}
|
||
return response.result;
|
||
}
|
||
|
||
async function waitForHealth(options) {
|
||
const startedAt = Date.now();
|
||
let lastError = null;
|
||
|
||
while (Date.now() - startedAt < options.timeoutMs) {
|
||
try {
|
||
const response = await fetch(options.healthUrl, { method: "GET" });
|
||
const payload = await response.json();
|
||
if (!response.ok) {
|
||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||
}
|
||
console.log(
|
||
`[smoke:knowledge-gui] DevBridge 已就绪 (${Date.now() - startedAt}ms)${
|
||
payload?.status ? ` status=${payload.status}` : ""
|
||
}`,
|
||
);
|
||
return;
|
||
} catch (error) {
|
||
lastError = error;
|
||
await sleep(options.intervalMs);
|
||
}
|
||
}
|
||
|
||
const detail =
|
||
lastError instanceof Error
|
||
? lastError.message
|
||
: String(lastError || "unknown error");
|
||
throw new Error(
|
||
`[smoke:knowledge-gui] DevBridge 未就绪,请先启动 npm run electron:dev。最后错误: ${detail}`,
|
||
);
|
||
}
|
||
|
||
async function waitForPageText(page, label, needles, timeoutMs) {
|
||
try {
|
||
await page.waitForFunction(
|
||
(expectedNeedles) => {
|
||
const text = document.body?.innerText || "";
|
||
const fieldText = Array.from(
|
||
document.querySelectorAll("textarea, input"),
|
||
)
|
||
.map((element) =>
|
||
element instanceof HTMLTextAreaElement ||
|
||
element instanceof HTMLInputElement
|
||
? element.value
|
||
: "",
|
||
)
|
||
.join("\n");
|
||
const searchableText = `${text}\n${fieldText}`;
|
||
return expectedNeedles.every((needle) =>
|
||
searchableText.includes(needle),
|
||
);
|
||
},
|
||
needles,
|
||
{ timeout: timeoutMs },
|
||
);
|
||
} catch (error) {
|
||
const pageUrl = page.url();
|
||
const pageTitle = await page.title().catch(() => "");
|
||
const text = await page
|
||
.locator("body")
|
||
.innerText()
|
||
.catch(() => "");
|
||
const fieldText = await page
|
||
.locator("textarea, input")
|
||
.evaluateAll((elements) =>
|
||
elements
|
||
.map((element) =>
|
||
element instanceof HTMLTextAreaElement ||
|
||
element instanceof HTMLInputElement
|
||
? element.value
|
||
: "",
|
||
)
|
||
.filter(Boolean)
|
||
.join("\n"),
|
||
)
|
||
.catch(() => "");
|
||
const searchableText = `${text}\n${fieldText}`;
|
||
const missing = needles.filter(
|
||
(needle) => !searchableText.includes(needle),
|
||
);
|
||
throw new Error(
|
||
`[smoke:knowledge-gui] ${label} 等待失败,缺少 ${JSON.stringify(
|
||
missing,
|
||
)},url=${pageUrl},title=${pageTitle},页面文本预览: ${searchableText.slice(0, 2_000)}`,
|
||
{ cause: error },
|
||
);
|
||
}
|
||
}
|
||
|
||
async function assertVisibleText(page, label, needles) {
|
||
const text = await page.locator("body").innerText();
|
||
const missing = needles.filter((needle) => !text.includes(needle));
|
||
if (missing.length > 0) {
|
||
throw new Error(
|
||
`[smoke:knowledge-gui] ${label} 验收失败,缺少 ${JSON.stringify(
|
||
missing,
|
||
)},页面文本预览: ${text.slice(0, 2_000)}`,
|
||
);
|
||
}
|
||
}
|
||
|
||
async function assertNoUserFacingInternalText(page, label) {
|
||
const pageMain = page.locator("main").nth(1);
|
||
const text =
|
||
(await pageMain.innerText().catch(() => "")) ||
|
||
(await page.locator("body").innerText());
|
||
const leaked = USER_FACING_FORBIDDEN_TEXT.filter((needle) =>
|
||
text.includes(needle),
|
||
);
|
||
if (leaked.length > 0) {
|
||
const firstLeak = leaked[0];
|
||
const index = text.indexOf(firstLeak);
|
||
const preview =
|
||
index >= 0 ? text.slice(Math.max(0, index - 180), index + 260) : "";
|
||
throw new Error(
|
||
`[smoke:knowledge-gui] ${label} 暴露内部实现文本: ${JSON.stringify(
|
||
leaked,
|
||
)},附近文本: ${preview}`,
|
||
);
|
||
}
|
||
}
|
||
|
||
async function clickPageControl(
|
||
page,
|
||
{ text, ariaLabel, index = 0, force = false },
|
||
) {
|
||
const locator = ariaLabel
|
||
? page.getByRole("button", { name: ariaLabel, exact: true }).nth(index)
|
||
: page
|
||
.locator("button, [role='button'], a")
|
||
.filter({ hasText: text })
|
||
.nth(index);
|
||
|
||
try {
|
||
await locator.waitFor({
|
||
state: "visible",
|
||
timeout: DEFAULT_ACTION_TIMEOUT_MS,
|
||
});
|
||
await locator.scrollIntoViewIfNeeded({
|
||
timeout: DEFAULT_ACTION_TIMEOUT_MS,
|
||
});
|
||
await locator.click({ timeout: DEFAULT_ACTION_TIMEOUT_MS });
|
||
} catch (error) {
|
||
let clickError = error;
|
||
if (force) {
|
||
try {
|
||
await locator.click({
|
||
timeout: DEFAULT_ACTION_TIMEOUT_MS,
|
||
force: true,
|
||
});
|
||
return;
|
||
} catch (forceError) {
|
||
clickError = forceError;
|
||
}
|
||
}
|
||
const errorMessage =
|
||
clickError instanceof Error ? clickError.message : String(clickError);
|
||
const pageText = await page
|
||
.locator("body")
|
||
.innerText()
|
||
.catch(() => "");
|
||
const buttons = await page
|
||
.locator("button, [role='button'], a")
|
||
.evaluateAll((items) =>
|
||
items.slice(0, 80).map((item) => ({
|
||
text: (item.textContent || "").trim().replace(/\s+/g, " "),
|
||
aria: item.getAttribute("aria-label"),
|
||
title: item.getAttribute("title"),
|
||
rect: (() => {
|
||
const rect = item.getBoundingClientRect();
|
||
return {
|
||
x: Math.round(rect.x),
|
||
y: Math.round(rect.y),
|
||
width: Math.round(rect.width),
|
||
height: Math.round(rect.height),
|
||
};
|
||
})(),
|
||
disabled:
|
||
item instanceof HTMLButtonElement ? item.disabled : undefined,
|
||
})),
|
||
)
|
||
.catch(() => []);
|
||
throw new Error(
|
||
`[smoke:knowledge-gui] 点击控件失败 ${JSON.stringify({
|
||
text,
|
||
ariaLabel,
|
||
index,
|
||
force,
|
||
error: errorMessage,
|
||
buttons,
|
||
pageTextPreview: pageText.slice(0, 2_000),
|
||
})}`,
|
||
{ cause: clickError },
|
||
);
|
||
}
|
||
}
|
||
|
||
async function clickInputbarKnowledgeEntry(page, label, timeoutMs) {
|
||
const boundedTimeoutMs = Math.min(timeoutMs, DEFAULT_ACTION_TIMEOUT_MS);
|
||
const startedAt = Date.now();
|
||
const entryTestIds = [
|
||
"inputbar-knowledge-pack-toggle",
|
||
"inputbar-knowledge-organize",
|
||
];
|
||
const clickErrors = [];
|
||
|
||
async function clickVisibleTestId(testId) {
|
||
const locator = page.locator(`[data-testid="${testId}"]`);
|
||
const count = await locator.count().catch(() => 0);
|
||
for (let index = 0; index < Math.min(count, 4); index += 1) {
|
||
const target = locator.nth(index);
|
||
const visible = await target
|
||
.isVisible({ timeout: 250 })
|
||
.catch(() => false);
|
||
if (!visible) {
|
||
continue;
|
||
}
|
||
try {
|
||
await target.scrollIntoViewIfNeeded({ timeout: 1_000 });
|
||
await target.click({ timeout: 1_000 });
|
||
return { testId, index };
|
||
} catch (error) {
|
||
clickErrors.push({
|
||
testId,
|
||
index,
|
||
error: error instanceof Error ? error.message : String(error),
|
||
});
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
while (Date.now() - startedAt < boundedTimeoutMs) {
|
||
const existingHubVisible = await page
|
||
.locator('[data-testid="inputbar-knowledge-hub"]')
|
||
.first()
|
||
.isVisible({ timeout: 100 })
|
||
.catch(() => false);
|
||
if (existingHubVisible) {
|
||
return "inputbar-knowledge-hub";
|
||
}
|
||
|
||
for (const testId of entryTestIds) {
|
||
const clicked = await clickVisibleTestId(testId);
|
||
if (clicked) {
|
||
return clicked.testId;
|
||
}
|
||
}
|
||
|
||
const plusClicked = await clickVisibleTestId("inputbar-plus-trigger");
|
||
if (plusClicked) {
|
||
const knowledgeClicked = await clickVisibleTestId(
|
||
"inputbar-plus-knowledge",
|
||
);
|
||
if (knowledgeClicked) {
|
||
return knowledgeClicked.testId;
|
||
}
|
||
}
|
||
|
||
await sleep(250);
|
||
}
|
||
|
||
const pageText = await page
|
||
.locator("body")
|
||
.innerText()
|
||
.catch(() => "");
|
||
const knowledgeControls = await page
|
||
.locator("[data-testid^='inputbar-knowledge-']")
|
||
.evaluateAll((items) =>
|
||
items.slice(0, 40).map((item) => ({
|
||
testId: item.getAttribute("data-testid"),
|
||
text: (item.textContent || "").trim().replace(/\s+/g, " "),
|
||
aria: item.getAttribute("aria-label"),
|
||
title: item.getAttribute("title"),
|
||
disabled: item instanceof HTMLButtonElement ? item.disabled : undefined,
|
||
})),
|
||
)
|
||
.catch(() => []);
|
||
const plusControls = await page
|
||
.locator("[data-testid^='inputbar-plus-']")
|
||
.evaluateAll((items) =>
|
||
items.slice(0, 40).map((item) => ({
|
||
testId: item.getAttribute("data-testid"),
|
||
text: (item.textContent || "").trim().replace(/\s+/g, " "),
|
||
aria: item.getAttribute("aria-label"),
|
||
title: item.getAttribute("title"),
|
||
disabled: item instanceof HTMLButtonElement ? item.disabled : undefined,
|
||
})),
|
||
)
|
||
.catch(() => []);
|
||
throw new Error(
|
||
`[smoke:knowledge-gui] ${label} 等待项目资料入口失败 ${JSON.stringify({
|
||
knowledgeControls,
|
||
plusControls,
|
||
clickErrors: clickErrors.slice(-8),
|
||
})},页面文本预览: ${pageText.slice(0, 2_000)}`,
|
||
);
|
||
}
|
||
|
||
async function clickTestId(page, { testId, label }) {
|
||
const locator = page.locator(`[data-testid="${testId}"]`).first();
|
||
|
||
try {
|
||
await locator.waitFor({
|
||
state: "visible",
|
||
timeout: DEFAULT_ACTION_TIMEOUT_MS,
|
||
});
|
||
await locator.scrollIntoViewIfNeeded({
|
||
timeout: DEFAULT_ACTION_TIMEOUT_MS,
|
||
});
|
||
const enabled = await locator
|
||
.isEnabled({ timeout: DEFAULT_ACTION_TIMEOUT_MS })
|
||
.catch(() => false);
|
||
if (!enabled) {
|
||
throw new Error("target control is not enabled");
|
||
}
|
||
await locator.click({ timeout: DEFAULT_ACTION_TIMEOUT_MS });
|
||
} catch (error) {
|
||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||
const pageText = await page
|
||
.locator("body")
|
||
.innerText()
|
||
.catch(() => "");
|
||
const candidates = await page
|
||
.locator("[data-testid]")
|
||
.evaluateAll((items) =>
|
||
items.slice(0, 100).map((item) => ({
|
||
testId: item.getAttribute("data-testid"),
|
||
text: (item.textContent || "").trim().replace(/\s+/g, " "),
|
||
aria: item.getAttribute("aria-label"),
|
||
title: item.getAttribute("title"),
|
||
})),
|
||
)
|
||
.catch(() => []);
|
||
throw new Error(
|
||
`[smoke:knowledge-gui] ${label} 点击失败 ${JSON.stringify({
|
||
testId,
|
||
error: errorMessage,
|
||
candidates,
|
||
})},页面文本预览: ${pageText.slice(0, 2_000)}`,
|
||
{ cause: error },
|
||
);
|
||
}
|
||
}
|
||
|
||
async function waitForInputbarKnowledgeHub(page, label, timeoutMs) {
|
||
const boundedTimeoutMs = Math.min(timeoutMs, DEFAULT_ACTION_TIMEOUT_MS);
|
||
try {
|
||
await page
|
||
.locator('[data-testid="inputbar-knowledge-hub"]')
|
||
.first()
|
||
.waitFor({ state: "visible", timeout: boundedTimeoutMs });
|
||
} catch (error) {
|
||
const pageText = await page
|
||
.locator("body")
|
||
.innerText()
|
||
.catch(() => "");
|
||
const knowledgeControls = await page
|
||
.locator("[data-testid^='inputbar-knowledge-']")
|
||
.evaluateAll((items) =>
|
||
items.slice(0, 40).map((item) => ({
|
||
testId: item.getAttribute("data-testid"),
|
||
text: (item.textContent || "").trim().replace(/\s+/g, " "),
|
||
aria: item.getAttribute("aria-label"),
|
||
title: item.getAttribute("title"),
|
||
})),
|
||
)
|
||
.catch(() => []);
|
||
throw new Error(
|
||
`[smoke:knowledge-gui] ${label} 等待项目资料浮层失败 ${JSON.stringify({
|
||
knowledgeControls,
|
||
})},页面文本预览: ${pageText.slice(0, 2_000)}`,
|
||
{ cause: error },
|
||
);
|
||
}
|
||
}
|
||
|
||
async function closeInputbarPlusMenuIfOpen(page) {
|
||
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||
const menuVisible = await page
|
||
.locator('[data-testid="inputbar-plus-menu"]')
|
||
.first()
|
||
.isVisible({ timeout: 200 })
|
||
.catch(() => false);
|
||
const hubVisible = await page
|
||
.locator('[data-testid="inputbar-knowledge-hub"]')
|
||
.first()
|
||
.isVisible({ timeout: 200 })
|
||
.catch(() => false);
|
||
if (!menuVisible && !hubVisible) {
|
||
return;
|
||
}
|
||
|
||
await page.keyboard.press("Escape").catch(() => undefined);
|
||
await page.mouse.click(300, 20).catch(() => undefined);
|
||
await page
|
||
.locator('[data-testid="inputbar-plus-menu"]')
|
||
.first()
|
||
.waitFor({ state: "hidden", timeout: 1_500 })
|
||
.catch(() => undefined);
|
||
await page
|
||
.locator('[data-testid="inputbar-knowledge-hub"]')
|
||
.first()
|
||
.waitFor({ state: "hidden", timeout: 1_500 })
|
||
.catch(() => undefined);
|
||
}
|
||
}
|
||
|
||
async function assertInputbarKnowledgeHubText(page, label, needles, timeoutMs) {
|
||
await closeInputbarPlusMenuIfOpen(page);
|
||
await clickInputbarKnowledgeEntry(page, label, timeoutMs);
|
||
await waitForInputbarKnowledgeHub(page, label, timeoutMs);
|
||
const hub = page.locator('[data-testid="inputbar-knowledge-hub"]').first();
|
||
const text = await hub.innerText({ timeout: DEFAULT_ACTION_TIMEOUT_MS });
|
||
const missing = needles.filter((needle) => !text.includes(needle));
|
||
if (missing.length > 0) {
|
||
throw new Error(
|
||
`[smoke:knowledge-gui] ${label} 验收失败,缺少 ${JSON.stringify(
|
||
missing,
|
||
)},资料浮层文本预览: ${text.slice(0, 2_000)}`,
|
||
);
|
||
}
|
||
await closeInputbarPlusMenuIfOpen(page);
|
||
}
|
||
|
||
async function waitForNotificationToastsToSettle(page) {
|
||
const toast = page
|
||
.locator('[aria-label="Notifications alt+T"] [data-sonner-toast]')
|
||
.first();
|
||
if (!(await toast.isVisible({ timeout: 500 }).catch(() => false))) {
|
||
return;
|
||
}
|
||
|
||
await toast
|
||
.waitFor({ state: "detached", timeout: 7_000 })
|
||
.catch(() => undefined);
|
||
}
|
||
|
||
async function closeFileManagerIfOpen(page) {
|
||
await waitForNotificationToastsToSettle(page);
|
||
|
||
const closeButton = page
|
||
.getByRole("button", { name: "关闭文件管理器" })
|
||
.first();
|
||
if (await closeButton.isVisible({ timeout: 1_000 }).catch(() => false)) {
|
||
await closeButton.click({ timeout: DEFAULT_ACTION_TIMEOUT_MS });
|
||
return;
|
||
}
|
||
|
||
const openButton = page
|
||
.getByRole("button", { name: "打开左侧文件管理器" })
|
||
.first();
|
||
if (await openButton.isVisible({ timeout: 1_000 }).catch(() => false)) {
|
||
return;
|
||
}
|
||
|
||
await clickPageControl(page, { ariaLabel: "关闭文件管理器" });
|
||
}
|
||
|
||
async function waitForExactButton(page, label, name, timeoutMs) {
|
||
try {
|
||
await page
|
||
.getByRole("button", { name, exact: true })
|
||
.waitFor({ state: "visible", timeout: timeoutMs });
|
||
} catch (error) {
|
||
const pageUrl = page.url();
|
||
const pageTitle = await page.title().catch(() => "");
|
||
const readyState = await page
|
||
.evaluate(() => document.readyState)
|
||
.catch(() => "unknown");
|
||
const pageText = await page
|
||
.locator("body")
|
||
.innerText()
|
||
.catch(() => "");
|
||
const buttons = await page
|
||
.locator("button, [role='button'], a")
|
||
.evaluateAll((items) =>
|
||
items.slice(0, 80).map((item) => ({
|
||
text: (item.textContent || "").trim().replace(/\s+/g, " "),
|
||
aria: item.getAttribute("aria-label"),
|
||
title: item.getAttribute("title"),
|
||
disabled:
|
||
item instanceof HTMLButtonElement ? item.disabled : undefined,
|
||
})),
|
||
)
|
||
.catch(() => []);
|
||
throw new Error(
|
||
`[smoke:knowledge-gui] ${label} 等待按钮失败 ${JSON.stringify({
|
||
name,
|
||
url: pageUrl,
|
||
title: pageTitle,
|
||
readyState,
|
||
buttons,
|
||
})},页面文本预览: ${pageText.slice(0, 2_000)}`,
|
||
{ cause: error },
|
||
);
|
||
}
|
||
}
|
||
|
||
async function openPackDetail(page, title) {
|
||
await page
|
||
.locator("article")
|
||
.filter({ hasText: title })
|
||
.getByRole("button", { name: "打开" })
|
||
.click({ timeout: DEFAULT_ACTION_TIMEOUT_MS });
|
||
}
|
||
|
||
async function openPackForCreation(page, title) {
|
||
await page
|
||
.locator("article")
|
||
.filter({ hasText: title })
|
||
.getByRole("button", { name: "用于创作" })
|
||
.click({ timeout: DEFAULT_ACTION_TIMEOUT_MS });
|
||
}
|
||
|
||
async function clickScopedButton(page, { scope, text, ariaLabel, index = 0 }) {
|
||
const scoped = page.locator(scope);
|
||
const locator = ariaLabel
|
||
? scoped.getByRole("button", { name: ariaLabel, exact: true }).nth(index)
|
||
: scoped.locator("button, a").filter({ hasText: text }).nth(index);
|
||
|
||
try {
|
||
await locator.click({ timeout: DEFAULT_ACTION_TIMEOUT_MS });
|
||
} catch (error) {
|
||
const buttons = await scoped
|
||
.locator("button, a")
|
||
.evaluateAll((items) =>
|
||
items.slice(0, 80).map((item) => ({
|
||
text: (item.textContent || "").trim().replace(/\s+/g, " "),
|
||
aria: item.getAttribute("aria-label"),
|
||
title: item.getAttribute("title"),
|
||
disabled:
|
||
item instanceof HTMLButtonElement ? item.disabled : undefined,
|
||
})),
|
||
)
|
||
.catch(() => []);
|
||
throw new Error(
|
||
`[smoke:knowledge-gui] 点击区域控件失败 ${JSON.stringify({
|
||
scope,
|
||
text,
|
||
ariaLabel,
|
||
index,
|
||
buttons,
|
||
})}`,
|
||
{ cause: error },
|
||
);
|
||
}
|
||
}
|
||
|
||
async function syncSmokeProjectStorage(page, options) {
|
||
await page.evaluate(
|
||
({ workingDir, onboardingVersion, projectId }) => {
|
||
localStorage.setItem("lime_onboarding_complete", "true");
|
||
localStorage.setItem("lime_onboarding_version", onboardingVersion);
|
||
localStorage.setItem("lime_user_profile", "developer");
|
||
localStorage.setItem("lime.knowledge.working-dir", workingDir);
|
||
localStorage.setItem("agent_last_project_id", JSON.stringify(projectId));
|
||
window.dispatchEvent(
|
||
new CustomEvent("agent-persisted-project-id-changed", {
|
||
detail: {
|
||
key: "agent_last_project_id",
|
||
projectId,
|
||
},
|
||
}),
|
||
);
|
||
},
|
||
{
|
||
workingDir: options.workingDir,
|
||
onboardingVersion: ONBOARDING_VERSION,
|
||
projectId: options.projectId,
|
||
},
|
||
);
|
||
}
|
||
|
||
async function restoreKnowledgeOverview(page, options, label) {
|
||
await page.goto(options.appUrl, {
|
||
timeout: navigationTimeoutMs(options),
|
||
waitUntil: "domcontentloaded",
|
||
});
|
||
await syncSmokeProjectStorage(page, options);
|
||
await page.reload({
|
||
timeout: navigationTimeoutMs(options),
|
||
waitUntil: "domcontentloaded",
|
||
});
|
||
await waitForPageText(
|
||
page,
|
||
`${label} 首页恢复`,
|
||
["青柠一下,灵感即来"],
|
||
options.timeoutMs,
|
||
);
|
||
await openKnowledgePageFromMainNav(page, options);
|
||
await waitForPageText(
|
||
page,
|
||
`${label} 项目资料首页恢复`,
|
||
["让 Lime 记住这个项目", "项目资料清单"],
|
||
options.timeoutMs,
|
||
);
|
||
}
|
||
|
||
async function confirmKnowledgeComposer(
|
||
page,
|
||
options,
|
||
{ selectSecondaryData = false, selectPersona = false } = {},
|
||
) {
|
||
await waitForPageText(
|
||
page,
|
||
"项目资料选择弹层打开",
|
||
[
|
||
"选择这次创作用哪些资料",
|
||
"写作口吻(只能选 1 个)",
|
||
"要参考的资料(可多选)",
|
||
],
|
||
options.timeoutMs,
|
||
);
|
||
|
||
if (selectPersona) {
|
||
await page
|
||
.locator(
|
||
`[data-testid="knowledge-composer-persona-${PERSONA_PACK.name}"]`,
|
||
)
|
||
.click({ timeout: DEFAULT_ACTION_TIMEOUT_MS });
|
||
}
|
||
|
||
if (selectSecondaryData) {
|
||
await page
|
||
.locator(`[data-testid="knowledge-composer-data-${SECONDARY_PACK.name}"]`)
|
||
.click({ timeout: DEFAULT_ACTION_TIMEOUT_MS });
|
||
await waitForPageText(
|
||
page,
|
||
"项目资料选择弹层多选",
|
||
[selectPersona ? "已选 3 份资料" : "已选 2 份资料"],
|
||
options.timeoutMs,
|
||
);
|
||
}
|
||
|
||
await clickPageControl(page, { text: "确认使用" });
|
||
}
|
||
|
||
async function verifyReviewPage(page, options, packTitle) {
|
||
await openPackDetail(page, packTitle);
|
||
await waitForPageText(
|
||
page,
|
||
"确认资料页加载",
|
||
[
|
||
packTitle,
|
||
"完整资料文档",
|
||
"查看文档内容",
|
||
"需要你确认的内容",
|
||
"确认后会发生什么",
|
||
"不会覆盖原始资料",
|
||
],
|
||
options.timeoutMs,
|
||
);
|
||
await assertNoUserFacingInternalText(page, "确认资料页");
|
||
|
||
await clickPageControl(page, { text: "查看文档内容" });
|
||
await waitForPageText(
|
||
page,
|
||
"完整资料文档展开",
|
||
["完整资料文档内容"],
|
||
options.timeoutMs,
|
||
);
|
||
await assertNoUserFacingInternalText(page, "完整资料文档展开");
|
||
}
|
||
|
||
async function verifyBuilderImportAndReview(page, options) {
|
||
await clickPageControl(page, { text: "整理新资料" });
|
||
await waitForPageText(
|
||
page,
|
||
"整理新资料页面加载",
|
||
[
|
||
"选择资料用途",
|
||
"添加原始资料",
|
||
"Lime 开始整理",
|
||
"没有确认的资料不会自动用于创作",
|
||
],
|
||
options.timeoutMs,
|
||
);
|
||
|
||
await page
|
||
.getByRole("button", { name: "内容运营", exact: true })
|
||
.click({ timeout: DEFAULT_ACTION_TIMEOUT_MS });
|
||
const nameInput = page.getByRole("textbox", { name: "资料名称" });
|
||
const sourceTextarea = page.getByRole("textbox", { name: "原始资料正文" });
|
||
await nameInput.waitFor({
|
||
state: "visible",
|
||
timeout: DEFAULT_ACTION_TIMEOUT_MS,
|
||
});
|
||
await sourceTextarea.waitFor({
|
||
state: "visible",
|
||
timeout: DEFAULT_ACTION_TIMEOUT_MS,
|
||
});
|
||
await nameInput.fill(BUILDER_ACCEPTANCE_PACK.title);
|
||
await sourceTextarea.fill(BUILDER_ACCEPTANCE_PACK.sourceText);
|
||
await waitForPageText(
|
||
page,
|
||
"Builder 表单填充",
|
||
[
|
||
BUILDER_ACCEPTANCE_PACK.title,
|
||
"栏目:每周二发布选题复盘",
|
||
"边界:没有来源的增长数据必须标记待确认",
|
||
],
|
||
options.timeoutMs,
|
||
);
|
||
await page
|
||
.getByRole("button", { name: "Lime 开始整理", exact: true })
|
||
.click({ timeout: DEFAULT_ACTION_TIMEOUT_MS });
|
||
|
||
try {
|
||
await waitForExactButton(
|
||
page,
|
||
"整理结果详情加载",
|
||
"确认可用",
|
||
Math.min(options.timeoutMs, 60_000),
|
||
);
|
||
} catch (error) {
|
||
console.warn(
|
||
`[smoke:knowledge-gui] 整理结果详情首轮等待失败,尝试从项目资料首页恢复打开: ${
|
||
error instanceof Error ? error.message : String(error)
|
||
}`,
|
||
);
|
||
await waitForKnowledgePack(
|
||
options,
|
||
"整理结果写入完成",
|
||
(pack) =>
|
||
pack.description === BUILDER_ACCEPTANCE_PACK.title ||
|
||
pack.name === BUILDER_ACCEPTANCE_PACK.title,
|
||
);
|
||
await restoreKnowledgeOverview(page, options, "整理结果详情恢复");
|
||
await openPackDetail(page, BUILDER_ACCEPTANCE_PACK.title);
|
||
await waitForExactButton(
|
||
page,
|
||
"整理结果详情恢复加载",
|
||
"确认可用",
|
||
options.timeoutMs,
|
||
);
|
||
}
|
||
await waitForPageText(
|
||
page,
|
||
"整理结果详情加载",
|
||
[
|
||
BUILDER_ACCEPTANCE_PACK.title,
|
||
"完整资料文档",
|
||
"需要你确认的内容",
|
||
"确认可用",
|
||
],
|
||
options.timeoutMs,
|
||
);
|
||
await assertNoUserFacingInternalText(page, "整理结果详情");
|
||
|
||
await page
|
||
.getByRole("button", { name: "确认可用", exact: true })
|
||
.click({ timeout: DEFAULT_ACTION_TIMEOUT_MS });
|
||
await waitForPageText(
|
||
page,
|
||
"整理结果确认可用",
|
||
["资料已确认可用", BUILDER_ACCEPTANCE_PACK.title],
|
||
options.timeoutMs,
|
||
);
|
||
}
|
||
|
||
async function openKnowledgePageFromMainNav(page, options) {
|
||
await syncSmokeProjectStorage(page, options);
|
||
let lastError = null;
|
||
|
||
for (let attempt = 1; attempt <= 3; attempt += 1) {
|
||
try {
|
||
await clickScopedButton(page, {
|
||
scope: '[data-testid="app-sidebar-main-nav"]',
|
||
ariaLabel: "项目资料",
|
||
});
|
||
await waitForPageText(
|
||
page,
|
||
`项目资料导航第 ${attempt} 次`,
|
||
["让 Lime 记住这个项目"],
|
||
Math.min(options.timeoutMs, 15_000),
|
||
);
|
||
return;
|
||
} catch (error) {
|
||
lastError = error;
|
||
await sleep(500);
|
||
}
|
||
}
|
||
|
||
throw new Error(
|
||
`[smoke:knowledge-gui] 项目资料导航失败: ${
|
||
lastError instanceof Error ? lastError.message : String(lastError)
|
||
}`,
|
||
);
|
||
}
|
||
|
||
async function openHomeFromMainNav(page, options, label) {
|
||
await syncSmokeProjectStorage(page, options);
|
||
await clickScopedButton(page, {
|
||
scope: '[data-testid="app-sidebar-main-nav"]',
|
||
ariaLabel: "新建任务",
|
||
});
|
||
await waitForPageText(
|
||
page,
|
||
`${label} 新建任务页加载`,
|
||
["青柠一下,灵感即来"],
|
||
options.timeoutMs,
|
||
);
|
||
}
|
||
|
||
async function waitForKnowledgePack(options, label, matcher) {
|
||
const startedAt = Date.now();
|
||
let lastError = null;
|
||
|
||
while (Date.now() - startedAt < options.timeoutMs) {
|
||
try {
|
||
const result = await invokeAppServerMethod(
|
||
options,
|
||
"knowledgePack/list",
|
||
{
|
||
workingDir: options.workingDir,
|
||
includeArchived: true,
|
||
},
|
||
);
|
||
const packs = Array.isArray(result?.packs) ? result.packs : [];
|
||
const found = packs.find((pack) => {
|
||
const metadata = pack?.metadata || {};
|
||
return matcher({
|
||
name: String(metadata.name || ""),
|
||
description: String(metadata.description || ""),
|
||
status: String(metadata.status || ""),
|
||
});
|
||
});
|
||
if (found) {
|
||
return found;
|
||
}
|
||
} catch (error) {
|
||
lastError = error;
|
||
}
|
||
await sleep(options.intervalMs);
|
||
}
|
||
|
||
const detail =
|
||
lastError instanceof Error
|
||
? lastError.message
|
||
: String(lastError || "未找到匹配资料");
|
||
throw new Error(`[smoke:knowledge-gui] 等待资料失败: ${label}。${detail}`);
|
||
}
|
||
|
||
async function seedAgentResultForKnowledgeCapture(page, options) {
|
||
await page.evaluate(
|
||
({ projectId, message }) => {
|
||
const now = new Date().toISOString();
|
||
sessionStorage.setItem(
|
||
`agent_messages_${projectId}`,
|
||
JSON.stringify([
|
||
{
|
||
id: message.id,
|
||
role: "assistant",
|
||
content: message.content,
|
||
timestamp: now,
|
||
},
|
||
]),
|
||
);
|
||
sessionStorage.removeItem(`agent_curr_sessionId_${projectId}`);
|
||
sessionStorage.removeItem(`agent_last_sessionId_${projectId}`);
|
||
sessionStorage.removeItem(`agent_thread_turns_${projectId}`);
|
||
sessionStorage.removeItem(`agent_thread_items_${projectId}`);
|
||
sessionStorage.removeItem(`agent_curr_turnId_${projectId}`);
|
||
},
|
||
{
|
||
projectId: options.projectId,
|
||
message: AGENT_RESULT_MESSAGE,
|
||
},
|
||
);
|
||
}
|
||
|
||
async function createSmokeProject(options) {
|
||
const projectName = `Knowledge GUI Smoke ${process.pid}`;
|
||
const project = await invoke(options, "workspace_create", {
|
||
request: {
|
||
name: projectName,
|
||
rootPath: options.workingDir,
|
||
workspaceType: "temporary",
|
||
},
|
||
});
|
||
const projectId = String(project?.id || "").trim();
|
||
if (!projectId) {
|
||
throw new Error("[smoke:knowledge-gui] workspace_create 未返回项目 ID");
|
||
}
|
||
options.projectId = projectId;
|
||
options.projectName = String(project?.name || projectName);
|
||
const projectRootPath = String(
|
||
project?.rootPath || project?.root_path || "",
|
||
).trim();
|
||
if (projectRootPath) {
|
||
options.workingDir = projectRootPath;
|
||
fs.mkdirSync(options.workingDir, { recursive: true });
|
||
}
|
||
}
|
||
|
||
async function cleanupSmokeProject(options) {
|
||
if (!options.projectId) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
await invoke(options, "workspace_delete", {
|
||
id: options.projectId,
|
||
deleteDirectory: false,
|
||
});
|
||
} catch (error) {
|
||
console.warn(
|
||
`[smoke:knowledge-gui] 清理临时项目失败: ${
|
||
error instanceof Error ? error.message : String(error)
|
||
}`,
|
||
);
|
||
}
|
||
}
|
||
|
||
async function runPlaywrightGuiFlow(options) {
|
||
const userDataDir = fs.mkdtempSync(
|
||
path.join(os.tmpdir(), `lime-knowledge-gui-playwright-${process.pid}-`),
|
||
);
|
||
const launchOptions = {
|
||
headless: true,
|
||
viewport: { width: 1440, height: 960 },
|
||
};
|
||
let context = null;
|
||
|
||
try {
|
||
context = await chromium.launchPersistentContext(userDataDir, {
|
||
...launchOptions,
|
||
channel: "chrome",
|
||
});
|
||
} catch (chromeError) {
|
||
console.warn(
|
||
`[smoke:knowledge-gui] Chrome channel 启动失败,尝试 Playwright 自带 Chromium: ${
|
||
chromeError instanceof Error ? chromeError.message : String(chromeError)
|
||
}`,
|
||
);
|
||
context = await chromium.launchPersistentContext(
|
||
userDataDir,
|
||
launchOptions,
|
||
);
|
||
}
|
||
|
||
const page = context.pages()[0] ?? (await context.newPage());
|
||
page.setDefaultTimeout(DEFAULT_ACTION_TIMEOUT_MS);
|
||
page.setDefaultNavigationTimeout(navigationTimeoutMs(options));
|
||
const consoleErrors = [];
|
||
let offlineBuilderCompileInterceptCount = 0;
|
||
|
||
page.on("console", (message) => {
|
||
if (message.type() === "error") {
|
||
consoleErrors.push(message.text());
|
||
}
|
||
});
|
||
page.on("pageerror", (error) => {
|
||
consoleErrors.push(error.stack || error.message);
|
||
});
|
||
await page.route(options.invokeUrl, async (route) => {
|
||
const rewrittenPostData = rewriteKnowledgeCompileInvokePayload(
|
||
route.request().postData(),
|
||
);
|
||
if (!rewrittenPostData) {
|
||
await route.continue();
|
||
return;
|
||
}
|
||
|
||
offlineBuilderCompileInterceptCount += 1;
|
||
const headers = {
|
||
...route.request().headers(),
|
||
"content-type": "application/json",
|
||
};
|
||
delete headers["content-length"];
|
||
|
||
await route.continue({
|
||
postData: rewrittenPostData,
|
||
headers,
|
||
});
|
||
});
|
||
|
||
try {
|
||
logStage("open-playwright-page");
|
||
await page.goto(options.appUrl, {
|
||
timeout: navigationTimeoutMs(options),
|
||
waitUntil: "domcontentloaded",
|
||
});
|
||
await syncSmokeProjectStorage(page, options);
|
||
await page.reload({
|
||
timeout: navigationTimeoutMs(options),
|
||
waitUntil: "domcontentloaded",
|
||
});
|
||
|
||
logStage("wait-home");
|
||
await waitForPageText(
|
||
page,
|
||
"首页加载",
|
||
["青柠一下,灵感即来"],
|
||
options.timeoutMs,
|
||
);
|
||
|
||
logStage("open-home-knowledge-hub");
|
||
await clickInputbarKnowledgeEntry(
|
||
page,
|
||
"首页项目资料入口",
|
||
options.timeoutMs,
|
||
);
|
||
await waitForInputbarKnowledgeHub(
|
||
page,
|
||
"首页添加资料入口打开浮层",
|
||
options.timeoutMs,
|
||
);
|
||
|
||
logStage("wait-home-knowledge-hub");
|
||
await waitForPageText(
|
||
page,
|
||
"首页资料入口加载",
|
||
["添加新资料", "检查资料", "使用这份资料"],
|
||
options.timeoutMs,
|
||
);
|
||
await page.keyboard.press("Escape");
|
||
|
||
logStage("open-file-manager");
|
||
await clickPageControl(page, { ariaLabel: "打开左侧文件管理器" });
|
||
|
||
logStage("wait-file-manager");
|
||
await waitForPageText(
|
||
page,
|
||
"文件管理器加载",
|
||
["default-source.md", "本地位置"],
|
||
options.timeoutMs,
|
||
);
|
||
|
||
logStage("open-file-manager-context-menu");
|
||
await page
|
||
.locator('[data-testid="file-manager-entry"]')
|
||
.filter({ hasText: DEFAULT_PACK.sourceFileName })
|
||
.first()
|
||
.click({ button: "right", timeout: DEFAULT_ACTION_TIMEOUT_MS });
|
||
await waitForPageText(
|
||
page,
|
||
"文件管理器右键菜单加载",
|
||
["加入对话", "设为资料"],
|
||
options.timeoutMs,
|
||
);
|
||
|
||
logStage("import-file-manager-source");
|
||
await clickScopedButton(page, {
|
||
scope: '[data-testid="file-manager-context-menu"]',
|
||
text: "设为资料",
|
||
});
|
||
|
||
logStage("wait-file-manager-source-imported");
|
||
await waitForKnowledgePack(
|
||
options,
|
||
"文件管理器资料导入完成",
|
||
(pack) =>
|
||
pack.description === FILE_MANAGER_SOURCE_ID ||
|
||
pack.name === FILE_MANAGER_SOURCE_ID ||
|
||
pack.description === FILE_MANAGER_SOURCE_VISIBLE_TITLE ||
|
||
pack.name === FILE_MANAGER_SOURCE_VISIBLE_TITLE,
|
||
);
|
||
|
||
await closeFileManagerIfOpen(page);
|
||
|
||
logStage("open-knowledge-page");
|
||
await openKnowledgePageFromMainNav(page, options);
|
||
|
||
logStage("wait-knowledge-overview");
|
||
await waitForPageText(
|
||
page,
|
||
"知识库总览加载",
|
||
[
|
||
"让 Lime 记住这个项目",
|
||
"回到创作",
|
||
"整理新资料",
|
||
"项目资料清单",
|
||
"可用于创作",
|
||
"建议本轮使用",
|
||
PERSONA_PACK.title,
|
||
DEFAULT_PACK.title,
|
||
SECONDARY_PACK.title,
|
||
FILE_MANAGER_SOURCE_VISIBLE_TITLE,
|
||
],
|
||
options.timeoutMs,
|
||
);
|
||
await assertVisibleText(page, "项目资料首页细节", [
|
||
"接下来你可以",
|
||
"确认待审资料",
|
||
"选择创作时使用的资料",
|
||
"资料只有确认后才会用于创作",
|
||
]);
|
||
await assertNoUserFacingInternalText(page, "项目资料首页");
|
||
|
||
logStage("verify-states-page");
|
||
await clickPageControl(page, { text: "查看状态说明" });
|
||
await waitForPageText(
|
||
page,
|
||
"项目资料状态说明页加载",
|
||
[
|
||
"项目资料状态说明",
|
||
"没有资料",
|
||
"已可用",
|
||
"待确认",
|
||
"需要补充",
|
||
"整理失败",
|
||
],
|
||
options.timeoutMs,
|
||
);
|
||
await assertNoUserFacingInternalText(page, "项目资料状态说明页");
|
||
await openHomeFromMainNav(page, options, "状态说明后返回");
|
||
await openKnowledgePageFromMainNav(page, options);
|
||
await waitForPageText(
|
||
page,
|
||
"状态说明后返回项目资料首页",
|
||
["让 Lime 记住这个项目", "项目资料清单", DEFAULT_PACK.title],
|
||
options.timeoutMs,
|
||
);
|
||
|
||
logStage("verify-review-page");
|
||
await verifyReviewPage(page, options, DEFAULT_PACK.title);
|
||
await clickPageControl(page, { text: "回到项目资料" });
|
||
|
||
logStage("open-agent-with-knowledge");
|
||
await openPackForCreation(page, DEFAULT_PACK.title);
|
||
await confirmKnowledgeComposer(page, options, {
|
||
selectSecondaryData: true,
|
||
selectPersona: true,
|
||
});
|
||
|
||
logStage("wait-agent");
|
||
try {
|
||
await waitForPageText(
|
||
page,
|
||
"Agent 页面加载",
|
||
["请基于当前项目资料创作内容"],
|
||
options.timeoutMs,
|
||
);
|
||
await assertInputbarKnowledgeHubText(
|
||
page,
|
||
"Agent 输入框资料浮层",
|
||
[DEFAULT_PACK.title, SECONDARY_PACK.title, PERSONA_PACK.title],
|
||
options.timeoutMs,
|
||
);
|
||
} catch (error) {
|
||
if (consoleErrors.length > 0) {
|
||
throw new Error(
|
||
`${
|
||
error instanceof Error ? error.message : String(error)
|
||
};console error: ${JSON.stringify(consoleErrors.slice(0, 5))}`,
|
||
);
|
||
}
|
||
throw error;
|
||
}
|
||
|
||
logStage("return-knowledge-before-agent-result");
|
||
await openKnowledgePageFromMainNav(page, options);
|
||
|
||
logStage("prepare-agent-result");
|
||
await seedAgentResultForKnowledgeCapture(page, options);
|
||
await openPackForCreation(page, DEFAULT_PACK.title);
|
||
await confirmKnowledgeComposer(page, options);
|
||
|
||
logStage("wait-agent-result");
|
||
await waitForPageText(
|
||
page,
|
||
"Agent 结果样本加载",
|
||
["保存到项目资料", "事实:该结果来自当前 Agent 对话"],
|
||
options.timeoutMs,
|
||
);
|
||
await assertInputbarKnowledgeHubText(
|
||
page,
|
||
"Agent 结果输入框资料浮层",
|
||
[DEFAULT_PACK.title],
|
||
options.timeoutMs,
|
||
);
|
||
|
||
logStage("capture-agent-result");
|
||
await clickPageControl(page, { ariaLabel: "保存到项目资料", force: true });
|
||
|
||
logStage("wait-agent-result-save-page");
|
||
await waitForPageText(
|
||
page,
|
||
"Agent 结果进入保存页",
|
||
[
|
||
"存到哪里?",
|
||
"补充已有资料",
|
||
"新建一份资料",
|
||
"保存到项目资料",
|
||
"保存后不会立刻用于创作,确认后才会生效",
|
||
"事实:该结果来自当前 Agent 对话",
|
||
],
|
||
options.timeoutMs,
|
||
);
|
||
await assertNoUserFacingInternalText(page, "Agent 结果保存页");
|
||
|
||
await clickPageControl(page, { text: "保存到项目资料" });
|
||
await waitForPageText(
|
||
page,
|
||
"Agent 结果保存完成",
|
||
["资料已保存,确认后才会用于创作", "内容已进入项目资料"],
|
||
options.timeoutMs,
|
||
);
|
||
|
||
logStage("return-knowledge-overview");
|
||
await openHomeFromMainNav(page, options, "Agent 保存后返回");
|
||
await openKnowledgePageFromMainNav(page, options);
|
||
|
||
logStage("wait-captured-agent-result");
|
||
await waitForPageText(
|
||
page,
|
||
"保存资料进入管理页",
|
||
[
|
||
"让 Lime 记住这个项目",
|
||
"项目资料清单",
|
||
DEFAULT_PACK.title,
|
||
"待确认",
|
||
"去确认",
|
||
],
|
||
options.timeoutMs,
|
||
);
|
||
|
||
logStage("verify-builder-import-review");
|
||
await verifyBuilderImportAndReview(page, options);
|
||
if (offlineBuilderCompileInterceptCount < 1) {
|
||
throw new Error(
|
||
"[smoke:knowledge-gui] 未拦截到整理新资料的 knowledgePack/compile 请求,无法证明默认 GUI smoke 没有调用真实 Provider",
|
||
);
|
||
}
|
||
console.log(
|
||
`[smoke:knowledge-gui] offline builder runtime enforced for ${offlineBuilderCompileInterceptCount} knowledgePack/compile request(s)`,
|
||
);
|
||
|
||
if (consoleErrors.length > 0) {
|
||
throw new Error(
|
||
`[smoke:knowledge-gui] 页面存在 ${consoleErrors.length} 条 console error: ${JSON.stringify(
|
||
consoleErrors.slice(0, 5),
|
||
)}`,
|
||
);
|
||
}
|
||
} finally {
|
||
await exportI18nPatchMetrics(page, options.i18nPatchMetricsOutput);
|
||
await context.close().catch(() => undefined);
|
||
fs.rmSync(userDataDir, { recursive: true, force: true });
|
||
}
|
||
}
|
||
|
||
async function seedPack(options, pack) {
|
||
await invokeAppServerMethod(options, "knowledgePack/source/import", {
|
||
workingDir: options.workingDir,
|
||
packName: pack.name,
|
||
description: pack.title,
|
||
packType: pack.type,
|
||
sourceFileName: pack.sourceFileName,
|
||
sourceText: pack.sourceText,
|
||
});
|
||
await invokeAppServerMethod(options, "knowledgePack/compile", {
|
||
workingDir: options.workingDir,
|
||
name: pack.name,
|
||
builderRuntime: {
|
||
enabled: false,
|
||
},
|
||
});
|
||
await invokeAppServerMethod(options, "knowledgePack/status/update", {
|
||
workingDir: options.workingDir,
|
||
name: pack.name,
|
||
status: "ready",
|
||
});
|
||
}
|
||
|
||
async function seedKnowledgePacks(options) {
|
||
fs.mkdirSync(options.workingDir, { recursive: true });
|
||
fs.writeFileSync(
|
||
path.join(options.workingDir, DEFAULT_PACK.sourceFileName),
|
||
DEFAULT_PACK.sourceText,
|
||
);
|
||
await seedPack(options, PERSONA_PACK);
|
||
await seedPack(options, DEFAULT_PACK);
|
||
await seedPack(options, SECONDARY_PACK);
|
||
await invokeAppServerMethod(options, "knowledgePack/default/set", {
|
||
workingDir: options.workingDir,
|
||
name: DEFAULT_PACK.name,
|
||
});
|
||
}
|
||
|
||
async function main() {
|
||
if (typeof fetch !== "function") {
|
||
throw new Error("当前 Node 运行时不支持 fetch,请使用 Node 18+");
|
||
}
|
||
|
||
const options = parseArgs(process.argv.slice(2));
|
||
options.workingDir = fs.mkdtempSync(
|
||
path.join(os.tmpdir(), `lime-knowledge-gui-smoke-${process.pid}-`),
|
||
);
|
||
|
||
try {
|
||
logStage("wait-health");
|
||
await waitForHealth(options);
|
||
await sleep(POST_HEALTH_SETTLE_MS);
|
||
|
||
logStage("create-smoke-project");
|
||
await createSmokeProject(options);
|
||
|
||
logStage("seed-knowledge-packs");
|
||
await seedKnowledgePacks(options);
|
||
|
||
await runPlaywrightGuiFlow(options);
|
||
console.log("[smoke:knowledge-gui] 通过");
|
||
} finally {
|
||
await cleanupSmokeProject(options);
|
||
fs.rmSync(options.workingDir, { recursive: true, force: true });
|
||
}
|
||
}
|
||
|
||
main().catch((error) => {
|
||
console.error(error instanceof Error ? error.message : String(error));
|
||
process.exit(1);
|
||
});
|