diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e372b7b21817..baf7d19c1f9c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2056,6 +2056,25 @@ jobs: }); } } + // The trusted planner may name owners added after a frozen checkout. + // Project them out here so the runner never receives impossible work. + const projectFrozenNodeTestPlan = (plan) => { + const configs = plan.configs?.filter((config) => existsSync(config)); + if (plan.configs?.length && !configs?.length) { + return null; + } + return { ...plan, configs }; + }; + const targetNodeTestShards = compatibilityTarget + ? rawNodeTestShards.flatMap((shard) => { + const groups = shard.groups + ?.map(projectFrozenNodeTestPlan) + .filter((group) => group !== null); + if (shard.groups?.length && !groups?.length) return []; + const projected = projectFrozenNodeTestPlan({ ...shard, groups }); + return projected ? [projected] : []; + }) + : rawNodeTestShards; // Node rows list every striped test file. Current targets pack the // projected runner contract; older targets keep their flat fields or // projected legacy groups because their shard runner predates the codec. @@ -2069,7 +2088,7 @@ jobs: }) : null; const nodeTestGroupsCodec = - (rawNodeTestShards.length > 0 || uiTestGroups !== null) && + (targetNodeTestShards.length > 0 || uiTestGroups !== null) && existsSync(nodeTestGroupsCodecPath) ? await importTargetPlan(nodeTestGroupsCodecPath) : null; @@ -2120,7 +2139,7 @@ jobs: "test/vitest/vitest.tooling-docker.config.ts", ]); // The same capped matrix owns compact and plugin work; admit its longest rows first. - const nodeTestShards = rawNodeTestShards + const nodeTestShards = targetNodeTestShards .toSorted((a, b) => Number(b.runner === "runson-c8i-8xlarge") - Number(a.runner === "runson-c8i-8xlarge") || (b.predictedSeconds ?? 0) - (a.predictedSeconds ?? 0)) @@ -2200,7 +2219,7 @@ jobs: !frozenTarget && !compatibilityTarget && !releaseGate && /^[0-9a-f]{40}$/u.test(checkoutRevision) && checkoutRevision === workflowRevision && typeof nodeTestPlan.hasCompleteStartupCorpusCoverage === "function" && - nodeTestPlan.hasCompleteStartupCorpusCoverage(rawNodeTestShards, startupCorpusTestFiles) + nodeTestPlan.hasCompleteStartupCorpusCoverage(targetNodeTestShards, startupCorpusTestFiles) ? checkoutRevision : ""; if (startupCorpusNodeRevision) { // The exact-tree Node receipt makes this row's only test step a no-op. diff --git a/scripts/e2e/lib/plugin-update/consent-scenario.mjs b/scripts/e2e/lib/plugin-update/consent-scenario.mjs index 622f530e9375..10a01f695d34 100644 --- a/scripts/e2e/lib/plugin-update/consent-scenario.mjs +++ b/scripts/e2e/lib/plugin-update/consent-scenario.mjs @@ -13,7 +13,7 @@ import { packFutureUpdateFixture } from "../update-first-hop-package-fixtures.mj import { observePostCoreCommand } from "./process-observer.mjs"; // Without a core tarball, run only the plugin reinstall boundary against the supplied CLI. -export async function runConsentScenario(entry, coreTarball) { +export async function runConsentScenario(entry, coreTarball, options = {}) { assert(entry, "expected CLI entry"); let coreTarballSha256; if (coreTarball) { @@ -280,10 +280,27 @@ export async function runConsentScenario(entry, coreTarball) { "accepted forced reinstall replaces package and acceptance while remaining disabled", "explicit enable activates the reviewed replacement", ]; - if (!coreTarball) { + if (!coreTarball || options.coreUpdateConsent === false) { console.log( JSON.stringify( - { status: "passed", root, assertions: reinstallAssertions, runs, snapshots }, + { + status: "passed", + root, + assertions: reinstallAssertions, + ...(coreTarball && options.coreUpdateConsent === false + ? { + omissions: [ + { + scenario: "core-update-consent", + reason: + "selected frozen target predates the recorded update compatibility contract", + }, + ], + } + : {}), + runs, + snapshots, + }, null, 2, ), diff --git a/scripts/e2e/lib/plugin-update/probe.mjs b/scripts/e2e/lib/plugin-update/probe.mjs index 3e9f0e91bbc3..9850ca4cf308 100644 --- a/scripts/e2e/lib/plugin-update/probe.mjs +++ b/scripts/e2e/lib/plugin-update/probe.mjs @@ -303,7 +303,10 @@ function assertCorruptPluginPolicyPreserved(configPath, pluginId) { const [command, arg, arg2] = process.argv.slice(2); const commands = { - consent: () => runConsentScenario(arg, arg2), + consent: () => + runConsentScenario(arg, arg2, { + coreUpdateConsent: process.env.OPENCLAW_E2E_CORE_UPDATE_CONSENT !== "0", + }), seed: seedInstallState, "wait-registry": waitRegistry, snapshot: () => process.stdout.write(JSON.stringify(pluginRecordSnapshot(), null, 2)), diff --git a/scripts/e2e/plugin-update-unchanged-docker.sh b/scripts/e2e/plugin-update-unchanged-docker.sh index 0013034edfdc..3eea8e3d631e 100755 --- a/scripts/e2e/plugin-update-unchanged-docker.sh +++ b/scripts/e2e/plugin-update-unchanged-docker.sh @@ -22,12 +22,33 @@ PACKAGE_TGZ="$(docker_e2e_prepare_package_tgz plugin-update "${OPENCLAW_CURRENT_ # Bare lanes mount the package artifact instead of baking app sources into the image. docker_e2e_package_mount_args "$PACKAGE_TGZ" +CORE_UPDATE_CONSENT=1 +source "$ROOT_DIR/scripts/lib/frozen-target-compat.sh" +TARGET_ROOT_DIR="$(cd "${OPENCLAW_DOCKER_E2E_REPO_ROOT:-$ROOT_DIR}" && pwd)" +context_status=0 +openclaw_prepare_frozen_target_context "$TARGET_ROOT_DIR" || context_status=$? +case "$context_status" in + 0) + source_status=0 + openclaw_frozen_target_source_has_path \ + "$TARGET_ROOT_DIR" scripts/lib/update-compat-contract.mjs || source_status=$? + case "$source_status" in + 0) ;; + 1) CORE_UPDATE_CONSENT=0 ;; + *) exit "$source_status" ;; + esac + ;; + 1) ;; + *) exit "$context_status" ;; +esac + docker_e2e_build_or_reuse "$IMAGE_NAME" plugin-update "$ROOT_DIR/scripts/e2e/Dockerfile" "$ROOT_DIR" "bare" "$SKIP_BUILD" OPENCLAW_TEST_STATE_SCRIPT_B64="$(docker_e2e_test_state_shell_b64 plugin-update empty)" echo "Running unchanged plugin update and capability consent smoke..." docker_e2e_run_with_harness \ -e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \ + -e "OPENCLAW_E2E_CORE_UPDATE_CONSENT=$CORE_UPDATE_CONSENT" \ -e OPENCLAW_SKIP_CHANNELS=1 \ -e OPENCLAW_SKIP_PROVIDERS=1 \ -e "OPENCLAW_TEST_STATE_SCRIPT_B64=$OPENCLAW_TEST_STATE_SCRIPT_B64" \ diff --git a/test/scripts/ci-workflow-manifest.test-support.ts b/test/scripts/ci-workflow-manifest.test-support.ts index 64501432bc8e..56cba081bb30 100644 --- a/test/scripts/ci-workflow-manifest.test-support.ts +++ b/test/scripts/ci-workflow-manifest.test-support.ts @@ -71,6 +71,8 @@ export function runCiManifestFixture(options: { nodeRunnerBackend?: "blacksmith" | "github" | "hybrid" | "runson"; runnerProfile?: "blacksmith" | "github" | "hybrid"; targetHostedRunnerProfileContract?: boolean; + targetFiles?: string[]; + missingTargetFiles?: string[]; uiE2eProjectsCapability?: boolean; uiReleaseTier?: boolean; uiRealGatewayShards?: boolean; @@ -81,6 +83,45 @@ export function runCiManifestFixture(options: { try { const scriptsDir = path.join(root, "scripts", "lib"); mkdirSync(scriptsDir, { recursive: true }); + const selectedTargetFiles = new Set(options.targetFiles ?? []); + if (!options.nodeTestShards) { + selectedTargetFiles.add( + options.bundledPlanner + ? options.runnerBackend === "runson" + ? "test/vitest/vitest.cron.config.ts" + : "test/vitest/bundled.config.ts" + : "test/vitest/legacy.config.ts", + ); + } + const collectSelectedTargetFiles = (plan: Record) => { + const configs = plan.configs; + if (Array.isArray(configs)) { + for (const config of configs) { + if (typeof config === "string") { + selectedTargetFiles.add(config); + } + } + } + const groups = plan.groups; + if (Array.isArray(groups)) { + for (const group of groups) { + if (group && typeof group === "object") { + collectSelectedTargetFiles(group as Record); + } + } + } + }; + for (const shard of options.nodeTestShards ?? []) { + collectSelectedTargetFiles(shard); + } + for (const missing of options.missingTargetFiles ?? []) { + selectedTargetFiles.delete(missing); + } + for (const file of selectedTargetFiles) { + const target = path.join(root, file); + mkdirSync(path.dirname(target), { recursive: true }); + writeFileSync(target, "export {};\n"); + } if (options.bunTestRuntime) { writeFileSync( path.join(scriptsDir, "ci-test-runtime.mts"), diff --git a/test/scripts/ci-workflow-planning.test.ts b/test/scripts/ci-workflow-planning.test.ts index 9ed3c1b6a594..005da457af07 100644 --- a/test/scripts/ci-workflow-planning.test.ts +++ b/test/scripts/ci-workflow-planning.test.ts @@ -9512,6 +9512,93 @@ describe("ci workflow guards", () => { ).toEqual(row.groups); }); + it.each([ + { label: "verified release candidate", releaseCandidateCompatibility: true }, + { label: "unverified manual target", releaseCandidateCompatibility: false }, + ])("projects current-only test owners only for $label", ({ releaseCandidateCompatibility }) => { + const currentConfig = "test/vitest/vitest.current.config.ts"; + const currentTest = "src/current.test.ts"; + const missingConfig = "test/vitest/vitest.future.config.ts"; + const missingTest = "src/future.test.ts"; + const result = runCiManifestFixture({ + bundledPlanner: true, + historicalCompatibility: false, + missingTargetFiles: [missingConfig], + nodeTestGroupsCodec: false, + releaseCandidateCompatibility, + targetFiles: [currentConfig], + nodeTestShards: [ + { + checkName: "mixed-flat", + configs: [currentConfig, missingConfig], + includePatterns: [currentTest, missingTest, "src/**/*.integration.test.ts"], + requiresDist: false, + runner: "ubuntu-24.04", + shardName: "mixed-flat", + }, + { + checkName: "missing-flat", + configs: [missingConfig], + includePatterns: [missingTest], + requiresDist: false, + runner: "ubuntu-24.04", + shardName: "missing-flat", + }, + { + checkName: "mixed-groups", + groups: [ + { + configs: [currentConfig, missingConfig], + includePatterns: [currentTest, missingTest], + shard_name: "current-group", + }, + { + configs: [missingConfig], + includePatterns: [missingTest], + shard_name: "missing-group", + }, + ], + requiresDist: false, + runner: "ubuntu-24.04", + shardName: "mixed-groups", + }, + ], + }); + expect(result.status, result.output).toBe(0); + const rows = JSON.parse( + expectDefined(result.outputs.checks_node_core_nondist_matrix, "frozen Node matrix"), + ).include; + expect(rows.map((row: { check_name: string }) => row.check_name)).toEqual( + releaseCandidateCompatibility + ? ["mixed-flat", "mixed-groups"] + : ["mixed-flat", "missing-flat", "mixed-groups"], + ); + expect(rows[0].configs).toEqual( + releaseCandidateCompatibility ? [currentConfig] : [currentConfig, missingConfig], + ); + expect(rows[0].includePatterns).toEqual([ + currentTest, + missingTest, + "src/**/*.integration.test.ts", + ]); + const groupedRow = releaseCandidateCompatibility ? rows[1] : rows[2]; + expect(groupedRow.groups).toEqual([ + expect.objectContaining({ + configs: releaseCandidateCompatibility ? [currentConfig] : [currentConfig, missingConfig], + includePatterns: [currentTest, missingTest], + shard_name: "current-group", + }), + ...(releaseCandidateCompatibility + ? [] + : [ + expect.objectContaining({ + configs: [missingConfig], + shard_name: "missing-group", + }), + ]), + ]); + }); + it("provisions ripgrep for real filesystem contract selections", () => { const contract = "src/agents/filesystem-tools-output-contract.test.ts"; const nativeTools = "src/agents/sessions/tools/index.test.ts"; diff --git a/test/scripts/plugin-update-consent-scenario.test.ts b/test/scripts/plugin-update-consent-scenario.test.ts index 7e5508238bea..63346e5e431a 100644 --- a/test/scripts/plugin-update-consent-scenario.test.ts +++ b/test/scripts/plugin-update-consent-scenario.test.ts @@ -288,6 +288,15 @@ describe("installed-CLI consent scenario report contract", () => { expect(fs.existsSync(path.join(installPath, "index.js"))).toBe(true); }); + it("retains plugin consent proof when a frozen target predates core update consent", async () => { + await expect( + runConsentScenario(entry, coreTarball, { coreUpdateConsent: false }), + ).resolves.toBeUndefined(); + expect(installed).toBe(2); + expect(adapters.future).not.toHaveBeenCalled(); + expect(console.log).toHaveBeenCalledWith(expect.stringContaining('"core-update-consent"')); + }); + it.each([ [ "missing plugin warning",