refactor: simplify test workflow helpers

This commit is contained in:
Peter Steinberger 2026-04-03 12:58:46 +01:00
parent 71a54d0c95
commit 685ef52284
No known key found for this signature in database
8 changed files with 167 additions and 327 deletions

View File

@ -129,7 +129,134 @@ jobs:
OPENCLAW_CI_RUN_SKILLS_PYTHON: ${{ steps.changed_scope.outputs.run_skills_python || 'false' }}
OPENCLAW_CI_HAS_CHANGED_EXTENSIONS: ${{ steps.changed_extensions.outputs.has_changed_extensions || 'false' }}
OPENCLAW_CI_CHANGED_EXTENSIONS_MATRIX: ${{ steps.changed_extensions.outputs.changed_extensions_matrix || '{"include":[]}' }}
run: node scripts/ci-write-manifest-outputs.mjs --workflow ci
run: |
node --input-type=module <<'EOF'
import { appendFileSync } from "node:fs";
const parseBoolean = (value, fallback = false) => {
if (value === undefined) return fallback;
const normalized = value.trim().toLowerCase();
if (normalized === "true" || normalized === "1") return true;
if (normalized === "false" || normalized === "0" || normalized === "") return false;
return fallback;
};
const parseJson = (value, fallback) => {
try {
return value ? JSON.parse(value) : fallback;
} catch {
return fallback;
}
};
const createMatrix = (include) => ({ include });
const outputPath = process.env.GITHUB_OUTPUT;
const eventName = process.env.GITHUB_EVENT_NAME ?? "pull_request";
const isPush = eventName === "push";
const docsOnly = parseBoolean(process.env.OPENCLAW_CI_DOCS_ONLY);
const docsChanged = parseBoolean(process.env.OPENCLAW_CI_DOCS_CHANGED);
const runNode = parseBoolean(process.env.OPENCLAW_CI_RUN_NODE) && !docsOnly;
const runMacos = parseBoolean(process.env.OPENCLAW_CI_RUN_MACOS) && !docsOnly;
const runAndroid = parseBoolean(process.env.OPENCLAW_CI_RUN_ANDROID) && !docsOnly;
const runWindows = parseBoolean(process.env.OPENCLAW_CI_RUN_WINDOWS) && !docsOnly;
const runSkillsPython = parseBoolean(process.env.OPENCLAW_CI_RUN_SKILLS_PYTHON) && !docsOnly;
const hasChangedExtensions =
parseBoolean(process.env.OPENCLAW_CI_HAS_CHANGED_EXTENSIONS) && !docsOnly;
const changedExtensionsMatrix = hasChangedExtensions
? parseJson(process.env.OPENCLAW_CI_CHANGED_EXTENSIONS_MATRIX, { include: [] })
: { include: [] };
const manifest = {
docs_only: docsOnly,
docs_changed: docsChanged,
run_node: runNode,
run_macos: runMacos,
run_android: runAndroid,
run_skills_python: runSkillsPython,
run_windows: runWindows,
has_changed_extensions: hasChangedExtensions,
changed_extensions_matrix: changedExtensionsMatrix,
run_build_artifacts: runNode,
run_checks_fast: runNode,
checks_fast_matrix: createMatrix(
runNode
? [
{ check_name: "checks-fast-bundled", runtime: "node", task: "bundled" },
{ check_name: "checks-fast-extensions", runtime: "node", task: "extensions" },
{
check_name: "checks-fast-contracts-protocol",
runtime: "node",
task: "contracts-protocol",
},
]
: [],
),
run_checks: runNode,
checks_matrix: createMatrix(
runNode
? [
{ check_name: "checks-node-test", runtime: "node", task: "test" },
{ check_name: "checks-node-channels", runtime: "node", task: "channels" },
...(isPush
? [
{
check_name: "checks-node-compat-node22",
runtime: "node",
task: "compat-node22",
node_version: "22.x",
cache_key_suffix: "node22",
},
]
: []),
]
: [],
),
run_extension_fast: hasChangedExtensions,
extension_fast_matrix: createMatrix(
hasChangedExtensions
? (changedExtensionsMatrix.include ?? []).map((entry) => ({
check_name: `extension-fast-${entry.extension}`,
extension: entry.extension,
}))
: [],
),
run_check: runNode,
run_check_additional: runNode,
run_build_smoke: runNode,
run_check_docs: docsChanged,
run_skills_python_job: runSkillsPython,
run_checks_windows: runWindows,
checks_windows_matrix: createMatrix(
runWindows
? [{ check_name: "checks-windows-node-test", runtime: "node", task: "test" }]
: [],
),
run_macos_node: runMacos,
macos_node_matrix: createMatrix(
runMacos ? [{ check_name: "macos-node", runtime: "node", task: "test" }] : [],
),
run_macos_swift: runMacos,
run_android_job: runAndroid,
android_matrix: createMatrix(
runAndroid
? [
{ check_name: "android-test-play", task: "test-play" },
{ check_name: "android-test-third-party", task: "test-third-party" },
{ check_name: "android-build-play", task: "build-play" },
{ check_name: "android-build-third-party", task: "build-third-party" },
]
: [],
),
};
for (const [key, value] of Object.entries(manifest)) {
appendFileSync(
outputPath,
`${key}=${typeof value === "string" ? value : JSON.stringify(value)}\n`,
"utf8",
);
}
EOF
# Run the fast security/SCM checks in parallel with scope detection so the
# main Node jobs do not have to wait for Python/pre-commit setup.

