mirror of
https://github.com/aiclientproxy/proxycast.git
synced 2026-09-24 23:10:56 +08:00
chore: release v0.96.0
This commit is contained in:
+36
-164
@@ -1,34 +1,15 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { execFileSync, spawnSync } from "node:child_process";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import process from "node:process";
|
||||
|
||||
import { planQualityTasks } from "./quality-task-planner.mjs";
|
||||
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
const rootDir = process.cwd();
|
||||
|
||||
const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm";
|
||||
const cargoCommand = process.platform === "win32" ? "cargo.exe" : "cargo";
|
||||
const gitCommand = process.platform === "win32" ? "git.exe" : "git";
|
||||
|
||||
const FRONTEND_ROOT_FILES = new Set([
|
||||
"package.json",
|
||||
"package-lock.json",
|
||||
"vite.config.ts",
|
||||
"tsconfig.json",
|
||||
"tsconfig.node.json",
|
||||
"eslint.config.js",
|
||||
"tailwind.config.js",
|
||||
"postcss.config.js",
|
||||
"index.html",
|
||||
]);
|
||||
|
||||
const BRIDGE_FILES = new Set([
|
||||
"vite.config.ts",
|
||||
"scripts/check-dev-bridge-health.mjs",
|
||||
"scripts/social-workbench-e2e-smoke.mjs",
|
||||
"scripts/chrome-bridge-e2e.mjs",
|
||||
"docs/aiprompts/playwright-e2e.md",
|
||||
]);
|
||||
|
||||
function parseArgs(argv) {
|
||||
const result = {
|
||||
@@ -96,143 +77,6 @@ function runCommand(command, args) {
|
||||
}
|
||||
}
|
||||
|
||||
function gitOutput(args) {
|
||||
try {
|
||||
return execFileSync(gitCommand, args, {
|
||||
cwd: rootDir,
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
}).trim();
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function splitLines(value) {
|
||||
if (!value) {
|
||||
return [];
|
||||
}
|
||||
return value
|
||||
.split("\n")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function resolveDiffBase() {
|
||||
if (options.base) {
|
||||
return options.base;
|
||||
}
|
||||
|
||||
const upstream = gitOutput(["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"]);
|
||||
if (upstream) {
|
||||
return upstream;
|
||||
}
|
||||
|
||||
for (const candidate of ["origin/main", "origin/master", "main", "master"]) {
|
||||
const exists = gitOutput(["rev-parse", "--verify", candidate]);
|
||||
if (exists) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
function collectChangedFiles() {
|
||||
if (options.full) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (options.staged) {
|
||||
return uniquePaths(
|
||||
splitLines(gitOutput(["diff", "--cached", "--name-only", "--diff-filter=ACMR"])),
|
||||
);
|
||||
}
|
||||
|
||||
const base = resolveDiffBase();
|
||||
const candidates = [];
|
||||
|
||||
if (base) {
|
||||
candidates.push(
|
||||
...splitLines(gitOutput(["diff", "--name-only", "--diff-filter=ACMR", `${base}...HEAD`])),
|
||||
);
|
||||
}
|
||||
|
||||
candidates.push(
|
||||
...splitLines(gitOutput(["diff", "--name-only", "--diff-filter=ACMR", "HEAD"])),
|
||||
);
|
||||
candidates.push(
|
||||
...splitLines(gitOutput(["ls-files", "--others", "--exclude-standard"])),
|
||||
);
|
||||
|
||||
return uniquePaths(candidates);
|
||||
}
|
||||
|
||||
function uniquePaths(paths) {
|
||||
return Array.from(new Set(paths));
|
||||
}
|
||||
|
||||
function isFrontendChange(file) {
|
||||
return (
|
||||
file.startsWith("src/") ||
|
||||
FRONTEND_ROOT_FILES.has(file)
|
||||
);
|
||||
}
|
||||
|
||||
function isRustChange(file) {
|
||||
return file.startsWith("src-tauri/");
|
||||
}
|
||||
|
||||
function isBridgeChange(file) {
|
||||
return (
|
||||
file.startsWith("src/lib/dev-bridge/") ||
|
||||
file.startsWith("src/lib/tauri-mock/") ||
|
||||
BRIDGE_FILES.has(file)
|
||||
);
|
||||
}
|
||||
|
||||
function isDocsOnlyChange(files) {
|
||||
return files.length > 0 && files.every((file) => file.startsWith("docs/"));
|
||||
}
|
||||
|
||||
function detectTasks(changedFiles) {
|
||||
if (options.full) {
|
||||
return {
|
||||
frontend: true,
|
||||
rust: true,
|
||||
bridge: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (changedFiles.length === 0) {
|
||||
return {
|
||||
frontend: true,
|
||||
rust: true,
|
||||
bridge: true,
|
||||
fallback: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (isDocsOnlyChange(changedFiles)) {
|
||||
return {
|
||||
frontend: false,
|
||||
rust: false,
|
||||
bridge: false,
|
||||
docsOnly: true,
|
||||
};
|
||||
}
|
||||
|
||||
const frontend = changedFiles.some(isFrontendChange);
|
||||
const rust = changedFiles.some(isRustChange);
|
||||
const bridge = changedFiles.some(isBridgeChange);
|
||||
|
||||
return {
|
||||
frontend,
|
||||
rust,
|
||||
bridge,
|
||||
};
|
||||
}
|
||||
|
||||
function printSummary(changedFiles, tasks) {
|
||||
console.log("[local-ci] 模式:", options.full ? "full" : "smart");
|
||||
if (!options.full) {
|
||||
@@ -243,7 +87,9 @@ function printSummary(changedFiles, tasks) {
|
||||
console.log(`[local-ci] - ${file}`);
|
||||
}
|
||||
if (changedFiles.length > preview.length) {
|
||||
console.log(`[local-ci] ... 其余 ${changedFiles.length - preview.length} 个文件省略`);
|
||||
console.log(
|
||||
`[local-ci] ... 其余 ${changedFiles.length - preview.length} 个文件省略`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -254,12 +100,18 @@ function printSummary(changedFiles, tasks) {
|
||||
}
|
||||
|
||||
console.log("[local-ci] 计划执行:");
|
||||
if (tasks.integrity) {
|
||||
console.log("[local-ci] - 一致性校验");
|
||||
}
|
||||
if (tasks.frontend) {
|
||||
console.log("[local-ci] - 前端校验");
|
||||
}
|
||||
if (tasks.bridge) {
|
||||
console.log("[local-ci] - bridge 校验");
|
||||
}
|
||||
if (tasks.guiSmoke) {
|
||||
console.log("[local-ci] - GUI 冒烟");
|
||||
}
|
||||
if (tasks.rust) {
|
||||
console.log("[local-ci] - Rust 校验");
|
||||
}
|
||||
@@ -273,6 +125,10 @@ function runSelectedTasks(tasks) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (tasks.integrity) {
|
||||
runCommand(npmCommand, ["run", "verify:app-version"]);
|
||||
}
|
||||
|
||||
if (tasks.frontend) {
|
||||
runCommand(npmCommand, ["run", "lint"]);
|
||||
runCommand(npmCommand, ["run", "typecheck"]);
|
||||
@@ -287,11 +143,23 @@ function runSelectedTasks(tasks) {
|
||||
}
|
||||
|
||||
if (tasks.rust) {
|
||||
runCommand(cargoCommand, ["test", "--manifest-path", "src-tauri/Cargo.toml"]);
|
||||
runCommand(cargoCommand, [
|
||||
"test",
|
||||
"--manifest-path",
|
||||
"src-tauri/Cargo.toml",
|
||||
]);
|
||||
if (options.full) {
|
||||
runCommand(cargoCommand, ["clippy", "--manifest-path", "src-tauri/Cargo.toml"]);
|
||||
runCommand(cargoCommand, [
|
||||
"clippy",
|
||||
"--manifest-path",
|
||||
"src-tauri/Cargo.toml",
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
if (tasks.guiSmoke) {
|
||||
runCommand(npmCommand, ["run", "verify:gui-smoke"]);
|
||||
}
|
||||
}
|
||||
|
||||
function main() {
|
||||
@@ -300,8 +168,12 @@ function main() {
|
||||
return;
|
||||
}
|
||||
|
||||
const changedFiles = collectChangedFiles();
|
||||
const tasks = detectTasks(changedFiles);
|
||||
const { changedFiles, tasks } = planQualityTasks({
|
||||
base: options.base,
|
||||
cwd: rootDir,
|
||||
full: options.full,
|
||||
staged: options.staged,
|
||||
});
|
||||
printSummary(changedFiles, tasks);
|
||||
runSelectedTasks(tasks);
|
||||
console.log("\n[local-ci] 本地校验完成。");
|
||||
|
||||
@@ -0,0 +1,362 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { execFileSync } from "node:child_process";
|
||||
import process from "node:process";
|
||||
|
||||
const DEFAULT_GIT_COMMAND = process.platform === "win32" ? "git.exe" : "git";
|
||||
|
||||
const IGNORED_PREFIXES = [
|
||||
".turbo/",
|
||||
"coverage/",
|
||||
"dist/",
|
||||
"docs/.output/",
|
||||
"node_modules/",
|
||||
"target/",
|
||||
"target-site-e2e/",
|
||||
];
|
||||
|
||||
const IGNORED_FILES = new Set([".DS_Store"]);
|
||||
|
||||
const FRONTEND_ROOT_FILES = new Set([
|
||||
"package.json",
|
||||
"package-lock.json",
|
||||
"vite.config.ts",
|
||||
"tsconfig.json",
|
||||
"tsconfig.node.json",
|
||||
"eslint.config.js",
|
||||
"tailwind.config.js",
|
||||
"postcss.config.js",
|
||||
"index.html",
|
||||
]);
|
||||
|
||||
const FRONTEND_TOOLING_FILES = new Set([
|
||||
"scripts/local-ci.mjs",
|
||||
"scripts/ai-code-verify.ts",
|
||||
"scripts/quality-task-planner.mjs",
|
||||
"scripts/quality-task-selector.mjs",
|
||||
]);
|
||||
|
||||
const BRIDGE_FILES = new Set([
|
||||
"vite.config.ts",
|
||||
"scripts/check-command-contracts.mjs",
|
||||
"scripts/check-dev-bridge-health.mjs",
|
||||
"scripts/social-workbench-e2e-smoke.mjs",
|
||||
"scripts/chrome-bridge-e2e.mjs",
|
||||
"scripts/verify-gui-smoke.mjs",
|
||||
"docs/aiprompts/playwright-e2e.md",
|
||||
]);
|
||||
|
||||
const INTEGRITY_FILES = new Set([
|
||||
"package.json",
|
||||
"src-tauri/Cargo.toml",
|
||||
"src-tauri/tauri.conf.json",
|
||||
"src-tauri/tauri.conf.headless.json",
|
||||
"scripts/check-app-version-consistency.mjs",
|
||||
"scripts/quality-task-planner.mjs",
|
||||
"scripts/quality-task-selector.mjs",
|
||||
]);
|
||||
|
||||
const GUI_SMOKE_FILES = new Set([
|
||||
"src/App.tsx",
|
||||
"src/main.tsx",
|
||||
"src-tauri/tauri.conf.json",
|
||||
"src-tauri/tauri.conf.headless.json",
|
||||
"src-tauri/src/app/runner.rs",
|
||||
"src-tauri/src/commands/workspace_cmd.rs",
|
||||
"src-tauri/src/workspace_support.rs",
|
||||
"scripts/check-dev-bridge-health.mjs",
|
||||
"scripts/workspace-ready-smoke.mjs",
|
||||
"scripts/verify-gui-smoke.mjs",
|
||||
]);
|
||||
|
||||
const GUI_SMOKE_PREFIXES = [
|
||||
"src/components/",
|
||||
"src/contexts/",
|
||||
"src/features/",
|
||||
"src/hooks/",
|
||||
"src/lib/dev-bridge/",
|
||||
"src/lib/navigation/",
|
||||
"src/lib/tauri/",
|
||||
"src/lib/tauri-mock/",
|
||||
"src/lib/workspace/",
|
||||
"src/pages/",
|
||||
"src/stores/",
|
||||
"src-tauri/src/app/",
|
||||
"src-tauri/src/dev_bridge/",
|
||||
];
|
||||
|
||||
function gitOutput({ cwd, gitCommand, args }) {
|
||||
try {
|
||||
return execFileSync(gitCommand, args, {
|
||||
cwd,
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
}).trim();
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function splitLines(value) {
|
||||
if (!value) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return value
|
||||
.split("\n")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function uniquePaths(paths) {
|
||||
return Array.from(new Set(paths)).filter((file) => !isIgnoredPath(file));
|
||||
}
|
||||
|
||||
function isIgnoredPath(file) {
|
||||
if (IGNORED_FILES.has(file)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return IGNORED_PREFIXES.some((prefix) => file.startsWith(prefix));
|
||||
}
|
||||
|
||||
function resolveDiffBase({ base = "", cwd, gitCommand = DEFAULT_GIT_COMMAND }) {
|
||||
if (base) {
|
||||
return base;
|
||||
}
|
||||
|
||||
const upstream = gitOutput({
|
||||
cwd,
|
||||
gitCommand,
|
||||
args: ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"],
|
||||
});
|
||||
if (upstream) {
|
||||
return upstream;
|
||||
}
|
||||
|
||||
for (const candidate of ["origin/main", "origin/master", "main", "master"]) {
|
||||
const exists = gitOutput({
|
||||
cwd,
|
||||
gitCommand,
|
||||
args: ["rev-parse", "--verify", candidate],
|
||||
});
|
||||
if (exists) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
function collectChangedFiles({
|
||||
full = false,
|
||||
staged = false,
|
||||
base = "",
|
||||
cwd = process.cwd(),
|
||||
gitCommand = DEFAULT_GIT_COMMAND,
|
||||
} = {}) {
|
||||
if (full) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (staged) {
|
||||
return uniquePaths(
|
||||
splitLines(
|
||||
gitOutput({
|
||||
cwd,
|
||||
gitCommand,
|
||||
args: ["diff", "--cached", "--name-only", "--diff-filter=ACMR"],
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const diffBase = resolveDiffBase({ base, cwd, gitCommand });
|
||||
const candidates = [];
|
||||
|
||||
if (diffBase) {
|
||||
candidates.push(
|
||||
...splitLines(
|
||||
gitOutput({
|
||||
cwd,
|
||||
gitCommand,
|
||||
args: [
|
||||
"diff",
|
||||
"--name-only",
|
||||
"--diff-filter=ACMR",
|
||||
`${diffBase}...HEAD`,
|
||||
],
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
candidates.push(
|
||||
...splitLines(
|
||||
gitOutput({
|
||||
cwd,
|
||||
gitCommand,
|
||||
args: ["diff", "--name-only", "--diff-filter=ACMR", "HEAD"],
|
||||
}),
|
||||
),
|
||||
);
|
||||
candidates.push(
|
||||
...splitLines(
|
||||
gitOutput({
|
||||
cwd,
|
||||
gitCommand,
|
||||
args: ["ls-files", "--others", "--exclude-standard"],
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
return uniquePaths(candidates);
|
||||
}
|
||||
|
||||
function isMarkdownLike(file) {
|
||||
return /\.(md|mdx)$/i.test(file);
|
||||
}
|
||||
|
||||
function isWorkflowChange(file) {
|
||||
return file.startsWith(".github/workflows/");
|
||||
}
|
||||
|
||||
function isDocsChange(file) {
|
||||
return file.startsWith("docs/") || isMarkdownLike(file);
|
||||
}
|
||||
|
||||
function isDocsOnlyChange(files) {
|
||||
return files.length > 0 && files.every((file) => isDocsChange(file));
|
||||
}
|
||||
|
||||
function isFrontendChange(file) {
|
||||
return (
|
||||
file.startsWith("src/") ||
|
||||
FRONTEND_ROOT_FILES.has(file) ||
|
||||
FRONTEND_TOOLING_FILES.has(file)
|
||||
);
|
||||
}
|
||||
|
||||
function isRustChange(file) {
|
||||
return file.startsWith("src-tauri/");
|
||||
}
|
||||
|
||||
function isBridgeChange(file) {
|
||||
return (
|
||||
file.startsWith("src/lib/dev-bridge/") ||
|
||||
file.startsWith("src/lib/tauri-mock/") ||
|
||||
BRIDGE_FILES.has(file)
|
||||
);
|
||||
}
|
||||
|
||||
function isGuiSmokeChange(file) {
|
||||
return (
|
||||
GUI_SMOKE_FILES.has(file) ||
|
||||
GUI_SMOKE_PREFIXES.some((prefix) => file.startsWith(prefix))
|
||||
);
|
||||
}
|
||||
|
||||
function isIntegrityChange(file) {
|
||||
return (
|
||||
INTEGRITY_FILES.has(file) ||
|
||||
isWorkflowChange(file) ||
|
||||
isFrontendChange(file) ||
|
||||
isRustChange(file)
|
||||
);
|
||||
}
|
||||
|
||||
function detectTasks(changedFiles, { full = false } = {}) {
|
||||
if (full) {
|
||||
return {
|
||||
integrity: true,
|
||||
frontend: true,
|
||||
rust: true,
|
||||
bridge: true,
|
||||
guiSmoke: true,
|
||||
docs: true,
|
||||
docsOnly: false,
|
||||
fallback: false,
|
||||
workflow: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (changedFiles.length === 0) {
|
||||
return {
|
||||
integrity: true,
|
||||
frontend: true,
|
||||
rust: true,
|
||||
bridge: true,
|
||||
guiSmoke: true,
|
||||
docs: true,
|
||||
docsOnly: false,
|
||||
fallback: true,
|
||||
workflow: false,
|
||||
};
|
||||
}
|
||||
|
||||
const workflow = changedFiles.some(isWorkflowChange);
|
||||
if (workflow) {
|
||||
return {
|
||||
integrity: true,
|
||||
frontend: true,
|
||||
rust: true,
|
||||
bridge: true,
|
||||
guiSmoke: true,
|
||||
docs: true,
|
||||
docsOnly: false,
|
||||
fallback: false,
|
||||
workflow: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (isDocsOnlyChange(changedFiles)) {
|
||||
return {
|
||||
integrity: false,
|
||||
frontend: false,
|
||||
rust: false,
|
||||
bridge: false,
|
||||
guiSmoke: false,
|
||||
docs: true,
|
||||
docsOnly: true,
|
||||
fallback: false,
|
||||
workflow: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
integrity: changedFiles.some(isIntegrityChange),
|
||||
frontend: changedFiles.some(isFrontendChange),
|
||||
rust: changedFiles.some(isRustChange),
|
||||
bridge: changedFiles.some(isBridgeChange),
|
||||
guiSmoke: changedFiles.some(isGuiSmokeChange),
|
||||
docs: changedFiles.some(isDocsChange),
|
||||
docsOnly: false,
|
||||
fallback: false,
|
||||
workflow: false,
|
||||
};
|
||||
}
|
||||
|
||||
function planQualityTasks({
|
||||
full = false,
|
||||
staged = false,
|
||||
base = "",
|
||||
cwd = process.cwd(),
|
||||
gitCommand = DEFAULT_GIT_COMMAND,
|
||||
} = {}) {
|
||||
const changedFiles = collectChangedFiles({
|
||||
full,
|
||||
staged,
|
||||
base,
|
||||
cwd,
|
||||
gitCommand,
|
||||
});
|
||||
|
||||
const tasks = detectTasks(changedFiles, { full });
|
||||
|
||||
return {
|
||||
changedFiles,
|
||||
tasks,
|
||||
};
|
||||
}
|
||||
|
||||
export { collectChangedFiles, detectTasks, planQualityTasks, resolveDiffBase };
|
||||
@@ -0,0 +1,98 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import process from "node:process";
|
||||
|
||||
import { planQualityTasks } from "./quality-task-planner.mjs";
|
||||
|
||||
function parseArgs(argv) {
|
||||
const result = {
|
||||
base: "",
|
||||
format: "json",
|
||||
full: false,
|
||||
help: false,
|
||||
staged: false,
|
||||
};
|
||||
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
if (arg === "--full") {
|
||||
result.full = true;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--staged") {
|
||||
result.staged = true;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--base" && argv[index + 1]) {
|
||||
result.base = String(argv[index + 1]).trim();
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--format" && argv[index + 1]) {
|
||||
result.format = String(argv[index + 1]).trim();
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--help" || arg === "-h") {
|
||||
result.help = true;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function printHelp() {
|
||||
console.log(`
|
||||
Lime 质量任务规划器
|
||||
|
||||
用法:
|
||||
node scripts/quality-task-selector.mjs
|
||||
node scripts/quality-task-selector.mjs --staged
|
||||
node scripts/quality-task-selector.mjs --base origin/main
|
||||
node scripts/quality-task-selector.mjs --format github
|
||||
|
||||
选项:
|
||||
--full 忽略改动检测,返回全量任务
|
||||
--staged 仅基于已暂存文件判断
|
||||
--base REF 基于指定基线判断
|
||||
--format FMT 输出格式:json | github
|
||||
-h, --help 显示帮助
|
||||
`);
|
||||
}
|
||||
|
||||
function printGithubFormat(result) {
|
||||
const { changedFiles, tasks } = result;
|
||||
const lines = [
|
||||
`changed_count=${changedFiles.length}`,
|
||||
`integrity=${tasks.integrity}`,
|
||||
`frontend=${tasks.frontend}`,
|
||||
`rust=${tasks.rust}`,
|
||||
`bridge=${tasks.bridge}`,
|
||||
`gui_smoke=${tasks.guiSmoke}`,
|
||||
`docs=${tasks.docs}`,
|
||||
`docs_only=${tasks.docsOnly}`,
|
||||
`fallback=${tasks.fallback}`,
|
||||
`workflow=${tasks.workflow}`,
|
||||
];
|
||||
|
||||
process.stdout.write(`${lines.join("\n")}\n`);
|
||||
}
|
||||
|
||||
function main() {
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
if (options.help) {
|
||||
printHelp();
|
||||
return;
|
||||
}
|
||||
|
||||
const result = planQualityTasks(options);
|
||||
|
||||
if (options.format === "github") {
|
||||
printGithubFormat(result);
|
||||
return;
|
||||
}
|
||||
|
||||
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,375 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { spawn, spawnSync } from "node:child_process";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const DEFAULTS = {
|
||||
appUrl: "http://127.0.0.1:1420/",
|
||||
healthUrl: "http://127.0.0.1:3030/health",
|
||||
timeoutMs: 120_000,
|
||||
intervalMs: 1_000,
|
||||
reuseRunning: false,
|
||||
sampleProjectName: "Lime Smoke Workspace",
|
||||
};
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const rootDir = path.resolve(__dirname, "..");
|
||||
const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm";
|
||||
|
||||
const state = {
|
||||
child: null,
|
||||
cleanedUp: false,
|
||||
};
|
||||
|
||||
function printHelp() {
|
||||
console.log(`
|
||||
Lime GUI 冒烟入口
|
||||
|
||||
用途:
|
||||
启动或复用 headless Tauri 环境,串联 GUI 壳、DevBridge 与默认 workspace 的最小回归校验。
|
||||
|
||||
用法:
|
||||
npm run verify:gui-smoke
|
||||
npm run verify:gui-smoke -- --reuse-running
|
||||
npm run verify:gui-smoke -- --timeout-ms 180000
|
||||
|
||||
选项:
|
||||
--app-url <url> 前端地址,默认 http://127.0.0.1:1420/
|
||||
--health-url <url> DevBridge 健康检查地址,默认 http://127.0.0.1:3030/health
|
||||
--timeout-ms <ms> 等待 headless / bridge / smoke 的超时,默认 120000
|
||||
--interval-ms <ms> 轮询间隔,默认 1000
|
||||
--sample-project-name <s> workspace 路径校验使用的示例项目名
|
||||
--reuse-running 复用已启动的 headless Tauri,不主动拉起
|
||||
-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 === "--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 === "--sample-project-name" && argv[index + 1]) {
|
||||
options.sampleProjectName = String(argv[index + 1]).trim();
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === "--reuse-running") {
|
||||
options.reuseRunning = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === "--help" || arg === "-h") {
|
||||
printHelp();
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
if (!Number.isFinite(options.timeoutMs) || options.timeoutMs < 1_000) {
|
||||
throw new Error("--timeout-ms 必须是 >= 1000 的数字");
|
||||
}
|
||||
|
||||
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.sampleProjectName) {
|
||||
throw new Error("--sample-project-name 不能为空");
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function assert(condition, message) {
|
||||
if (!condition) {
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
function formatCommand(command, args) {
|
||||
return [command, ...args].join(" ");
|
||||
}
|
||||
|
||||
function runCommand(command, args, label) {
|
||||
console.log(`\n[verify:gui-smoke] > ${formatCommand(command, args)}`);
|
||||
const result = spawnSync(command, args, {
|
||||
cwd: rootDir,
|
||||
stdio: "inherit",
|
||||
env: process.env,
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
throw result.error;
|
||||
}
|
||||
|
||||
if (typeof result.status === "number" && result.status !== 0) {
|
||||
const error = new Error(`[verify:gui-smoke] ${label} 失败`);
|
||||
error.exitCode = result.status;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function startHeadlessTauri() {
|
||||
console.log("[verify:gui-smoke] 启动 headless Tauri 环境...");
|
||||
state.child = spawn(npmCommand, ["run", "tauri:dev:headless"], {
|
||||
cwd: rootDir,
|
||||
stdio: "inherit",
|
||||
env: process.env,
|
||||
detached: process.platform !== "win32",
|
||||
});
|
||||
}
|
||||
|
||||
async function stopHeadlessTauri() {
|
||||
const child = state.child;
|
||||
if (!child || state.cleanedUp) {
|
||||
return;
|
||||
}
|
||||
|
||||
state.cleanedUp = true;
|
||||
console.log("[verify:gui-smoke] 停止 headless Tauri 环境...");
|
||||
|
||||
if (typeof child.pid !== "number") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (process.platform === "win32") {
|
||||
spawnSync("taskkill", ["/pid", String(child.pid), "/T", "/F"], {
|
||||
stdio: "ignore",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
process.kill(-child.pid, "SIGTERM");
|
||||
} catch {
|
||||
try {
|
||||
child.kill("SIGTERM");
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
for (let attempt = 0; attempt < 25; attempt += 1) {
|
||||
if (child.exitCode !== null || child.signalCode) {
|
||||
return;
|
||||
}
|
||||
await sleep(200);
|
||||
}
|
||||
|
||||
try {
|
||||
process.kill(-child.pid, "SIGKILL");
|
||||
} catch {
|
||||
try {
|
||||
child.kill("SIGKILL");
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForAppShell(options) {
|
||||
const startedAt = Date.now();
|
||||
let lastError = null;
|
||||
|
||||
while (Date.now() - startedAt < options.timeoutMs) {
|
||||
try {
|
||||
const response = await fetch(options.appUrl, { method: "GET" });
|
||||
const html = await response.text();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
assert(
|
||||
html.includes("<title>Lime</title>") ||
|
||||
html.includes('<div id="root"></div>') ||
|
||||
html.includes('<div id="root"></div'),
|
||||
"前端首页返回成功,但未检测到 Lime 根页面标记",
|
||||
);
|
||||
|
||||
console.log(
|
||||
`[verify:gui-smoke] 前端壳已就绪: ${options.appUrl} (${Date.now() - startedAt}ms)`,
|
||||
);
|
||||
return;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
await sleep(options.intervalMs);
|
||||
}
|
||||
}
|
||||
|
||||
const detail =
|
||||
lastError instanceof Error
|
||||
? lastError.message
|
||||
: String(lastError || "unknown error");
|
||||
throw new Error(
|
||||
`[verify:gui-smoke] 前端壳未就绪: ${options.appUrl}。最后错误: ${detail}`,
|
||||
);
|
||||
}
|
||||
|
||||
async function isUrlReady(url, timeoutMs) {
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: "GET",
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
});
|
||||
return response.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveStartupMode(options) {
|
||||
if (options.reuseRunning) {
|
||||
return {
|
||||
shouldStart: false,
|
||||
reusedExisting: true,
|
||||
};
|
||||
}
|
||||
|
||||
const existingAppShell = await isUrlReady(options.appUrl, 1_500);
|
||||
if (!existingAppShell) {
|
||||
return {
|
||||
shouldStart: true,
|
||||
reusedExisting: false,
|
||||
};
|
||||
}
|
||||
|
||||
const existingBridge = await isUrlReady(options.healthUrl, 1_500);
|
||||
if (existingBridge) {
|
||||
console.log(
|
||||
"[verify:gui-smoke] 检测到已有 headless 环境,自动复用现有前端与 DevBridge。",
|
||||
);
|
||||
return {
|
||||
shouldStart: false,
|
||||
reusedExisting: true,
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`[verify:gui-smoke] 检测到已有前端 dev server 正在占用 ${options.appUrl},但 DevBridge 尚未就绪(${options.healthUrl})。请先关闭现有 dev server,或启动完整 headless 环境后使用 --reuse-running。`,
|
||||
);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
if (typeof fetch !== "function") {
|
||||
throw new Error("当前 Node 运行时不支持 fetch,请使用 Node 18+");
|
||||
}
|
||||
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
const startupMode = await resolveStartupMode(options);
|
||||
const startedByScript = startupMode.shouldStart;
|
||||
|
||||
const handleSignal = async (signal) => {
|
||||
try {
|
||||
await stopHeadlessTauri();
|
||||
} finally {
|
||||
process.kill(process.pid, signal);
|
||||
}
|
||||
};
|
||||
|
||||
process.once("SIGINT", () => {
|
||||
void handleSignal("SIGINT");
|
||||
});
|
||||
process.once("SIGTERM", () => {
|
||||
void handleSignal("SIGTERM");
|
||||
});
|
||||
|
||||
try {
|
||||
if (startedByScript) {
|
||||
startHeadlessTauri();
|
||||
await sleep(1_500);
|
||||
} else if (startupMode.reusedExisting) {
|
||||
console.log("[verify:gui-smoke] 复用已运行的 headless Tauri 环境。");
|
||||
}
|
||||
|
||||
runCommand(
|
||||
npmCommand,
|
||||
[
|
||||
"run",
|
||||
"bridge:health",
|
||||
"--",
|
||||
"--url",
|
||||
options.healthUrl,
|
||||
"--timeout-ms",
|
||||
String(options.timeoutMs),
|
||||
"--interval-ms",
|
||||
String(options.intervalMs),
|
||||
],
|
||||
"bridge:health",
|
||||
);
|
||||
|
||||
await waitForAppShell(options);
|
||||
|
||||
runCommand(
|
||||
npmCommand,
|
||||
[
|
||||
"run",
|
||||
"smoke:workspace-ready",
|
||||
"--",
|
||||
"--timeout-ms",
|
||||
String(options.timeoutMs),
|
||||
"--interval-ms",
|
||||
String(options.intervalMs),
|
||||
"--sample-project-name",
|
||||
options.sampleProjectName,
|
||||
],
|
||||
"smoke:workspace-ready",
|
||||
);
|
||||
|
||||
console.log("\n[verify:gui-smoke] 通过");
|
||||
} finally {
|
||||
if (startedByScript) {
|
||||
await stopHeadlessTauri();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
const exitCode =
|
||||
typeof error?.exitCode === "number" && error.exitCode > 0
|
||||
? error.exitCode
|
||||
: 1;
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exit(exitCode);
|
||||
});
|
||||
Reference in New Issue
Block a user