From 099d18f4329a15e767bbcdf9b8152826d33dbe56 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 26 Apr 2026 01:47:35 +0100 Subject: [PATCH] test: narrow live Docker ACP changed gate --- docs/help/testing.md | 3 +- scripts/changed-lanes.mjs | 20 ++++++++--- scripts/check-changed.mjs | 53 +++++++++++++++++++++++++++-- test/scripts/changed-lanes.test.ts | 54 ++++++++++++++++++++++++++++++ 4 files changed, 123 insertions(+), 7 deletions(-) diff --git a/docs/help/testing.md b/docs/help/testing.md index 7cec063f5e9..eb4249bc974 100644 --- a/docs/help/testing.md +++ b/docs/help/testing.md @@ -399,7 +399,8 @@ Think of the suites as “increasing realism” (and increasing flakiness/cost): - `pnpm test --watch` still uses the native root `vitest.config.ts` project graph, because a multi-shard watch loop is not practical. - `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, 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. + - `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. - 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 3c5c7bf15ac..b21dc6e6a29 100644 --- a/scripts/changed-lanes.mjs +++ b/scripts/changed-lanes.mjs @@ -10,6 +10,8 @@ const TOOLING_PATH_RE = /^(?:scripts\/|test\/vitest\/|\.github\/|git-hooks\/|vitest(?:\..+)?\.config\.ts$|tsconfig.*\.json$|\.gitignore$|\.oxlint.*|\.oxfmt.*)/u; 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 TEST_PATH_RE = /(?:^|\/)(?:test|__tests__)\/|(?:\.|\/)(?:test|spec|e2e|browser\.test)\.[cm]?[jt]sx?$/u; const PUBLIC_EXTENSION_CONTRACT_RE = @@ -28,7 +30,7 @@ export const RELEASE_METADATA_PATHS = new Set([ "src/config/schema.base.generated.ts", ]); -/** @typedef {"core" | "coreTests" | "extensions" | "extensionTests" | "apps" | "docs" | "tooling" | "releaseMetadata" | "all"} ChangedLane */ +/** @typedef {"core" | "coreTests" | "extensions" | "extensionTests" | "apps" | "docs" | "tooling" | "liveDockerTooling" | "releaseMetadata" | "all"} ChangedLane */ /** * @typedef {{ @@ -56,6 +58,7 @@ export function createEmptyChangedLanes() { apps: false, docs: false, tooling: false, + liveDockerTooling: false, releaseMetadata: false, all: false, }; @@ -66,9 +69,9 @@ export function createEmptyChangedLanes() { * @returns {ChangedLaneResult} */ export function detectChangedLanes(changedPaths) { - const paths = [...new Set(changedPaths.map(normalizeChangedPath).filter(Boolean))].toSorted( - (left, right) => left.localeCompare(right), - ); + const paths = [...new Set(changedPaths.map(normalizeChangedPath).filter(Boolean))] + .toSorted((left, right) => left.localeCompare(right)) + .filter((changedPath) => changedPath !== "--"); const lanes = createEmptyChangedLanes(); const reasons = []; let extensionImpactFromCore = false; @@ -101,6 +104,12 @@ export function detectChangedLanes(changedPaths) { hasNonDocs = true; + if (LIVE_DOCKER_TOOLING_PATH_RE.test(changedPath)) { + lanes.liveDockerTooling = true; + reasons.push(`${changedPath}: live Docker tooling surface`); + continue; + } + if (ROOT_GLOBAL_PATH_RE.test(changedPath)) { lanes.all = true; extensionImpactFromCore = true; @@ -262,6 +271,9 @@ function parseArgs(argv) { ], { onUnhandledArg(arg, target) { + if (arg === "--") { + return "handled"; + } target.paths.push(arg); return "handled"; }, diff --git a/scripts/check-changed.mjs b/scripts/check-changed.mjs index 4aeffbba6f2..fea2b5c3cc8 100644 --- a/scripts/check-changed.mjs +++ b/scripts/check-changed.mjs @@ -19,6 +19,14 @@ import { resolveChangedTestTargetPlan } from "./test-projects.test-support.mjs"; export const CHANGED_CHECK_VITEST_NO_OUTPUT_TIMEOUT_MS = "600000"; const VITEST_NO_OUTPUT_TIMEOUT_ENV_KEY = "OPENCLAW_VITEST_NO_OUTPUT_TIMEOUT_MS"; const VITEST_NO_OUTPUT_RETRY_ENV_KEY = "OPENCLAW_VITEST_NO_OUTPUT_RETRY"; +const LIVE_DOCKER_AUTH_SHELL_TARGETS = [ + "scripts/lib/live-docker-auth.sh", + "scripts/test-live-acp-bind-docker.sh", + "scripts/test-live-cli-backend-docker.sh", + "scripts/test-live-codex-harness-docker.sh", + "scripts/test-live-gateway-models-docker.sh", + "scripts/test-live-models-docker.sh", +]; export function createChangedCheckChildEnv(baseEnv = process.env) { const resolvedBaseEnv = resolveLocalHeavyCheckEnv(baseEnv); @@ -67,6 +75,15 @@ export function createChangedCheckPlan(result, options = {}) { commands.push({ name, args, ...(env ? { env } : {}) }); } }; + const addCommand = (name, bin, args, env) => { + if ( + !commands.some( + (command) => command.name === name && command.bin === bin && sameArgs(command.args, args), + ) + ) { + commands.push({ name, bin, args, ...(env ? { env } : {}) }); + } + }; const addTypecheck = (name, args) => add(name, args, createSparseTsgoSkipEnv(baseEnv)); const addLint = (name, args) => add(name, args, baseEnv); @@ -138,10 +155,17 @@ export function createChangedCheckPlan(result, options = {}) { if (lanes.core || lanes.coreTests) { addLint("lint core", ["lint:core"]); } + if ( + lanes.liveDockerTooling && + result.paths.some((changedPath) => changedPath.startsWith("src/")) + ) { + addTypecheck("typecheck core tests", ["tsgo:core:test"]); + addLint("lint core", ["lint:core"]); + } if (lanes.extensions || lanes.extensionTests) { addLint("lint extensions", ["lint:extensions"]); } - if (lanes.tooling) { + if (lanes.tooling || lanes.liveDockerTooling) { addLint("lint scripts", ["lint:scripts"]); } if (lanes.apps) { @@ -157,6 +181,21 @@ export function createChangedCheckPlan(result, options = {}) { add("pairing account guard", ["lint:auth:pairing-account-scope"]); } + if (lanes.liveDockerTooling) { + addCommand("live Docker shell syntax", "bash", ["-n", ...LIVE_DOCKER_AUTH_SHELL_TARGETS]); + addCommand("live Docker scheduler dry run", "node", ["scripts/test-docker-all.mjs"], { + ...baseEnv, + OPENCLAW_DOCKER_ALL_DRY_RUN: "1", + OPENCLAW_DOCKER_ALL_LIVE_MODE: "only", + }); + add( + "ACP bind unit tests", + ["test", "src/gateway/live-agent-probes.test.ts", "src/agents/acp-spawn.test.ts"], + createChangedCheckVitestEnv(baseEnv), + ); + add("ACPX extension tests", ["test:extension", "acpx"], createChangedCheckVitestEnv(baseEnv)); + } + const testPlan = resolveChangedTestTargetPlan(result.paths); const runExtensionTests = result.extensionImpactFromCore; const testTargets = runExtensionTests @@ -197,7 +236,7 @@ export async function runChangedCheck(result, options = {}) { const timings = []; for (const command of plan.commands) { - const status = await runPnpm(command, timings); + const status = await runPlanCommand(command, timings); if (status !== 0) { printSummary(timings, options); return status; @@ -291,6 +330,13 @@ async function runPnpm(command, timings) { return await runCommand({ ...command, bin: "pnpm" }, timings); } +async function runPlanCommand(command, timings) { + if (command.bin) { + return await runCommand(command, timings); + } + return await runPnpm(command, timings); +} + async function runCommand(command, timings) { const startedAt = performance.now(); console.error(`\n[check:changed] ${command.name}`); @@ -338,6 +384,9 @@ function parseArgs(argv) { ], { onUnhandledArg(arg, target) { + if (arg === "--") { + return "handled"; + } target.paths.push(normalizeChangedPath(arg)); return "handled"; }, diff --git a/test/scripts/changed-lanes.test.ts b/test/scripts/changed-lanes.test.ts index e07be614b43..33a0c57f610 100644 --- a/test/scripts/changed-lanes.test.ts +++ b/test/scripts/changed-lanes.test.ts @@ -81,6 +81,14 @@ describe("scripts/changed-lanes", () => { }); }); + it("ignores the explicit path separator", () => { + const result = detectChangedLanes(["--", "scripts/test-live-acp-bind-docker.sh"]); + + expect(result.paths).toEqual(["scripts/test-live-acp-bind-docker.sh"]); + expect(result.lanes.liveDockerTooling).toBe(true); + expect(result.lanes.all).toBe(false); + }); + it("routes core production changes to core prod and core test lanes", () => { const result = detectChangedLanes(["src/shared/string-normalization.ts"]); const plan = createChangedCheckPlan(result, { env: { PATH: "/usr/bin" } }); @@ -232,6 +240,51 @@ describe("scripts/changed-lanes", () => { expect(plan.commands.map((command) => command.args[0])).not.toContain("tsgo:all"); }); + it("routes live Docker ACP tooling changes through a focused gate", () => { + const result = detectChangedLanes([ + "scripts/lib/live-docker-auth.sh", + "scripts/test-docker-all.mjs", + "scripts/test-live-acp-bind-docker.sh", + "src/gateway/gateway-acp-bind.live.test.ts", + "docs/help/testing-live.md", + ]); + const plan = createChangedCheckPlan(result); + + expect(result.lanes).toMatchObject({ + liveDockerTooling: true, + all: false, + tooling: false, + }); + expect(plan.runFullTests).toBe(false); + expect(plan.runChangedTestsBroad).toBe(false); + expect(plan.commands.map((command) => command.name)).toEqual([ + "conflict markers", + "typecheck core tests", + "lint core", + "lint scripts", + "live Docker shell syntax", + "live Docker scheduler dry run", + "ACP bind unit tests", + "ACPX extension tests", + ]); + expect( + plan.commands.find((command) => command.name === "live Docker shell syntax"), + ).toMatchObject({ + bin: "bash", + args: expect.arrayContaining(["-n", "scripts/test-live-acp-bind-docker.sh"]), + }); + expect( + plan.commands.find((command) => command.name === "live Docker scheduler dry run"), + ).toMatchObject({ + bin: "node", + args: ["scripts/test-docker-all.mjs"], + env: expect.objectContaining({ + OPENCLAW_DOCKER_ALL_DRY_RUN: "1", + OPENCLAW_DOCKER_ALL_LIVE_MODE: "only", + }), + }); + }); + it("keeps release metadata commits off the full changed gate", () => { const result = detectChangedLanes([ "CHANGELOG.md", @@ -383,6 +436,7 @@ describe("scripts/changed-lanes", () => { apps: false, docs: false, tooling: false, + liveDockerTooling: false, releaseMetadata: false, all: false, });