View File

@ -67,16 +67,18 @@ jobs:
id: manifest
env:
OPENCLAW_CI_DOCS_ONLY: ${{ steps.docs_scope.outputs.docs_only }}
OPENCLAW_CI_DOCS_CHANGED: "false"
OPENCLAW_CI_RUN_NODE: "false"
OPENCLAW_CI_RUN_MACOS: "false"
OPENCLAW_CI_RUN_ANDROID: "false"
OPENCLAW_CI_RUN_WINDOWS: "false"
OPENCLAW_CI_RUN_SKILLS_PYTHON: "false"
OPENCLAW_CI_HAS_CHANGED_EXTENSIONS: "false"
OPENCLAW_CI_CHANGED_EXTENSIONS_MATRIX: '{"include":[]}'
OPENCLAW_CI_RUN_CHANGED_SMOKE: ${{ steps.changed_scope.outputs.run_changed_smoke || 'false' }}
run: node scripts/ci-write-manifest-outputs.mjs --workflow install-smoke
run: |
docs_only="${OPENCLAW_CI_DOCS_ONLY:-false}"
run_changed_smoke="${OPENCLAW_CI_RUN_CHANGED_SMOKE:-false}"
run_install_smoke=false
if [ "$docs_only" != "true" ] && [ "$run_changed_smoke" = "true" ]; then
run_install_smoke=true
fi
{
echo "docs_only=$docs_only"
echo "run_install_smoke=$run_install_smoke"
} >> "$GITHUB_OUTPUT"
install-smoke:
needs: [preflight]

View File

@ -67,7 +67,7 @@ OpenClaw has three public release lanes:
so we do not ship an empty browser dashboard again
- If the release work touched CI planning, extension timing manifests, or fast
test matrices, regenerate and review the planner-owned `checks-fast-extensions`
shard plan via `node scripts/ci-write-manifest-outputs.mjs --workflow ci`
workflow matrix outputs from `.github/workflows/ci.yml`
before approval so release notes do not describe a stale CI layout
- Stable macOS release readiness also includes the updater surfaces:
- the GitHub release must end up with the packaged `.zip`, `.dmg`, and `.dSYM.zip`

View File

