| |
| import { execFileSync } from "node:child_process"; |
| import fs from "node:fs"; |
| import os from "node:os"; |
| import path from "node:path"; |
| import { bundledPluginFile } from "openclaw/plugin-sdk/test-fixtures"; |
| import { afterEach, describe, expect, it } from "vitest"; |
| import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js"; |
|
|
| const { |
| detectChangedScope, |
| detectInstallSmokeScope, |
| detectNodeFastScope, |
| listChangedPaths, |
| parseArgs, |
| shouldRunIosScreenshots, |
| shouldRunNativeI18n, |
| writeGitHubOutput, |
| } = await import("../../scripts/ci-changed-scope.mjs"); |
|
|
| const markerPaths: string[] = []; |
| const tempDirs = useAutoCleanupTempDirTracker(afterEach); |
|
|
| afterEach(() => { |
| for (const markerPath of markerPaths) { |
| try { |
| fs.unlinkSync(markerPath); |
| } catch {} |
| } |
| markerPaths.length = 0; |
| }); |
|
|
| function parseGitHubOutput(output: string): Record<string, string> { |
| const parsed: Record<string, string> = {}; |
| for (const line of output.trim().split("\n")) { |
| if (!line) { |
| continue; |
| } |
| const separator = line.indexOf("="); |
| parsed[line.slice(0, separator)] = line.slice(separator + 1); |
| } |
| return parsed; |
| } |
|
|
| function git(repoDir: string, args: string[]): string { |
| return execFileSync("git", args, { cwd: repoDir, encoding: "utf8" }).trim(); |
| } |
|
|
| function writeRepoFile(repoDir: string, filePath: string, contents: string): void { |
| const absolutePath = path.join(repoDir, filePath); |
| fs.mkdirSync(path.dirname(absolutePath), { recursive: true }); |
| fs.writeFileSync(absolutePath, contents, "utf8"); |
| } |
|
|
| function createSyntheticMergeRepo(prefix: string): { repoDir: string; staleBase: string } { |
| const repoDir = tempDirs.make(prefix); |
|
|
| git(repoDir, ["init", "-b", "main"]); |
| git(repoDir, ["config", "user.email", "ci@example.invalid"]); |
| git(repoDir, ["config", "user.name", "CI"]); |
| writeRepoFile(repoDir, "README.md", "base\n"); |
| git(repoDir, ["add", "."]); |
| git(repoDir, ["commit", "-m", "base"]); |
| const staleBase = git(repoDir, ["rev-parse", "HEAD"]); |
|
|
| git(repoDir, ["switch", "-c", "feature"]); |
| writeRepoFile(repoDir, "src/pr.ts", "export const pr = true;\n"); |
| git(repoDir, ["add", "."]); |
| git(repoDir, ["commit", "-m", "feature"]); |
|
|
| git(repoDir, ["switch", "main"]); |
| writeRepoFile(repoDir, "src/main-only.ts", "export const mainOnly = true;\n"); |
| git(repoDir, ["add", "."]); |
| git(repoDir, ["commit", "-m", "main only"]); |
| git(repoDir, ["merge", "--no-ff", "feature", "-m", "synthetic merge"]); |
|
|
| return { repoDir, staleBase }; |
| } |
|
|
| describe("parseArgs", () => { |
| it("parses CI diff refs", () => { |
| expect(parseArgs(["--base", "origin/main", "--head", "HEAD"])).toEqual({ |
| base: "origin/main", |
| head: "HEAD", |
| mergeHeadFirstParent: false, |
| }); |
| }); |
|
|
| it("rejects missing CI diff refs", () => { |
| expect(() => parseArgs(["--base", "--head", "HEAD"])).toThrow("--base requires a value"); |
| expect(() => parseArgs(["--base", "-h", "--head", "HEAD"])).toThrow("--base requires a value"); |
| expect(() => parseArgs(["--head"])).toThrow("--head requires a value"); |
| expect(() => parseArgs(["--head", "-h"])).toThrow("--head requires a value"); |
| expect(() => parseArgs(["--base", ""])).toThrow("--base requires a value"); |
| expect(() => parseArgs([])).toThrow("--base is required"); |
| expect(() => parseArgs(["--head", "HEAD"])).toThrow("--base is required"); |
| expect(() => parseArgs(["--base", "HEAD", "--mystery"])).toThrow("Unknown argument: --mystery"); |
| }); |
| }); |
|
|
| describe("detectChangedScope", () => { |
| const expectedNodeOnlyScope = { |
| runNode: true, |
| runMacos: false, |
| runMacosNode: false, |
| runIosBuild: false, |
| runAndroid: false, |
| runWindows: false, |
| runSkillsPython: false, |
| runChangedSmoke: false, |
| runControlUiI18n: false, |
| runUiTests: false, |
| }; |
|
|
| const expectedNodeAndChangedSmokeScope = { |
| runNode: true, |
| runMacos: false, |
| runMacosNode: false, |
| runIosBuild: false, |
| runAndroid: false, |
| runWindows: false, |
| runSkillsPython: false, |
| runChangedSmoke: true, |
| runControlUiI18n: false, |
| runUiTests: false, |
| }; |
|
|
| it("routes only native i18n-owned paths to the native inventory job", () => { |
| for (const changedPath of [ |
| "apps/.i18n/native-source.json", |
| "apps/android/app/src/main/java/ai/openclaw/app/MainActivity.kt", |
| "apps/android/wear/src/main/java/ai/openclaw/wear/WearScreens.kt", |
| "apps/ios/Sources/RootTabs.swift", |
| "apps/macos/Sources/OpenClaw/Settings.swift", |
| "apps/shared/OpenClawKit/Sources/OpenClawKit/Client.swift", |
| "scripts/native-app-i18n.ts", |
| "scripts/android-app-i18n.ts", |
| "scripts/apple-app-i18n.ts", |
| "test/scripts/native-app-i18n.test.ts", |
| ".github/workflows/native-app-locale-refresh.yml", |
| ".github/workflows/ci.yml", |
| ]) { |
| expect(shouldRunNativeI18n([changedPath]), changedPath).toBe(true); |
| } |
|
|
| expect(shouldRunNativeI18n(["src/config/defaults.ts"])).toBe(false); |
| expect(shouldRunNativeI18n(["scripts/install.sh"])).toBe(false); |
| }); |
|
|
| it("fails safe when no paths are provided", () => { |
| expect(detectChangedScope([])).toEqual({ |
| runNode: true, |
| runMacos: true, |
| runMacosNode: true, |
| runIosBuild: true, |
| runAndroid: true, |
| runWindows: true, |
| runSkillsPython: true, |
| runChangedSmoke: true, |
| runControlUiI18n: true, |
| runUiTests: true, |
| }); |
| }); |
|
|
| it("keeps all lanes off for docs-only changes", () => { |
| expect(detectChangedScope(["docs/ci.md", "README.md"])).toEqual({ |
| runNode: false, |
| runMacos: false, |
| runMacosNode: false, |
| runIosBuild: false, |
| runAndroid: false, |
| runWindows: false, |
| runSkillsPython: false, |
| runChangedSmoke: false, |
| runControlUiI18n: false, |
| runUiTests: false, |
| }); |
| }); |
|
|
| it("enables node lane for node-relevant files", () => { |
| expect(detectChangedScope(["src/config/defaults.ts"])).toEqual(expectedNodeOnlyScope); |
| }); |
|
|
| it("runs Android and Node CI for Android toolchain action changes", () => { |
| expect(detectChangedScope([".github/actions/setup-android-toolchain/action.yml"])).toEqual({ |
| ...expectedNodeOnlyScope, |
| runAndroid: true, |
| }); |
| }); |
|
|
| it("keeps node lane off for native-only changes", () => { |
| expect(detectChangedScope(["apps/macos/Sources/Foo.swift"])).toEqual({ |
| runNode: false, |
| runMacos: true, |
| runMacosNode: true, |
| runIosBuild: false, |
| runAndroid: false, |
| runWindows: false, |
| runSkillsPython: false, |
| runChangedSmoke: false, |
| runControlUiI18n: false, |
| runUiTests: false, |
| }); |
| expect( |
| detectChangedScope(["apps/macos-mlx-tts/Sources/OpenClawMLXTTSHelper/main.swift"]), |
| ).toEqual({ |
| runNode: false, |
| runMacos: true, |
| runMacosNode: true, |
| runIosBuild: false, |
| runAndroid: false, |
| runWindows: false, |
| runSkillsPython: false, |
| runChangedSmoke: false, |
| runControlUiI18n: false, |
| runUiTests: false, |
| }); |
| expect(detectChangedScope(["apps/ios/Sources/RootTabs.swift"])).toEqual({ |
| runNode: false, |
| runMacos: false, |
| runMacosNode: false, |
| runIosBuild: true, |
| runAndroid: false, |
| runWindows: false, |
| runSkillsPython: false, |
| runChangedSmoke: false, |
| runControlUiI18n: false, |
| runUiTests: false, |
| }); |
| expect(detectChangedScope(["apps/shared/OpenClawKit/Sources/Foo.swift"])).toEqual({ |
| runNode: false, |
| runMacos: true, |
| runMacosNode: true, |
| runIosBuild: true, |
| runAndroid: true, |
| runWindows: false, |
| runSkillsPython: false, |
| runChangedSmoke: false, |
| runControlUiI18n: false, |
| runUiTests: false, |
| }); |
| expect(detectChangedScope(["apps/swabble/Sources/SwabbleKit/WakeWordGate.swift"])).toEqual({ |
| runNode: false, |
| runMacos: true, |
| runMacosNode: true, |
| runIosBuild: true, |
| runAndroid: false, |
| runWindows: false, |
| runSkillsPython: false, |
| runChangedSmoke: false, |
| runControlUiI18n: false, |
| runUiTests: false, |
| }); |
| expect(detectChangedScope(["Swabble/Sources/SwabbleKit/WakeWordGate.swift"])).toEqual({ |
| runNode: false, |
| runMacos: true, |
| runMacosNode: true, |
| runIosBuild: true, |
| runAndroid: false, |
| runWindows: false, |
| runSkillsPython: false, |
| runChangedSmoke: false, |
| runControlUiI18n: false, |
| runUiTests: false, |
| }); |
| }); |
|
|
| it("runs both Apple lanes for shared Swift tooling changes", () => { |
| for (const toolingPath of [ |
| "config/swiftformat", |
| "config/swiftlint.yml", |
| "scripts/check-swift-tools.sh", |
| "scripts/format-swift.sh", |
| "scripts/install-swift-tools.sh", |
| "scripts/install-xcodegen.sh", |
| "scripts/lint-swift.sh", |
| "scripts/prepare-apple-mermaid.mjs", |
| ]) { |
| expect(detectChangedScope([toolingPath])).toEqual({ |
| runNode: true, |
| runMacos: true, |
| runMacosNode: true, |
| runIosBuild: true, |
| runAndroid: false, |
| runWindows: false, |
| runSkillsPython: false, |
| runChangedSmoke: false, |
| runControlUiI18n: false, |
| runUiTests: false, |
| }); |
| expect(shouldRunIosScreenshots([toolingPath])).toBe(true); |
| } |
| }); |
|
|
| it("enables the iOS build lane for iOS build helper changes", () => { |
| for (const helperPath of [ |
| "scripts/ios-team-id.sh", |
| "scripts/ios-write-swift-filelist.mjs", |
| "scripts/ios-write-swift-filelist.mts", |
| "scripts/ios-version.ts", |
| "scripts/lib/ios-version.ts", |
| "scripts/lib/release-version.mjs", |
| "scripts/lib/version-script-args.ts", |
| ]) { |
| expect(detectChangedScope([helperPath])).toEqual({ |
| runNode: true, |
| runMacos: false, |
| runMacosNode: false, |
| runIosBuild: true, |
| runAndroid: false, |
| runWindows: false, |
| runSkillsPython: false, |
| runChangedSmoke: false, |
| runControlUiI18n: false, |
| runUiTests: false, |
| }); |
| } |
| }); |
|
|
| it("runs the iOS build but not macOS for generated protocol model-only changes", () => { |
| expect( |
| detectChangedScope(["apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift"]), |
| ).toEqual({ |
| runNode: false, |
| runMacos: false, |
| runMacosNode: false, |
| runIosBuild: true, |
| runAndroid: false, |
| runWindows: false, |
| runSkillsPython: false, |
| runChangedSmoke: false, |
| runControlUiI18n: false, |
| runUiTests: false, |
| }); |
| }); |
|
|
| it("enables node lane for non-native non-doc files by fallback", () => { |
| expect(detectChangedScope(["README.md"])).toEqual({ |
| runNode: false, |
| runMacos: false, |
| runMacosNode: false, |
| runIosBuild: false, |
| runAndroid: false, |
| runWindows: false, |
| runSkillsPython: false, |
| runChangedSmoke: false, |
| runControlUiI18n: false, |
| runUiTests: false, |
| }); |
|
|
| expect(detectChangedScope([".crabbox.yaml"])).toEqual(expectedNodeOnlyScope); |
| }); |
|
|
| it("keeps windows lane off for non-runtime GitHub metadata files", () => { |
| expect(detectChangedScope([".github/labeler.yml"])).toEqual(expectedNodeOnlyScope); |
| }); |
|
|
| it("runs Python skill tests when skills change", () => { |
| expect(detectChangedScope(["skills/skill-creator/scripts/test_quick_validate.py"])).toEqual({ |
| runNode: true, |
| runMacos: false, |
| runMacosNode: false, |
| runIosBuild: false, |
| runAndroid: false, |
| runWindows: false, |
| runSkillsPython: true, |
| runChangedSmoke: false, |
| runControlUiI18n: false, |
| runUiTests: false, |
| }); |
| }); |
|
|
| it("runs Python skill tests when shared Python config changes", () => { |
| expect(detectChangedScope(["skills/pyproject.toml"])).toEqual({ |
| runNode: true, |
| runMacos: false, |
| runMacosNode: false, |
| runIosBuild: false, |
| runAndroid: false, |
| runWindows: false, |
| runSkillsPython: true, |
| runChangedSmoke: false, |
| runControlUiI18n: false, |
| runUiTests: false, |
| }); |
| }); |
|
|
| it("runs CI-owned platform lanes when the CI workflow changes", () => { |
| expect(detectChangedScope([".github/workflows/ci.yml"])).toEqual({ |
| runNode: true, |
| runMacos: false, |
| runMacosNode: false, |
| runIosBuild: false, |
| runAndroid: false, |
| runWindows: true, |
| runSkillsPython: false, |
| runChangedSmoke: false, |
| runControlUiI18n: false, |
| runUiTests: true, |
| }); |
| }); |
|
|
| it.each([ |
| "scripts/codesign-mac-app.sh", |
| "scripts/create-dmg.sh", |
| "scripts/lib/plistbuddy.sh", |
| "scripts/lib/swift-toolchain.sh", |
| "scripts/notarize-mac-artifact.sh", |
| "scripts/package-mac-app.sh", |
| "scripts/package-mac-dist.sh", |
| "scripts/build-and-run-mac.sh", |
| "scripts/prepush-ci.sh", |
| "scripts/stage-mac-node-worker.sh", |
| "scripts/restart-mac.sh", |
| "scripts/lib/mac-app-bundle.sh", |
| "test/scripts/restart-mac.test.ts", |
| "scripts/materialize-mac-node-worker.py", |
| "scripts/swift-build-cache-metadata.py", |
| "test/scripts/swift-build-cache-metadata.test.ts", |
| "scripts/lib/mac-native-inventory.py", |
| "scripts/lib/mac-bundle-mutation.py", |
| "scripts/verify-mac-node-worker.mjs", |
| "scripts/verify-mac-node-worker-fs.mjs", |
| "scripts/lib/mac-node-worker-proof-state.mjs", |
| "scripts/lib/mac-worker-portability.mjs", |
| "test/helpers/mac-native.ts", |
| "test/helpers/mac-signing.ts", |
| "test/scripts/codesign-mac-app.test.ts", |
| "test/scripts/create-dmg.test.ts", |
| "test/scripts/notarize-mac-artifact.test.ts", |
| "test/scripts/package-mac-app.test.ts", |
| "test/scripts/package-mac-dist.test.ts", |
| "test/scripts/mac-elevation-artifact.test-support.ts", |
| "test/scripts/mac-native-fixtures.test-support.ts", |
| "test/scripts/mac-node-worker-materialization.test-support.ts", |
| "test/scripts/mac-node-worker.test.ts", |
| "test/scripts/verify-mac-node-worker-fs.test.ts", |
| ])("runs macOS CI for packaging owner %s", (changedPath) => { |
| expect(detectChangedScope([changedPath])).toEqual({ |
| runNode: true, |
| runMacos: true, |
| runMacosNode: true, |
| runIosBuild: false, |
| runAndroid: false, |
| runWindows: false, |
| runSkillsPython: false, |
| runChangedSmoke: false, |
| runControlUiI18n: false, |
| runUiTests: false, |
| }); |
| }); |
|
|
| it.each<[string, boolean, boolean]>([ |
| ["extensions/memory-lancedb/index.test.ts", false, false], |
| ["src/auto-reply/reply/streaming-directives.ts", false, false], |
| ["src/process/exec.ts", true, false], |
| ["src/process/exec.windows.test.ts", true, false], |
| ["src/daemon/schtasks.ts", true, false], |
| ["src/daemon/schtasks-exec.ts", true, false], |
| ["src/daemon/schtasks.startup-fallback.test.ts", true, false], |
| ["src/daemon/runtime-hints.windows-paths.test.ts", true, false], |
| ["src/daemon/test-helpers/schtasks-fixtures.ts", true, false], |
| ["src/shared/runtime-import.ts", true, false], |
| ["src/shared/runtime-import.test.ts", true, false], |
| ["scripts/npm-runner.mts", true, false], |
| ["scripts/lib/format-generated-module.mts", true, false], |
| ["test/scripts/format-generated-module.test.ts", true, false], |
| [".github/workflows/openclaw-cross-os-release-checks-reusable.yml", true, false], |
| [".github/workflows/windows-testbox-probe.yml", true, false], |
| ["scripts/github/run-openclaw-cross-os-release-checks.sh", true, false], |
| ["scripts/openclaw-cross-os-release-checks.ts", true, false], |
| ["scripts/lib/cross-os-release-checks/runtime.ts", true, false], |
| ["test/scripts/openclaw-cross-os-release-workflow.test.ts", true, false], |
| ["scripts/install.ps1", true, true], |
| ])( |
| "runs Windows only for Windows-relevant changes (%s)", |
| (changedPath, runWindows, runChangedSmoke) => { |
| expect(detectChangedScope([changedPath])).toEqual({ |
| runNode: true, |
| runMacos: false, |
| runMacosNode: false, |
| runIosBuild: false, |
| runAndroid: false, |
| runWindows, |
| runSkillsPython: false, |
| runChangedSmoke, |
| runControlUiI18n: false, |
| runUiTests: false, |
| }); |
| }, |
| ); |
|
|
| it("runs changed-smoke for install and packaging surfaces", () => { |
| expect(detectChangedScope(["scripts/install.sh"])).toEqual(expectedNodeAndChangedSmokeScope); |
| expect(detectChangedScope(["scripts/install-cli.sh"])).toEqual( |
| expectedNodeAndChangedSmokeScope, |
| ); |
| expect(detectChangedScope([bundledPluginFile("matrix", "package.json")])).toEqual( |
| expectedNodeAndChangedSmokeScope, |
| ); |
| expect(detectChangedScope([".github/workflows/install-smoke.yml"])).toEqual( |
| expectedNodeAndChangedSmokeScope, |
| ); |
| expect(detectChangedScope(["scripts/e2e/qr-import-docker.sh"])).toEqual( |
| expectedNodeAndChangedSmokeScope, |
| ); |
| expect(detectChangedScope(["scripts/e2e/gateway-network-docker.sh"])).toEqual( |
| expectedNodeAndChangedSmokeScope, |
| ); |
| expect(detectChangedScope(["scripts/e2e/Dockerfile"])).toEqual( |
| expectedNodeAndChangedSmokeScope, |
| ); |
| expect(detectChangedScope(["scripts/e2e/agents-delete-shared-workspace-docker.sh"])).toEqual( |
| expectedNodeAndChangedSmokeScope, |
| ); |
| expect(detectChangedScope(["scripts/e2e/plugin-update-unchanged-docker.sh"])).toEqual( |
| expectedNodeOnlyScope, |
| ); |
| expect(detectChangedScope(["scripts/postinstall-bundled-plugins.mjs"])).toEqual( |
| expectedNodeAndChangedSmokeScope, |
| ); |
| expect(detectChangedScope(["scripts/ci-changed-scope.mjs"])).toEqual( |
| expectedNodeAndChangedSmokeScope, |
| ); |
| }); |
|
|
| it("runs changed-smoke for Docker-covered core runtime surfaces", () => { |
| expect(detectChangedScope(["src/plugins/loader.ts"])).toEqual(expectedNodeAndChangedSmokeScope); |
| expect(detectChangedScope(["src/plugin-sdk/provider-entry.ts"])).toEqual( |
| expectedNodeAndChangedSmokeScope, |
| ); |
| expect(detectChangedScope(["packages/gateway-protocol/src/schema/messages.ts"])).toEqual( |
| expectedNodeAndChangedSmokeScope, |
| ); |
| expect(detectChangedScope(["packages/gateway-client/src/client.ts"])).toEqual( |
| expectedNodeAndChangedSmokeScope, |
| ); |
| expect(detectChangedScope(["src/channels/plugins/catalog.ts"])).toEqual( |
| expectedNodeAndChangedSmokeScope, |
| ); |
| expect(detectChangedScope([bundledPluginFile("matrix", "index.ts")])).toEqual( |
| expectedNodeOnlyScope, |
| ); |
| }); |
|
|
| it("splits install smoke into fast and full scopes", () => { |
| expect(detectInstallSmokeScope([])).toEqual({ |
| runFastInstallSmoke: true, |
| runFullInstallSmoke: true, |
| }); |
| expect(detectInstallSmokeScope(["docs/ci.md"])).toEqual({ |
| runFastInstallSmoke: false, |
| runFullInstallSmoke: false, |
| }); |
| expect(detectInstallSmokeScope(["scripts/install.sh"])).toEqual({ |
| runFastInstallSmoke: true, |
| runFullInstallSmoke: true, |
| }); |
| expect(detectInstallSmokeScope(["scripts/install-cli.sh"])).toEqual({ |
| runFastInstallSmoke: true, |
| runFullInstallSmoke: true, |
| }); |
| expect(detectInstallSmokeScope(["scripts/install.ps1"])).toEqual({ |
| runFastInstallSmoke: true, |
| runFullInstallSmoke: true, |
| }); |
| expect(detectInstallSmokeScope(["Dockerfile"])).toEqual({ |
| runFastInstallSmoke: true, |
| runFullInstallSmoke: true, |
| }); |
| expect(detectInstallSmokeScope([bundledPluginFile("matrix", "package.json")])).toEqual({ |
| runFastInstallSmoke: true, |
| runFullInstallSmoke: false, |
| }); |
| expect(detectInstallSmokeScope(["src/plugins/loader.ts"])).toEqual({ |
| runFastInstallSmoke: true, |
| runFullInstallSmoke: false, |
| }); |
| expect(detectInstallSmokeScope(["packages/gateway-client/src/client.ts"])).toEqual({ |
| runFastInstallSmoke: true, |
| runFullInstallSmoke: false, |
| }); |
| expect(detectInstallSmokeScope([bundledPluginFile("matrix", "index.ts")])).toEqual({ |
| runFastInstallSmoke: false, |
| runFullInstallSmoke: false, |
| }); |
| }); |
|
|
| it("keeps changed-smoke off for runtime-surface tests", () => { |
| expect(detectChangedScope(["src/plugins/loader.test.ts"])).toEqual(expectedNodeOnlyScope); |
| expect(detectChangedScope([bundledPluginFile("matrix", "index.test.ts")])).toEqual( |
| expectedNodeOnlyScope, |
| ); |
| }); |
|
|
| it("runs control-ui locale check only for control-ui i18n surfaces", () => { |
| const expected = { |
| runNode: true, |
| runMacos: false, |
| runMacosNode: false, |
| runIosBuild: false, |
| runAndroid: false, |
| runWindows: false, |
| runSkillsPython: false, |
| runChangedSmoke: false, |
| runControlUiI18n: true, |
| runUiTests: true, |
| }; |
| expect(detectChangedScope(["ui/src/i18n/locales/en.ts"])).toEqual(expected); |
|
|
| for (const scriptPath of [ |
| "scripts/control-ui-i18n.ts", |
| "scripts/control-ui-i18n-verify.ts", |
| "scripts/lib/control-ui-i18n-catalog.ts", |
| "scripts/lib/control-ui-i18n-raw-copy.ts", |
| "scripts/lib/control-ui-i18n-sync-plan.ts", |
| ]) { |
| expect(detectChangedScope([scriptPath])).toEqual({ ...expected, runUiTests: false }); |
| } |
| }); |
|
|
| it.each([ |
| "ui/src/pages/chat/chat-realtime.test.ts", |
| "ui/package.json", |
| "test/vitest/vitest.shared.config.ts", |
| "scripts/ensure-playwright-chromium.mts", |
| ])("runs control-ui tests for %s", (changedPath) => { |
| expect(detectChangedScope([changedPath]).runUiTests).toBe(true); |
| }); |
|
|
| it("identifies plugin contract helper changes as fast Node-only CI scope", () => { |
| const bundledCapabilityMetadataPath = [ |
| "src/plugins/contracts", |
| "inventory/bundled-capability-metadata.ts", |
| ].join("/"); |
| expect( |
| detectNodeFastScope([ |
| bundledCapabilityMetadataPath, |
| "src/plugins/contracts/registry.ts", |
| "src/plugins/contracts/tts-contract-suites.ts", |
| "scripts/test-projects.test-support.mts", |
| "test/scripts/test-projects.test.ts", |
| ]), |
| ).toEqual({ |
| runFastOnly: true, |
| runPluginContracts: true, |
| runCiRouting: true, |
| }); |
| }); |
|
|
| it("identifies CI routing changes as fast Node-only CI scope", () => { |
| expect( |
| detectNodeFastScope([ |
| "scripts/check-changed.mjs", |
| "scripts/ci-changed-scope.mjs", |
| "scripts/run-vitest.mts", |
| "scripts/test-projects.test-support.mts", |
| "scripts/lib/ci-docker-seed-plan.mts", |
| "test/scripts/ci-docker-seed-plan.test.ts", |
| "src/commands/status.scan-result.test.ts", |
| "src/scripts/ci-changed-scope.control-ui.test.ts", |
| "src/scripts/ci-changed-scope.native-i18n.test.ts", |
| "src/scripts/ci-changed-scope.test.ts", |
| "src/scripts/ci-changed-scope.windows.test.ts", |
| "test/scripts/changed-lanes.test.ts", |
| "test/scripts/run-vitest.test.ts", |
| "test/scripts/test-projects.test.ts", |
| "docs/ci.md", |
| ]), |
| ).toEqual({ |
| runFastOnly: true, |
| runPluginContracts: false, |
| runCiRouting: true, |
| }); |
| }); |
|
|
| it("keeps CI workflow edits off fast-only scope so native lanes can run", () => { |
| expect(detectNodeFastScope([".github/workflows/ci.yml"])).toEqual({ |
| runFastOnly: false, |
| runPluginContracts: false, |
| runCiRouting: false, |
| }); |
| }); |
|
|
| it("keeps broad source changes on the full Node CI scope", () => { |
| expect( |
| detectNodeFastScope([ |
| "src/plugins/contracts/manifest-loader.ts", |
| "src/plugins/contracts/registry.ts", |
| ]), |
| ).toEqual({ |
| runFastOnly: false, |
| runPluginContracts: false, |
| runCiRouting: false, |
| }); |
| }); |
|
|
| it("treats base and head as literal git args", () => { |
| const markerPath = path.join( |
| os.tmpdir(), |
| `openclaw-ci-changed-scope-${Date.now()}-${Math.random().toString(16).slice(2)}.tmp`, |
| ); |
| markerPaths.push(markerPath); |
|
|
| const injectedBase = |
| process.platform === "win32" |
| ? `HEAD & echo injected > "${markerPath}" & rem` |
| : `HEAD; touch "${markerPath}" #`; |
|
|
| let error: unknown; |
| try { |
| listChangedPaths(injectedBase, "HEAD"); |
| } catch (caught) { |
| error = caught; |
| } |
| expect(error).toBeInstanceOf(Error); |
| expect((error as Error).message).toContain(injectedBase); |
| expect(fs.existsSync(markerPath)).toBe(false); |
| }); |
|
|
| it("uses the merge commit first parent instead of a stale PR payload base", () => { |
| const { repoDir, staleBase } = createSyntheticMergeRepo("openclaw-ci-scope-merge-"); |
|
|
| expect( |
| execFileSync("git", ["diff", "--name-only", staleBase, "HEAD"], { |
| cwd: repoDir, |
| encoding: "utf8", |
| }) |
| .trim() |
| .split("\n") |
| .toSorted(), |
| ).toEqual(["src/main-only.ts", "src/pr.ts"]); |
|
|
| expect(listChangedPaths(staleBase, "HEAD", repoDir, true)).toEqual(["src/pr.ts"]); |
| }); |
|
|
| it("reports both sides of a rename so deleted paths force safe planning", () => { |
| const repoDir = tempDirs.make("openclaw-ci-scope-rename-"); |
| git(repoDir, ["init", "-b", "main"]); |
| git(repoDir, ["config", "user.email", "ci@example.invalid"]); |
| git(repoDir, ["config", "user.name", "CI"]); |
| writeRepoFile(repoDir, "src/old.ts", "export const value = 1;\n"); |
| git(repoDir, ["add", "."]); |
| git(repoDir, ["commit", "-m", "base"]); |
| const base = git(repoDir, ["rev-parse", "HEAD"]); |
| fs.renameSync(path.join(repoDir, "src/old.ts"), path.join(repoDir, "src/new.ts")); |
| git(repoDir, ["add", "-A"]); |
| git(repoDir, ["commit", "-m", "rename"]); |
|
|
| expect(listChangedPaths(base, "HEAD", repoDir)).toEqual(["src/new.ts", "src/old.ts"]); |
| }); |
|
|
| it("preserves leading spaces and newlines in Git filename tokens", () => { |
| if (process.platform === "win32") { |
| return; |
| } |
| const repoDir = tempDirs.make("openclaw-ci-scope-raw-paths-"); |
| git(repoDir, ["init", "-b", "main"]); |
| git(repoDir, ["config", "user.email", "ci@example.invalid"]); |
| git(repoDir, ["config", "user.name", "CI"]); |
| writeRepoFile(repoDir, "README.md", "base\n"); |
| git(repoDir, ["add", "."]); |
| git(repoDir, ["commit", "-m", "base"]); |
| const base = git(repoDir, ["rev-parse", "HEAD"]); |
| const changedPaths = [" scripts/changed-lanes.mts", "scripts/changed\nlanes.mts"]; |
| for (const changedPath of changedPaths) { |
| writeRepoFile(repoDir, changedPath, "export {};\n"); |
| } |
| git(repoDir, ["add", "--", ...changedPaths]); |
| git(repoDir, ["commit", "-m", "raw paths"]); |
|
|
| expect(listChangedPaths(base, "HEAD", repoDir).toSorted()).toEqual(changedPaths.toSorted()); |
| }); |
|
|
| it("drops oversized changed-path payloads before workflow environment interpolation", () => { |
| const outputPath = path.join(os.tmpdir(), `openclaw-ci-scope-output-${Date.now()}.txt`); |
| markerPaths.push(outputPath); |
| const changedPaths = Array.from( |
| { length: 1_000 }, |
| (_, index) => `src/generated/${index}-${"x".repeat(100)}.ts`, |
| ); |
| writeGitHubOutput( |
| detectChangedScope(["docs/ci.md"]), |
| outputPath, |
| undefined, |
| undefined, |
| false, |
| changedPaths, |
| ); |
|
|
| expect(parseGitHubOutput(fs.readFileSync(outputPath, "utf8")).changed_paths_json).toBe("null"); |
| }); |
|
|
| it.each<[string, string, string, boolean, string[]?]>([ |
| ["missing base", "", "missing", true, ["--head", "HEAD"]], |
| ["unknown option", "", "missing", true, ["--base", "HEAD", "--head", "HEAD", "--mystery"]], |
| ["empty diff without a manifest", "", "missing", false], |
| ["declared native test", "src/process/exec.windows.integration.test.ts", "valid", false], |
| ["Mac fixture helper", "test/scripts/mac-script-fixture.test-support.ts", "valid", false], |
| ["unrelated process test", "src/process/exec.test.ts", "valid", false], |
| ["missing manifest", "src/process/exec.test.ts", "missing", true], |
| ["invalid manifest", "src/process/exec.test.ts", "invalid", true], |
| ["empty native inventory", "src/process/exec.test.ts", "empty", true], |
| ])( |
| "runs zero-install scope detection for %s", |
| (_label, changedPath, manifest, failSafe, cliArgs) => { |
| const repoDir = fs.realpathSync(tempDirs.make("openclaw-ci-scope-empty-")); |
| const outputPath = path.join(repoDir, "github-output.txt"); |
| const scriptPath = path.join(repoDir, "scripts/ci-changed-scope.mjs"); |
|
|
| execFileSync("git", ["init", "-b", "main"], { cwd: repoDir }); |
| execFileSync("git", ["config", "user.email", "ci@example.invalid"], { cwd: repoDir }); |
| execFileSync("git", ["config", "user.name", "CI"], { cwd: repoDir }); |
| for (const sourcePath of [ |
| "scripts/ci-changed-scope.mjs", |
| "scripts/lib/arg-utils.runtime.mjs", |
| "scripts/lib/changed-path-facts.mjs", |
| "scripts/lib/direct-run.mjs", |
| "scripts/lib/merge-head-diff-base.mjs", |
| ]) { |
| writeRepoFile(repoDir, sourcePath, fs.readFileSync(path.resolve(sourcePath), "utf8")); |
| } |
| fs.writeFileSync(path.join(repoDir, "README.md"), "test\n", "utf8"); |
| execFileSync("git", ["add", "README.md"], { cwd: repoDir }); |
| execFileSync("git", ["commit", "-m", "test"], { cwd: repoDir }); |
|
|
| if (manifest !== "missing") { |
| const contents = |
| manifest === "valid" |
| ? fs.readFileSync("package.json", "utf8") |
| : manifest === "invalid" |
| ? "{" |
| : JSON.stringify({ scripts: { "test:windows:ci:1": "" } }); |
| writeRepoFile(repoDir, "package.json", contents); |
| } |
| if (changedPath) { |
| writeRepoFile(repoDir, changedPath, "export {};\n"); |
| git(repoDir, ["add", changedPath]); |
| git(repoDir, ["commit", "-m", "changed test"]); |
| } |
| const base = changedPath ? "HEAD^" : "HEAD"; |
| expect(fs.existsSync(path.join(repoDir, "node_modules"))).toBe(false); |
| execFileSync( |
| process.execPath, |
| [scriptPath, ...(cliArgs ?? ["--base", base, "--head", "HEAD"])], |
| { |
| cwd: repoDir, |
| env: { ...process.env, GITHUB_OUTPUT: outputPath }, |
| }, |
| ); |
|
|
| const output = parseGitHubOutput(fs.readFileSync(outputPath, "utf8")); |
| expect(Object.keys(output).toSorted()).toEqual( |
| "changed_paths_json run_android run_changed_smoke run_control_ui_i18n run_fast_install_smoke run_full_install_smoke run_ios_build run_ios_screenshots run_macos run_macos_node run_native_i18n run_node run_node_fast_ci_routing run_node_fast_only run_node_fast_plugin_contracts run_skills_python run_ui_tests run_windows strict_control_ui_i18n strict_native_i18n".split( |
| " ", |
| ), |
| ); |
| expect(output.changed_paths_json).toBe( |
| failSafe ? "null" : JSON.stringify(changedPath ? [changedPath] : []), |
| ); |
| for (const [key, value] of Object.entries(output)) { |
| if (key !== "changed_paths_json") { |
| const selected = |
| (failSafe && !key.startsWith("run_node_fast")) || |
| (key === "run_node" && Boolean(changedPath)) || |
| (key === "run_macos_node" && |
| changedPath === "test/scripts/mac-script-fixture.test-support.ts") || |
| (key === "run_windows" && |
| changedPath === "src/process/exec.windows.integration.test.ts"); |
| expect(value, key).toBe(String(selected)); |
| } |
| } |
| }, |
| ); |
| }); |
|
|