From 87142b5fb10a1705d079b27c4835e77c8ffcef2a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 26 Apr 2026 01:59:07 +0100 Subject: [PATCH] test: narrow live Docker package script changes --- docs/help/testing.md | 2 +- scripts/changed-lanes.mjs | 112 ++++++++++++++++++++++- scripts/check-changed.mjs | 10 ++- test/scripts/changed-lanes.test.ts | 139 ++++++++++++++++++++++++++++- 4 files changed, 257 insertions(+), 6 deletions(-) diff --git a/docs/help/testing.md b/docs/help/testing.md index eb4249bc974..78a1d253bb8 100644 --- a/docs/help/testing.md +++ b/docs/help/testing.md @@ -400,7 +400,7 @@ Think of the suites as “increasing realism” (and increasing flakiness/cost): - `pnpm test`, `pnpm test:watch`, and `pnpm test:perf:imports` route explicit file/directory targets through scoped lanes first, so `pnpm test extensions/discord/src/monitor/message-handler.preflight.test.ts` avoids paying the full root project startup tax. - `pnpm test:changed` expands changed git paths into the same scoped lanes when the diff only touches routable source/test files; config/setup edits still fall back to the broad root-project rerun. - `pnpm check:changed` is the normal smart local gate for narrow work. It classifies the diff into core, core tests, extensions, extension tests, apps, docs, release metadata, live Docker tooling, and tooling, then runs the matching typecheck/lint/test lanes. Public Plugin SDK and plugin-contract changes include one extension validation pass because extensions depend on those core contracts. Release metadata-only version bumps run targeted version/config/root-dependency checks instead of the full suite, with a guard that rejects package changes outside the top-level version field. - - Live Docker ACP harness edits run a focused local gate: shell syntax for the live Docker auth scripts, live Docker scheduler dry-run, ACP bind unit tests, and the ACPX extension tests. They do not trigger the full Vitest matrix unless the diff also touches a root/global surface such as dependencies or shared Vitest setup. + - Live Docker ACP harness edits run a focused local gate: shell syntax for the live Docker auth scripts, live Docker scheduler dry-run, ACP bind unit tests, and the ACPX extension tests. `package.json` changes are included only when the diff is limited to `scripts["test:docker:live-*"]`; dependency, export, version, and other package-surface edits still use the broader guards. - Import-light unit tests from agents, commands, plugins, auto-reply helpers, `plugin-sdk`, and similar pure utility areas route through the `unit-fast` lane, which skips `test/setup-openclaw-runtime.ts`; stateful/runtime-heavy files stay on the existing lanes. - Selected `plugin-sdk` and `commands` helper source files also map changed-mode runs to explicit sibling tests in those light lanes, so helper edits avoid rerunning the full heavy suite for that directory. - `auto-reply` has dedicated buckets for top-level core helpers, top-level `reply.*` integration tests, and the `src/auto-reply/reply/**` subtree. CI further splits the reply subtree into agent-runner, dispatch, and commands/state-routing shards so one import-heavy bucket does not own the full Node tail. diff --git a/scripts/changed-lanes.mjs b/scripts/changed-lanes.mjs index b21dc6e6a29..fdf25890412 100644 --- a/scripts/changed-lanes.mjs +++ b/scripts/changed-lanes.mjs @@ -1,5 +1,5 @@ import { execFileSync } from "node:child_process"; -import { appendFileSync } from "node:fs"; +import { appendFileSync, existsSync, readFileSync } from "node:fs"; import { booleanFlag, parseFlagArgs, stringFlag } from "./lib/arg-utils.mjs"; const DOCS_PATH_RE = /^(?:docs\/|README\.md$|AGENTS\.md$|.*\.mdx?$)/u; @@ -12,6 +12,7 @@ const ROOT_GLOBAL_PATH_RE = /^(?:package\.json$|pnpm-lock\.yaml$|pnpm-workspace\.yaml$|tsdown\.config\.ts$|vitest\.config\.ts$)/u; const LIVE_DOCKER_TOOLING_PATH_RE = /^(?:scripts\/test-docker-all\.mjs|scripts\/test-docker-all\.sh|scripts\/lib\/live-docker-auth\.sh|scripts\/test-live-(?:acp-bind|cli-backend|codex-harness|gateway-models|models)-docker\.sh|src\/gateway\/gateway-acp-bind\.live\.test\.ts|src\/gateway\/live-agent-probes\.test\.ts)$/u; +const LIVE_DOCKER_PACKAGE_SCRIPT_RE = /^test:docker:live-[\w:-]+$/u; const TEST_PATH_RE = /(?:^|\/)(?:test|__tests__)\/|(?:\.|\/)(?:test|spec|e2e|browser\.test)\.[cm]?[jt]sx?$/u; const PUBLIC_EXTENSION_CONTRACT_RE = @@ -66,9 +67,10 @@ export function createEmptyChangedLanes() { /** * @param {string[]} changedPaths + * @param {{ packageJsonChangeKind?: "liveDockerTooling" | null }} [options] * @returns {ChangedLaneResult} */ -export function detectChangedLanes(changedPaths) { +export function detectChangedLanes(changedPaths, options = {}) { const paths = [...new Set(changedPaths.map(normalizeChangedPath).filter(Boolean))] .toSorted((left, right) => left.localeCompare(right)) .filter((changedPath) => changedPath !== "--"); @@ -76,6 +78,8 @@ export function detectChangedLanes(changedPaths) { const reasons = []; let extensionImpactFromCore = false; let hasNonDocs = false; + const packageJsonIsLiveDockerTooling = + paths.includes("package.json") && options.packageJsonChangeKind === "liveDockerTooling"; if (paths.length === 0) { reasons.push("no changed paths"); @@ -83,6 +87,7 @@ export function detectChangedLanes(changedPaths) { } if ( + !packageJsonIsLiveDockerTooling && paths.some((changedPath) => RELEASE_METADATA_PATHS.has(changedPath)) && paths.every( (changedPath) => RELEASE_METADATA_PATHS.has(changedPath) || DOCS_PATH_RE.test(changedPath), @@ -104,6 +109,12 @@ export function detectChangedLanes(changedPaths) { hasNonDocs = true; + if (changedPath === "package.json" && packageJsonIsLiveDockerTooling) { + lanes.liveDockerTooling = true; + reasons.push(`${changedPath}: live Docker package scripts`); + continue; + } + if (LIVE_DOCKER_TOOLING_PATH_RE.test(changedPath)) { lanes.liveDockerTooling = true; reasons.push(`${changedPath}: live Docker tooling surface`); @@ -231,6 +242,94 @@ export function listStagedChangedPaths() { return output.split("\n").map(normalizeChangedPath).filter(Boolean); } +export function classifyPackageJsonChangeFromGit(params) { + try { + const { before, after } = readPackageJsonBeforeAfter(params); + return isLiveDockerPackageScriptOnlyChange(before, after) ? "liveDockerTooling" : null; + } catch { + return null; + } +} + +export function isLiveDockerPackageScriptOnlyChange(before, after) { + const beforePackage = JSON.parse(before); + const afterPackage = JSON.parse(after); + const beforeAllowed = extractLiveDockerPackageScripts(beforePackage); + const afterAllowed = extractLiveDockerPackageScripts(afterPackage); + const beforeStripped = stripLiveDockerPackageScripts(beforePackage); + const afterStripped = stripLiveDockerPackageScripts(afterPackage); + + return ( + stableJson(beforeStripped) === stableJson(afterStripped) && + stableJson(beforeAllowed) !== stableJson(afterAllowed) + ); +} + +function readPackageJsonBeforeAfter(params) { + const before = readGitText(params.staged ? "HEAD" : params.base, "package.json"); + if (params.staged) { + return { before, after: readGitText("INDEX", "package.json") }; + } + + let after = readGitText(params.head ?? "HEAD", "package.json"); + if (params.includeWorktree !== false && existsSync("package.json")) { + const worktree = readGitText("WORKTREE", "package.json"); + if (worktree !== after) { + after = worktree; + } + } + return { before, after }; +} + +function readGitText(ref, filePath) { + if (ref === "WORKTREE") { + return readFileSync(filePath, "utf8"); + } + const spec = ref === "INDEX" ? `:${filePath}` : `${ref}:${filePath}`; + return execFileSync("git", ["show", spec], { + stdio: ["ignore", "pipe", "pipe"], + encoding: "utf8", + maxBuffer: 16 * 1024 * 1024, + }); +} + +function extractLiveDockerPackageScripts(packageJson) { + const scripts = packageJson?.scripts; + if (!scripts || typeof scripts !== "object" || Array.isArray(scripts)) { + return {}; + } + return Object.fromEntries( + Object.entries(scripts).filter(([name]) => LIVE_DOCKER_PACKAGE_SCRIPT_RE.test(name)), + ); +} + +function stripLiveDockerPackageScripts(packageJson) { + const clone = JSON.parse(JSON.stringify(packageJson)); + const scripts = clone.scripts; + if (!scripts || typeof scripts !== "object" || Array.isArray(scripts)) { + return clone; + } + for (const name of Object.keys(scripts)) { + if (LIVE_DOCKER_PACKAGE_SCRIPT_RE.test(name)) { + delete scripts[name]; + } + } + return clone; +} + +function stableJson(value) { + if (Array.isArray(value)) { + return `[${value.map(stableJson).join(",")}]`; + } + if (value && typeof value === "object") { + return `{${Object.keys(value) + .toSorted((left, right) => left.localeCompare(right)) + .map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`) + .join(",")}}`; + } + return JSON.stringify(value); +} + export function writeChangedLaneGitHubOutput(result, outputPath = process.env.GITHUB_OUTPUT) { if (!outputPath) { throw new Error("GITHUB_OUTPUT is required"); @@ -319,7 +418,14 @@ if (isDirectRun()) { : args.staged ? listStagedChangedPaths() : listChangedPathsFromGit({ base: args.base, head: args.head }); - const result = detectChangedLanes(paths); + const packageJsonChangeKind = paths.includes("package.json") + ? classifyPackageJsonChangeFromGit({ + base: args.base, + head: args.head, + staged: args.staged, + }) + : null; + const result = detectChangedLanes(paths, { packageJsonChangeKind }); if (args.githubOutput) { writeChangedLaneGitHubOutput(result); } diff --git a/scripts/check-changed.mjs b/scripts/check-changed.mjs index fea2b5c3cc8..f8a5df47902 100644 --- a/scripts/check-changed.mjs +++ b/scripts/check-changed.mjs @@ -1,5 +1,6 @@ import { performance } from "node:perf_hooks"; import { + classifyPackageJsonChangeFromGit, detectChangedLanes, listChangedPathsFromGit, listStagedChangedPaths, @@ -407,7 +408,14 @@ if (isDirectRun()) { : args.staged ? listStagedChangedPaths() : listChangedPathsFromGit({ base: args.base, head: args.head }); - const result = detectChangedLanes(paths); + const packageJsonChangeKind = paths.includes("package.json") + ? classifyPackageJsonChangeFromGit({ + base: args.base, + head: args.head, + staged: args.staged, + }) + : null; + const result = detectChangedLanes(paths, { packageJsonChangeKind }); process.exitCode = await runChangedCheck(result, { ...args, explicitPaths: args.paths.length > 0, diff --git a/test/scripts/changed-lanes.test.ts b/test/scripts/changed-lanes.test.ts index 33a0c57f610..eea547079be 100644 --- a/test/scripts/changed-lanes.test.ts +++ b/test/scripts/changed-lanes.test.ts @@ -2,7 +2,10 @@ import { execFileSync } from "node:child_process"; import { mkdirSync, writeFileSync } from "node:fs"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; -import { detectChangedLanes } from "../../scripts/changed-lanes.mjs"; +import { + detectChangedLanes, + isLiveDockerPackageScriptOnlyChange, +} from "../../scripts/changed-lanes.mjs"; import { CHANGED_CHECK_VITEST_NO_OUTPUT_TIMEOUT_MS, createChangedCheckChildEnv, @@ -285,6 +288,140 @@ describe("scripts/changed-lanes", () => { }); }); + it("routes live Docker package script-only changes through the focused gate", () => { + const before = `${JSON.stringify( + { + name: "fixture", + scripts: { + "test:docker:all": "node scripts/test-docker-all.mjs", + }, + dependencies: { + leftpad: "1.0.0", + }, + }, + null, + 2, + )}\n`; + const after = `${JSON.stringify( + { + name: "fixture", + scripts: { + "test:docker:all": "node scripts/test-docker-all.mjs", + "test:docker:live-acp-bind:droid": + "OPENCLAW_LIVE_ACP_BIND_AGENT=droid bash scripts/test-live-acp-bind-docker.sh", + }, + dependencies: { + leftpad: "1.0.0", + }, + }, + null, + 2, + )}\n`; + + expect(isLiveDockerPackageScriptOnlyChange(before, after)).toBe(true); + + const result = detectChangedLanes(["package.json"], { + packageJsonChangeKind: "liveDockerTooling", + }); + const plan = createChangedCheckPlan(result); + + expect(result.lanes).toMatchObject({ + liveDockerTooling: true, + releaseMetadata: false, + all: false, + }); + expect(plan.runFullTests).toBe(false); + expect(plan.commands.map((command) => command.name)).toContain("live Docker scheduler dry run"); + }); + + it("classifies live Docker package script changes from the git diff", () => { + const dir = makeTempRepoRoot(tempDirs, "openclaw-live-docker-package-"); + git(dir, ["init", "-q", "--initial-branch=main"]); + writeFileSync( + path.join(dir, "package.json"), + `${JSON.stringify( + { + name: "fixture", + scripts: { + "test:docker:all": "node scripts/test-docker-all.mjs", + }, + }, + null, + 2, + )}\n`, + "utf8", + ); + git(dir, ["add", "package.json"]); + git(dir, [ + "-c", + "user.email=test@example.com", + "-c", + "user.name=Test User", + "commit", + "-q", + "-m", + "initial", + ]); + + writeFileSync( + path.join(dir, "package.json"), + `${JSON.stringify( + { + name: "fixture", + scripts: { + "test:docker:all": "node scripts/test-docker-all.mjs", + "test:docker:live-acp-bind:droid": + "OPENCLAW_LIVE_ACP_BIND_AGENT=droid bash scripts/test-live-acp-bind-docker.sh", + }, + }, + null, + 2, + )}\n`, + "utf8", + ); + + const output = execFileSync( + process.execPath, + [path.join(repoRoot, "scripts", "changed-lanes.mjs"), "--json", "--base", "HEAD"], + { + cwd: dir, + encoding: "utf8", + env: createNestedGitEnv(), + }, + ); + + expect(JSON.parse(output)).toMatchObject({ + paths: ["package.json"], + lanes: { + liveDockerTooling: true, + releaseMetadata: false, + all: false, + }, + }); + }); + + it("keeps non-script package changes off the live Docker focused gate", () => { + const before = `${JSON.stringify( + { name: "fixture", scripts: {}, dependencies: { leftpad: "1.0.0" } }, + null, + 2, + )}\n`; + const after = `${JSON.stringify( + { + name: "fixture", + scripts: { + "test:docker:live-acp-bind:droid": + "OPENCLAW_LIVE_ACP_BIND_AGENT=droid bash scripts/test-live-acp-bind-docker.sh", + }, + dependencies: { leftpad: "1.0.1" }, + }, + null, + 2, + )}\n`; + + expect(isLiveDockerPackageScriptOnlyChange(before, after)).toBe(false); + }); + it("keeps release metadata commits off the full changed gate", () => { const result = detectChangedLanes([ "CHANGELOG.md",