mirror of
https://github.com/aiclientproxy/proxycast.git
synced 2026-09-24 23:10:56 +08:00
chore: release v0.98.0
This commit is contained in:
@@ -5,9 +5,13 @@ import process from "node:process";
|
||||
|
||||
const DEFAULTS = {
|
||||
server: "ws://127.0.0.1:8787",
|
||||
healthUrl: "http://127.0.0.1:3030/health",
|
||||
invokeUrl: "http://127.0.0.1:3030/invoke",
|
||||
key: "",
|
||||
profile: "default",
|
||||
timeoutMs: 15000,
|
||||
intervalMs: 1000,
|
||||
verifyForceDisconnect: true,
|
||||
};
|
||||
|
||||
function parseArgs(argv) {
|
||||
@@ -19,6 +23,16 @@ function parseArgs(argv) {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--health-url" && argv[i + 1]) {
|
||||
args.healthUrl = argv[i + 1];
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--invoke-url" && argv[i + 1]) {
|
||||
args.invokeUrl = argv[i + 1];
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--key" && argv[i + 1]) {
|
||||
args.key = argv[i + 1];
|
||||
i += 1;
|
||||
@@ -34,6 +48,15 @@ function parseArgs(argv) {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--interval-ms" && argv[i + 1]) {
|
||||
args.intervalMs = Number(argv[i + 1]);
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--skip-force-disconnect") {
|
||||
args.verifyForceDisconnect = false;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--help" || arg === "-h") {
|
||||
printHelp();
|
||||
process.exit(0);
|
||||
@@ -51,13 +74,18 @@ Lime Chrome Bridge E2E 联调脚本
|
||||
|
||||
选项:
|
||||
--server <ws_url> 服务地址,默认 ws://127.0.0.1:8787
|
||||
--health-url <url> DevBridge 健康检查地址,默认 http://127.0.0.1:3030/health
|
||||
--invoke-url <url> DevBridge invoke 地址,默认 http://127.0.0.1:3030/invoke
|
||||
--key <api_key> Lime API Key(必填)
|
||||
--profile <profile_key> profileKey,默认 default
|
||||
--timeout-ms <ms> 单步超时毫秒,默认 15000
|
||||
--interval-ms <ms> 状态轮询间隔毫秒,默认 1000
|
||||
--skip-force-disconnect 跳过桌面端主动断开验证,仅校验 WebSocket 命令链路
|
||||
-h, --help 显示帮助
|
||||
|
||||
示例:
|
||||
node scripts/chrome-bridge-e2e.mjs --server ws://127.0.0.1:8787 --key proxy_cast --profile default
|
||||
node scripts/chrome-bridge-e2e.mjs --server ws://127.0.0.1:8787 --key proxy_cast --profile default --skip-force-disconnect
|
||||
`);
|
||||
}
|
||||
|
||||
@@ -76,6 +104,124 @@ function normalizeServer(server) {
|
||||
.replace(/\/$/, "");
|
||||
}
|
||||
|
||||
function appendProfileKey(url, profileKey) {
|
||||
const endpoint = new URL(url);
|
||||
endpoint.searchParams.set("profileKey", profileKey);
|
||||
return endpoint.toString();
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function assert(condition, message) {
|
||||
if (!condition) {
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
async function invoke(invokeUrl, cmd, args) {
|
||||
const response = await fetch(invokeUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ cmd, args }),
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
async function waitForHealth(options) {
|
||||
const startedAt = Date.now();
|
||||
let lastError = null;
|
||||
|
||||
while (Date.now() - startedAt < options.timeoutMs) {
|
||||
try {
|
||||
const response = await fetch(options.healthUrl, { method: "GET" });
|
||||
const payload = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
console.log(
|
||||
`[E2E] DevBridge 已就绪 (${Date.now() - startedAt}ms)${
|
||||
payload?.status ? ` status=${payload.status}` : ""
|
||||
}`,
|
||||
);
|
||||
return;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
await sleep(options.intervalMs);
|
||||
}
|
||||
}
|
||||
|
||||
const detail =
|
||||
lastError instanceof Error
|
||||
? lastError.message
|
||||
: String(lastError || "unknown error");
|
||||
throw new Error(
|
||||
`[E2E] DevBridge 未就绪,请先启动 npm run tauri:dev 或 npm run tauri:dev:headless。最后错误: ${detail}`,
|
||||
);
|
||||
}
|
||||
|
||||
function summarizeStatus(status) {
|
||||
if (!status || typeof status !== "object") {
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
return `observer=${status.observer_count ?? "?"}, control=${
|
||||
status.control_count ?? "?"
|
||||
}, pending=${status.pending_command_count ?? "?"}`;
|
||||
}
|
||||
|
||||
function isStatusEmpty(status) {
|
||||
return (
|
||||
Number(status?.observer_count || 0) === 0 &&
|
||||
Number(status?.control_count || 0) === 0 &&
|
||||
Number(status?.pending_command_count || 0) === 0
|
||||
);
|
||||
}
|
||||
|
||||
function hasObserverForProfile(status, profileKey) {
|
||||
return (status?.observers || []).some(
|
||||
(observer) => observer?.profile_key === profileKey,
|
||||
);
|
||||
}
|
||||
|
||||
async function getBridgeStatus(invokeUrl) {
|
||||
return invoke(invokeUrl, "get_chrome_bridge_status");
|
||||
}
|
||||
|
||||
async function waitForStatus(invokeUrl, predicate, timeoutMs, intervalMs, desc) {
|
||||
const startedAt = Date.now();
|
||||
let lastStatus = null;
|
||||
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
lastStatus = await getBridgeStatus(invokeUrl);
|
||||
if (predicate(lastStatus)) {
|
||||
return lastStatus;
|
||||
}
|
||||
await sleep(intervalMs);
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`[E2E] 等待桥接状态超时(${timeoutMs}ms): ${desc}\n最近状态: ${JSON.stringify(
|
||||
lastStatus,
|
||||
null,
|
||||
2,
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
|
||||
function toText(data) {
|
||||
if (typeof data === "string") return data;
|
||||
if (Buffer.isBuffer(data)) return data.toString("utf8");
|
||||
@@ -186,6 +332,9 @@ async function closeClient(client) {
|
||||
}
|
||||
|
||||
async function main() {
|
||||
if (typeof fetch !== "function") {
|
||||
throw new Error("当前 Node 运行时不支持 fetch,请使用 Node 18+");
|
||||
}
|
||||
assertGlobalWebSocket();
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
|
||||
@@ -196,12 +345,44 @@ async function main() {
|
||||
if (!Number.isFinite(args.timeoutMs) || args.timeoutMs < 1000) {
|
||||
throw new Error("--timeout-ms 必须是 >= 1000 的数字");
|
||||
}
|
||||
if (!Number.isFinite(args.intervalMs) || args.intervalMs < 100) {
|
||||
throw new Error("--interval-ms 必须是 >= 100 的数字");
|
||||
}
|
||||
|
||||
const server = normalizeServer(args.server);
|
||||
const key = encodeURIComponent(args.key);
|
||||
const profile = encodeURIComponent(args.profile || "default");
|
||||
const observerUrl = `${server}/lime-chrome-observer/Lime_Key=${key}?profileKey=${profile}`;
|
||||
const controlUrl = `${server}/lime-chrome-control/Lime_Key=${key}`;
|
||||
const profile = String(args.profile || "default").trim() || "default";
|
||||
let observerBaseUrl = `${server}/lime-chrome-observer/${key}`;
|
||||
let controlUrl = `${server}/lime-chrome-control/${key}`;
|
||||
let baselineStatus = null;
|
||||
if (args.verifyForceDisconnect) {
|
||||
console.log("[E2E] invoke :", args.invokeUrl);
|
||||
await waitForHealth(args);
|
||||
const endpointInfo = await invoke(args.invokeUrl, "get_chrome_bridge_endpoint_info");
|
||||
if (typeof endpointInfo?.observer_ws_url === "string" && endpointInfo.observer_ws_url) {
|
||||
observerBaseUrl = endpointInfo.observer_ws_url;
|
||||
}
|
||||
if (typeof endpointInfo?.control_ws_url === "string" && endpointInfo.control_ws_url) {
|
||||
controlUrl = endpointInfo.control_ws_url;
|
||||
}
|
||||
if (
|
||||
typeof endpointInfo?.bridge_key === "string" &&
|
||||
endpointInfo.bridge_key &&
|
||||
endpointInfo.bridge_key !== args.key
|
||||
) {
|
||||
console.warn(
|
||||
`[E2E] 传入 key 与当前运行态 bridge_key 不一致,将以运行态 endpoint 为准: cli=${args.key} runtime=${endpointInfo.bridge_key}`,
|
||||
);
|
||||
}
|
||||
baselineStatus = await getBridgeStatus(args.invokeUrl);
|
||||
console.log("[E2E] 基线状态:", summarizeStatus(baselineStatus));
|
||||
if (!isStatusEmpty(baselineStatus)) {
|
||||
console.warn(
|
||||
"[E2E] 检测到已有桥接连接,本次将校验 force_disconnect 消息和目标 profile 清理,但不强制要求全局状态归零。",
|
||||
);
|
||||
}
|
||||
}
|
||||
const observerUrl = appendProfileKey(observerBaseUrl, profile);
|
||||
|
||||
console.log("[E2E] observer:", observerUrl);
|
||||
console.log("[E2E] control :", controlUrl);
|
||||
@@ -341,6 +522,71 @@ async function main() {
|
||||
);
|
||||
console.log("[E2E] 非 wait_for_page_info 命令链路通过");
|
||||
|
||||
if (args.verifyForceDisconnect) {
|
||||
const forceDisconnectObserver = waitForMessage(
|
||||
observer,
|
||||
(msg) => msg.type === "force_disconnect",
|
||||
args.timeoutMs,
|
||||
"observer 收到 force_disconnect",
|
||||
);
|
||||
const forceDisconnectControl = waitForMessage(
|
||||
control,
|
||||
(msg) => msg.type === "force_disconnect",
|
||||
args.timeoutMs,
|
||||
"control 收到 force_disconnect",
|
||||
);
|
||||
|
||||
const disconnectResult = await invoke(
|
||||
args.invokeUrl,
|
||||
"disconnect_browser_connector_session",
|
||||
{
|
||||
profileKey: args.profile,
|
||||
},
|
||||
);
|
||||
|
||||
assert(
|
||||
Number(disconnectResult?.disconnected_observer_count || 0) >= 1,
|
||||
`disconnect_browser_connector_session 未断开 observer: ${JSON.stringify(
|
||||
disconnectResult,
|
||||
null,
|
||||
2,
|
||||
)}`,
|
||||
);
|
||||
assert(
|
||||
Number(disconnectResult?.disconnected_control_count || 0) >= 1,
|
||||
`disconnect_browser_connector_session 未断开 control: ${JSON.stringify(
|
||||
disconnectResult,
|
||||
null,
|
||||
2,
|
||||
)}`,
|
||||
);
|
||||
|
||||
await Promise.all([forceDisconnectObserver, forceDisconnectControl]);
|
||||
console.log("[E2E] force_disconnect 消息链路通过");
|
||||
|
||||
const finalStatus = await waitForStatus(
|
||||
args.invokeUrl,
|
||||
(status) =>
|
||||
isStatusEmpty(baselineStatus)
|
||||
? isStatusEmpty(status)
|
||||
: !hasObserverForProfile(status, args.profile),
|
||||
args.timeoutMs,
|
||||
args.intervalMs,
|
||||
isStatusEmpty(baselineStatus)
|
||||
? "桥接状态归零"
|
||||
: `profile=${args.profile} observer 已清理`,
|
||||
);
|
||||
|
||||
if (isStatusEmpty(baselineStatus)) {
|
||||
console.log("[E2E] force_disconnect 后状态归零:", summarizeStatus(finalStatus));
|
||||
} else {
|
||||
console.log(
|
||||
"[E2E] force_disconnect 后目标 profile 已清理:",
|
||||
summarizeStatus(finalStatus),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("\n[E2E] ✅ Chrome Bridge 联调通过");
|
||||
} finally {
|
||||
await closeClient(control);
|
||||
|
||||
@@ -1,22 +1,88 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { spawn } from 'node:child_process';
|
||||
import process from 'node:process';
|
||||
|
||||
const DEV_URL = process.env.LIME_WEB_BRIDGE_URL?.trim() || 'http://127.0.0.1:1420/';
|
||||
const DEV_URL_TIMEOUT_MS = 1_500;
|
||||
const ROOT_MARKERS = ['<title>Lime</title>', '<div id="root"></div>'];
|
||||
|
||||
const env = { ...process.env };
|
||||
delete env.TAURI_ENV_PLATFORM;
|
||||
|
||||
env.LIME_BROWSER_BRIDGE = '1';
|
||||
|
||||
const child = spawn('npx', ['vite'], {
|
||||
stdio: 'inherit',
|
||||
shell: true,
|
||||
env,
|
||||
});
|
||||
function isLimeDevShell(html) {
|
||||
return ROOT_MARKERS.some((marker) => html.includes(marker));
|
||||
}
|
||||
|
||||
child.on('exit', (code, signal) => {
|
||||
if (signal) {
|
||||
process.kill(process.pid, signal);
|
||||
async function probeExistingDevServer(url) {
|
||||
if (typeof fetch !== 'function') {
|
||||
return { reachable: false };
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
signal: AbortSignal.timeout(DEV_URL_TIMEOUT_MS),
|
||||
});
|
||||
const html = await response.text();
|
||||
|
||||
return {
|
||||
reachable: true,
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
isLimeDevShell: response.ok && isLimeDevShell(html),
|
||||
};
|
||||
} catch {
|
||||
return { reachable: false };
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForExitSignal() {
|
||||
await new Promise((resolve) => {
|
||||
const handleExit = () => resolve();
|
||||
process.once('SIGINT', handleExit);
|
||||
process.once('SIGTERM', handleExit);
|
||||
});
|
||||
}
|
||||
|
||||
function startVite() {
|
||||
const child = spawn('npx', ['vite'], {
|
||||
stdio: 'inherit',
|
||||
shell: true,
|
||||
env,
|
||||
});
|
||||
|
||||
child.on('exit', (code, signal) => {
|
||||
if (signal) {
|
||||
process.kill(process.pid, signal);
|
||||
return;
|
||||
}
|
||||
process.exit(code ?? 0);
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const existingServer = await probeExistingDevServer(DEV_URL);
|
||||
|
||||
if (existingServer.reachable) {
|
||||
if (!existingServer.isLimeDevShell) {
|
||||
const statusLabel = `${existingServer.status} ${existingServer.statusText}`.trim();
|
||||
throw new Error(
|
||||
`[dev:web-bridge] ${DEV_URL} 已被其他服务占用,且返回内容不是 Lime dev shell(${statusLabel})。请先关闭占用进程后重试。`,
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`[dev:web-bridge] 复用已存在的 Lime dev server: ${DEV_URL}`);
|
||||
await waitForExitSignal();
|
||||
return;
|
||||
}
|
||||
process.exit(code ?? 0);
|
||||
|
||||
startVite();
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user