diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a200abe..54afc0c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 }} @@ -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 diff --git a/.gitignore b/.gitignore index d6b77b6..4524f83 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ dist/ +.artifacts/ extension/target/ .DS_Store diff --git a/README.md b/README.md index 1232072..c60ed04 100644 --- a/README.md +++ b/README.md @@ -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 不加载未签名的开发包)。 diff --git a/scripts/bundle-manifest.mjs b/scripts/bundle-manifest.mjs new file mode 100644 index 0000000..a91852c --- /dev/null +++ b/scripts/bundle-manifest.mjs @@ -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 }; +} diff --git a/scripts/package.mjs b/scripts/package.mjs index f808a63..4f0a899 100644 --- a/scripts/package.mjs +++ b/scripts/package.mjs @@ -9,7 +9,8 @@ // // Usage: node scripts/package.mjs [--expect-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"; @@ -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(); + } +}); diff --git a/scripts/process.mjs b/scripts/process.mjs new file mode 100644 index 0000000..b552db7 --- /dev/null +++ b/scripts/process.mjs @@ -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; + } +} diff --git a/scripts/report.mjs b/scripts/report.mjs new file mode 100644 index 0000000..75bfb9b --- /dev/null +++ b/scripts/report.mjs @@ -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) => ({ + "&": "&", "<": "<", ">": ">", '"': """, "'": "'", +})[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 ` ${test.details ? `<${tag} message="step failed">${escape(test.details)}` : ""}`; + }); + writeFileSync(join(directory, `${lane}.junit.xml`), `\n\n${cases.join("\n")}\n\n`); + const rows = tests.map((test) => `${escape(test.name)}${test.durationMs.toFixed(1)}${test.status}${test.details ? `
${escape(test.details)}
` : ""}`); + writeFileSync(join(directory, `${lane}.html`), `${escape(lane)}

${escape(lane)}

${tests.length} steps, ${failures} failures, ${errors} errors, ${durationMs.toFixed(1)} ms total

${rows.join("\n")}
StepDuration (ms)Result
\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 }; +} diff --git a/scripts/test-process.mjs b/scripts/test-process.mjs new file mode 100644 index 0000000..7b95d0c --- /dev/null +++ b/scripts/test-process.mjs @@ -0,0 +1,144 @@ +#!/usr/bin/env node +// Node-only lifecycle regressions; no Maven, network, or installed Lithe needed. +import assert from "node:assert/strict"; +import { chmodSync, cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { performance } from "node:perf_hooks"; +import { mavenTimeout, runCommand as execute, withInterrupts } from "./process.mjs"; +import { createReport } from "./report.mjs"; + +await withInterrupts(async (signal) => { + const runCommand = (command, args, options = {}) => execute(command, args, { signal, ...options }); + const report = createReport("process-regressions"); + const scratch = mkdtempSync(join(tmpdir(), "lithe-process-test-")); + const fixture = resolve("test/fixtures/process-tree.mjs"); + let failed = false; + let sequence = 0; + const ownedRecords = new Set(); + // Independent from the implementation under test: a broken watchdog must not + // turn its own regression into an unbounded CI wait. + const suiteDeadline = setTimeout(() => { + const error = new Error("process regression suite exceeded 30000 ms"); + console.error(error.message); + report.failure("regression suite deadline", error); + for (const file of ownedRecords) cleanup(file); + report.write(); + process.exit(1); + }, 30_000); + const readPids = (file) => readFileSync(file, "utf8").trim().split("\n").map(Number); + const assertGone = (file, count) => { + const pids = readPids(file); + assert.equal(pids.length, count, "the ready handshake must include every descendant"); + for (const pid of pids) assert.throws(() => process.kill(pid, 0), { code: "ESRCH" }, `process ${pid} was reaped`); + }; + const check = async (name, body) => { + try { await report.step(name, body); } + catch (error) { failed = true; console.error(error.stack); } + }; + function cleanup(records) { + try { + for (const pid of readPids(records)) { + try { process.kill(pid, "SIGKILL"); } + catch (error) { if (error.code !== "ESRCH") throw error; } + } + } catch (error) { if (error.code !== "ENOENT") throw error; } + } + const tree = async (mode, action, body) => { + const records = join(scratch, `pids-${++sequence}`); + ownedRecords.add(records); + try { await body(records, [fixture, mode, records, action]); } + finally { + // Test teardown owns even a broken implementation's surviving fixtures. + cleanup(records); + ownedRecords.delete(records); + } + }; + + try { + await check("success and nonzero exit preserve status, cwd, and command diagnostics", async () => { + const listeners = [process.listenerCount("SIGINT"), process.listenerCount("SIGTERM")]; + await runCommand(process.execPath, ["-e", "process.exit(0)"], { cwd: scratch, stdio: "ignore", timeoutMs: 2_000 }); + await assert.rejects(runCommand(process.execPath, ["-e", "process.exit(7)"], { + cwd: scratch, stdio: "ignore", timeoutMs: 2_000, + }), (error) => error.message.includes("status 7") && error.message.includes(scratch) && error.message.includes(process.execPath)); + assert.deepEqual([process.listenerCount("SIGINT"), process.listenerCount("SIGTERM")], listeners); + }); + await check("missing executable fails promptly with a useful diagnostic", async () => { + await assert.rejects(runCommand(join(scratch, "missing"), [], { stdio: "ignore", timeoutMs: 2_000 }), /ENOENT/); + }); + await check("timeout escalates past ignored SIGTERM and reaps the whole tree", () => tree("tree", "timeout", async (records, args) => { + const started = performance.now(); + await assert.rejects(runCommand(process.execPath, args, { + stdio: ["ignore", "pipe", "pipe"], timeoutMs: 1_500, graceMs: 100, killWaitMs: 1_000, + }), /timed out \(limit 1500 ms\)/); + assert.ok(performance.now() - started < 3_500, "timeout and cleanup have a local bound"); + assertGone(records, 2); + })); + await check("an exited parent cannot leave a descendant holding output pipes", () => tree("tree", "exit", async (records, args) => { + await runCommand(process.execPath, args, { + stdio: ["ignore", "pipe", "pipe"], timeoutMs: 3_000, graceMs: 100, killWaitMs: 1_000, + }); + assertGone(records, 2); + })); + for (const signal of ["SIGINT", "SIGTERM"]) { + await check(`${signal} cleans up descendants, runs finally, and returns the conventional status`, () => tree("owner", signal, async (records, args) => { + await assert.rejects(runCommand(process.execPath, args, { + stdio: "ignore", timeoutMs: 8_000, + }), new RegExp(`status ${signal === "SIGINT" ? 130 : 143}`)); + assert.equal(readFileSync(`${records}.cleaned`, "utf8"), "owner cleanup ran\n"); + assertGone(records, 3); + })); + } + await check("missing and malformed command output fail both the lane and its reports", async () => { + const cwd = join(scratch, "invalid-output"); + mkdirSync(cwd); + mkdirSync(join(cwd, "test/fixtures/reactor"), { recursive: true }); + const packageId = "io.github.lithe-idea.maven-build"; + const plugin = join(cwd, "dist/package", packageId); + mkdirSync(join(plugin, "lib"), { recursive: true }); + cpSync(join(packageId, "plugin.json"), join(plugin, "plugin.json")); + writeFileSync(join(plugin, "lib/lithe-maven-extension.jar"), "test placeholder"); + const fake = join(cwd, "fake-maven.mjs"); + cpSync(resolve("test/fixtures/fake-maven.mjs"), fake); + chmodSync(fake, 0o755); + const temporary = join(cwd, "tmp"); + mkdirSync(temporary); + for (const mode of ["missing", "malformed"]) { + const reports = join(cwd, mode); + await assert.rejects(runCommand(process.execPath, [resolve("scripts/test.mjs")], { + cwd, env: { ...process.env, MVN: fake, FAKE_MAVEN_MODE: mode, TMPDIR: temporary, LITHE_REPORT_DIR: reports }, + stdio: "ignore", timeoutMs: 5_000, graceMs: 4_000, + }), /status 1/); + const result = JSON.parse(readFileSync(join(reports, "maven-integration.json"), "utf8")); + assert.ok(result.tests.some((test) => test.status === "error")); + assert.match(readFileSync(join(reports, "maven-integration.junit.xml"), "utf8"), / { + const controller = new AbortController(); + controller.abort(new Error("already cancelled")); + assert.throws(() => runCommand(process.execPath, [], { signal: controller.signal }), /already cancelled/); + for (const timeoutMs of [0, -1, Infinity, NaN, 3_600_001]) { + assert.throws(() => runCommand(process.execPath, [], { timeoutMs }), /timeoutMs must be/); + } + const previous = process.env.LITHE_MAVEN_TIMEOUT_MS; + try { + process.env.LITHE_MAVEN_TIMEOUT_MS = "invalid"; + assert.throws(mavenTimeout, /LITHE_MAVEN_TIMEOUT_MS/); + process.env.LITHE_MAVEN_TIMEOUT_MS = "1000"; + assert.equal(mavenTimeout(), 1_000); + } finally { + if (previous === undefined) delete process.env.LITHE_MAVEN_TIMEOUT_MS; + else process.env.LITHE_MAVEN_TIMEOUT_MS = previous; + } + }); + } finally { + clearTimeout(suiteDeadline); + rmSync(scratch, { recursive: true, force: true }); + report.write(); + } + process.exitCode = failed ? 1 : 0; +}); diff --git a/scripts/test.mjs b/scripts/test.mjs index f23aa62..4f114d8 100644 --- a/scripts/test.mjs +++ b/scripts/test.mjs @@ -12,7 +12,8 @@ // Usage: node scripts/test.mjs import assert from "node:assert/strict"; -import { execFileSync } from "node:child_process"; +import { mavenTimeout, runCommand, withInterrupts } from "./process.mjs"; +import { createReport } from "./report.mjs"; import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; @@ -58,9 +59,13 @@ function expand(list, { outputFile, project, suite }) { return result; } -function run(root, list, values = {}) { +const report = createReport("maven-integration"); +const timeoutMs = mavenTimeout(); +function run(name, root, list, signal, values = {}) { const argv = [...expand(buildSystem.invocation.globalArguments, values), ...expand(list, values)]; - execFileSync(maven, argv, { cwd: root, stdio: ["ignore", "ignore", "inherit"] }); + return report.step(name, () => runCommand(maven, argv, { + cwd: root, stdio: ["ignore", "ignore", "inherit"], timeoutMs, signal, + })); } const scratch = mkdtempSync(join(tmpdir(), "lithe-maven-test-")); @@ -75,104 +80,110 @@ function capture(name, file) { writeFileSync(join(captureDir, name), text); } let failed = false; -const check = (name, body) => { +const check = async (name, body) => { try { - body(); - console.log(`✓ ${name}`); + await report.step(name, body); } catch (error) { failed = true; - console.error(`✗ ${name}\n${error.stack}`); + console.error(error.stack); } }; -try { - cpSync(resolve("test", "fixtures", "reactor"), root, { recursive: true }); - - const modelFile = join(scratch, "model.json"); - run(root, buildSystem.model.arguments, { outputFile: modelFile }); - capture("model.json", modelFile); - const model = JSON.parse(readFileSync(modelFile, "utf8")); - const byName = (name) => model.projects.find((project) => project.name === name); - - check("the model lists the reactor and its aggregator tree", () => { - assert.equal(model.format, "litheBuildModel"); - assert.deepEqual(model.projects.map((project) => project.name), ["reactor", "core", "app"]); - assert.equal(byName("reactor").displayName, "Demo Reactor"); - assert.equal(byName("core").parent, byName("reactor").path); - assert.deepEqual(byName("app").properties, { - groupId: "demo", - artifactId: "app", - version: "1.0.0-SNAPSHOT", - packaging: "jar", +async function test(signal) { + try { + cpSync(resolve("test", "fixtures", "reactor"), root, { recursive: true }); + + const modelFile = join(scratch, "model.json"); + await run("read the reactor model", root, buildSystem.model.arguments, signal, { outputFile: modelFile }); + capture("model.json", modelFile); + const model = JSON.parse(readFileSync(modelFile, "utf8")); + const byName = (name) => model.projects.find((project) => project.name === name); + + await check("the model lists the reactor and its aggregator tree", () => { + assert.equal(model.format, "litheBuildModel"); + assert.deepEqual(model.projects.map((project) => project.name), ["reactor", "core", "app"]); + assert.equal(byName("reactor").displayName, "Demo Reactor"); + assert.equal(byName("core").parent, byName("reactor").path); + assert.deepEqual(byName("app").properties, { + groupId: "demo", + artifactId: "app", + version: "1.0.0-SNAPSHOT", + packaging: "jar", + }); + }); + + await check("profiles become choices of the Profiles option", () => { + assert.deepEqual(model.optionValues.profiles, ["ci", "release"]); + }); + + await check("plugin goals are model tasks filed under Plugins", () => { + const compile = model.tasks.find((task) => task.project === byName("core").path && task.name === "compiler:compile"); + assert.ok(compile, "core has compiler:compile"); + assert.equal(compile.template, "plugin-goal"); + assert.deepEqual(compile.category, ["Plugins", "compiler"]); + assert.match(compile.properties.goal, /^org\.apache\.maven\.plugins:maven-compiler-plugin:[^:]+:compile$/); + const templates = new Set(buildSystem.tasks.filter((task) => task.fromModel).map((task) => task.id)); + assert.ok(model.tasks.every((task) => templates.has(task.template)), "every task names a fromModel template"); }); - }); - - check("profiles become choices of the Profiles option", () => { - assert.deepEqual(model.optionValues.profiles, ["ci", "release"]); - }); - - check("plugin goals are model tasks filed under Plugins", () => { - const compile = model.tasks.find((task) => task.project === byName("core").path && task.name === "compiler:compile"); - assert.ok(compile, "core has compiler:compile"); - assert.equal(compile.template, "plugin-goal"); - assert.deepEqual(compile.category, ["Plugins", "compiler"]); - assert.match(compile.properties.goal, /^org\.apache\.maven\.plugins:maven-compiler-plugin:[^:]+:compile$/); - const templates = new Set(buildSystem.tasks.filter((task) => task.fromModel).map((task) => task.id)); - assert.ok(model.tasks.every((task) => templates.has(task.template)), "every task names a fromModel template"); - }); - - check("jar modules have content roots and the pom aggregator has none", () => { - const core = byName("core").path; - const kinds = model.contentRoots.filter((entry) => entry.project === core).map((entry) => [entry.kind, entry.path]); - assert.deepEqual(kinds.slice(0, 4), [ - ["source", join(core, "src", "main", "java")], - ["resource", join(core, "src", "main", "resources")], - ["testSource", join(core, "src", "test", "java")], - ["testResource", join(core, "src", "test", "resources")], - ]); - assert.ok(!model.contentRoots.some((entry) => entry.project === byName("reactor").path)); - }); - - const projectDependencies = join(scratch, "app-dependencies.json"); - run(root, buildSystem.dependencies.arguments, { outputFile: projectDependencies, project: "app" }); - capture("app-dependencies.json", projectDependencies); - check("a project's tree resolves an uninstalled sibling from the reactor", () => { - const tree = JSON.parse(readFileSync(projectDependencies, "utf8")); - assert.equal(tree.format, "litheDependencies"); - assert.deepEqual(tree.dependencies.map((node) => [node.group, node.name, node.scope]), [["demo", "core", "compile"]]); - }); - - const workspaceDependencies = join(scratch, "dependencies.json"); - run(root, buildSystem.dependencies.arguments, { outputFile: workspaceDependencies }); - capture("dependencies.json", workspaceDependencies); - check("the workspace tree has one node per project with transitive children", () => { - const tree = JSON.parse(readFileSync(workspaceDependencies, "utf8")); - assert.deepEqual(tree.dependencies.map((node) => node.name), ["reactor", "core", "app"]); - const junit = tree.dependencies[1].children[0]; - assert.deepEqual([junit.name, junit.version, junit.scope], ["junit", "4.13.2", "test"]); - assert.equal(junit.children[0].name, "hamcrest-core"); - }); - - check("reading the model and dependencies builds nothing", () => { - for (const directory of ["target", "core/target", "app/target"]) { - assert.ok(!existsSync(join(root, directory)), `${directory} was not created`); - } - }); - - run(root, buildSystem.test.arguments, { project: "core", suite: "demo.core.GreeterTest" }); - check("a filtered test run writes the report the results glob reads", () => { - const report = readFileSync(join(root, "core", "target", "surefire-reports", "TEST-demo.core.GreeterTest.xml"), "utf8"); - assert.match(report, /]*name="demo\.core\.GreeterTest"[^>]*tests="2"/); - }); - - check("without Lithe's properties the extension does nothing", () => { - rmSync(join(root, "core", "target"), { recursive: true, force: true }); - execFileSync(maven, ["-B", "-ntp", "-q", `-Dmaven.ext.class.path=${pluginDir}/lib/lithe-maven-extension.jar`, "validate"], { - cwd: root, - stdio: ["ignore", "ignore", "inherit"], + + await check("jar modules have content roots and the pom aggregator has none", () => { + const core = byName("core").path; + const kinds = model.contentRoots.filter((entry) => entry.project === core).map((entry) => [entry.kind, entry.path]); + assert.deepEqual(kinds.slice(0, 4), [ + ["source", join(core, "src", "main", "java")], + ["resource", join(core, "src", "main", "resources")], + ["testSource", join(core, "src", "test", "java")], + ["testResource", join(core, "src", "test", "resources")], + ]); + assert.ok(!model.contentRoots.some((entry) => entry.project === byName("reactor").path)); + }); + + const projectDependencies = join(scratch, "app-dependencies.json"); + await run("read project dependencies", root, buildSystem.dependencies.arguments, signal, { outputFile: projectDependencies, project: "app" }); + capture("app-dependencies.json", projectDependencies); + await check("a project's tree resolves an uninstalled sibling from the reactor", () => { + const tree = JSON.parse(readFileSync(projectDependencies, "utf8")); + assert.equal(tree.format, "litheDependencies"); + assert.deepEqual(tree.dependencies.map((node) => [node.group, node.name, node.scope]), [["demo", "core", "compile"]]); + }); + + const workspaceDependencies = join(scratch, "dependencies.json"); + await run("read workspace dependencies", root, buildSystem.dependencies.arguments, signal, { outputFile: workspaceDependencies }); + capture("dependencies.json", workspaceDependencies); + await check("the workspace tree has one node per project with transitive children", () => { + const tree = JSON.parse(readFileSync(workspaceDependencies, "utf8")); + assert.deepEqual(tree.dependencies.map((node) => node.name), ["reactor", "core", "app"]); + const junit = tree.dependencies[1].children[0]; + assert.deepEqual([junit.name, junit.version, junit.scope], ["junit", "4.13.2", "test"]); + assert.equal(junit.children[0].name, "hamcrest-core"); }); - }); -} finally { - rmSync(scratch, { recursive: true, force: true }); + + await check("reading the model and dependencies builds nothing", () => { + for (const directory of ["target", "core/target", "app/target"]) { + assert.ok(!existsSync(join(root, directory)), `${directory} was not created`); + } + }); + + await run("run the filtered Maven test suite", root, buildSystem.test.arguments, signal, { project: "core", suite: "demo.core.GreeterTest" }); + await check("a filtered test run writes the report the results glob reads", () => { + const report = readFileSync(join(root, "core", "target", "surefire-reports", "TEST-demo.core.GreeterTest.xml"), "utf8"); + assert.match(report, /]*name="demo\.core\.GreeterTest"[^>]*tests="2"/); + }); + + await check("without Lithe's properties the extension does nothing", async () => { + rmSync(join(root, "core", "target"), { recursive: true, force: true }); + await runCommand(maven, ["-B", "-ntp", "-q", `-Dmaven.ext.class.path=${pluginDir}/lib/lithe-maven-extension.jar`, "validate"], { + cwd: root, stdio: ["ignore", "ignore", "inherit"], timeoutMs, signal, + }); + }); + } catch (error) { + failed = true; + report.failure("integration lane failed", error); + console.error(error.stack); + } finally { + try { await report.step("remove the scratch reactor", () => rmSync(scratch, { recursive: true, force: true })); } + finally { report.write(); } + } + process.exitCode = failed ? 1 : 0; } -process.exit(failed ? 1 : 0); +await withInterrupts(test); diff --git a/scripts/verify-scripts.mjs b/scripts/verify-scripts.mjs new file mode 100644 index 0000000..71f4a5c --- /dev/null +++ b/scripts/verify-scripts.mjs @@ -0,0 +1,87 @@ +#!/usr/bin/env node +// Run real packaging/testing in a disposable source copy. Existing accepted ZIPs, +// development packages and explicitly supplied host bundles stay read-only. +// Usage: node scripts/verify-scripts.mjs --host-bundle /path/to/Lithe.app [--host-bundle ...] +import assert from "node:assert/strict"; +import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { basename, join, resolve } from "node:path"; +import { bundleManifest } from "./bundle-manifest.mjs"; +import { mavenTimeout, runCommand, withInterrupts } from "./process.mjs"; +import { createReport } from "./report.mjs"; + +const hosts = []; +for (let i = 2; i < process.argv.length; i += 2) { + if (process.argv[i] !== "--host-bundle" || !process.argv[i + 1]) throw new Error("expected --host-bundle /path/to/Lithe.app"); + hosts.push(resolve(process.argv[i + 1])); +} +if (!hosts.length) throw new Error("at least one --host-bundle is required for the host immutability check"); +const packageId = "io.github.lithe-idea.maven-build"; +const originalPackage = resolve("dist/package", packageId); +const protectedPaths = [resolve(packageId), ...hosts]; +if (existsSync(originalPackage)) protectedPaths.push(originalPackage); +const manifest = JSON.parse(readFileSync(join(packageId, "plugin.json"), "utf8")); +const originalZip = resolve("dist", `maven-build-${manifest.version}-darwin.zip`); +if (existsSync(originalZip)) protectedPaths.push(originalZip); +const artifacts = resolve(".artifacts/script-verification"); +mkdirSync(artifacts, { recursive: true }); +const evidence = mkdtempSync(join(artifacts, "run-")); +const scratch = mkdtempSync(join(tmpdir(), "lithe-script-verification-")); +const report = createReport("script-verification"); +const before = protectedPaths.map(bundleManifest); +const snapshot = (name, manifests) => writeFileSync(join(evidence, `${name}.json`), `${JSON.stringify(manifests, null, 2)}\n`); +snapshot("before", before); +const unchanged = (name) => { + const after = protectedPaths.map(bundleManifest); + snapshot(name, after); + for (let i = 0; i < before.length; i++) assert.deepEqual(after[i], before[i], `${protectedPaths[i]} changed`); +}; + +await withInterrupts(async (signal) => { + try { + for (const path of [packageId, "scripts", "extension", "test", "LICENSE", "NOTICE"]) { + cpSync(resolve(path), join(scratch, path), { + recursive: true, + filter: (source) => source !== resolve("extension/target"), + }); + } + const timeoutMs = mavenTimeout(); + const env = { ...process.env, LITHE_REPORT_DIR: join(scratch, ".artifacts/test-stability") }; + await report.step("package in an isolated source copy", () => runCommand(process.execPath, ["scripts/package.mjs"], { + cwd: scratch, env, timeoutMs: timeoutMs + 40_000, graceMs: 4_000, signal, + })); + await report.step("packaging preserves original plugins, accepted ZIP and host bundles", () => unchanged("after-package")); + const built = join(scratch, "dist/package", packageId); + const packaged = bundleManifest(built); + snapshot("test-before", [packaged]); + await report.step("test the newly built package with real Maven", () => runCommand(process.execPath, ["scripts/test.mjs"], { + cwd: scratch, env, timeoutMs: timeoutMs * 5 + 20_000, graceMs: 4_000, signal, + })); + await report.step("integration testing preserves the new package and host bundles", () => { + const tested = bundleManifest(built); + snapshot("test-after", [tested]); + assert.deepEqual(tested, packaged, "Maven integration tests modified the packaged plugin"); + unchanged("after-test"); + }); + cpSync(join(scratch, "dist"), join(evidence, "new-build"), { recursive: true }); + console.log(`Verification evidence: ${evidence}`); + console.log(`New build: ${join(evidence, "new-build", basename(originalZip))}`); + } catch (error) { + report.failure("script verification failed", error); + console.error(error.stack); + process.exitCode = 1; + } finally { + try { + // Preserve partial timings on failure as well as successful runs. + await report.step("preserve individual command timings", () => { + if (existsSync(join(scratch, ".artifacts/test-stability"))) { + cpSync(join(scratch, ".artifacts/test-stability"), join(evidence, "timing"), { recursive: true }); + } + }); + await report.step("final inventory preserves all protected files", () => unchanged("final")); + } finally { + rmSync(scratch, { recursive: true, force: true }); + report.write(); + } + } +}); diff --git a/test/fixtures/fake-maven.mjs b/test/fixtures/fake-maven.mjs new file mode 100644 index 0000000..b5666fb --- /dev/null +++ b/test/fixtures/fake-maven.mjs @@ -0,0 +1,7 @@ +#!/usr/bin/env node +// Deterministic command failures for the integration script's reporting paths. +import { writeFileSync } from "node:fs"; +if (process.env.FAKE_MAVEN_MODE === "missing") process.exit(0); +const output = process.argv.find((arg) => arg.startsWith("-Dlithe.modelOutput=")); +if (!output) throw new Error("expected a model output argument"); +writeFileSync(output.slice("-Dlithe.modelOutput=".length), "{invalid JSON"); diff --git a/test/fixtures/process-tree.mjs b/test/fixtures/process-tree.mjs new file mode 100644 index 0000000..dfbaae6 --- /dev/null +++ b/test/fixtures/process-tree.mjs @@ -0,0 +1,42 @@ +// Synthetic subprocesses for the script lifecycle regressions. Readiness flows +// over IPC; timers only keep an idle process alive, never coordinate assertions. +import { spawn } from "node:child_process"; +import { appendFileSync, writeFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { runCommand, withInterrupts } from "../../scripts/process.mjs"; + +const [mode, records, action = "timeout"] = process.argv.slice(2); +const file = fileURLToPath(import.meta.url); +appendFileSync(records, `${process.pid}\n`); + +if (mode === "owner") { + await withInterrupts(async (signal) => { + try { + await runCommand(process.execPath, [file, "tree", records, action], { + signal, timeoutMs: 10_000, graceMs: 100, killWaitMs: 1_000, + }); + } catch (error) { + console.error(error.message); + process.exitCode = 1; + } finally { + writeFileSync(`${records}.cleaned`, "owner cleanup ran\n"); + } + }); +} else if (mode === "tree") { + process.on("SIGTERM", () => {}); + const leaf = spawn(process.execPath, [file, "leaf", records], { stdio: ["ignore", "inherit", "inherit", "ipc"] }); + const readyDeadline = setTimeout(() => process.exit(91), 5_000); + leaf.once("message", () => { + clearTimeout(readyDeadline); + console.log("tree ready"); + if (action === "exit") process.exit(0); + if (action === "SIGINT" || action === "SIGTERM") process.kill(process.ppid, action); + }); +} else if (mode === "leaf") { + process.on("SIGTERM", () => {}); + process.send("ready"); + process.disconnect(); + setInterval(() => {}, 60_000); +} else { + throw new Error(`unknown fixture mode: ${mode}`); +}