chore: release v1.29.0

This commit is contained in:
coso
2026-05-06 20:31:15 +08:00
parent 21b6495cc5
commit d75eb57e4c
192 changed files with 18808 additions and 3993 deletions
@@ -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) {
+2 -2
View File
@@ -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] 通过");
+1
View File
@@ -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({
+119 -2
View File
@@ -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 对话",
],
+43
View File
@@ -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 "监控结束"
+11 -11
View File
@@ -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");
+347
View File
@@ -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);
});
+38
View File
@@ -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");