Files
proxycast/scripts/project-memory-current-smoke.mjs
T
2026-06-08 02:57:23 +08:00

579 lines
17 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: 120_000,
intervalMs: 1_000,
evidenceDir: path.join(
process.cwd(),
".lime",
"qc",
"gui-evidence",
"project-memory-current",
),
prefix: "project-memory-current",
headless: true,
};
const APP_SERVER_HANDLE_JSON_LINES_COMMAND = "app_server_handle_json_lines";
const REQUIRED_APP_SERVER_METHODS = ["projectMemory/read"];
const LEGACY_PROJECT_MEMORY_COMMANDS = ["project_memory_get"];
const LAST_PROJECT_ID_KEY = "agent_last_project_id";
const APP_SIDEBAR_COLLAPSED_STORAGE_KEY = "lime.app-sidebar.collapsed";
function printHelp() {
console.log(`
Project Memory Current Smoke
用途:
通过真实 Agent 首页 / 工作区和 DevBridge /invoke 网络请求,验证项目记忆聚合读取走
App Server JSON-RPC current 主链,而不是 legacy project_memory_get 或 mock。脚本依赖
产品自身的默认工作区 ensure,不主动调用 workspace_create / workspace_delete。
用法:
node scripts/project-memory-current-smoke.mjs
选项:
--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> 总超时,默认 120000
--interval-ms <ms> 健康检查轮询间隔,默认 1000
--evidence-dir <path> 证据目录,默认 .lime/qc/gui-evidence/project-memory-current
--prefix <name> 证据文件前缀,默认 project-memory-current
--headed 使用有界面 Chrome
-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 === "--evidence-dir" && argv[index + 1]) {
options.evidenceDir = path.resolve(String(argv[index + 1]).trim());
index += 1;
continue;
}
if (arg === "--prefix" && argv[index + 1]) {
options.prefix = String(argv[index + 1]).trim();
index += 1;
continue;
}
if (arg === "--headed") {
options.headless = false;
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) {
throw new Error("--app-url 不能为空");
}
if (!options.healthUrl) {
throw new Error("--health-url 不能为空");
}
if (!options.invokeUrl) {
throw new Error("--invoke-url 不能为空");
}
if (!options.evidenceDir) {
throw new Error("--evidence-dir 不能为空");
}
if (!options.prefix) {
throw new Error("--prefix 不能为空");
}
return options;
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function assert(condition, message) {
if (!condition) {
throw new Error(message);
}
}
function logStage(stage) {
console.log(`[smoke:project-memory-current] stage=${stage}`);
}
async function waitForHealth(options) {
const startedAt = Date.now();
let lastError = null;
while (Date.now() - startedAt < options.timeoutMs) {
try {
const response = await fetch(options.healthUrl, {
signal: AbortSignal.timeout(Math.min(5_000, options.timeoutMs)),
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
console.log(
`[smoke:project-memory-current] DevBridge 已就绪 (${Date.now() - startedAt}ms) status=${payload?.status ?? response.status}`,
);
return payload;
} catch (error) {
lastError = error;
await sleep(options.intervalMs);
}
}
const detail =
lastError instanceof Error
? lastError.message
: String(lastError || "unknown error");
throw new Error(
`[smoke:project-memory-current] DevBridge 未就绪,请先启动 npm run electron:dev。最后错误: ${detail}`,
);
}
function normalizeUrl(value) {
try {
const parsed = new URL(value);
parsed.hash = "";
return parsed.toString();
} catch {
return value;
}
}
function requestMatchesInvoke(url, invokeUrl) {
const requestUrl = normalizeUrl(url);
const expectedUrl = normalizeUrl(invokeUrl);
return requestUrl === expectedUrl || requestUrl.endsWith("/invoke");
}
function parseJson(value) {
if (!value) {
return null;
}
try {
return JSON.parse(value);
} catch {
return null;
}
}
function parseJsonRpcLine(line) {
const trimmed = String(line || "").trim();
if (!trimmed) {
return null;
}
try {
const parsed = JSON.parse(trimmed);
return parsed && typeof parsed === "object" ? parsed : null;
} catch {
return null;
}
}
function decodeJsonRpcLines(lines) {
return Array.isArray(lines)
? lines.map(parseJsonRpcLine).filter(Boolean)
: [];
}
function sanitizeText(value) {
const sanitized = String(value ?? "")
.replace(
/((?:api[_-]?key|authorization|password|secret|session|token)[^=\s]*=)(["']?)[^\s"']+/gi,
"$1$2[redacted]",
)
.replace(/(Bearer\s+)[A-Za-z0-9._~+/=-]+/gi, "$1[redacted]");
return sanitized.length > 1_200
? `${sanitized.slice(0, 1_200)}... [truncated ${sanitized.length - 1_200} chars]`
: sanitized;
}
function sanitizeJson(value, depth = 0) {
if (depth > 5) {
return "[truncated-depth]";
}
if (typeof value === "string") {
return sanitizeText(value);
}
if (
value === null ||
value === undefined ||
typeof value === "number" ||
typeof value === "boolean"
) {
return value ?? null;
}
if (Array.isArray(value)) {
return value.slice(0, 40).map((item) => sanitizeJson(item, depth + 1));
}
if (typeof value === "object") {
return Object.fromEntries(
Object.entries(value)
.slice(0, 80)
.map(([key, item]) => [key, sanitizeJson(item, depth + 1)]),
);
}
return sanitizeText(String(value));
}
function collectInvokeEntry(requestPayload, responsePayload, requestUrl) {
const requestMessages = decodeJsonRpcLines(
requestPayload?.args?.request?.lines,
);
const responseMessages = decodeJsonRpcLines(responsePayload?.result?.lines);
return {
url: requestUrl,
cmd: requestPayload?.cmd ?? null,
appServerRequests: requestMessages
.filter((message) => typeof message?.method === "string")
.map((message) => ({
id: message.id ?? null,
method: message.method,
params: sanitizeJson(message.params ?? {}),
})),
responseMessageCount: responseMessages.length,
responseMessages: responseMessages.map(sanitizeJson),
};
}
function summarizeInvokeEntries(entries) {
const appServerRequests = entries.flatMap((entry) => entry.appServerRequests);
const appServerMethodsSeen = Array.from(
new Set(appServerRequests.map((request) => request.method)),
).sort();
const projectMemoryReadRequests = appServerRequests.filter(
(request) => request.method === "projectMemory/read",
);
const legacyProjectMemoryCommandsSeen = Array.from(
new Set(
entries
.map((entry) =>
typeof entry.cmd === "string" &&
LEGACY_PROJECT_MEMORY_COMMANDS.includes(entry.cmd)
? entry.cmd
: null,
)
.filter(Boolean),
),
).sort();
const appServerHandleJsonLinesSeen = entries.some(
(entry) => entry.cmd === APP_SERVER_HANDLE_JSON_LINES_COMMAND,
);
return {
appServerHandleJsonLinesSeen,
appServerMethodsSeen,
legacyProjectMemoryCommandsSeen,
projectMemoryReadRequestCount: projectMemoryReadRequests.length,
projectMemoryReadRequests,
missingRequiredAppServerMethods: REQUIRED_APP_SERVER_METHODS.filter(
(method) => !appServerMethodsSeen.includes(method),
),
};
}
async function waitForInvokeEvidence(entries, options) {
const startedAt = Date.now();
while (Date.now() - startedAt < Math.min(75_000, options.timeoutMs)) {
const observed = summarizeInvokeEntries(entries);
if (
observed.appServerHandleJsonLinesSeen &&
observed.missingRequiredAppServerMethods.length === 0 &&
observed.projectMemoryReadRequestCount >= 1 &&
observed.legacyProjectMemoryCommandsSeen.length === 0
) {
return observed;
}
await sleep(250);
}
return summarizeInvokeEntries(entries);
}
function resolveLocalChromeExecutablePath() {
const candidates = [
process.env.PLAYWRIGHT_CHROME_EXECUTABLE,
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
"/Applications/Chromium.app/Contents/MacOS/Chromium",
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
].filter(Boolean);
return candidates.find((candidate) => fs.existsSync(candidate)) ?? undefined;
}
function writeJsonFile(filePath, value) {
fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`);
}
async function primeAgentHomeStorage(page, options) {
await page.addInitScript(
({ collapsedKey, lastProjectKey }) => {
localStorage.removeItem(lastProjectKey);
localStorage.setItem(collapsedKey, "false");
},
{
collapsedKey: APP_SIDEBAR_COLLAPSED_STORAGE_KEY,
lastProjectKey: LAST_PROJECT_ID_KEY,
},
);
}
async function openAgentHome(page, options) {
await primeAgentHomeStorage(page, options);
await page.goto(options.appUrl, {
waitUntil: "domcontentloaded",
timeout: options.timeoutMs,
});
await page.waitForSelector('[data-testid="workspace-shell-scene"]', {
timeout: options.timeoutMs,
});
await page.waitForSelector('[data-testid="home-start-surface"]', {
timeout: options.timeoutMs,
});
}
async function run() {
const options = parseArgs(process.argv.slice(2));
fs.mkdirSync(options.evidenceDir, { recursive: true });
const summaryPath = path.join(
options.evidenceDir,
`${options.prefix}-summary.json`,
);
const networkPath = path.join(
options.evidenceDir,
`${options.prefix}-network-invoke.json`,
);
const screenshotPath = path.join(
options.evidenceDir,
`${options.prefix}.png`,
);
const failureScreenshotPath = path.join(
options.evidenceDir,
`${options.prefix}-failure.png`,
);
let browser = null;
let tmpProfileDir = null;
const consoleErrors = [];
const failedRequests = [];
const invokeEntries = [];
const summary = {
ok: false,
checkedAt: new Date().toISOString(),
appUrl: options.appUrl,
healthUrl: options.healthUrl,
invokeUrl: options.invokeUrl,
health: null,
agentWorkspaceMounted: false,
homeStartSurfaceMounted: false,
rememberedProjectId: null,
appServerHandleJsonLinesSeen: false,
appServerMethodsSeen: [],
legacyProjectMemoryCommandsSeen: [],
projectMemoryReadRequestCount: 0,
projectMemoryReadRequests: [],
missingRequiredAppServerMethods: [...REQUIRED_APP_SERVER_METHODS],
consoleErrors: [],
failedRequests: [],
screenshot: null,
network: networkPath,
summary: summaryPath,
};
try {
logStage("wait-health");
summary.health = await waitForHealth(options);
logStage("launch-browser");
tmpProfileDir = fs.mkdtempSync(
path.join(os.tmpdir(), "project-memory-current-"),
);
browser = await chromium.launchPersistentContext(tmpProfileDir, {
headless: options.headless,
viewport: { width: 1440, height: 1000 },
executablePath: resolveLocalChromeExecutablePath(),
});
const page = await browser.newPage();
page.on("console", (message) => {
if (message.type() === "error") {
consoleErrors.push(sanitizeText(message.text()));
}
});
page.on("requestfailed", (request) => {
failedRequests.push({
url: sanitizeText(request.url()),
method: request.method(),
failure: sanitizeText(request.failure()?.errorText || "unknown"),
});
});
page.on("response", async (response) => {
try {
const request = response.request();
if (
request.method() !== "POST" ||
!requestMatchesInvoke(request.url(), options.invokeUrl)
) {
return;
}
const requestPayload = parseJson(request.postData() || "");
const responsePayload = parseJson(await response.text());
invokeEntries.push(
collectInvokeEntry(requestPayload, responsePayload, request.url()),
);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (message.includes("No resource with given identifier found")) {
return;
}
console.warn(
"[smoke:project-memory-current] 解析 invoke 网络证据失败:",
message,
);
}
});
logStage("open-agent-home");
await openAgentHome(page, options);
const observed = await waitForInvokeEvidence(invokeEntries, options);
Object.assign(summary, observed);
summary.agentWorkspaceMounted =
(await page.locator('[data-testid="workspace-shell-scene"]').count()) > 0;
summary.homeStartSurfaceMounted =
(await page.locator('[data-testid="home-start-surface"]').count()) > 0;
summary.rememberedProjectId = await page.evaluate((lastProjectKey) => {
const raw = localStorage.getItem(lastProjectKey);
if (!raw) {
return null;
}
try {
const parsed = JSON.parse(raw);
return typeof parsed === "string" ? parsed : raw;
} catch {
return raw;
}
}, LAST_PROJECT_ID_KEY);
summary.consoleErrors = consoleErrors;
summary.failedRequests = failedRequests.slice(0, 20);
summary.screenshot = screenshotPath;
await page.screenshot({ path: screenshotPath, fullPage: true });
writeJsonFile(networkPath, {
entries: invokeEntries,
summary: observed,
});
assert(summary.agentWorkspaceMounted, "Agent 工作区未挂载");
assert(summary.homeStartSurfaceMounted, "Agent 首页输入面未挂载");
assert(
summary.appServerHandleJsonLinesSeen,
"未观察到 app_server_handle_json_lines",
);
assert(
summary.missingRequiredAppServerMethods.length === 0,
`缺少 App Server JSON-RPC 方法: ${summary.missingRequiredAppServerMethods.join(", ")}`,
);
assert(
summary.projectMemoryReadRequestCount >= 1,
`projectMemory/read 请求不足,实际 ${summary.projectMemoryReadRequestCount}`,
);
assert(
summary.legacyProjectMemoryCommandsSeen.length === 0,
`观察到 legacy project memory 命令: ${summary.legacyProjectMemoryCommandsSeen.join(", ")}`,
);
summary.ok = true;
writeJsonFile(summaryPath, summary);
console.log(`[smoke:project-memory-current] summary=${summaryPath}`);
console.log(`[smoke:project-memory-current] screenshot=${screenshotPath}`);
} catch (error) {
summary.error = error instanceof Error ? error.message : String(error);
summary.consoleErrors = consoleErrors;
summary.failedRequests = failedRequests.slice(0, 20);
const observed = summarizeInvokeEntries(invokeEntries);
Object.assign(summary, observed);
writeJsonFile(networkPath, {
entries: invokeEntries,
summary: observed,
});
writeJsonFile(summaryPath, summary);
try {
const pages = browser?.pages?.() ?? [];
const page = pages.at(-1);
if (page) {
await page.screenshot({
path: failureScreenshotPath,
fullPage: true,
});
summary.screenshot = failureScreenshotPath;
writeJsonFile(summaryPath, summary);
}
} catch {
// 失败截图只是诊断证据,不能阻断错误上抛。
}
console.error(`[smoke:project-memory-current] summary=${summaryPath}`);
throw error;
} finally {
if (browser) {
await browser.close().catch(() => {});
}
if (tmpProfileDir) {
fs.rmSync(tmpProfileDir, { recursive: true, force: true });
}
}
}
run().catch((error) => {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
});