@ -1,182 +0,0 @@
import { appendFileSync } from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
const WORKFLOWS = new Set(["ci", "install-smoke"]);
const parseArgs = (argv) => {
const parsed = {
workflow: "ci",
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === "--workflow") {
const nextValue = argv[index + 1] ?? "";
if (!WORKFLOWS.has(nextValue)) {
throw new Error(
`Unsupported --workflow value "${String(nextValue || "<missing>")}". Supported values: ci, install-smoke.`,
);
}
parsed.workflow = nextValue;
index += 1;
}
}
return parsed;
};
const parseBooleanEnv = (value, defaultValue = false) => {
if (value === undefined) {
return defaultValue;
}
const normalized = value.trim().toLowerCase();
if (normalized === "true" || normalized === "1") {
return true;
}
if (normalized === "false" || normalized === "0" || normalized === "") {
return false;
}
return defaultValue;
};
const parseJsonEnv = (value, fallback) => {
try {
return value ? JSON.parse(value) : fallback;
} catch {
return fallback;
}
};
const createMatrix = (include) => ({ include });
export function buildWorkflowManifest(env = process.env, workflow = "ci") {
const eventName = env.GITHUB_EVENT_NAME ?? "pull_request";
const isPush = eventName === "push";
const docsOnly = parseBooleanEnv(env.OPENCLAW_CI_DOCS_ONLY);
const docsChanged = parseBooleanEnv(env.OPENCLAW_CI_DOCS_CHANGED);
const runNode = parseBooleanEnv(env.OPENCLAW_CI_RUN_NODE);
const runMacos = parseBooleanEnv(env.OPENCLAW_CI_RUN_MACOS);
const runAndroid = parseBooleanEnv(env.OPENCLAW_CI_RUN_ANDROID);
const runWindows = parseBooleanEnv(env.OPENCLAW_CI_RUN_WINDOWS);
const runSkillsPython = parseBooleanEnv(env.OPENCLAW_CI_RUN_SKILLS_PYTHON);
const hasChangedExtensions = parseBooleanEnv(env.OPENCLAW_CI_HAS_CHANGED_EXTENSIONS);
const changedExtensionsMatrix = parseJsonEnv(env.OPENCLAW_CI_CHANGED_EXTENSIONS_MATRIX, {
include: [],
});
const runChangedSmoke = parseBooleanEnv(env.OPENCLAW_CI_RUN_CHANGED_SMOKE);
const checksFastMatrix = createMatrix(
runNode
? [
{ check_name: "checks-fast-bundled", runtime: "node", task: "bundled" },
{ check_name: "checks-fast-extensions", runtime: "node", task: "extensions" },
{
check_name: "checks-fast-contracts-protocol",
runtime: "node",
task: "contracts-protocol",
},
]
: [],
);
const checksMatrixInclude = runNode
? [
{ check_name: "checks-node-test", runtime: "node", task: "test" },
{ check_name: "checks-node-channels", runtime: "node", task: "channels" },
...(isPush
? [
{
check_name: "checks-node-compat-node22",
runtime: "node",
task: "compat-node22",
node_version: "22.x",
cache_key_suffix: "node22",
},
]
: []),
]
: [];
const windowsMatrix = createMatrix(
runWindows ? [{ check_name: "checks-windows-node-test", runtime: "node", task: "test" }] : [],
);
const macosNodeMatrix = createMatrix(
runMacos ? [{ check_name: "macos-node", runtime: "node", task: "test" }] : [],
);
const androidMatrix = createMatrix(
runAndroid
? [
{ check_name: "android-test-play", task: "test-play" },
{ check_name: "android-test-third-party", task: "test-third-party" },
{ check_name: "android-build-play", task: "build-play" },
{ check_name: "android-build-third-party", task: "build-third-party" },
]
: [],
);
const extensionFastMatrix = createMatrix(
hasChangedExtensions
? (changedExtensionsMatrix.include ?? []).map((entry) => ({
check_name: `extension-fast-${entry.extension}`,
extension: entry.extension,
}))
: [],
);
if (workflow === "install-smoke") {
return {
docs_only: docsOnly,
run_install_smoke: !docsOnly && runChangedSmoke,
};
}
return {
docs_only: docsOnly,
docs_changed: docsChanged,
run_node: !docsOnly && runNode,
run_macos: !docsOnly && runMacos,
run_android: !docsOnly && runAndroid,
run_skills_python: !docsOnly && runSkillsPython,
run_windows: !docsOnly && runWindows,
has_changed_extensions: !docsOnly && hasChangedExtensions,
changed_extensions_matrix: changedExtensionsMatrix,
run_build_artifacts: !docsOnly && runNode,
run_checks_fast: !docsOnly && runNode,
checks_fast_matrix: checksFastMatrix,
run_checks: !docsOnly && runNode,
checks_matrix: createMatrix(checksMatrixInclude),
run_extension_fast: !docsOnly && hasChangedExtensions,
extension_fast_matrix: extensionFastMatrix,
run_check: !docsOnly && runNode,
run_check_additional: !docsOnly && runNode,
run_build_smoke: !docsOnly && runNode,
run_check_docs: docsChanged,
run_skills_python_job: !docsOnly && runSkillsPython,
run_checks_windows: !docsOnly && runWindows,
checks_windows_matrix: windowsMatrix,
run_macos_node: !docsOnly && runMacos,
macos_node_matrix: macosNodeMatrix,
run_macos_swift: !docsOnly && runMacos,
run_android_job: !docsOnly && runAndroid,
android_matrix: androidMatrix,
};
}
const entryHref = process.argv[1] ? pathToFileURL(path.resolve(process.argv[1])).href : "";
if (import.meta.url === entryHref) {
const outputPath = process.env.GITHUB_OUTPUT;
if (!outputPath) {
throw new Error("GITHUB_OUTPUT is required");
}
const { workflow } = parseArgs(process.argv.slice(2));
const manifest = buildWorkflowManifest(process.env, workflow);
const writeOutput = (name, value) => {
appendFileSync(outputPath, `${name}=${value}\n`, "utf8");
};
for (const [key, value] of Object.entries(manifest)) {
writeOutput(key, typeof value === "string" ? value : JSON.stringify(value));
}
}

