Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ jobs:
with:
distribution: temurin
java-version: 17
- name: Check script process deadlines and cleanup
run: node scripts/test-process.mjs
- name: Check manifest and build the archive
run: node scripts/package.mjs
- name: Download Maven ${{ matrix.maven }}
Expand Down Expand Up @@ -57,3 +59,10 @@ jobs:
}
process.exit(failed ? 1 : 0);
"
- name: Upload script timing reports
if: always()
uses: actions/upload-artifact@v4
with:
name: script-timings-maven-${{ matrix.maven }}
path: .artifacts/test-stability/
if-no-files-found: warn
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
dist/
.artifacts/
extension/target/
.DS_Store
23 changes: 22 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,28 @@ node scripts/package.mjs
node scripts/test.mjs
```

`MVN=/path/to/mvn node scripts/test.mjs` 可以换一个 Maven 版本测试。
`MVN=/path/to/mvn` 可为打包和测试指定 Maven 版本。脚本在 macOS/Linux 上运行;每次 Maven 调用默认限时 120 秒,
可用 `LITHE_MAVEN_TIMEOUT_MS` 调整(1–600000 毫秒);`zip` 限时 30 秒。到期或收到 SIGINT/SIGTERM 时,
脚本会终止本次启动的进程组,500 毫秒后升级为 SIGKILL,再最多等待 2 秒并报告清理失败。
每个命令和检查的耗时、结果会写入 `.artifacts/test-stability/` 的 JSON、JUnit XML 和 HTML;
`LITHE_REPORT_DIR` 可以指定其他报告目录。真实 Maven 测试是依赖本机工具及 Maven 仓库的集成测试。

Node 子进程回归不需要 Maven 或联网,覆盖拒绝 SIGTERM 的子孙进程、父进程先退出、异常退出和中断清理:

```bash
node scripts/test-process.mjs
```

要验证打包/测试前后已有插件、ZIP 和宿主 bundle 的文件清单及 SHA-256 不变,可运行:

```bash
node scripts/verify-scripts.mjs --host-bundle /Applications/Lithe.app
```

`--host-bundle` 可以重复传入,以同时检查其他宿主实例。该验证器在临时源码副本中重新打包并测试,
保留原有 `dist/`,将新包、前后清单和逐项计时保存在 `.artifacts/script-verification/run-*/`,最后清理临时目录。
这只验证本次脚本工作流;宿主 UI 的实机验收需要另外执行。

在 Lithe 开发版中,通过 **设置 → 插件 → ⚙ → 从磁盘安装插件…** 选择 `dist/package/io.github.lithe-idea.maven-build`
即可安装本地开发包(发行版 Lithe 不加载未签名的开发包)。

Expand Down
24 changes: 24 additions & 0 deletions scripts/bundle-manifest.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
// Read-only file inventory: names, kinds, permissions, link targets and bytes.
import { createHash } from "node:crypto";
import { lstatSync, readdirSync, readFileSync, readlinkSync } from "node:fs";
import { join } from "node:path";

export function bundleManifest(root) {
const entries = [];
const walk = (path, relative) => {
const stat = lstatSync(path);
const entry = { path: relative, mode: stat.mode & 0o777 };
if (stat.isSymbolicLink()) {
entries.push({ ...entry, kind: "link", target: readlinkSync(path) });
} else if (stat.isDirectory()) {
entries.push({ ...entry, kind: "directory" });
for (const name of readdirSync(path).sort()) walk(join(path, name), relative ? `${relative}/${name}` : name);
} else if (stat.isFile()) {
entries.push({ ...entry, kind: "file", size: stat.size, sha256: createHash("sha256").update(readFileSync(path)).digest("hex") });
} else {
throw new Error(`unsupported bundle entry: ${path}`);
}
};
walk(root, "");
return { root, sha256: createHash("sha256").update(JSON.stringify(entries)).digest("hex"), entries };
}
52 changes: 36 additions & 16 deletions scripts/package.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@
//
// Usage: node scripts/package.mjs [--expect-tag <tag>]

import { execFileSync } from "node:child_process";
import { mavenTimeout, runCommand, withInterrupts, ZIP_TIMEOUT_MS } from "./process.mjs";
import { createReport } from "./report.mjs";
import { createHash } from "node:crypto";
import { cpSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, rmSync } from "node:fs";
import { join, resolve } from "node:path";
Expand Down Expand Up @@ -55,20 +56,39 @@ function assertNoLinks(directory) {
}
}

execFileSync("mvn", ["-B", "-ntp", "-q", "-f", "extension/pom.xml", "clean", "package"], { stdio: "inherit" });
if (!existsSync(EXTENSION_JAR)) throw new Error(`${EXTENSION_JAR} was not built`);
const report = createReport("package");
const timeoutMs = mavenTimeout();
await withInterrupts(async (signal) => {
try {
await report.step("build the Maven extension", async () => {
await runCommand(process.env.MVN ?? "mvn", ["-B", "-ntp", "-q", "-f", "extension/pom.xml", "clean", "package"], { timeoutMs, signal });
if (!existsSync(EXTENSION_JAR)) throw new Error(`${EXTENSION_JAR} was not built`);
});

rmSync("dist", { recursive: true, force: true });
const packageRoot = join(PACKAGE_ROOT, PACKAGE_DIR);
cpSync(PACKAGE_DIR, packageRoot, { recursive: true });
mkdirSync(join(packageRoot, "lib"), { recursive: true });
cpSync(EXTENSION_JAR, join(packageRoot, "lib", "lithe-maven-extension.jar"));
cpSync("LICENSE", join(packageRoot, "LICENSE"));
cpSync("NOTICE", join(packageRoot, "NOTICE"));
assertNoLinks(packageRoot);
await report.step("assemble the plugin package", () => {
signal.throwIfAborted();
rmSync("dist", { recursive: true, force: true });
const packageRoot = join(PACKAGE_ROOT, PACKAGE_DIR);
cpSync(PACKAGE_DIR, packageRoot, { recursive: true });
mkdirSync(join(packageRoot, "lib"), { recursive: true });
cpSync(EXTENSION_JAR, join(packageRoot, "lib", "lithe-maven-extension.jar"));
cpSync("LICENSE", join(packageRoot, "LICENSE"));
cpSync("NOTICE", join(packageRoot, "NOTICE"));
assertNoLinks(packageRoot);
});

const asset = resolve("dist", `${ASSET_PREFIX}-${manifest.version}-${PLATFORM}.zip`);
// -X drops extra file attributes; -y stores a symbolic link as a link so
// Lithe refuses it instead of the archive silently carrying its target.
execFileSync("zip", ["-qrXy", asset, PACKAGE_DIR, "-x", "*.DS_Store"], { cwd: PACKAGE_ROOT });
console.log(`dist/${ASSET_PREFIX}-${manifest.version}-${PLATFORM}.zip ${sha256(asset)}`);
const asset = resolve("dist", `${ASSET_PREFIX}-${manifest.version}-${PLATFORM}.zip`);
// -X drops extra file attributes; -y stores a symbolic link as a link so
// Lithe refuses it instead of the archive silently carrying its target.
await report.step("archive the plugin package", () => runCommand("zip", ["-qrXy", asset, PACKAGE_DIR, "-x", "*.DS_Store"], {
cwd: PACKAGE_ROOT, timeoutMs: ZIP_TIMEOUT_MS, signal,
}));
console.log(`dist/${ASSET_PREFIX}-${manifest.version}-${PLATFORM}.zip ${sha256(asset)}`);
} catch (error) {
report.failure("packaging lane failed", error);
console.error(error.stack);
process.exitCode = 1;
} finally {
report.write();
}
});
135 changes: 135 additions & 0 deletions scripts/process.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
// Own each command's POSIX process group, including Maven's forked JVMs.
// Async spawn keeps the watchdog and interrupt handlers runnable even when
// the command ignores SIGTERM; execFileSync's timeout cannot guarantee that.
import { spawn } from "node:child_process";
import { performance } from "node:perf_hooks";

export const MAVEN_TIMEOUT_MS = 120_000;
export const ZIP_TIMEOUT_MS = 30_000;
const GRACE_MS = 500;
const KILL_WAIT_MS = 2_000;

export function mavenTimeout() {
const value = process.env.LITHE_MAVEN_TIMEOUT_MS;
if (value === undefined) return MAVEN_TIMEOUT_MS;
const timeout = Number(value);
if (!/^\d+$/.test(value) || !Number.isSafeInteger(timeout) || timeout < 1 || timeout > 600_000) {
throw new Error("LITHE_MAVEN_TIMEOUT_MS must be an integer from 1 to 600000");
}
return timeout;
}

export function runCommand(command, args, {
cwd = process.cwd(), env = process.env, stdio = "inherit", timeoutMs = MAVEN_TIMEOUT_MS,
graceMs = GRACE_MS, killWaitMs = KILL_WAIT_MS, signal,
} = {}) {
for (const [name, value] of Object.entries({ timeoutMs, graceMs, killWaitMs })) {
if (!Number.isSafeInteger(value) || value < 1 || value > 3_600_000) {
throw new Error(`${name} must be an integer from 1 to 3600000`);
}
}
if (process.platform === "win32") throw new Error("These packaging/test scripts require POSIX process groups (macOS or Linux)");
signal?.throwIfAborted();
const started = performance.now();
const invocation = [command, ...args].map((arg) => JSON.stringify(arg)).join(" ");

return new Promise((resolve, reject) => {
const child = spawn(command, args, { cwd, env, stdio, detached: true, shell: false });
let closed = false;
let code;
let exitSignal;
let failure;
let stopping = false;
let settled = false;
let graceTimer;
let killTimer;
let pollTimer;

const diagnostic = (message) => new Error(
`${message} after ${Math.round(performance.now() - started)} ms\ncommand: ${invocation}\ncwd: ${cwd}`,
);
const groupExists = () => {
if (!child.pid) return false;
try { process.kill(-child.pid, 0); return true; }
catch (error) { if (error.code === "ESRCH") return false; throw error; }
};
const send = (value) => {
if (!child.pid) return;
try { process.kill(-child.pid, value); }
catch (error) { if (error.code !== "ESRCH") failure ??= diagnostic(`cannot send ${value}: ${error.message}`); }
};
const finish = () => {
if (settled) return;
settled = true;
clearTimeout(timeoutTimer);
clearTimeout(graceTimer);
clearTimeout(killTimer);
clearTimeout(pollTimer);
signal?.removeEventListener("abort", onAbort);
if (failure) reject(failure);
else if (code !== 0) reject(diagnostic(`command exited with ${exitSignal ? `signal ${exitSignal}` : `status ${code}`}`));
else resolve();
};
// Poll only the owned OS process group, with the cleanup deadline below.
const inspect = () => {
try {
if (closed && !groupExists()) return finish();
} catch (error) { failure ??= diagnostic(`cannot inspect process group: ${error.message}`); }
pollTimer = setTimeout(inspect, 20);
};
const stop = () => {
if (stopping) return;
stopping = true;
clearTimeout(timeoutTimer);
send("SIGTERM");
graceTimer = setTimeout(() => send("SIGKILL"), graceMs);
killTimer = setTimeout(() => {
send("SIGKILL");
const cleanup = diagnostic("process group did not disappear before the cleanup deadline");
failure = failure ? new Error(`${failure.message}\ncleanup: ${cleanup.message}`, { cause: failure }) : cleanup;
// A descendant may keep a pipe open after its parent has exited.
for (const stream of child.stdio) stream?.destroy?.();
child.unref();
finish();
}, graceMs + killWaitMs);
inspect();
};
const onAbort = () => {
failure ??= diagnostic(`command cancelled: ${signal.reason?.message ?? signal.reason ?? "abort"}`);
stop();
};
const timeoutTimer = setTimeout(() => {
failure ??= diagnostic(`command timed out (limit ${timeoutMs} ms)`);
stop();
}, timeoutMs);
child.once("error", (error) => {
failure ??= diagnostic(`cannot run command: ${error.message}`);
stop();
});
child.once("exit", (status, value) => {
code = status;
exitSignal = value;
stop();
});
child.once("close", () => { closed = true; stop(); });
signal?.addEventListener("abort", onAbort, { once: true });
if (signal?.aborted) onAbort();
});
}

// Keep handlers installed through scratch cleanup and report writing. A repeated
// interrupt still follows the same bounded escalation instead of orphaning JVMs.
export async function withInterrupts(body) {
const controller = new AbortController();
let interrupted;
const handlers = new Map(["SIGINT", "SIGTERM"].map((name) => [name, () => {
interrupted ??= name;
controller.abort(new Error(name));
}]));
for (const [name, handler] of handlers) process.on(name, handler);
try { await body(controller.signal); }
finally {
for (const [name, handler] of handlers) process.off(name, handler);
if (interrupted) process.exitCode = interrupted === "SIGINT" ? 130 : 143;
}
}
48 changes: 48 additions & 0 deletions scripts/report.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
// Dependency-free per-step reports for packaging and the Maven integration lane.
import { mkdirSync, writeFileSync } from "node:fs";
import { join, resolve } from "node:path";
import { performance } from "node:perf_hooks";

const escape = (value) => String(value).replace(/[&<>"']/g, (char) => ({
"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&apos;",
})[char]);

export function createReport(lane) {
const started = performance.now();
const tests = [];
const step = async (name, body) => {
const before = performance.now();
const test = { name, suite: lane, status: "passed", durationMs: 0 };
tests.push(test);
try { return await body(); }
catch (error) {
test.status = "failed";
test.details = error.stack ?? String(error);
throw error;
} finally {
test.durationMs = performance.now() - before;
console.log(`${test.status === "passed" ? "✓" : "✗"} ${name} (${test.durationMs.toFixed(1)} ms)`);
}
};
const write = () => {
const directory = resolve(process.env.LITHE_REPORT_DIR ?? ".artifacts/test-stability");
mkdirSync(directory, { recursive: true });
const durationMs = performance.now() - started;
const failures = tests.filter((test) => test.status === "failed").length;
const errors = tests.filter((test) => test.status === "error").length;
const report = { schemaVersion: 1, runner: "node", lane, warnMs: 1_000, maxMs: 610_000, durationMs, tests };
writeFileSync(join(directory, `${lane}.json`), `${JSON.stringify(report, null, 2)}\n`);
const cases = tests.map((test) => {
const tag = test.status === "error" ? "error" : "failure";
return ` <testcase classname="${escape(test.suite)}" name="${escape(test.name)}" time="${test.durationMs / 1000}">${test.details ? `<${tag} message="step failed">${escape(test.details)}</${tag}>` : ""}</testcase>`;
});
writeFileSync(join(directory, `${lane}.junit.xml`), `<?xml version="1.0" encoding="UTF-8"?>\n<testsuite name="${escape(lane)}" tests="${tests.length}" failures="${failures}" errors="${errors}" time="${durationMs / 1000}">\n${cases.join("\n")}\n</testsuite>\n`);
const rows = tests.map((test) => `<tr><td>${escape(test.name)}</td><td>${test.durationMs.toFixed(1)}</td><td>${test.status}${test.details ? `<pre>${escape(test.details)}</pre>` : ""}</td></tr>`);
writeFileSync(join(directory, `${lane}.html`), `<!doctype html><html lang="en"><meta charset="utf-8"><title>${escape(lane)}</title><style>body{font:16px system-ui;margin:2rem}table{border-collapse:collapse;width:100%}td,th{text-align:left;padding:.5rem;border-bottom:1px solid #aaa}pre{white-space:pre-wrap}</style><h1>${escape(lane)}</h1><p>${tests.length} steps, ${failures} failures, ${errors} errors, ${durationMs.toFixed(1)} ms total</p><table><thead><tr><th>Step</th><th>Duration (ms)</th><th>Result</th></tr></thead><tbody>${rows.join("\n")}</tbody></table></html>\n`);
console.log(`Timing reports: ${join(directory, `${lane}.html`)} (JSON, JUnit alongside)`);
};
const failure = (name, error) => {
tests.push({ name, suite: lane, status: "error", durationMs: 0, details: error.stack ?? String(error) });
};
return { step, write, failure };
}
Loading
Loading