mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-22 21:41:16 +00:00
check-prompt-snapshots costs 200-310s per PR regenerating real prompt stacks, but snapshots only change when the generator's import graph or its fixtures do. The manifest now computes that reach (same pattern as QA smoke gating: always-run surface regex incl. the codex extension whose test API loads via a dynamic module id, plus import-graph walk from the snapshot helper; deletions and null diffs fail safe to running). Unaffected PR diffs skip the lane with an explicit log line; pushes and dispatches always run.
274 lines
11 KiB
JavaScript
274 lines
11 KiB
JavaScript
import { existsSync } from "node:fs";
|
|
import path from "node:path";
|
|
import { detectChangedLanes } from "../changed-lanes.mjs";
|
|
import {
|
|
buildVitestRunPlans,
|
|
findUnmatchedExplicitTestTargets,
|
|
hasImportGraphImpactOnTargets,
|
|
isTestFileTarget,
|
|
resolveChangedTestTargetPlan,
|
|
} from "../test-projects.test-support.mjs";
|
|
import { createNodeTestShards } from "./ci-node-test-plan.mjs";
|
|
import { buildPluginSdkEntrySources, publicPluginSdkEntrypoints } from "./plugin-sdk-entries.mjs";
|
|
|
|
const DEFAULT_NODE_TEST_RUNNER = "blacksmith-8vcpu-ubuntu-2404";
|
|
const MAX_CHANGED_NODE_TEST_TARGETS = 96;
|
|
// Each target runs in its own child process (isolation contract), so bound the
|
|
// serial tail per job; the shard runner overlaps two children at a time.
|
|
const CHANGED_NODE_TEST_TARGETS_PER_JOB = 12;
|
|
const BOUNDARY_NODE_TEST_CONFIG = "test/vitest/vitest.boundary.config.ts";
|
|
const publicPluginSdkEntrySources = Object.values(
|
|
buildPluginSdkEntrySources(publicPluginSdkEntrypoints),
|
|
);
|
|
|
|
const fullNodeTestShards = createNodeTestShards({ includeReleaseOnlyPluginShards: false });
|
|
const configsRequiringFullSuiteMetadata = new Set(
|
|
fullNodeTestShards
|
|
.filter((shard) => shard.env || shard.shardName.startsWith("core-tooling"))
|
|
.flatMap((shard) => shard.configs),
|
|
);
|
|
const splitNodeTestConfigs = new Set(
|
|
fullNodeTestShards.filter((shard) => shard.includePatterns).flatMap((shard) => shard.configs),
|
|
);
|
|
|
|
function isTestOnlyPath(changedPath) {
|
|
return isTestFileTarget(changedPath) || changedPath.startsWith("test/");
|
|
}
|
|
|
|
// Inputs `build:ci-artifacts` consumes: runtime/plugin/package sources plus
|
|
// the build pipeline itself (mirrors the build-all cache key in ci.yml).
|
|
// Paths outside this set — repo scripts, workflows, qa scenarios, docs mixes —
|
|
// cannot change dist or bundled plugin asset bytes.
|
|
const BUILD_INPUT_RE =
|
|
/^(?:src|extensions|packages)\/|^(?:openclaw\.mjs|package\.json|pnpm-lock\.yaml|npm-shrinkwrap\.json|pnpm-workspace\.yaml)$|^tsconfig[^/]*\.json$|^scripts\/(?:build-[^/]+|write-plugin-sdk-entry-dts\.ts|copy-export-html-templates\.ts)$|^scripts\/lib\/(?:copy-assets\.ts|plugin-sdk-entries\.mjs)$/u;
|
|
|
|
/**
|
|
* True when a changed path can influence built dist/packaging bytes: a
|
|
* non-test build-input source or the build pipeline itself. Diffs entirely
|
|
* outside that set (tests, repo scripts, workflows, qa scenarios) let the
|
|
* manifest skip the build-artifacts lane.
|
|
*/
|
|
export function hasBuildArtifactAffectingChange(changedPaths) {
|
|
return changedPaths.some(
|
|
(changedPath) => BUILD_INPUT_RE.test(changedPath) && !isTestOnlyPath(changedPath),
|
|
);
|
|
}
|
|
|
|
// Surfaces the CI smoke scenarios exercise outside the core runtime import
|
|
// graph: the qa-lab harness and scenario data, the packaged-CLI build inputs,
|
|
// the control UI (playwright scenario), the two channels the smoke profile
|
|
// drives (matrix, telegram), and workspace packages whose package-specifier
|
|
// imports the relative import graph cannot see. The QA lane's own
|
|
// orchestration (this planner, the CI workflow, composite actions) is also
|
|
// QA-impacting: changes to the gate must not be able to skip the gated lane.
|
|
const QA_SMOKE_SURFACE_RE =
|
|
/^(?:extensions\/(?:matrix|qa-lab|telegram)|packages|qa|ui)\/|^scripts\/(?:build-all\.mjs|package-openclaw-for-docker\.mjs)$|^scripts\/lib\/ci-changed-node-test-plan\.mjs$|^\.github\/(?:workflows\/ci\.yml$|actions\/)|^(?:openclaw\.mjs|package\.json|pnpm-lock\.yaml|npm-shrinkwrap\.json|pnpm-workspace\.yaml|tsdown\.config\.ts)$/u;
|
|
// The smoke profile runs the packaged CLI end to end, so its runtime blast
|
|
// radius is exactly the CLI entry's import graph (dynamic imports included).
|
|
const QA_SMOKE_RUNTIME_ENTRY = "src/index.ts";
|
|
|
|
/**
|
|
* True when a changed path can influence the QA smoke scenarios: it touches
|
|
* the smoke surface directly, or the packaged CLI's import graph reaches it.
|
|
* Diffs outside both are invisible to the smoke profile, so the manifest may
|
|
* skip that lane regardless of whether test targeting fired.
|
|
*/
|
|
export function hasQaSmokeAffectingChange(changedPaths, options = {}) {
|
|
const cwd = options.cwd ?? process.cwd();
|
|
if (changedPaths.some((changedPath) => QA_SMOKE_SURFACE_RE.test(changedPath))) {
|
|
return true;
|
|
}
|
|
const sourcePaths = changedPaths.filter(
|
|
(changedPath) => changedPath.startsWith("src/") && !isTestFileTarget(changedPath),
|
|
);
|
|
if (sourcePaths.length === 0) {
|
|
return false;
|
|
}
|
|
// Deleted sources cannot be graphed; fail safe to running the smoke lane.
|
|
if (sourcePaths.some((changedPath) => !existsSync(path.join(cwd, changedPath)))) {
|
|
return true;
|
|
}
|
|
return hasImportGraphImpactOnTargets(sourcePaths, [QA_SMOKE_RUNTIME_ENTRY], cwd);
|
|
}
|
|
|
|
// Surfaces the prompt-snapshot check exercises outside its generator's
|
|
// relative import graph: the snapshot fixtures and generator scripts, the
|
|
// codex extension (its test API loads through a dynamic bundled-plugin module
|
|
// id the graph walk cannot see), and the gate's own orchestration — changes
|
|
// to the gate must not be able to skip the gated lane.
|
|
const PROMPT_SNAPSHOT_SURFACE_RE =
|
|
/^(?:test\/(?:helpers\/agents|fixtures\/agents\/prompt-snapshots)|extensions\/codex)\/|^scripts\/(?:generate-prompt-snapshots\.ts|prompt-snapshot-files\.[cm]?[jt]s)$|^scripts\/lib\/ci-changed-node-test-plan\.mjs$|^\.github\/(?:workflows\/ci\.yml$|actions\/)|^(?:package\.json|pnpm-lock\.yaml|npm-shrinkwrap\.json|pnpm-workspace\.yaml)$/u;
|
|
// The generator renders real prompt-layer stacks, so its runtime blast radius
|
|
// is the snapshot helper's import graph (auto-reply prompts, channel typing,
|
|
// plugin-sdk agent harness, codex catalog fixtures).
|
|
const PROMPT_SNAPSHOT_ENTRY = "test/helpers/agents/happy-path-prompt-snapshots.ts";
|
|
|
|
/**
|
|
* True when a changed path can influence generated prompt snapshots: it
|
|
* touches the snapshot surface directly, or the generator's import graph
|
|
* reaches it. Diffs outside both cannot change generator output, so the
|
|
* manifest may skip the check lane.
|
|
*/
|
|
export function hasPromptSnapshotAffectingChange(changedPaths, options = {}) {
|
|
const cwd = options.cwd ?? process.cwd();
|
|
if (changedPaths.some((changedPath) => PROMPT_SNAPSHOT_SURFACE_RE.test(changedPath))) {
|
|
return true;
|
|
}
|
|
const sourcePaths = changedPaths.filter(
|
|
(changedPath) => changedPath.startsWith("src/") && !isTestFileTarget(changedPath),
|
|
);
|
|
if (sourcePaths.length === 0) {
|
|
return false;
|
|
}
|
|
// Deleted sources cannot be graphed; fail safe to running the check.
|
|
if (sourcePaths.some((changedPath) => !existsSync(path.join(cwd, changedPath)))) {
|
|
return true;
|
|
}
|
|
return hasImportGraphImpactOnTargets(sourcePaths, [PROMPT_SNAPSHOT_ENTRY], cwd);
|
|
}
|
|
|
|
function createBoundaryShard() {
|
|
// Boundary tests scan the source tree (including test files) and build
|
|
// their own fixtures; they do not consume the built dist artifact. When the
|
|
// build-artifacts lane is skipped, this shard keeps that coverage.
|
|
return {
|
|
checkName: "checks-node-changed-boundary",
|
|
configs: [BOUNDARY_NODE_TEST_CONFIG],
|
|
requiresDist: false,
|
|
runner: DEFAULT_NODE_TEST_RUNNER,
|
|
shardName: "changed-boundary",
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Builds bounded PR jobs from precise changed-test targets.
|
|
* Null means the caller must fail safe to the compact full-suite plan.
|
|
*/
|
|
export function createChangedNodeTestShards(changedPaths, options = {}) {
|
|
const cwd = options.cwd ?? process.cwd();
|
|
if (!Array.isArray(changedPaths) || changedPaths.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
const livePaths = [];
|
|
const deletedPaths = [];
|
|
for (const changedPath of changedPaths) {
|
|
(existsSync(path.join(cwd, changedPath)) ? livePaths : deletedPaths).push(changedPath);
|
|
}
|
|
// Deleted test files cannot regress runtime behavior, so they never block
|
|
// targeting. Deleted source files cannot be import-graphed from the merged
|
|
// tree and no live-path heuristic proves their consumers are covered, so
|
|
// any source deletion keeps the full-suite plan.
|
|
if (deletedPaths.some((deletedPath) => !isTestFileTarget(deletedPath))) {
|
|
return null;
|
|
}
|
|
|
|
// Workspace package consumers often use package specifiers, which the
|
|
// relative import graph cannot connect back to the changed package source.
|
|
if (changedPaths.some((changedPath) => changedPath.startsWith("packages/"))) {
|
|
return null;
|
|
}
|
|
|
|
// Package-specifier consumers are invisible to the relative import graph.
|
|
// Fail safe when a core change reaches a public SDK entrypoint indirectly.
|
|
if (
|
|
detectChangedLanes(changedPaths).extensionImpactFromCore ||
|
|
(livePaths.some((changedPath) => changedPath.startsWith("src/")) &&
|
|
hasImportGraphImpactOnTargets(livePaths, publicPluginSdkEntrySources, cwd))
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
const resolveTargetPlan = (paths) =>
|
|
resolveChangedTestTargetPlan(paths, {
|
|
broad: true,
|
|
combineSiblingWithImportGraph: true,
|
|
cwd,
|
|
forceFullImportGraph: true,
|
|
includeExtensionImpact: false,
|
|
});
|
|
const plan =
|
|
livePaths.length > 0 ? resolveTargetPlan(livePaths) : { mode: "targets", targets: [] };
|
|
// Aggregate resolution must not let one precise path hide another path that
|
|
// contributes no tests. Partial plans silently drop coverage.
|
|
if (
|
|
livePaths.some((changedPath) => {
|
|
const changedPathPlan = resolveTargetPlan([changedPath]);
|
|
return changedPathPlan.mode !== "targets" || changedPathPlan.targets.length === 0;
|
|
})
|
|
) {
|
|
return null;
|
|
}
|
|
if (plan.mode !== "targets") {
|
|
return null;
|
|
}
|
|
const targets = [...new Set(plan.targets)];
|
|
if (
|
|
targets.length > MAX_CHANGED_NODE_TEST_TARGETS ||
|
|
targets.some(
|
|
(target) =>
|
|
/^test\/vitest\/vitest\.full-.*\.config\.ts$/u.test(target) ||
|
|
splitNodeTestConfigs.has(target),
|
|
)
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
if (
|
|
targets.some(
|
|
(target) =>
|
|
!isTestFileTarget(target) || findUnmatchedExplicitTestTargets([target], cwd).length > 0,
|
|
)
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
const targetPlans = targets.map((target) => ({
|
|
plans: buildVitestRunPlans([target], cwd),
|
|
target,
|
|
}));
|
|
if (
|
|
targetPlans.some(
|
|
({ plans }) => plans.length === 0 || plans.some((targetPlan) => !targetPlan.includePatterns),
|
|
)
|
|
) {
|
|
return null;
|
|
}
|
|
// Preserve special shard setup (for example Go and TUI PTY coverage) by using
|
|
// the compact plan until targeted jobs can carry per-config prerequisites.
|
|
if (
|
|
targetPlans.some(({ plans }) =>
|
|
plans.some(({ config }) => configsRequiringFullSuiteMetadata.has(config)),
|
|
)
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
// Boundary-config targets run as regular nondist targets: the boundary
|
|
// suite scans the checked-out tree and never consumes the built dist.
|
|
const orderedTargets = targetPlans.map(({ target }) => target);
|
|
const targetChunks = [];
|
|
for (
|
|
let offset = 0;
|
|
offset < orderedTargets.length;
|
|
offset += CHANGED_NODE_TEST_TARGETS_PER_JOB
|
|
) {
|
|
targetChunks.push(orderedTargets.slice(offset, offset + CHANGED_NODE_TEST_TARGETS_PER_JOB));
|
|
}
|
|
const shards = [
|
|
...targetChunks.map((chunk, index) => {
|
|
const suffix = targetChunks.length === 1 ? "" : `-${index + 1}`;
|
|
return {
|
|
checkName: `checks-node-changed${suffix}`,
|
|
configs: [],
|
|
requiresDist: false,
|
|
runner: DEFAULT_NODE_TEST_RUNNER,
|
|
shardName: `changed${suffix}`,
|
|
targets: chunk,
|
|
};
|
|
}),
|
|
...(hasBuildArtifactAffectingChange(changedPaths) ? [] : [createBoundaryShard()]),
|
|
];
|
|
return shards.length > 0 ? shards : null;
|
|
}
|