diff --git a/scripts/ci-changed-scope.mjs b/scripts/ci-changed-scope.mjs index ffce43ee719b..dd385aeade71 100644 --- a/scripts/ci-changed-scope.mjs +++ b/scripts/ci-changed-scope.mjs @@ -58,7 +58,7 @@ const WINDOWS_SQLITE_SCOPE_RE = /^src\/(?:state\/|.*sqlite.*\.ts$)/; const WINDOWS_FILE_URL_SCOPE_RE = /^(?:src\/media\/(?:local-media-path(?:\.windows\.test)?|local-roots(?:\.test)?|web-media(?:\.file-url\.windows\.test)?)|src\/channels\/inbound-event\/media(?:\.test)?|src\/gateway\/managed-image-attachments(?:\.test)?|extensions\/msteams\/src\/(?:media-helpers|messenger)(?:\.test)?)\.ts$/; const WINDOWS_SCOPE_RE = - /^(extensions\/mxc\/|src\/agents\/(?:bash-tools\.exec-script-(?:preflight|target)|bash-tools\.exec\.script-preflight\.test)\.ts$|src\/config\/sessions\/(?:session-accessor\.sqlite-archive(?:\.worker(?:\.test)?)?|store\.session-lifecycle-mutation\.test)\.ts$|src\/process\/|src\/infra\/(?:(?:exec-allowlist-pattern|fs-safe-remove)(?:\.test)?|ssh-client(?:\.windows\.test)?|update-managed-service-handoff(?:-(?:command|lifecycle)\.test)?|windows-install-roots)\.ts$|src\/shared\/(?:import-specifier|runtime-import)(?:\.test)?\.ts$|src\/test-utils\/openclaw-test-state(?:\.test)?\.ts$|scripts\/(?:android-(?:app-i18n|pin-version)\.ts|ci-run-timings\.mjs|e2e\/lib\/package-compat\.mjs|generate-bundled-channel-config-metadata\.ts|install\.ps1|openclaw-cross-os-release-checks\.ts|plan-release-workflow-matrix\.mjs|run-additional-boundary-checks\.mts|verify-docker-attestations\.mjs|github\/run-openclaw-cross-os-release-checks\.sh|(?:npm-runner|pnpm-runner|ui|vitest-process-group)\.(?:mjs|mts|js)|lib\/(?:direct-run\.(?:mjs|mts)|format-generated-module\.mts|cross-os-release-checks\/[^/]+\.ts))$|test\/scripts\/(?:direct-run-entrypoints|format-generated-module|install-ps1|npm-runner|openclaw-cross-os-release-workflow|pnpm-runner|ui|vitest-process-group)\.test\.ts$|package\.json$|pnpm-lock\.yaml$|pnpm-workspace\.yaml$|\.github\/workflows\/(?:ci|openclaw-cross-os-release-checks-reusable)\.yml$|\.github\/actions\/setup-node-env\/action\.yml$|\.github\/actions\/setup-pnpm-store-cache\/action\.yml$)/; + /^(extensions\/mxc\/|src\/agents\/(?:bash-tools\.exec-script-(?:preflight|target)|bash-tools\.exec\.script-preflight\.test)\.ts$|src\/config\/sessions\/(?:session-accessor\.sqlite-archive(?:\.worker(?:\.test)?)?|store\.session-lifecycle-mutation\.test)\.ts$|src\/process\/|src\/infra\/(?:(?:exec-allowlist-pattern|fs-safe-remove)(?:\.test)?|ssh-client(?:\.windows\.test)?|update-managed-service-handoff(?:-(?:command|lifecycle)\.test)?|windows-install-roots)\.ts$|src\/shared\/(?:import-specifier|runtime-import)(?:\.test)?\.ts$|src\/test-utils\/openclaw-test-state(?:\.test)?\.ts$|scripts\/(?:android-(?:app-i18n|pin-version)\.ts|ci-run-timings\.mjs|e2e\/lib\/package-compat\.mjs|generate-bundled-channel-config-metadata\.ts|install\.ps1|openclaw-cross-os-release-checks\.ts|plan-release-workflow-matrix\.mjs|run-additional-boundary-checks\.mts|verify-docker-attestations\.mjs|github\/run-openclaw-cross-os-release-checks\.sh|(?:npm-runner|pnpm-runner|ui|vitest-process-group)\.(?:mjs|mts|js)|lib\/(?:direct-run\.(?:mjs|mts)|format-generated-module\.mts|tsx-cli-shim\.mjs|cross-os-release-checks\/[^/]+\.ts))$|test\/scripts\/(?:direct-run-entrypoints|format-generated-module|install-ps1|npm-runner|openclaw-cross-os-release-workflow|pnpm-runner|ui|vitest-process-group)\.test\.ts$|package\.json$|pnpm-lock\.yaml$|pnpm-workspace\.yaml$|\.github\/workflows\/(?:ci|openclaw-cross-os-release-checks-reusable)\.yml$|\.github\/actions\/setup-node-env\/action\.yml$|\.github\/actions\/setup-pnpm-store-cache\/action\.yml$)/; const WINDOWS_TEST_SCOPE_RE = /^(extensions\/mxc\/test\/(?:mxc-backend|sandbox-policy-loader)\.test\.ts$|src\/agents\/bash-tools\.exec\.script-preflight\.test\.ts$|src\/config\/sessions\/(?:session-accessor\.sqlite-archive\.worker|store\.session-lifecycle-mutation)\.test\.ts$|src\/process\/(?:exec\.windows|windows-command)\.test\.ts$|src\/infra\/(?:exec-allowlist-pattern|fs-safe-remove|ssh-client\.windows|update-managed-service-handoff-(?:command|lifecycle)|windows-install-roots)\.test\.ts$|src\/shared\/runtime-import\.test\.ts$|src\/state\/openclaw-database-paths\.windows\.test\.ts$|src\/test-utils\/openclaw-test-state\.test\.ts$|test\/scripts\/(?:direct-run-entrypoints|format-generated-module|npm-runner|openclaw-cross-os-release-workflow|pnpm-runner|ui|vitest-process-group)\.test\.ts$)/; const WINDOWS_SECRETREF_SCOPE_RE = diff --git a/scripts/lib/tsx-cli-shim.mjs b/scripts/lib/tsx-cli-shim.mjs index f2b85f1bae54..35fb73a1f934 100644 --- a/scripts/lib/tsx-cli-shim.mjs +++ b/scripts/lib/tsx-cli-shim.mjs @@ -7,10 +7,11 @@ import { fileURLToPath, pathToFileURL } from "node:url"; const FORWARDED_SIGNALS = ["SIGINT", "SIGTERM", "SIGHUP"]; const FORCE_KILL_DELAY_MS = 5_000; +const SHIM_CHECKOUT_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", ".."); -function resolvePrimaryRoot(repoRoot) { +function resolvePrimaryRoot(checkoutRoot) { const result = spawnSync("git", ["rev-parse", "--git-common-dir"], { - cwd: repoRoot, + cwd: checkoutRoot, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], }); @@ -21,13 +22,22 @@ function resolvePrimaryRoot(repoRoot) { if (!commonDir) { return null; } - const resolved = path.resolve(repoRoot, commonDir); + const resolved = path.resolve(checkoutRoot, commonDir); return path.basename(resolved) === ".git" ? path.dirname(resolved) : null; } -function resolveTsxImport(repoRoot) { +function resolveTsxImport(checkoutRoot) { + const modulesDir = + process.env.PNPM_CONFIG_MODULES_DIR?.trim() || process.env.npm_config_modules_dir?.trim(); + const hydratedTsxRoot = modulesDir + ? path.join(path.resolve(checkoutRoot, modulesDir), "tsx") + : null; let resolutionError; - for (const candidateRoot of [repoRoot, resolvePrimaryRoot(repoRoot)].filter(Boolean)) { + for (const candidateRoot of [ + hydratedTsxRoot, + checkoutRoot, + resolvePrimaryRoot(checkoutRoot), + ].filter(Boolean)) { try { const require = createRequire(path.join(candidateRoot, "package.json")); return pathToFileURL(require.resolve("tsx")).href; @@ -100,8 +110,7 @@ async function runTsxCliShimInner(moduleUrl, options) { try { const implementationUrl = new URL(options.implementation, moduleUrl); const implementationPath = fileURLToPath(implementationUrl); - const repoRoot = path.resolve(path.dirname(implementationPath), ".."); - const tsxImport = resolveTsxImport(repoRoot); + const tsxImport = resolveTsxImport(SHIM_CHECKOUT_ROOT); const nodeExecutable = process.versions.bun ? "node" : process.execPath; child = spawn( nodeExecutable, diff --git a/test/scripts/direct-run-entrypoints.test.ts b/test/scripts/direct-run-entrypoints.test.ts index 9ed1391177f2..9c5510fd834a 100644 --- a/test/scripts/direct-run-entrypoints.test.ts +++ b/test/scripts/direct-run-entrypoints.test.ts @@ -1,5 +1,14 @@ import { spawnSync } from "node:child_process"; -import { readFileSync } from "node:fs"; +import { + copyFileSync, + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; import { detectChangedScope } from "../../scripts/ci-changed-scope.mjs"; @@ -71,6 +80,78 @@ function runEntrypoint(entrypoint: (typeof EXECUTABLE_ENTRYPOINTS)[number]) { }); } +const TSX_SHIM_WRAPPERS = [ + "scripts/run-vitest.mjs", + "scripts/lib/plugin-npm-package-manifest.mjs", + "scripts/e2e/kitchen-sink-rpc-walk.mjs", + "scripts/perf/summarize-cpuprofile.mjs", +] as const; + +type ModulesEnv = Partial>; + +function writeTsxFixture(modulesDir: string, marker: string) { + const packageDir = path.join(modulesDir, "tsx"); + mkdirSync(packageDir, { recursive: true }); + writeFileSync( + path.join(packageDir, "package.json"), + JSON.stringify({ name: "tsx", type: "module", exports: "./loader.mjs" }), + ); + writeFileSync( + path.join(packageDir, "loader.mjs"), + `process.env.OPENCLAW_TSX_FIXTURE_LOADER = ${JSON.stringify(marker)};\n`, + ); +} + +function runShimFixture( + wrapper: (typeof TSX_SHIM_WRAPPERS)[number], + configureModules: (paths: { + checkoutRoot: string; + fixtureRoot: string; + }) => ModulesEnv = () => ({}), +) { + const fixtureRoot = realpathSync(mkdtempSync(path.join(tmpdir(), "openclaw-tsx-cli-shim-"))); + const checkoutRoot = path.join(fixtureRoot, "checkout"); + const wrapperPath = path.join(checkoutRoot, wrapper); + const implementationPath = wrapperPath.replace(/\.mjs$/u, ".mts"); + try { + mkdirSync(path.dirname(wrapperPath), { recursive: true }); + mkdirSync(path.join(checkoutRoot, "scripts", "lib"), { recursive: true }); + copyFileSync(wrapper, wrapperPath); + copyFileSync( + "scripts/lib/tsx-cli-shim.mjs", + path.join(checkoutRoot, "scripts", "lib", "tsx-cli-shim.mjs"), + ); + writeFileSync(path.join(checkoutRoot, "pnpm-lock.yaml"), "lockfileVersion: '9.0'\n"); + writeFileSync( + implementationPath, + "process.stdout.write(JSON.stringify({ loader: process.env.OPENCLAW_TSX_FIXTURE_LOADER, args: process.argv.slice(2) }));\n", + ); + writeTsxFixture(path.join(checkoutRoot, "node_modules"), "checkout"); + const modulesEnv = configureModules({ checkoutRoot, fixtureRoot }); + + const env = { ...process.env }; + delete env.NODE_OPTIONS; + delete env.NODE_PATH; + delete env.PNPM_CONFIG_MODULES_DIR; + delete env.npm_config_modules_dir; + Object.assign(env, modulesEnv); + return spawnSync(process.execPath, [wrapperPath, "--hydrated-proof"], { + cwd: fixtureRoot, + encoding: "utf8", + env, + timeout: 10_000, + }); + } finally { + rmSync(fixtureRoot, { recursive: true, force: true }); + } +} + +function expectShimLoader(result: ReturnType, loader: string) { + expect(result.error).toBeUndefined(); + expect(result.status, result.stderr).toBe(0); + expect(JSON.parse(result.stdout)).toEqual({ loader, args: ["--hydrated-proof"] }); +} + describe("script direct-run entrypoints", () => { it.each(EXECUTABLE_ENTRYPOINTS)("runs $script through its guarded CLI", (entrypoint) => { const result = runEntrypoint(entrypoint); @@ -81,6 +162,40 @@ describe("script direct-run entrypoints", () => { expect(output).toContain(entrypoint.output); }); + it.each([ + { envKey: "PNPM_CONFIG_MODULES_DIR", mode: "absolute", wrapper: TSX_SHIM_WRAPPERS[0] }, + { envKey: "npm_config_modules_dir", mode: "relative", wrapper: TSX_SHIM_WRAPPERS[1] }, + { envKey: "PNPM_CONFIG_MODULES_DIR", mode: "relative", wrapper: TSX_SHIM_WRAPPERS[2] }, + { envKey: "npm_config_modules_dir", mode: "absolute", wrapper: TSX_SHIM_WRAPPERS[3] }, + ] as const)("boots $wrapper from a $mode $envKey", ({ envKey, mode, wrapper }) => { + const result = runShimFixture(wrapper, ({ checkoutRoot, fixtureRoot }) => { + const modulesDir = path.join(fixtureRoot, "hydrated-modules"); + writeTsxFixture(modulesDir, "hydrated"); + const configuredDir = + mode === "absolute" ? modulesDir : path.relative(checkoutRoot, modulesDir); + return { [envKey]: configuredDir }; + }); + expectShimLoader(result, "hydrated"); + }); + + it("prefers PNPM_CONFIG_MODULES_DIR over npm_config_modules_dir", () => { + const result = runShimFixture(TSX_SHIM_WRAPPERS[2], ({ fixtureRoot }) => { + const preferredDir = path.join(fixtureRoot, "preferred-modules"); + const fallbackDir = path.join(fixtureRoot, "fallback-modules"); + writeTsxFixture(preferredDir, "preferred"); + writeTsxFixture(fallbackDir, "lowercase"); + return { + PNPM_CONFIG_MODULES_DIR: preferredDir, + npm_config_modules_dir: fallbackDir, + }; + }); + expectShimLoader(result, "preferred"); + }); + + it("falls back to checkout dependencies without an external modules directory", () => { + expectShimLoader(runShimFixture(TSX_SHIM_WRAPPERS[3]), "checkout"); + }); + it("matches Windows drive paths case-insensitively", () => { expect( isDirectRunPath( @@ -102,6 +217,7 @@ describe("script direct-run entrypoints", () => { it.each([ ...DIRECT_RUN_SCRIPTS, "scripts/lib/direct-run.mjs", + "scripts/lib/tsx-cli-shim.mjs", "test/scripts/direct-run-entrypoints.test.ts", ])("routes %s through Windows CI", (changedPath) => { expect(detectChangedScope([changedPath]).runWindows).toBe(true);