View File

@ -28,12 +28,8 @@ function normalizeRelative(inputPath) {
return inputPath.split(path.sep).join("/");
}
function isTestFile(filePath) {
return filePath.endsWith(".test.ts") || filePath.endsWith(".test.tsx");
}
function collectTestFiles(rootPath) {
const results = [];
function countTestFiles(rootPath) {
let total = 0;
const stack = [rootPath];
while (stack.length > 0) {
@ -50,13 +46,13 @@ function collectTestFiles(rootPath) {
stack.push(fullPath);
continue;
}
if (entry.isFile() && isTestFile(fullPath)) {
results.push(fullPath);
if (entry.isFile() && (fullPath.endsWith(".test.ts") || fullPath.endsWith(".test.tsx"))) {
total += 1;
}
}
}
return results.toSorted((left, right) => left.localeCompare(right));
return total;
}
function hasGitCommit(ref) {
@ -224,22 +220,22 @@ export function resolveExtensionTestPlan(params = {}) {
const pairedCoreRoot = path.join(repoRoot, "src", extensionId);
if (fs.existsSync(pairedCoreRoot)) {
const pairedRelativeRoot = normalizeRelative(path.relative(repoRoot, pairedCoreRoot));
if (collectTestFiles(pairedCoreRoot).length > 0) {
roots.push(pairedRelativeRoot);
}
roots.push(pairedRelativeRoot);
}
const usesChannelConfig = roots.some((root) => channelTestRoots.includes(root));
const config = usesChannelConfig ? "vitest.channels.config.ts" : "vitest.extensions.config.ts";
const testFiles = roots
.flatMap((root) => collectTestFiles(path.join(repoRoot, root)))
.map((filePath) => normalizeRelative(path.relative(repoRoot, filePath)));
const testFileCount = roots.reduce(
(sum, root) => sum + countTestFiles(path.join(repoRoot, root)),
0,
);
return {
config,
extensionDir: relativeExtensionDir,
extensionId,
hasTests: testFileCount > 0,
roots,
testFiles,
testFileCount,
};
}
@ -247,7 +243,7 @@ async function runVitestBatch(params) {
return await new Promise((resolve, reject) => {
const child = spawn(
pnpm,
["exec", "vitest", "run", "--config", params.config, ...params.files, ...params.args],
["exec", "vitest", "run", "--config", params.config, ...params.targets, ...params.args],
{
cwd: repoRoot,
stdio: "inherit",
@ -382,23 +378,23 @@ async function run() {
console.log(`[test-extension] ${plan.extensionId}`);
console.log(`config: ${plan.config}`);
console.log(`roots: ${plan.roots.join(", ")}`);
console.log(`tests: ${plan.testFiles.length}`);
console.log(`tests: ${plan.testFileCount}`);
}
return;
}
if (plan.testFiles.length === 0) {
if (!plan.hasTests) {
process.exit(printNoTestsMessage(plan, requireTests));
}
console.log(
`[test-extension] Running ${plan.testFiles.length} test files for ${plan.extensionId} with ${plan.config}`,
`[test-extension] Running ${plan.testFileCount} test files for ${plan.extensionId} with ${plan.config}`,
);
const exitCode = await runVitestBatch({
args: passthroughArgs,
config: plan.config,
env: process.env,
files: plan.testFiles,
targets: plan.roots,
});
process.exit(exitCode);
}

View File

@ -1,99 +0,0 @@
import { describe, expect, it } from "vitest";
import { buildWorkflowManifest } from "../../scripts/ci-write-manifest-outputs.mjs";
describe("buildWorkflowManifest", () => {
it("builds static CI matrices from scope env", () => {
const manifest = buildWorkflowManifest({
GITHUB_EVENT_NAME: "pull_request",
OPENCLAW_CI_DOCS_ONLY: "false",
OPENCLAW_CI_DOCS_CHANGED: "false",
OPENCLAW_CI_RUN_NODE: "true",
OPENCLAW_CI_RUN_MACOS: "true",
OPENCLAW_CI_RUN_ANDROID: "true",
OPENCLAW_CI_RUN_WINDOWS: "true",
OPENCLAW_CI_RUN_SKILLS_PYTHON: "false",
OPENCLAW_CI_HAS_CHANGED_EXTENSIONS: "true",
OPENCLAW_CI_CHANGED_EXTENSIONS_MATRIX: '{"include":[{"extension":"discord"}]}',
});
expect(manifest.run_checks).toBe(true);
expect(manifest.checks_fast_matrix).toEqual({
include: [
{ check_name: "checks-fast-bundled", runtime: "node", task: "bundled" },
{ check_name: "checks-fast-extensions", runtime: "node", task: "extensions" },
{
check_name: "checks-fast-contracts-protocol",
runtime: "node",
task: "contracts-protocol",
},
],
});
expect(manifest.checks_matrix).toEqual({
include: [
{ check_name: "checks-node-test", runtime: "node", task: "test" },
{ check_name: "checks-node-channels", runtime: "node", task: "channels" },
],
});
expect(manifest.checks_windows_matrix).toEqual({
include: [{ check_name: "checks-windows-node-test", runtime: "node", task: "test" }],
});
expect(manifest.extension_fast_matrix).toEqual({
include: [{ check_name: "extension-fast-discord", extension: "discord" }],
});
expect(manifest.android_matrix).toHaveProperty("include");
expect(manifest.macos_node_matrix).toEqual({
include: [{ check_name: "macos-node", runtime: "node", task: "test" }],
});
});
it("includes the push-only compat lane on pushes", () => {
const manifest = buildWorkflowManifest({
GITHUB_EVENT_NAME: "push",
OPENCLAW_CI_DOCS_ONLY: "false",
OPENCLAW_CI_DOCS_CHANGED: "false",
OPENCLAW_CI_RUN_NODE: "true",
});
expect(manifest.checks_matrix).toEqual({
include: [
{ check_name: "checks-node-test", runtime: "node", task: "test" },
{ check_name: "checks-node-channels", runtime: "node", task: "channels" },
{
check_name: "checks-node-compat-node22",
runtime: "node",
task: "compat-node22",
node_version: "22.x",
cache_key_suffix: "node22",
},
],
});
});
it("suppresses heavy jobs for docs-only changes", () => {
const manifest = buildWorkflowManifest({
OPENCLAW_CI_DOCS_ONLY: "true",
OPENCLAW_CI_DOCS_CHANGED: "true",
OPENCLAW_CI_RUN_NODE: "true",
OPENCLAW_CI_RUN_WINDOWS: "true",
});
expect(manifest.run_checks).toBe(false);
expect(manifest.run_checks_windows).toBe(false);
expect(manifest.run_check_docs).toBe(true);
});
it("builds install-smoke outputs separately", () => {
const manifest = buildWorkflowManifest(
{
OPENCLAW_CI_DOCS_ONLY: "false",
OPENCLAW_CI_RUN_CHANGED_SMOKE: "true",
},
"install-smoke",
);
expect(manifest).toEqual({
docs_only: false,
run_install_smoke: true,
});
});
});

View File

@ -28,8 +28,7 @@ function runScript(args: string[], cwd = process.cwd()) {
function findExtensionWithoutTests() {
const extensionId = listAvailableExtensionIds().find(
(candidate) =>
resolveExtensionTestPlan({ targetArg: candidate, cwd: process.cwd() }).testFiles.length === 0,
(candidate) => !resolveExtensionTestPlan({ targetArg: candidate, cwd: process.cwd() }).hasTests,
);
expect(extensionId).toBeDefined();
@ -43,9 +42,8 @@ describe("scripts/test-extension.mjs", () => {
expect(plan.extensionId).toBe("slack");
expect(plan.extensionDir).toBe(bundledPluginRoot("slack"));
expect(plan.config).toBe("vitest.channels.config.ts");
expect(plan.testFiles.some((file) => file.startsWith(`${bundledPluginRoot("slack")}/`))).toBe(
true,
);
expect(plan.roots).toContain(bundledPluginRoot("slack"));
expect(plan.hasTests).toBe(true);
});
it("resolves provider extensions onto the extensions vitest config", () => {
@ -53,19 +51,17 @@ describe("scripts/test-extension.mjs", () => {
expect(plan.extensionId).toBe("firecrawl");
expect(plan.config).toBe("vitest.extensions.config.ts");
expect(
plan.testFiles.some((file) => file.startsWith(`${bundledPluginRoot("firecrawl")}/`)),
).toBe(true);
expect(plan.roots).toContain(bundledPluginRoot("firecrawl"));
expect(plan.hasTests).toBe(true);
});
it("includes paired src roots when they contain tests", () => {
it("keeps extension-root plans lean when there is no paired core test root", () => {
const plan = resolveExtensionTestPlan({ targetArg: "line", cwd: process.cwd() });
expect(plan.roots).toContain(bundledPluginRoot("line"));
expect(plan.roots).not.toContain("src/line");
expect(plan.config).toBe("vitest.extensions.config.ts");
expect(plan.testFiles.some((file) => file.startsWith(`${bundledPluginRoot("line")}/`))).toBe(
true,
);
expect(plan.hasTests).toBe(true);
});
it("infers the extension from the current working directory", () => {
@ -111,7 +107,8 @@ describe("scripts/test-extension.mjs", () => {
const plan = readPlan([extensionId]);
expect(plan.extensionId).toBe(extensionId);
expect(plan.testFiles).toEqual([]);
expect(plan.hasTests).toBe(false);
expect(plan.testFileCount).toBe(0);
});
it("treats extensions without tests as a no-op by default", () => {

View File

@ -79,7 +79,6 @@ export default defineConfig({
"test/setup.shared.ts",
"test/setup.extensions.ts",
"scripts/test-projects.mjs",
"scripts/ci-write-manifest-outputs.mjs",
"vitest.channel-paths.mjs",
"vitest.channels.config.ts",
"vitest.bundled.config.ts",