mirror of
https://github.com/aiclientproxy/proxycast.git
synced 2026-09-24 23:10:56 +08:00
chore: release v1.29.0
This commit is contained in:
@@ -15,6 +15,7 @@ const INVOKE_RETRY_COUNT = 10;
|
||||
const INVOKE_RETRY_DELAY_MS = 1_000;
|
||||
const BROWSER_ACTION_RETRY_COUNT = 6;
|
||||
const BROWSER_ACTION_RETRY_DELAY_MS = 1_000;
|
||||
const BROWSER_SESSION_RECOVERY_LIMIT = 2;
|
||||
const POST_HEALTH_SETTLE_MS = 1_500;
|
||||
const POST_LAUNCH_SETTLE_MS = 1_500;
|
||||
const DEFAULT_ACTION_TIMEOUT_MS = 45_000;
|
||||
@@ -551,11 +552,40 @@ function isRetryableBrowserActionFailure(detail) {
|
||||
return (
|
||||
typeof detail === "string" &&
|
||||
(detail.includes("CDP 调试端口不可用") ||
|
||||
detail.includes("没有可用的 Chrome 会话"))
|
||||
detail.includes("没有可用的 Chrome 会话") ||
|
||||
detail.includes("未找到 profile_key="))
|
||||
);
|
||||
}
|
||||
|
||||
async function runBrowserAction(options, profileKey, action, args = {}, label = action) {
|
||||
async function launchSmokeBrowserSession(options, profileKey) {
|
||||
const launchResponse = await invoke(options, "launch_browser_session", {
|
||||
request: {
|
||||
profile_key: profileKey,
|
||||
url: options.appUrl,
|
||||
headless: true,
|
||||
open_window: false,
|
||||
// 真实 Lime 页面在 cdp_direct + frames/both 下会持续产出 frame 流,
|
||||
// 这里会把后续 Runtime.evaluate 挤到超时;页面 smoke 只需要事件流即可。
|
||||
stream_mode: "events",
|
||||
},
|
||||
});
|
||||
const sessionId = launchResponse?.session?.session_id ?? null;
|
||||
assert(
|
||||
typeof sessionId === "string" && sessionId.trim(),
|
||||
"launch_browser_session 未返回 session.session_id",
|
||||
);
|
||||
await sleep(POST_LAUNCH_SETTLE_MS);
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
async function runBrowserAction(
|
||||
options,
|
||||
profileKey,
|
||||
action,
|
||||
args = {},
|
||||
label = action,
|
||||
recovery,
|
||||
) {
|
||||
for (let attempt = 1; attempt <= BROWSER_ACTION_RETRY_COUNT; attempt += 1) {
|
||||
const result = await invoke(options, "browser_execute_action", {
|
||||
request: {
|
||||
@@ -576,6 +606,17 @@ async function runBrowserAction(options, profileKey, action, args = {}, label =
|
||||
isRetryableBrowserActionFailure(detail) &&
|
||||
attempt < BROWSER_ACTION_RETRY_COUNT
|
||||
) {
|
||||
if (recovery && recovery.count < BROWSER_SESSION_RECOVERY_LIMIT) {
|
||||
recovery.count += 1;
|
||||
console.warn(
|
||||
`[smoke:agent-runtime-tool-surface-page] browser_execute_action(${label}) 丢失托管 Chrome 会话,尝试第 ${recovery.count} 次重启: ${detail}`,
|
||||
);
|
||||
recovery.sessionId = await launchSmokeBrowserSession(
|
||||
options,
|
||||
profileKey,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
console.warn(
|
||||
`[smoke:agent-runtime-tool-surface-page] browser_execute_action(${label}) 第 ${attempt} 次失败,${BROWSER_ACTION_RETRY_DELAY_MS}ms 后重试: ${detail}`,
|
||||
);
|
||||
@@ -593,7 +634,13 @@ async function runBrowserAction(options, profileKey, action, args = {}, label =
|
||||
);
|
||||
}
|
||||
|
||||
async function runJavascript(options, profileKey, expression, label = "javascript") {
|
||||
async function runJavascript(
|
||||
options,
|
||||
profileKey,
|
||||
expression,
|
||||
label = "javascript",
|
||||
recovery,
|
||||
) {
|
||||
const result = await runBrowserAction(
|
||||
options,
|
||||
profileKey,
|
||||
@@ -603,12 +650,20 @@ async function runJavascript(options, profileKey, expression, label = "javascrip
|
||||
return_by_value: true,
|
||||
},
|
||||
`javascript:${label}`,
|
||||
recovery,
|
||||
);
|
||||
return extractJavascriptValue(result);
|
||||
}
|
||||
|
||||
async function readPageMarkdown(options, profileKey) {
|
||||
const result = await runBrowserAction(options, profileKey, "read_page");
|
||||
async function readPageMarkdown(options, profileKey, recovery) {
|
||||
const result = await runBrowserAction(
|
||||
options,
|
||||
profileKey,
|
||||
"read_page",
|
||||
{},
|
||||
"read_page",
|
||||
recovery,
|
||||
);
|
||||
return String(result?.data?.markdown || "");
|
||||
}
|
||||
|
||||
@@ -641,7 +696,10 @@ async function main() {
|
||||
await waitForHealth(options);
|
||||
await sleep(POST_HEALTH_SETTLE_MS);
|
||||
const profileKey = SMOKE_PROFILE_KEY;
|
||||
let sessionId = null;
|
||||
const browserRecovery = {
|
||||
count: 0,
|
||||
sessionId: null,
|
||||
};
|
||||
|
||||
try {
|
||||
logStage("cleanup-old-profile");
|
||||
@@ -652,24 +710,10 @@ async function main() {
|
||||
);
|
||||
|
||||
logStage("launch-browser-session");
|
||||
const launchResponse = await invoke(options, "launch_browser_session", {
|
||||
request: {
|
||||
profile_key: profileKey,
|
||||
url: options.appUrl,
|
||||
headless: true,
|
||||
open_window: false,
|
||||
// 真实 Lime 页面在 cdp_direct + frames/both 下会持续产出 frame 流,
|
||||
// 这里会把后续 Runtime.evaluate 挤到超时;页面 smoke 只需要事件流即可。
|
||||
stream_mode: "events",
|
||||
},
|
||||
});
|
||||
|
||||
sessionId = launchResponse?.session?.session_id ?? null;
|
||||
assert(
|
||||
typeof sessionId === "string" && sessionId.trim(),
|
||||
"launch_browser_session 未返回 session.session_id",
|
||||
browserRecovery.sessionId = await launchSmokeBrowserSession(
|
||||
options,
|
||||
profileKey,
|
||||
);
|
||||
await sleep(POST_LAUNCH_SETTLE_MS);
|
||||
|
||||
logStage("wait-page-storage-ready");
|
||||
await waitForCheck(options, "Lime 首页 origin 可访问", async () => {
|
||||
@@ -678,6 +722,7 @@ async function main() {
|
||||
profileKey,
|
||||
buildPageStorageReadyScript(options.appUrl),
|
||||
"wait-page-storage-ready",
|
||||
browserRecovery,
|
||||
);
|
||||
return {
|
||||
ok: value?.ok === true,
|
||||
@@ -691,9 +736,17 @@ async function main() {
|
||||
profileKey,
|
||||
buildHarnessBootstrapScript(),
|
||||
"bootstrap-harness-storage",
|
||||
browserRecovery,
|
||||
);
|
||||
logStage("refresh-page");
|
||||
await runBrowserAction(options, profileKey, "refresh_page");
|
||||
await runBrowserAction(
|
||||
options,
|
||||
profileKey,
|
||||
"refresh_page",
|
||||
{},
|
||||
"refresh_page",
|
||||
browserRecovery,
|
||||
);
|
||||
|
||||
logStage("wait-empty-state");
|
||||
await waitForCheck(options, "首页空态加载", async () => {
|
||||
@@ -702,6 +755,7 @@ async function main() {
|
||||
profileKey,
|
||||
'document.body ? document.body.innerText : ""',
|
||||
"wait-empty-state-text",
|
||||
browserRecovery,
|
||||
);
|
||||
return {
|
||||
ok:
|
||||
@@ -715,11 +769,12 @@ async function main() {
|
||||
logStage("fill-prompt");
|
||||
await waitForCheck(options, "首页输入框出现", async () => {
|
||||
const value = await runJavascript(
|
||||
options,
|
||||
profileKey,
|
||||
buildComposerReadyScript(),
|
||||
"wait-composer-ready",
|
||||
);
|
||||
options,
|
||||
profileKey,
|
||||
buildComposerReadyScript(),
|
||||
"wait-composer-ready",
|
||||
browserRecovery,
|
||||
);
|
||||
return {
|
||||
ok: value?.ok === true,
|
||||
value,
|
||||
@@ -735,6 +790,7 @@ async function main() {
|
||||
profileKey,
|
||||
buildFillPromptScript(PROMPT_TEXT),
|
||||
"fill-prompt",
|
||||
browserRecovery,
|
||||
);
|
||||
return {
|
||||
ok: value?.ok === true,
|
||||
@@ -754,6 +810,7 @@ async function main() {
|
||||
profileKey,
|
||||
buildSendReadyScript(),
|
||||
"wait-send-ready",
|
||||
browserRecovery,
|
||||
);
|
||||
return {
|
||||
ok: value?.ok === true,
|
||||
@@ -771,6 +828,7 @@ async function main() {
|
||||
profileKey,
|
||||
buildClickSendScript(),
|
||||
"click-send",
|
||||
browserRecovery,
|
||||
);
|
||||
assert(
|
||||
submitted?.ok === true,
|
||||
@@ -784,6 +842,7 @@ async function main() {
|
||||
profileKey,
|
||||
buildWorkbenchButtonCheckScript(),
|
||||
"wait-harness-button",
|
||||
browserRecovery,
|
||||
);
|
||||
return {
|
||||
ok: value?.hasButton === true,
|
||||
@@ -797,6 +856,7 @@ async function main() {
|
||||
profileKey,
|
||||
buildOpenWorkbenchScript(),
|
||||
"open-harness",
|
||||
browserRecovery,
|
||||
);
|
||||
assert(
|
||||
openWorkbench?.ok === true,
|
||||
@@ -813,6 +873,7 @@ async function main() {
|
||||
profileKey,
|
||||
buildRuntimeSummaryCheckScript(),
|
||||
"check-runtime-summary",
|
||||
browserRecovery,
|
||||
);
|
||||
const hasAllRequired = REQUIRED_RUNTIME_SUMMARY_FLAGS.every(
|
||||
(key) => value?.[key] === true,
|
||||
@@ -827,7 +888,11 @@ async function main() {
|
||||
);
|
||||
|
||||
logStage("read-page-markdown");
|
||||
const pageMarkdown = await readPageMarkdown(options, profileKey);
|
||||
const pageMarkdown = await readPageMarkdown(
|
||||
options,
|
||||
profileKey,
|
||||
browserRecovery,
|
||||
);
|
||||
for (const warning of FORBIDDEN_PAGE_WARNINGS) {
|
||||
assert(
|
||||
!pageMarkdown.includes(warning),
|
||||
@@ -836,18 +901,18 @@ async function main() {
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[smoke:agent-runtime-tool-surface-page] 通过 session=${sessionId} profile=${profileKey}`,
|
||||
`[smoke:agent-runtime-tool-surface-page] 通过 session=${browserRecovery.sessionId} profile=${profileKey}`,
|
||||
);
|
||||
console.log(
|
||||
`[smoke:agent-runtime-tool-surface-page] summary=${JSON.stringify(summaryFlags)}`,
|
||||
);
|
||||
} finally {
|
||||
if (sessionId) {
|
||||
if (browserRecovery.sessionId) {
|
||||
logStage("close-cdp-session");
|
||||
try {
|
||||
await invoke(options, "close_cdp_session", {
|
||||
request: {
|
||||
session_id: sessionId,
|
||||
session_id: browserRecovery.sessionId,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
@@ -45,9 +45,9 @@ function main() {
|
||||
|
||||
runVitest("Agent 对话内 A2UI 挂起主链", [
|
||||
"src/components/agent/chat/index.test.tsx",
|
||||
"--hookTimeout=60000",
|
||||
"--hookTimeout=180000",
|
||||
"-t",
|
||||
"AgentChatPage 服务技能 A2UI|AgentChatPage legacy 问卷 A2UI",
|
||||
"AgentChatPage 服务技能 A2UI|AgentChatPage 当前 A2UI 事实源",
|
||||
]);
|
||||
|
||||
console.log("\n[smoke:agent-service-skill-entry] 通过");
|
||||
|
||||
@@ -284,6 +284,7 @@ async function runPageFlow(options, smokeUrl) {
|
||||
await waitForText(page, "刷新入口", "刷新生成结果");
|
||||
await waitForText(page, "单层重生成入口", "重生成当前层");
|
||||
await waitForText(page, "导出入口", "导出设计工程");
|
||||
await waitForText(page, "工程恢复入口", "打开最近工程");
|
||||
|
||||
logStage("interact-layer");
|
||||
await page.getByRole("button", { name: "选择图层 主标题" }).click({
|
||||
|
||||
@@ -61,6 +61,8 @@ const AGENT_RESULT_MESSAGE = {
|
||||
].join("\n"),
|
||||
};
|
||||
|
||||
const FILE_MANAGER_SOURCE_TITLE = "brief";
|
||||
|
||||
function printHelp() {
|
||||
console.log(`
|
||||
Lime Knowledge GUI Smoke
|
||||
@@ -311,6 +313,80 @@ async function clickPageControl(page, { text, ariaLabel, index = 0 }) {
|
||||
}
|
||||
}
|
||||
|
||||
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 waitForKnowledgePack(options, label, matcher) {
|
||||
const startedAt = Date.now();
|
||||
let lastError = null;
|
||||
|
||||
while (Date.now() - startedAt < options.timeoutMs) {
|
||||
try {
|
||||
const result = await invoke(options, "knowledge_list_packs", {
|
||||
request: {
|
||||
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 }) => {
|
||||
@@ -440,6 +516,46 @@ async function runPlaywrightGuiFlow(options) {
|
||||
logStage("wait-home");
|
||||
await waitForPageText(page, "首页加载", ["青柠一下,灵感即来"], options.timeoutMs);
|
||||
|
||||
logStage("open-home-knowledge-hub");
|
||||
await clickPageControl(page, { text: "添加资料" });
|
||||
|
||||
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,
|
||||
"文件管理器加载",
|
||||
["brief.md", "加入对话", "设为资料", "本地位置"],
|
||||
options.timeoutMs,
|
||||
);
|
||||
|
||||
logStage("import-file-manager-source");
|
||||
await clickScopedButton(page, {
|
||||
scope: '[data-testid="file-manager-sidebar"]',
|
||||
ariaLabel: "设为项目资料 brief.md",
|
||||
});
|
||||
|
||||
logStage("wait-file-manager-source-imported");
|
||||
await waitForKnowledgePack(
|
||||
options,
|
||||
"文件管理器资料导入完成",
|
||||
(pack) =>
|
||||
pack.description === FILE_MANAGER_SOURCE_TITLE ||
|
||||
pack.name === FILE_MANAGER_SOURCE_TITLE,
|
||||
);
|
||||
|
||||
await clickPageControl(page, { ariaLabel: "关闭文件管理器" });
|
||||
|
||||
logStage("open-knowledge-page");
|
||||
await clickPageControl(page, { ariaLabel: "项目资料" });
|
||||
|
||||
@@ -458,6 +574,7 @@ async function runPlaywrightGuiFlow(options) {
|
||||
"已确认可用",
|
||||
DEFAULT_PACK.title,
|
||||
SECONDARY_PACK.title,
|
||||
FILE_MANAGER_SOURCE_TITLE,
|
||||
options.projectName,
|
||||
],
|
||||
options.timeoutMs,
|
||||
@@ -472,7 +589,7 @@ async function runPlaywrightGuiFlow(options) {
|
||||
page,
|
||||
"Agent 页面加载",
|
||||
[
|
||||
`正在使用:${DEFAULT_PACK.title}`,
|
||||
`资料:${DEFAULT_PACK.title}`,
|
||||
"请基于当前项目资料生成内容",
|
||||
],
|
||||
options.timeoutMs,
|
||||
@@ -500,7 +617,7 @@ async function runPlaywrightGuiFlow(options) {
|
||||
page,
|
||||
"Agent 结果样本加载",
|
||||
[
|
||||
`正在使用:${DEFAULT_PACK.title}`,
|
||||
`资料:${DEFAULT_PACK.title}`,
|
||||
"沉淀为项目资料",
|
||||
"事实:该结果来自当前 Agent 对话",
|
||||
],
|
||||
|
||||
Executable
+43
@@ -0,0 +1,43 @@
|
||||
#!/bin/bash
|
||||
|
||||
echo "🚀 Tauri 打包进度监控"
|
||||
echo "===================="
|
||||
echo ""
|
||||
|
||||
while true; do
|
||||
clear
|
||||
echo "🚀 Tauri 打包进度监控"
|
||||
echo "===================="
|
||||
echo ""
|
||||
echo "⏰ 当前时间: $(date '+%H:%M:%S')"
|
||||
echo ""
|
||||
|
||||
# 检查 Rust 编译进度
|
||||
if [ -f /tmp/tauri-build.log ]; then
|
||||
echo "📝 最新日志 (最后 20 行):"
|
||||
echo "---"
|
||||
tail -20 /tmp/tauri-build.log
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# 检查是否完成
|
||||
if [ -d "src-tauri/target/release/bundle" ]; then
|
||||
echo "✅ 打包完成!"
|
||||
echo ""
|
||||
echo "📦 生成的安装包:"
|
||||
ls -lh src-tauri/target/release/bundle/dmg/*.dmg 2>/dev/null || echo " DMG 文件生成中..."
|
||||
ls -lh src-tauri/target/release/bundle/macos/*.app 2>/dev/null || echo " APP 文件生成中..."
|
||||
break
|
||||
fi
|
||||
|
||||
# 检查进程是否还在运行
|
||||
if ! pgrep -f "tauri build" > /dev/null; then
|
||||
echo "⚠️ 打包进程已结束"
|
||||
break
|
||||
fi
|
||||
|
||||
sleep 10
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "监控结束"
|
||||
@@ -303,7 +303,7 @@ describe("GitHub release asset staging", () => {
|
||||
"arm-sig",
|
||||
);
|
||||
writeFile(
|
||||
path.join(assetsDir, "aarch64-apple-darwin", "Lime_1.28.0_aarch64.dmg"),
|
||||
path.join(assetsDir, "aarch64-apple-darwin", "Lime_1.29.0_aarch64.dmg"),
|
||||
);
|
||||
writeFile(path.join(assetsDir, "x86_64-apple-darwin", "Lime.app.tar.gz"));
|
||||
writeFile(
|
||||
@@ -311,7 +311,7 @@ describe("GitHub release asset staging", () => {
|
||||
"x64-sig",
|
||||
);
|
||||
writeFile(
|
||||
path.join(assetsDir, "x86_64-apple-darwin", "Lime_1.28.0_x64.dmg"),
|
||||
path.join(assetsDir, "x86_64-apple-darwin", "Lime_1.29.0_x64.dmg"),
|
||||
);
|
||||
writeFile(latestPath, "{}");
|
||||
|
||||
@@ -319,29 +319,29 @@ describe("GitHub release asset staging", () => {
|
||||
assetsDir,
|
||||
extraAssets: [latestPath],
|
||||
outDir,
|
||||
version: "v1.28.0",
|
||||
version: "v1.29.0",
|
||||
});
|
||||
|
||||
expect(copied.map((item) => item.name).sort()).toEqual(
|
||||
[
|
||||
"Lime_1.28.0_aarch64.app.tar.gz",
|
||||
"Lime_1.28.0_aarch64.app.tar.gz.sig",
|
||||
"Lime_1.28.0_aarch64.dmg",
|
||||
"Lime_1.28.0_x64.app.tar.gz",
|
||||
"Lime_1.28.0_x64.app.tar.gz.sig",
|
||||
"Lime_1.28.0_x64.dmg",
|
||||
"Lime_1.29.0_aarch64.app.tar.gz",
|
||||
"Lime_1.29.0_aarch64.app.tar.gz.sig",
|
||||
"Lime_1.29.0_aarch64.dmg",
|
||||
"Lime_1.29.0_x64.app.tar.gz",
|
||||
"Lime_1.29.0_x64.app.tar.gz.sig",
|
||||
"Lime_1.29.0_x64.dmg",
|
||||
"latest.json",
|
||||
].sort(),
|
||||
);
|
||||
expect(
|
||||
fs.readFileSync(
|
||||
path.join(outDir, "Lime_1.28.0_aarch64.app.tar.gz.sig"),
|
||||
path.join(outDir, "Lime_1.29.0_aarch64.app.tar.gz.sig"),
|
||||
"utf8",
|
||||
),
|
||||
).toBe("arm-sig");
|
||||
expect(
|
||||
fs.readFileSync(
|
||||
path.join(outDir, "Lime_1.28.0_x64.app.tar.gz.sig"),
|
||||
path.join(outDir, "Lime_1.29.0_x64.app.tar.gz.sig"),
|
||||
"utf8",
|
||||
),
|
||||
).toBe("x64-sig");
|
||||
|
||||
@@ -0,0 +1,347 @@
|
||||
/**
|
||||
* 启动排版诊断 E2E 测试
|
||||
*
|
||||
* 使用 Playwright MCP 测试应用启动时的排版稳定性
|
||||
* 检测 CLS (Cumulative Layout Shift) 和关键渲染时间点
|
||||
*/
|
||||
|
||||
import { chromium, type Browser, type Page, type BrowserContext } from "playwright";
|
||||
|
||||
interface LayoutShiftMetric {
|
||||
timestamp: number;
|
||||
value: number;
|
||||
sources: Array<{
|
||||
node: string;
|
||||
previousRect: { x: number; y: number; width: number; height: number };
|
||||
currentRect: { x: number; y: number; width: number; height: number };
|
||||
}>;
|
||||
}
|
||||
|
||||
interface PerformanceMetrics {
|
||||
domContentLoaded: number;
|
||||
loadComplete: number;
|
||||
firstPaint: number;
|
||||
firstContentfulPaint: number;
|
||||
largestContentfulPaint: number;
|
||||
cumulativeLayoutShift: number;
|
||||
layoutShifts: LayoutShiftMetric[];
|
||||
}
|
||||
|
||||
async function collectLayoutShifts(page: Page): Promise<LayoutShiftMetric[]> {
|
||||
return page.evaluate(() => {
|
||||
return new Promise<LayoutShiftMetric[]>((resolve) => {
|
||||
const shifts: LayoutShiftMetric[] = [];
|
||||
|
||||
const observer = new PerformanceObserver((list) => {
|
||||
for (const entry of list.getEntries()) {
|
||||
if (entry.entryType === "layout-shift" && !(entry as any).hadRecentInput) {
|
||||
const layoutShiftEntry = entry as any;
|
||||
shifts.push({
|
||||
timestamp: entry.startTime,
|
||||
value: layoutShiftEntry.value,
|
||||
sources: (layoutShiftEntry.sources || []).map((source: any) => ({
|
||||
node: source.node?.nodeName || "unknown",
|
||||
previousRect: {
|
||||
x: source.previousRect.x,
|
||||
y: source.previousRect.y,
|
||||
width: source.previousRect.width,
|
||||
height: source.previousRect.height,
|
||||
},
|
||||
currentRect: {
|
||||
x: source.currentRect.x,
|
||||
y: source.currentRect.y,
|
||||
width: source.currentRect.width,
|
||||
height: source.currentRect.height,
|
||||
},
|
||||
})),
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe({ type: "layout-shift", buffered: true });
|
||||
|
||||
// 等待 3 秒后返回结果
|
||||
setTimeout(() => {
|
||||
observer.disconnect();
|
||||
resolve(shifts);
|
||||
}, 3000);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function collectPerformanceMetrics(page: Page): Promise<PerformanceMetrics> {
|
||||
const performanceTiming = await page.evaluate(() => {
|
||||
const timing = performance.timing;
|
||||
const navigationStart = timing.navigationStart;
|
||||
|
||||
return {
|
||||
domContentLoaded: timing.domContentLoadedEventEnd - navigationStart,
|
||||
loadComplete: timing.loadEventEnd - navigationStart,
|
||||
};
|
||||
});
|
||||
|
||||
const paintMetrics = await page.evaluate(() => {
|
||||
const entries = performance.getEntriesByType("paint");
|
||||
const result: Record<string, number> = {};
|
||||
|
||||
for (const entry of entries) {
|
||||
result[entry.name] = entry.startTime;
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
const lcpMetric = await page.evaluate(() => {
|
||||
return new Promise<number>((resolve) => {
|
||||
let lcp = 0;
|
||||
const observer = new PerformanceObserver((list) => {
|
||||
const entries = list.getEntries();
|
||||
const lastEntry = entries[entries.length - 1] as any;
|
||||
lcp = lastEntry.renderTime || lastEntry.loadTime;
|
||||
});
|
||||
|
||||
observer.observe({ type: "largest-contentful-paint", buffered: true });
|
||||
|
||||
setTimeout(() => {
|
||||
observer.disconnect();
|
||||
resolve(lcp);
|
||||
}, 3000);
|
||||
});
|
||||
});
|
||||
|
||||
const layoutShifts = await collectLayoutShifts(page);
|
||||
const cumulativeLayoutShift = layoutShifts.reduce((sum, shift) => sum + shift.value, 0);
|
||||
|
||||
return {
|
||||
domContentLoaded: performanceTiming.domContentLoaded,
|
||||
loadComplete: performanceTiming.loadComplete,
|
||||
firstPaint: paintMetrics["first-paint"] || 0,
|
||||
firstContentfulPaint: paintMetrics["first-contentful-paint"] || 0,
|
||||
largestContentfulPaint: lcpMetric,
|
||||
cumulativeLayoutShift,
|
||||
layoutShifts,
|
||||
};
|
||||
}
|
||||
|
||||
async function takeScreenshotSequence(page: Page, outputDir: string): Promise<void> {
|
||||
const timestamps = [0, 100, 200, 300, 500, 800, 1200, 2000];
|
||||
|
||||
for (const delay of timestamps) {
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
await page.screenshot({
|
||||
path: `${outputDir}/startup-${delay}ms.png`,
|
||||
fullPage: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function runStartupDiagnostics() {
|
||||
console.log("🚀 启动排版诊断 E2E 测试\n");
|
||||
|
||||
let browser: Browser | null = null;
|
||||
let context: BrowserContext | null = null;
|
||||
let page: Page | null = null;
|
||||
|
||||
try {
|
||||
// 启动浏览器
|
||||
console.log("1. 启动 Chrome 浏览器...");
|
||||
browser = await chromium.launch({
|
||||
headless: false,
|
||||
args: [
|
||||
"--disable-blink-features=AutomationControlled",
|
||||
"--window-size=1280,800",
|
||||
],
|
||||
});
|
||||
|
||||
context = await browser.newContext({
|
||||
viewport: { width: 1280, height: 800 },
|
||||
deviceScaleFactor: 1,
|
||||
});
|
||||
|
||||
page = await context.newPage();
|
||||
|
||||
// 启用性能监控
|
||||
await page.evaluateOnNewDocument(() => {
|
||||
(window as any).__STARTUP_DIAGNOSTICS_ENABLED__ = true;
|
||||
});
|
||||
|
||||
console.log("2. 导航到应用首页...");
|
||||
const startTime = Date.now();
|
||||
|
||||
await page.goto("http://127.0.0.1:1420/?debug-startup&debug-layout-shift", {
|
||||
waitUntil: "domcontentloaded",
|
||||
});
|
||||
|
||||
console.log("3. 等待应用加载完成...");
|
||||
|
||||
// 等待 Splash 消失
|
||||
try {
|
||||
await page.waitForSelector('[data-testid="splash-screen"]', {
|
||||
state: "hidden",
|
||||
timeout: 2000,
|
||||
});
|
||||
console.log(" ✓ Splash 已消失");
|
||||
} catch {
|
||||
console.log(" ⚠ 未检测到 Splash 或已提前消失");
|
||||
}
|
||||
|
||||
// 等待主应用渲染
|
||||
await page.waitForSelector('[data-lime-window-drag-region]', {
|
||||
state: "visible",
|
||||
timeout: 5000,
|
||||
});
|
||||
console.log(" ✓ 主应用已渲染");
|
||||
|
||||
const loadTime = Date.now() - startTime;
|
||||
console.log(` 总加载时间: ${loadTime}ms\n`);
|
||||
|
||||
// 收集性能指标
|
||||
console.log("4. 收集性能指标...");
|
||||
const metrics = await collectPerformanceMetrics(page);
|
||||
|
||||
console.log("\n📊 性能指标:");
|
||||
console.log(` DOM Content Loaded: ${metrics.domContentLoaded.toFixed(2)}ms`);
|
||||
console.log(` Load Complete: ${metrics.loadComplete.toFixed(2)}ms`);
|
||||
console.log(` First Paint: ${metrics.firstPaint.toFixed(2)}ms`);
|
||||
console.log(` First Contentful Paint: ${metrics.firstContentfulPaint.toFixed(2)}ms`);
|
||||
console.log(` Largest Contentful Paint: ${metrics.largestContentfulPaint.toFixed(2)}ms`);
|
||||
console.log(` Cumulative Layout Shift: ${metrics.cumulativeLayoutShift.toFixed(4)}`);
|
||||
|
||||
// 分析布局偏移
|
||||
console.log("\n📐 布局偏移分析:");
|
||||
if (metrics.layoutShifts.length === 0) {
|
||||
console.log(" ✓ 未检测到布局偏移");
|
||||
} else {
|
||||
console.log(` ⚠ 检测到 ${metrics.layoutShifts.length} 次布局偏移:\n`);
|
||||
|
||||
metrics.layoutShifts.forEach((shift, index) => {
|
||||
console.log(` #${index + 1} @ ${shift.timestamp.toFixed(2)}ms`);
|
||||
console.log(` Score: ${shift.value.toFixed(4)}`);
|
||||
console.log(` Affected elements: ${shift.sources.length}`);
|
||||
|
||||
shift.sources.forEach((source, sourceIndex) => {
|
||||
console.log(` - ${source.node}`);
|
||||
console.log(` Previous: ${source.previousRect.width}x${source.previousRect.height} @ (${source.previousRect.x}, ${source.previousRect.y})`);
|
||||
console.log(` Current: ${source.currentRect.width}x${source.currentRect.height} @ (${source.currentRect.x}, ${source.currentRect.y})`);
|
||||
});
|
||||
console.log();
|
||||
});
|
||||
}
|
||||
|
||||
// CLS 评分标准
|
||||
console.log("\n🎯 CLS 评分:");
|
||||
if (metrics.cumulativeLayoutShift < 0.1) {
|
||||
console.log(" ✓ 优秀 (< 0.1)");
|
||||
} else if (metrics.cumulativeLayoutShift < 0.25) {
|
||||
console.log(" ⚠ 需要改进 (0.1 - 0.25)");
|
||||
} else {
|
||||
console.log(" ❌ 差 (> 0.25)");
|
||||
}
|
||||
|
||||
// 检查控制台错误
|
||||
console.log("\n🔍 控制台检查:");
|
||||
const consoleLogs: Array<{ type: string; text: string }> = [];
|
||||
|
||||
page.on("console", (msg) => {
|
||||
consoleLogs.push({
|
||||
type: msg.type(),
|
||||
text: msg.text(),
|
||||
});
|
||||
});
|
||||
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
const errors = consoleLogs.filter((log) => log.type === "error");
|
||||
const warnings = consoleLogs.filter((log) => log.type === "warning");
|
||||
|
||||
console.log(` Errors: ${errors.length}`);
|
||||
console.log(` Warnings: ${warnings.length}`);
|
||||
|
||||
if (errors.length > 0) {
|
||||
console.log("\n 错误详情:");
|
||||
errors.forEach((error, index) => {
|
||||
console.log(` ${index + 1}. ${error.text}`);
|
||||
});
|
||||
}
|
||||
|
||||
// 截图序列
|
||||
console.log("\n📸 生成截图序列...");
|
||||
await page.goto("http://127.0.0.1:1420/?debug-startup&debug-layout-shift");
|
||||
await takeScreenshotSequence(page, "./screenshots");
|
||||
console.log(" ✓ 截图已保存到 ./screenshots/");
|
||||
|
||||
// 生成报告
|
||||
console.log("\n📝 生成诊断报告...");
|
||||
const report = {
|
||||
timestamp: new Date().toISOString(),
|
||||
loadTime,
|
||||
metrics,
|
||||
consoleLogs: {
|
||||
errors: errors.length,
|
||||
warnings: warnings.length,
|
||||
errorDetails: errors.slice(0, 10),
|
||||
},
|
||||
recommendations: generateRecommendations(metrics),
|
||||
};
|
||||
|
||||
const fs = await import("fs/promises");
|
||||
await fs.mkdir("./diagnostics", { recursive: true });
|
||||
await fs.writeFile(
|
||||
"./diagnostics/startup-report.json",
|
||||
JSON.stringify(report, null, 2),
|
||||
);
|
||||
console.log(" ✓ 报告已保存到 ./diagnostics/startup-report.json");
|
||||
|
||||
console.log("\n✅ 诊断完成!");
|
||||
|
||||
} catch (error) {
|
||||
console.error("\n❌ 测试失败:", error);
|
||||
throw error;
|
||||
} finally {
|
||||
if (page) await page.close();
|
||||
if (context) await context.close();
|
||||
if (browser) await browser.close();
|
||||
}
|
||||
}
|
||||
|
||||
function generateRecommendations(metrics: PerformanceMetrics): string[] {
|
||||
const recommendations: string[] = [];
|
||||
|
||||
if (metrics.cumulativeLayoutShift > 0.1) {
|
||||
recommendations.push(
|
||||
"CLS 分数过高,建议检查启动时的 CSS 变量注入时机和侧边栏显示逻辑",
|
||||
);
|
||||
}
|
||||
|
||||
if (metrics.largestContentfulPaint > 2500) {
|
||||
recommendations.push(
|
||||
"LCP 过慢,建议优化关键资源加载顺序或延长 Splash 显示时间",
|
||||
);
|
||||
}
|
||||
|
||||
if (metrics.layoutShifts.length > 3) {
|
||||
recommendations.push(
|
||||
`检测到 ${metrics.layoutShifts.length} 次布局偏移,建议为关键元素设置固定尺寸或使用 skeleton`,
|
||||
);
|
||||
}
|
||||
|
||||
const earlyShifts = metrics.layoutShifts.filter((shift) => shift.timestamp < 1000);
|
||||
if (earlyShifts.length > 0) {
|
||||
recommendations.push(
|
||||
"启动前 1 秒内发生布局偏移,建议在 HTML 中预注入关键 CSS 变量",
|
||||
);
|
||||
}
|
||||
|
||||
if (recommendations.length === 0) {
|
||||
recommendations.push("启动性能良好,无需优化");
|
||||
}
|
||||
|
||||
return recommendations;
|
||||
}
|
||||
|
||||
// 运行测试
|
||||
runStartupDiagnostics().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* 启动排版诊断 - 简化版
|
||||
*
|
||||
* 使用 Playwright MCP 工具进行交互式测试
|
||||
* 适合在 Claude Code 中直接调用
|
||||
*/
|
||||
|
||||
console.log("🚀 启动排版诊断测试\n");
|
||||
console.log("请确保已启动开发服务器:");
|
||||
console.log(" npm run tauri:dev:headless\n");
|
||||
console.log("然后在 Claude Code 中使用 Playwright MCP 工具:\n");
|
||||
|
||||
console.log("1. 导航到应用:");
|
||||
console.log(' mcp__playwright__browser_navigate({ url: "http://127.0.0.1:1420/?debug-startup&debug-layout-shift" })\n');
|
||||
|
||||
console.log("2. 等待加载完成:");
|
||||
console.log(' 等待 2-3 秒,观察页面渲染过程\n');
|
||||
|
||||
console.log("3. 截图记录:");
|
||||
console.log(' mcp__playwright__browser_take_screenshot({ filename: "startup-initial.png" })\n');
|
||||
|
||||
console.log("4. 检查控制台:");
|
||||
console.log(' mcp__playwright__browser_console_messages({ level: "error" })\n');
|
||||
|
||||
console.log("5. 查看性能报告:");
|
||||
console.log(' 打开浏览器 DevTools,查看 Console 中的:');
|
||||
console.log(' - 🚀 Startup Performance Report');
|
||||
console.log(' - 📐 Layout Shift Report\n');
|
||||
|
||||
console.log("6. 分析结果:");
|
||||
console.log(' - CLS < 0.1: 优秀');
|
||||
console.log(' - CLS 0.1-0.25: 需要改进');
|
||||
console.log(' - CLS > 0.25: 差\n');
|
||||
|
||||
console.log("📝 完整的自动化测试脚本:");
|
||||
console.log(" node scripts/startup-layout-e2e.mjs\n");
|
||||
Reference in New Issue
Block a user