mirror of
https://github.com/aiclientproxy/proxycast.git
synced 2026-09-24 23:10:56 +08:00
release: v1.12.0
This commit is contained in:
@@ -13,12 +13,16 @@ const DEFAULTS = {
|
||||
const INVOKE_TIMEOUT_CEILING_MS = 180_000;
|
||||
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 POST_HEALTH_SETTLE_MS = 1_500;
|
||||
const POST_CONFIG_SETTLE_MS = 1_000;
|
||||
const POST_LAUNCH_SETTLE_MS = 1_500;
|
||||
const DEFAULT_ACTION_TIMEOUT_MS = 15_000;
|
||||
const DEFAULT_ACTION_TIMEOUT_MS = 45_000;
|
||||
const ONBOARDING_VERSION = "1.1.0";
|
||||
const PROMPT_TEXT = "请回复一句:smoke harness";
|
||||
const SMOKE_PROFILE_KEY = "smoke-agent-runtime-tool-surface-page";
|
||||
const WORKSPACE_HARNESS_DEBUG_OVERRIDE_KEY =
|
||||
"lime:debug:workspace-harness-enabled:v1";
|
||||
const RUNTIME_TOOL_AVAILABILITY_OVERRIDE = {
|
||||
known: true,
|
||||
agentInitialized: true,
|
||||
@@ -133,6 +137,10 @@ function assert(condition, message) {
|
||||
}
|
||||
}
|
||||
|
||||
function logStage(label) {
|
||||
console.log(`[smoke:agent-runtime-tool-surface-page] stage=${label}`);
|
||||
}
|
||||
|
||||
function deepClone(value) {
|
||||
return JSON.parse(JSON.stringify(value));
|
||||
}
|
||||
@@ -193,6 +201,20 @@ async function invoke(options, cmd, args) {
|
||||
);
|
||||
}
|
||||
|
||||
async function closeSmokeProfileSession(options, profileKey, label) {
|
||||
try {
|
||||
await invoke(options, "close_chrome_profile_session", {
|
||||
profile_key: profileKey,
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`[smoke:agent-runtime-tool-surface-page] ${label}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForHealth(options) {
|
||||
const startedAt = Date.now();
|
||||
let lastError = null;
|
||||
@@ -230,6 +252,10 @@ function buildHarnessBootstrapScript() {
|
||||
localStorage.setItem("lime_onboarding_complete", "true");
|
||||
localStorage.setItem("lime_onboarding_version", ${JSON.stringify(ONBOARDING_VERSION)});
|
||||
localStorage.setItem("lime_user_profile", "developer");
|
||||
localStorage.setItem(
|
||||
${JSON.stringify(WORKSPACE_HARNESS_DEBUG_OVERRIDE_KEY)},
|
||||
"true"
|
||||
);
|
||||
localStorage.setItem("lime.chat.harness-panel.visible.v1", "true");
|
||||
localStorage.setItem(
|
||||
"lime:debug:runtime-tool-availability:v1",
|
||||
@@ -239,17 +265,53 @@ function buildHarnessBootstrapScript() {
|
||||
})()`;
|
||||
}
|
||||
|
||||
function buildFillPromptAndSendScript(prompt) {
|
||||
function buildPageStorageReadyScript(appUrl) {
|
||||
return `(() => {
|
||||
try {
|
||||
const href = window.location.href;
|
||||
const readyState = document.readyState;
|
||||
void window.localStorage;
|
||||
return {
|
||||
ok: href.startsWith(${JSON.stringify(appUrl)}) && readyState !== "loading",
|
||||
href,
|
||||
readyState,
|
||||
title: document.title,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
href: window.location.href,
|
||||
readyState: document.readyState,
|
||||
title: document.title,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
})()`;
|
||||
}
|
||||
|
||||
function buildFillPromptScript(prompt) {
|
||||
return `(() => {
|
||||
const collectButtons = () =>
|
||||
Array.from(document.querySelectorAll("button")).map((button) => ({
|
||||
text: (button.textContent || "").trim(),
|
||||
aria: button.getAttribute("aria-label"),
|
||||
disabled: Boolean(button.disabled),
|
||||
}));
|
||||
const textarea = document.querySelector('textarea[placeholder="有什么我可以帮你的?"]');
|
||||
const send = document.querySelector('button[aria-label="发送"]');
|
||||
if (!textarea || !send) {
|
||||
return { ok: false, reason: "missing-input-or-send" };
|
||||
const initialSend = document.querySelector('button[aria-label="发送"]');
|
||||
if (!textarea || !initialSend) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: "missing-input-or-send",
|
||||
buttons: collectButtons(),
|
||||
};
|
||||
}
|
||||
const setter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, "value")?.set;
|
||||
if (!setter) {
|
||||
return { ok: false, reason: "missing-native-textarea-setter" };
|
||||
}
|
||||
|
||||
textarea.focus();
|
||||
setter.call(textarea, ${JSON.stringify(prompt)});
|
||||
textarea.dispatchEvent(new InputEvent("input", {
|
||||
bubbles: true,
|
||||
@@ -257,10 +319,33 @@ function buildFillPromptAndSendScript(prompt) {
|
||||
inputType: "insertText",
|
||||
}));
|
||||
textarea.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
|
||||
const currentTextarea =
|
||||
document.querySelector('textarea[placeholder="有什么我可以帮你的?"]') ||
|
||||
textarea;
|
||||
const currentSend = document.querySelector('button[aria-label="发送"]');
|
||||
return {
|
||||
ok: true,
|
||||
value: textarea.value,
|
||||
sendDisabled: Boolean(send.disabled),
|
||||
value: currentTextarea?.value ?? "",
|
||||
sendDisabled: Boolean(currentSend?.disabled),
|
||||
buttons: collectButtons(),
|
||||
};
|
||||
})()`;
|
||||
}
|
||||
|
||||
function buildSendReadyScript() {
|
||||
return `(() => {
|
||||
const textarea = document.querySelector('textarea[placeholder="有什么我可以帮你的?"]');
|
||||
const send = document.querySelector('button[aria-label="发送"]');
|
||||
return {
|
||||
ok:
|
||||
Boolean(textarea) &&
|
||||
typeof textarea?.value === "string" &&
|
||||
textarea.value.trim().length > 0 &&
|
||||
Boolean(send) &&
|
||||
send.disabled === false,
|
||||
value: textarea?.value ?? "",
|
||||
sendDisabled: Boolean(send?.disabled),
|
||||
};
|
||||
})()`;
|
||||
}
|
||||
@@ -269,21 +354,50 @@ function buildClickSendScript() {
|
||||
return `(() => {
|
||||
const send = document.querySelector('button[aria-label="发送"]');
|
||||
if (!send) {
|
||||
return { ok: false, reason: "missing-send-button" };
|
||||
return {
|
||||
ok: false,
|
||||
reason: "missing-send-button",
|
||||
};
|
||||
}
|
||||
send.click();
|
||||
if (send.disabled) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: "send-disabled",
|
||||
};
|
||||
}
|
||||
|
||||
send.dispatchEvent(new MouseEvent("mousedown", {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
view: window,
|
||||
}));
|
||||
send.dispatchEvent(new MouseEvent("mouseup", {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
view: window,
|
||||
}));
|
||||
send.dispatchEvent(new MouseEvent("click", {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
view: window,
|
||||
}));
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
disabled: Boolean(send.disabled),
|
||||
ariaExpanded: send.getAttribute("aria-expanded"),
|
||||
submitted: true,
|
||||
};
|
||||
})()`;
|
||||
}
|
||||
|
||||
function buildOpenWorkbenchScript() {
|
||||
return `(() => {
|
||||
const bodyText = document.body ? document.body.innerText : "";
|
||||
const target = Array.from(document.querySelectorAll("button")).find(
|
||||
(button) => (button.textContent || "").trim() === "工作台",
|
||||
(button) =>
|
||||
((button.textContent || "").trim() === "工作台" ||
|
||||
(button.getAttribute("aria-label") || "").includes("工作台") ||
|
||||
(button.getAttribute("title") || "").includes("工作台")) &&
|
||||
button instanceof HTMLButtonElement,
|
||||
);
|
||||
if (!target) {
|
||||
return {
|
||||
@@ -294,10 +408,28 @@ function buildOpenWorkbenchScript() {
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
const alreadyOpen =
|
||||
bodyText.includes("处理工作台") ||
|
||||
target.getAttribute("aria-expanded") === "true" ||
|
||||
(target.getAttribute("aria-label") || "").includes("收起工作台") ||
|
||||
(target.getAttribute("title") || "").includes("收起工作台");
|
||||
|
||||
if (alreadyOpen) {
|
||||
return {
|
||||
ok: true,
|
||||
alreadyOpen: true,
|
||||
ariaExpanded: target.getAttribute("aria-expanded"),
|
||||
ariaLabel: target.getAttribute("aria-label"),
|
||||
};
|
||||
}
|
||||
|
||||
target.click();
|
||||
return {
|
||||
ok: true,
|
||||
alreadyOpen: false,
|
||||
ariaExpanded: target.getAttribute("aria-expanded"),
|
||||
ariaLabel: target.getAttribute("aria-label"),
|
||||
};
|
||||
})()`;
|
||||
}
|
||||
@@ -321,30 +453,72 @@ function buildRuntimeSummaryCheckScript() {
|
||||
|
||||
function extractJavascriptValue(actionResult) {
|
||||
return (
|
||||
actionResult?.data?.result?.result?.value ??
|
||||
actionResult?.data?.result ??
|
||||
actionResult?.data?.value ??
|
||||
actionResult?.data?.result?.result ??
|
||||
actionResult?.data?.result?.value ??
|
||||
actionResult?.data?.result ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
async function runBrowserAction(options, profileKey, action, args = {}) {
|
||||
return invoke(options, "browser_execute_action", {
|
||||
request: {
|
||||
profile_key: profileKey,
|
||||
backend: "cdp_direct",
|
||||
action,
|
||||
args,
|
||||
timeout_ms: DEFAULT_ACTION_TIMEOUT_MS,
|
||||
},
|
||||
});
|
||||
function isRetryableBrowserActionFailure(detail) {
|
||||
return (
|
||||
typeof detail === "string" &&
|
||||
(detail.includes("CDP 调试端口不可用") ||
|
||||
detail.includes("没有可用的 Chrome 会话"))
|
||||
);
|
||||
}
|
||||
|
||||
async function runJavascript(options, profileKey, expression) {
|
||||
const result = await runBrowserAction(options, profileKey, "javascript", {
|
||||
expression,
|
||||
return_by_value: true,
|
||||
});
|
||||
async function runBrowserAction(options, profileKey, action, args = {}, label = action) {
|
||||
for (let attempt = 1; attempt <= BROWSER_ACTION_RETRY_COUNT; attempt += 1) {
|
||||
const result = await invoke(options, "browser_execute_action", {
|
||||
request: {
|
||||
profile_key: profileKey,
|
||||
backend: "cdp_direct",
|
||||
action,
|
||||
args,
|
||||
timeout_ms: DEFAULT_ACTION_TIMEOUT_MS,
|
||||
},
|
||||
});
|
||||
|
||||
if (result?.success === true) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const detail = String(result?.error || JSON.stringify(result ?? null));
|
||||
if (
|
||||
isRetryableBrowserActionFailure(detail) &&
|
||||
attempt < BROWSER_ACTION_RETRY_COUNT
|
||||
) {
|
||||
console.warn(
|
||||
`[smoke:agent-runtime-tool-surface-page] browser_execute_action(${label}) 第 ${attempt} 次失败,${BROWSER_ACTION_RETRY_DELAY_MS}ms 后重试: ${detail}`,
|
||||
);
|
||||
await sleep(BROWSER_ACTION_RETRY_DELAY_MS);
|
||||
continue;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`[smoke:agent-runtime-tool-surface-page] browser_execute_action(${label}) 失败: ${detail}`,
|
||||
);
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`[smoke:agent-runtime-tool-surface-page] browser_execute_action(${label}) 失败: unknown error`,
|
||||
);
|
||||
}
|
||||
|
||||
async function runJavascript(options, profileKey, expression, label = "javascript") {
|
||||
const result = await runBrowserAction(
|
||||
options,
|
||||
profileKey,
|
||||
"javascript",
|
||||
{
|
||||
expression,
|
||||
return_by_value: true,
|
||||
},
|
||||
`javascript:${label}`,
|
||||
);
|
||||
return extractJavascriptValue(result);
|
||||
}
|
||||
|
||||
@@ -372,51 +546,36 @@ async function waitForCheck(options, label, check) {
|
||||
);
|
||||
}
|
||||
|
||||
async function ensureHarnessEnabled(options) {
|
||||
const originalConfig = await invoke(options, "get_config");
|
||||
const enabled =
|
||||
originalConfig?.developer?.workspace_harness_enabled === true;
|
||||
if (enabled) {
|
||||
return {
|
||||
originalConfig,
|
||||
changed: false,
|
||||
};
|
||||
}
|
||||
|
||||
const nextConfig = deepClone(originalConfig);
|
||||
nextConfig.developer = {
|
||||
...(nextConfig.developer || {}),
|
||||
workspace_harness_enabled: true,
|
||||
};
|
||||
await invoke(options, "save_config", nextConfig);
|
||||
await sleep(POST_CONFIG_SETTLE_MS);
|
||||
return {
|
||||
originalConfig,
|
||||
changed: true,
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
if (typeof fetch !== "function") {
|
||||
throw new Error("当前 Node 运行时不支持 fetch,请使用 Node 18+");
|
||||
}
|
||||
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
logStage("wait-health");
|
||||
await waitForHealth(options);
|
||||
await sleep(POST_HEALTH_SETTLE_MS);
|
||||
|
||||
const { originalConfig, changed } = await ensureHarnessEnabled(options);
|
||||
const profileKey = `smoke-agent-runtime-tool-surface-page-${Date.now()}`;
|
||||
const profileKey = SMOKE_PROFILE_KEY;
|
||||
let sessionId = null;
|
||||
|
||||
try {
|
||||
logStage("cleanup-old-profile");
|
||||
await closeSmokeProfileSession(
|
||||
options,
|
||||
profileKey,
|
||||
"预清理旧 smoke profile 失败",
|
||||
);
|
||||
|
||||
logStage("launch-browser-session");
|
||||
const launchResponse = await invoke(options, "launch_browser_session", {
|
||||
request: {
|
||||
profile_key: profileKey,
|
||||
url: options.appUrl,
|
||||
headless: true,
|
||||
open_window: false,
|
||||
stream_mode: "both",
|
||||
// 真实 Lime 页面在 cdp_direct + frames/both 下会持续产出 frame 流,
|
||||
// 这里会把后续 Runtime.evaluate 挤到超时;页面 smoke 只需要事件流即可。
|
||||
stream_mode: "events",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -427,14 +586,37 @@ async function main() {
|
||||
);
|
||||
await sleep(POST_LAUNCH_SETTLE_MS);
|
||||
|
||||
await runJavascript(options, profileKey, buildHarnessBootstrapScript());
|
||||
logStage("wait-page-storage-ready");
|
||||
await waitForCheck(options, "Lime 首页 origin 可访问", async () => {
|
||||
const value = await runJavascript(
|
||||
options,
|
||||
profileKey,
|
||||
buildPageStorageReadyScript(options.appUrl),
|
||||
"wait-page-storage-ready",
|
||||
);
|
||||
return {
|
||||
ok: value?.ok === true,
|
||||
value,
|
||||
};
|
||||
});
|
||||
|
||||
logStage("bootstrap-harness-storage");
|
||||
await runJavascript(
|
||||
options,
|
||||
profileKey,
|
||||
buildHarnessBootstrapScript(),
|
||||
"bootstrap-harness-storage",
|
||||
);
|
||||
logStage("refresh-page");
|
||||
await runBrowserAction(options, profileKey, "refresh_page");
|
||||
|
||||
logStage("wait-empty-state");
|
||||
await waitForCheck(options, "首页空态加载", async () => {
|
||||
const text = await runJavascript(
|
||||
options,
|
||||
profileKey,
|
||||
'document.body ? document.body.innerText : ""',
|
||||
"wait-empty-state-text",
|
||||
);
|
||||
return {
|
||||
ok:
|
||||
@@ -445,32 +627,55 @@ async function main() {
|
||||
};
|
||||
});
|
||||
|
||||
const prepared = await runJavascript(
|
||||
logStage("fill-prompt");
|
||||
const filled = await runJavascript(
|
||||
options,
|
||||
profileKey,
|
||||
buildFillPromptAndSendScript(PROMPT_TEXT),
|
||||
buildFillPromptScript(PROMPT_TEXT),
|
||||
"fill-prompt",
|
||||
);
|
||||
assert(
|
||||
prepared?.ok === true,
|
||||
`准备输入失败: ${JSON.stringify(prepared ?? null)}`,
|
||||
filled?.ok === true,
|
||||
`准备输入失败: ${JSON.stringify(filled ?? null)}`,
|
||||
);
|
||||
assert(prepared?.sendDisabled === false, "发送按钮仍处于禁用状态");
|
||||
|
||||
const sendResult = await runJavascript(
|
||||
logStage("wait-send-ready");
|
||||
const sendReady = await waitForCheck(options, "发送按钮可用", async () => {
|
||||
const value = await runJavascript(
|
||||
options,
|
||||
profileKey,
|
||||
buildSendReadyScript(),
|
||||
"wait-send-ready",
|
||||
);
|
||||
return {
|
||||
ok: value?.ok === true,
|
||||
value,
|
||||
};
|
||||
});
|
||||
assert(
|
||||
sendReady?.ok === true,
|
||||
`发送按钮未就绪: ${JSON.stringify(sendReady ?? null)}`,
|
||||
);
|
||||
|
||||
logStage("click-send");
|
||||
const submitted = await runJavascript(
|
||||
options,
|
||||
profileKey,
|
||||
buildClickSendScript(),
|
||||
"click-send",
|
||||
);
|
||||
assert(
|
||||
sendResult?.ok === true,
|
||||
`发送最小请求失败: ${JSON.stringify(sendResult ?? null)}`,
|
||||
submitted?.ok === true,
|
||||
`提交输入失败: ${JSON.stringify(submitted ?? null)}`,
|
||||
);
|
||||
|
||||
logStage("wait-workbench-button");
|
||||
await waitForCheck(options, "运行态工作台按钮出现", async () => {
|
||||
const text = await runJavascript(
|
||||
options,
|
||||
profileKey,
|
||||
'document.body ? document.body.innerText : ""',
|
||||
"wait-workbench-button",
|
||||
);
|
||||
return {
|
||||
ok: typeof text === "string" && text.includes("工作台"),
|
||||
@@ -478,16 +683,19 @@ async function main() {
|
||||
};
|
||||
});
|
||||
|
||||
logStage("open-workbench");
|
||||
const openWorkbench = await runJavascript(
|
||||
options,
|
||||
profileKey,
|
||||
buildOpenWorkbenchScript(),
|
||||
"open-workbench",
|
||||
);
|
||||
assert(
|
||||
openWorkbench?.ok === true,
|
||||
`打开工作台失败: ${JSON.stringify(openWorkbench ?? null)}`,
|
||||
);
|
||||
|
||||
logStage("wait-runtime-summary");
|
||||
const summaryFlags = await waitForCheck(
|
||||
options,
|
||||
"Runtime 能力摘要出现",
|
||||
@@ -496,6 +704,7 @@ async function main() {
|
||||
options,
|
||||
profileKey,
|
||||
buildRuntimeSummaryCheckScript(),
|
||||
"check-runtime-summary",
|
||||
);
|
||||
const hasAllRequired = REQUIRED_RUNTIME_SUMMARY_FLAGS.every(
|
||||
(key) => value?.[key] === true,
|
||||
@@ -509,6 +718,7 @@ async function main() {
|
||||
},
|
||||
);
|
||||
|
||||
logStage("read-page-markdown");
|
||||
const pageMarkdown = await readPageMarkdown(options, profileKey);
|
||||
for (const warning of FORBIDDEN_PAGE_WARNINGS) {
|
||||
assert(
|
||||
@@ -525,6 +735,7 @@ async function main() {
|
||||
);
|
||||
} finally {
|
||||
if (sessionId) {
|
||||
logStage("close-cdp-session");
|
||||
try {
|
||||
await invoke(options, "close_cdp_session", {
|
||||
request: {
|
||||
@@ -540,17 +751,12 @@ async function main() {
|
||||
}
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
try {
|
||||
await invoke(options, "save_config", originalConfig);
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`[smoke:agent-runtime-tool-surface-page] 恢复 developer.workspace_harness_enabled 失败: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
logStage("close-profile-session");
|
||||
await closeSmokeProfileSession(
|
||||
options,
|
||||
profileKey,
|
||||
"关闭 smoke profile 失败",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ function runVitest(label, args) {
|
||||
}
|
||||
|
||||
function main() {
|
||||
runVitest("runtime tool surface 派生与页级提示", [
|
||||
runVitest("runtime tool surface 派生与应用层透传", [
|
||||
"src/components/agent/chat/utils/runtimeToolAvailability.test.ts",
|
||||
"src/components/agent/chat/components/AgentRuntimeStrip.test.tsx",
|
||||
"src/components/agent/chat/components/EmptyState.test.tsx",
|
||||
|
||||
@@ -19,6 +19,7 @@ const INVOKE_RETRY_DELAY_MS = 1_000;
|
||||
const POST_HEALTH_SETTLE_MS = 3_000;
|
||||
const POST_LAUNCH_SETTLE_MS = 1_500;
|
||||
const READ_PAGE_TIMEOUT_MS = 45_000;
|
||||
const SMOKE_PROFILE_KEY = "smoke-browser-runtime";
|
||||
|
||||
function printHelp() {
|
||||
console.log(`
|
||||
@@ -170,6 +171,20 @@ async function invoke(options, cmd, args) {
|
||||
throw new Error(`[smoke:browser-runtime] ${cmd} 请求失败: unknown error`);
|
||||
}
|
||||
|
||||
async function closeSmokeProfileSession(options, profileKey, label) {
|
||||
try {
|
||||
await invoke(options, "close_chrome_profile_session", {
|
||||
profile_key: profileKey,
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`[smoke:browser-runtime] ${label}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForHealth(options) {
|
||||
const startedAt = Date.now();
|
||||
let lastError = null;
|
||||
@@ -215,10 +230,16 @@ async function main() {
|
||||
await waitForHealth(options);
|
||||
await sleep(POST_HEALTH_SETTLE_MS);
|
||||
|
||||
const profileKey = `smoke-browser-runtime-${Date.now()}`;
|
||||
const profileKey = SMOKE_PROFILE_KEY;
|
||||
let sessionId = null;
|
||||
|
||||
try {
|
||||
await closeSmokeProfileSession(
|
||||
options,
|
||||
profileKey,
|
||||
"预清理旧 smoke profile 失败",
|
||||
);
|
||||
|
||||
const launchResponse = await invoke(options, "launch_browser_session", {
|
||||
request: {
|
||||
profile_key: profileKey,
|
||||
@@ -332,6 +353,12 @@ async function main() {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await closeSmokeProfileSession(
|
||||
options,
|
||||
profileKey,
|
||||
"关闭 smoke profile 失败",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,8 @@ const DEFAULTS = {
|
||||
intervalMs: 1_000,
|
||||
invokeTimeoutMs: 20_000,
|
||||
};
|
||||
const INVOKE_RETRY_COUNT = 3;
|
||||
const INVOKE_RETRY_DELAY_MS = 1_000;
|
||||
|
||||
function printHelp() {
|
||||
console.log(`
|
||||
@@ -126,35 +128,49 @@ async function waitForHealth(options) {
|
||||
|
||||
async function invoke(options, cmd, args) {
|
||||
console.log(`[smoke:site-adapters] invoke ${cmd}`);
|
||||
let response;
|
||||
try {
|
||||
response = await fetch(options.invokeUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ cmd, args }),
|
||||
signal: AbortSignal.timeout(options.invokeTimeoutMs),
|
||||
});
|
||||
} catch (error) {
|
||||
if (error?.name === "TimeoutError") {
|
||||
throw new Error(
|
||||
`[smoke:site-adapters] ${cmd} 超时,${options.invokeTimeoutMs}ms 内未收到 DevBridge 响应`,
|
||||
);
|
||||
for (let attempt = 1; attempt <= INVOKE_RETRY_COUNT; attempt += 1) {
|
||||
let response;
|
||||
try {
|
||||
response = await fetch(options.invokeUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ cmd, args }),
|
||||
signal: AbortSignal.timeout(options.invokeTimeoutMs),
|
||||
});
|
||||
} catch (error) {
|
||||
const isTimeout = error?.name === "TimeoutError";
|
||||
const isFetchFailed =
|
||||
error instanceof TypeError && error.message === "fetch failed";
|
||||
if ((isTimeout || isFetchFailed) && attempt < INVOKE_RETRY_COUNT) {
|
||||
console.warn(
|
||||
`[smoke:site-adapters] ${cmd} 第 ${attempt} 次请求失败,${INVOKE_RETRY_DELAY_MS}ms 后重试: ${
|
||||
isTimeout ? "timeout" : error.message
|
||||
}`,
|
||||
);
|
||||
await sleep(INVOKE_RETRY_DELAY_MS);
|
||||
continue;
|
||||
}
|
||||
if (isTimeout) {
|
||||
throw new Error(
|
||||
`[smoke:site-adapters] ${cmd} 超时,${options.invokeTimeoutMs}ms 内未收到 DevBridge 响应`,
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
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));
|
||||
}
|
||||
const payload = await response.json();
|
||||
if (payload?.error) {
|
||||
throw new Error(String(payload.error));
|
||||
}
|
||||
|
||||
return payload?.result;
|
||||
return payload?.result;
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
|
||||
+218
-13
@@ -16,6 +16,7 @@ const LIME_DISABLE_SINGLE_INSTANCE = "LIME_DISABLE_SINGLE_INSTANCE";
|
||||
const LIME_WEB_BRIDGE_REUSE_EXISTING_ONLY =
|
||||
"LIME_WEB_BRIDGE_REUSE_EXISTING_ONLY";
|
||||
const LIME_WEB_BRIDGE_URL = "LIME_WEB_BRIDGE_URL";
|
||||
const DEFAULT_HEALTH_URL = "http://127.0.0.1:3030/health";
|
||||
const ROOT_MARKERS = ["<title>Lime</title>", '<div id="root"></div>'];
|
||||
const SHARED_TAURI_TARGET_DIR = path.join(rootDir, "src-tauri", "target");
|
||||
const ISOLATED_GUI_SMOKE_TARGET_DIR = path.join(
|
||||
@@ -29,6 +30,10 @@ const GUI_SMOKE_BRIDGE_HEARTBEAT_MS = 30_000;
|
||||
const GUI_SMOKE_COMPILE_GRACE_MS = 900_000;
|
||||
const GUI_SMOKE_MAX_COMPILE_GRACE_EXTENSIONS = 2;
|
||||
const GUI_SMOKE_BOOT_GRACE_MS = 60_000;
|
||||
const GUI_SMOKE_CHILD_EXIT_GRACE_MS = 30_000;
|
||||
const INVOKE_TIMEOUT_CEILING_MS = 180_000;
|
||||
const INVOKE_RETRY_COUNT = 10;
|
||||
const INVOKE_RETRY_DELAY_MS = 1_000;
|
||||
const HEADLESS_TAURI_CONFIG_PATH = path.join(
|
||||
rootDir,
|
||||
"src-tauri",
|
||||
@@ -95,7 +100,8 @@ function resolveDefaultTimeoutMs(cargoTargetDir) {
|
||||
|
||||
const DEFAULTS = {
|
||||
appUrl: "http://127.0.0.1:1420/",
|
||||
healthUrl: "http://127.0.0.1:3030/health",
|
||||
healthUrl: DEFAULT_HEALTH_URL,
|
||||
invokeUrl: "http://127.0.0.1:3030/invoke",
|
||||
cargoTargetDir: resolvePreferredCargoTargetDir(),
|
||||
intervalMs: 1_000,
|
||||
reuseRunning: false,
|
||||
@@ -103,6 +109,18 @@ const DEFAULTS = {
|
||||
};
|
||||
DEFAULTS.timeoutMs = resolveDefaultTimeoutMs(DEFAULTS.cargoTargetDir);
|
||||
|
||||
function resolveInvokeUrl(healthUrl) {
|
||||
try {
|
||||
const url = new URL(healthUrl);
|
||||
url.pathname = "/invoke";
|
||||
url.search = "";
|
||||
url.hash = "";
|
||||
return url.toString();
|
||||
} catch {
|
||||
return "http://127.0.0.1:3030/invoke";
|
||||
}
|
||||
}
|
||||
|
||||
function printHelp() {
|
||||
console.log(`
|
||||
Lime GUI 冒烟入口
|
||||
@@ -118,6 +136,7 @@ Lime 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 地址,默认随 health-url 推导 /invoke
|
||||
--timeout-ms <ms> 等待 headless / bridge / smoke 的超时,默认冷启动 1800000 / 热启动 600000
|
||||
--interval-ms <ms> 轮询间隔,默认 1000
|
||||
--sample-project-name <s> workspace 路径校验使用的示例项目名
|
||||
@@ -129,6 +148,7 @@ Lime GUI 冒烟入口
|
||||
|
||||
function parseArgs(argv) {
|
||||
const options = { ...DEFAULTS };
|
||||
let invokeUrlExplicit = false;
|
||||
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
@@ -145,6 +165,13 @@ function parseArgs(argv) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === "--invoke-url" && argv[index + 1]) {
|
||||
options.invokeUrl = String(argv[index + 1]).trim();
|
||||
invokeUrlExplicit = true;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === "--timeout-ms" && argv[index + 1]) {
|
||||
options.timeoutMs = Number(argv[index + 1]);
|
||||
index += 1;
|
||||
@@ -196,6 +223,13 @@ function parseArgs(argv) {
|
||||
throw new Error("--health-url 不能为空");
|
||||
}
|
||||
|
||||
if (!invokeUrlExplicit) {
|
||||
options.invokeUrl = resolveInvokeUrl(options.healthUrl);
|
||||
}
|
||||
if (!options.invokeUrl) {
|
||||
throw new Error("--invoke-url 不能为空");
|
||||
}
|
||||
|
||||
if (!options.sampleProjectName) {
|
||||
throw new Error("--sample-project-name 不能为空");
|
||||
}
|
||||
@@ -211,6 +245,57 @@ function sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function isTransientInvokeError(error) {
|
||||
return (
|
||||
error?.name === "TimeoutError" ||
|
||||
(error instanceof TypeError && error.message === "fetch failed")
|
||||
);
|
||||
}
|
||||
|
||||
async function invokeBridgeCommand(options, cmd, args) {
|
||||
const invokeTimeoutMs = Math.min(options.timeoutMs, INVOKE_TIMEOUT_CEILING_MS);
|
||||
const requestInit = {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ cmd, args }),
|
||||
signal: AbortSignal.timeout(invokeTimeoutMs),
|
||||
};
|
||||
|
||||
for (let attempt = 1; attempt <= INVOKE_RETRY_COUNT; attempt += 1) {
|
||||
try {
|
||||
const response = await fetch(options.invokeUrl, requestInit);
|
||||
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(
|
||||
`[verify:gui-smoke] ${cmd} 超时,${invokeTimeoutMs}ms 内未收到 DevBridge 响应`,
|
||||
);
|
||||
}
|
||||
throw new Error(`[verify:gui-smoke] ${cmd} 请求失败: ${detail}`);
|
||||
}
|
||||
|
||||
console.warn(
|
||||
`[verify:gui-smoke] ${cmd} 第 ${attempt} 次请求失败,${INVOKE_RETRY_DELAY_MS}ms 后重试: ${detail}`,
|
||||
);
|
||||
await sleep(INVOKE_RETRY_DELAY_MS);
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`[verify:gui-smoke] ${cmd} 请求失败: unknown error`);
|
||||
}
|
||||
|
||||
function assert(condition, message) {
|
||||
if (!condition) {
|
||||
throw new Error(message);
|
||||
@@ -433,8 +518,22 @@ function listGuiSmokeGroupProcesses(startedByScript) {
|
||||
return listProcessStats().filter((item) => item.pgid === targetGroupId);
|
||||
}
|
||||
|
||||
function isZombieProcess(item) {
|
||||
return typeof item?.stat === "string" && item.stat.includes("Z");
|
||||
}
|
||||
|
||||
function listActiveGuiSmokeGroupProcesses(startedByScript) {
|
||||
return listGuiSmokeGroupProcesses(startedByScript).filter(
|
||||
(item) => !isZombieProcess(item),
|
||||
);
|
||||
}
|
||||
|
||||
function hasActiveGuiSmokeProcesses(startedByScript) {
|
||||
return listActiveGuiSmokeGroupProcesses(startedByScript).length > 0;
|
||||
}
|
||||
|
||||
function hasActiveGuiSmokeCompile(startedByScript) {
|
||||
return listGuiSmokeGroupProcesses(startedByScript).some(
|
||||
return listActiveGuiSmokeGroupProcesses(startedByScript).some(
|
||||
(item) =>
|
||||
item.command.includes("/bin/rustc") ||
|
||||
item.command.includes("cargo run --no-default-features"),
|
||||
@@ -442,7 +541,7 @@ function hasActiveGuiSmokeCompile(startedByScript) {
|
||||
}
|
||||
|
||||
function describeGuiSmokeHeartbeat(startedByScript) {
|
||||
const interestingProcesses = listGuiSmokeGroupProcesses(startedByScript)
|
||||
const interestingProcesses = listActiveGuiSmokeGroupProcesses(startedByScript)
|
||||
.filter(
|
||||
(item) =>
|
||||
item.command.includes("tauri dev") ||
|
||||
@@ -565,6 +664,61 @@ async function cleanupStaleGuiSmokeProcesses() {
|
||||
return snapshot.stale.length;
|
||||
}
|
||||
|
||||
async function cleanupStaleGuiSmokeChromeProfiles(
|
||||
options,
|
||||
{ label, required, skipIfBridgeUnavailable = false },
|
||||
) {
|
||||
if (
|
||||
skipIfBridgeUnavailable &&
|
||||
!(await isUrlReady(options.healthUrl, Math.min(options.intervalMs, 1_500)))
|
||||
) {
|
||||
console.warn(`[verify:gui-smoke] ${label}: DevBridge 未就绪,跳过。`);
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await invokeBridgeCommand(
|
||||
options,
|
||||
"cleanup_gui_smoke_chrome_profiles",
|
||||
);
|
||||
const matchedProfiles = Array.isArray(result?.matched_profiles)
|
||||
? result.matched_profiles
|
||||
: [];
|
||||
const removedProfiles = Array.isArray(result?.removed_profiles)
|
||||
? result.removed_profiles
|
||||
: [];
|
||||
const skippedProfiles = Array.isArray(result?.skipped_profiles)
|
||||
? result.skipped_profiles
|
||||
: [];
|
||||
const terminatedProcessCount = Number.isFinite(
|
||||
result?.terminated_process_count,
|
||||
)
|
||||
? Number(result.terminated_process_count)
|
||||
: 0;
|
||||
|
||||
if (matchedProfiles.length === 0) {
|
||||
return result;
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[verify:gui-smoke] ${label}: 匹配 ${matchedProfiles.length} 个 smoke Chrome profiles,删除 ${removedProfiles.length} 个目录,结束 ${terminatedProcessCount} 个残留进程。`,
|
||||
);
|
||||
if (skippedProfiles.length > 0) {
|
||||
console.warn(
|
||||
`[verify:gui-smoke] ${label}: 仍有 ${skippedProfiles.length} 个 profile 未删掉:${skippedProfiles.join(", ")}`,
|
||||
);
|
||||
}
|
||||
return result;
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
if (required) {
|
||||
throw error;
|
||||
}
|
||||
console.warn(`[verify:gui-smoke] ${label}: ${detail}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function listListeningCommandsForPort(port) {
|
||||
if (!port || process.platform === "win32") {
|
||||
return [];
|
||||
@@ -767,6 +921,8 @@ async function waitForBridgeHealth(options, startedByScript) {
|
||||
let deadlineAt = startedAt + options.timeoutMs;
|
||||
let compileGraceCount = 0;
|
||||
let bootGraceUsed = false;
|
||||
let childExitObservedAt = null;
|
||||
let lastExitedChildLogAt = 0;
|
||||
let lastError = null;
|
||||
let lastHeartbeatAt = startedAt;
|
||||
|
||||
@@ -778,14 +934,40 @@ async function waitForBridgeHealth(options, startedByScript) {
|
||||
state.child &&
|
||||
(typeof state.child.exitCode === "number" || state.child.signalCode)
|
||||
) {
|
||||
const now = Date.now();
|
||||
if (childExitObservedAt === null) {
|
||||
childExitObservedAt = now;
|
||||
}
|
||||
|
||||
const exitDetail = describeChildExit(state.child);
|
||||
const lastDetail =
|
||||
lastError instanceof Error
|
||||
? `;最近一次健康检查错误: ${lastError.message}`
|
||||
: "";
|
||||
throw new Error(
|
||||
`[verify:gui-smoke] headless Tauri 在 DevBridge 就绪前提前退出(${exitDetail})${lastDetail}`,
|
||||
);
|
||||
const activeProcessCount = listActiveGuiSmokeGroupProcesses(
|
||||
startedByScript,
|
||||
).length;
|
||||
const heartbeat = describeGuiSmokeHeartbeat(startedByScript);
|
||||
|
||||
if (activeProcessCount > 0) {
|
||||
if (now - lastExitedChildLogAt >= GUI_SMOKE_BRIDGE_HEARTBEAT_MS) {
|
||||
lastExitedChildLogAt = now;
|
||||
console.log(
|
||||
`[bridge:health] headless Tauri 父进程已退出(${exitDetail}),但进程组仍有 ${activeProcessCount} 个活跃进程,继续等待 DevBridge${heartbeat ? `;进程组: ${heartbeat}` : ""}`,
|
||||
);
|
||||
}
|
||||
} else if (
|
||||
now - childExitObservedAt >= GUI_SMOKE_CHILD_EXIT_GRACE_MS
|
||||
) {
|
||||
const lastDetail =
|
||||
lastError instanceof Error
|
||||
? `;最近一次健康检查错误: ${lastError.message}`
|
||||
: "";
|
||||
throw new Error(
|
||||
`[verify:gui-smoke] headless Tauri 在 DevBridge 就绪前提前退出(${exitDetail}),且 ${GUI_SMOKE_CHILD_EXIT_GRACE_MS}ms 内未检测到仍在运行的 GUI smoke 进程组${lastDetail}`,
|
||||
);
|
||||
} else if (now - lastExitedChildLogAt >= GUI_SMOKE_BRIDGE_HEARTBEAT_MS) {
|
||||
lastExitedChildLogAt = now;
|
||||
console.log(
|
||||
`[bridge:health] headless Tauri 父进程已退出(${exitDetail}),等待最多 ${GUI_SMOKE_CHILD_EXIT_GRACE_MS}ms 确认是否还有后续启动链。`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -833,9 +1015,10 @@ async function waitForBridgeHealth(options, startedByScript) {
|
||||
if (
|
||||
!bootGraceUsed &&
|
||||
startedByScript &&
|
||||
state.child &&
|
||||
state.child.exitCode === null &&
|
||||
!state.child.signalCode
|
||||
((state.child &&
|
||||
state.child.exitCode === null &&
|
||||
!state.child.signalCode) ||
|
||||
hasActiveGuiSmokeProcesses(startedByScript))
|
||||
) {
|
||||
bootGraceUsed = true;
|
||||
deadlineAt = heartbeatAt + GUI_SMOKE_BOOT_GRACE_MS;
|
||||
@@ -1040,6 +1223,11 @@ async function main() {
|
||||
|
||||
await waitForAppShell(options);
|
||||
|
||||
await cleanupStaleGuiSmokeChromeProfiles(options, {
|
||||
label: "预清理历史残留 smoke Chrome profiles",
|
||||
required: true,
|
||||
});
|
||||
|
||||
runCommand(
|
||||
npmCommand,
|
||||
[
|
||||
@@ -1102,8 +1290,25 @@ async function main() {
|
||||
options.timeoutMs + 30_000,
|
||||
);
|
||||
|
||||
runCommand(
|
||||
npmCommand,
|
||||
["run", "smoke:agent-runtime-tool-surface-page"],
|
||||
"smoke:agent-runtime-tool-surface-page",
|
||||
options.timeoutMs + 30_000,
|
||||
);
|
||||
|
||||
await cleanupStaleGuiSmokeChromeProfiles(options, {
|
||||
label: "收尾清理本轮 smoke Chrome profiles",
|
||||
required: true,
|
||||
});
|
||||
|
||||
console.log("\n[verify:gui-smoke] 通过");
|
||||
} finally {
|
||||
await cleanupStaleGuiSmokeChromeProfiles(options, {
|
||||
label: "兜底清理 smoke Chrome profiles",
|
||||
required: false,
|
||||
skipIfBridgeUnavailable: true,
|
||||
});
|
||||
if (startedByScript) {
|
||||
await stopHeadlessTauri();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user