From a3d5e5bc7247427db222d57051f5cb28813d2f29 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Wed, 10 Jun 2026 15:37:11 +0900 Subject: [PATCH] fix(test): support macOS Bash 3 script suites --- scripts/lib/docker-e2e-container.sh | 12 +++- scripts/lib/docker-e2e-package.sh | 29 +++++++-- test/scripts/claude-auth-status.test.ts | 3 +- test/scripts/docker-build-helper.test.ts | 66 +++++++++++++++++++++ test/scripts/github-activity-helper.test.ts | 2 + test/scripts/test-helpers.ts | 38 ++++++++++++ test/scripts/test-report-utils.test.ts | 6 +- 7 files changed, 146 insertions(+), 10 deletions(-) diff --git a/scripts/lib/docker-e2e-container.sh b/scripts/lib/docker-e2e-container.sh index a3deeaffe4c..1313ea0f0be 100644 --- a/scripts/lib/docker-e2e-container.sh +++ b/scripts/lib/docker-e2e-container.sh @@ -142,7 +142,11 @@ docker_e2e_docker_cmd() { if [ "${1:-}" = "run" ]; then shift docker_e2e_docker_run_resource_args "$@" - docker_e2e_timeout_cmd "$timeout_value" docker run "${DOCKER_E2E_RUN_RESOURCE_ARGS[@]}" "$@" + if [ "${#DOCKER_E2E_RUN_RESOURCE_ARGS[@]}" -gt 0 ]; then + docker_e2e_timeout_cmd "$timeout_value" docker run "${DOCKER_E2E_RUN_RESOURCE_ARGS[@]}" "$@" + else + docker_e2e_timeout_cmd "$timeout_value" docker run "$@" + fi return fi docker_e2e_timeout_cmd "$timeout_value" docker "$@" @@ -153,7 +157,11 @@ docker_e2e_docker_run_cmd() { if [ "${1:-}" = "run" ]; then shift docker_e2e_docker_run_resource_args "$@" - docker_e2e_timeout_cmd "$timeout_value" docker run "${DOCKER_E2E_RUN_RESOURCE_ARGS[@]}" "$@" + if [ "${#DOCKER_E2E_RUN_RESOURCE_ARGS[@]}" -gt 0 ]; then + docker_e2e_timeout_cmd "$timeout_value" docker run "${DOCKER_E2E_RUN_RESOURCE_ARGS[@]}" "$@" + else + docker_e2e_timeout_cmd "$timeout_value" docker run "$@" + fi return fi docker_e2e_timeout_cmd "$timeout_value" docker "$@" diff --git a/scripts/lib/docker-e2e-package.sh b/scripts/lib/docker-e2e-package.sh index 847744f79a4..d32141c0a90 100644 --- a/scripts/lib/docker-e2e-package.sh +++ b/scripts/lib/docker-e2e-package.sh @@ -104,10 +104,18 @@ if ! declare -F docker_e2e_docker_run_cmd >/dev/null 2>&1; then shift docker_e2e_docker_run_resource_args "$@" if declare -F docker_e2e_timeout_cmd >/dev/null 2>&1; then - docker_e2e_timeout_cmd "${DOCKER_COMMAND_TIMEOUT:-${OPENCLAW_DOCKER_E2E_RUN_TIMEOUT:-3600s}}" docker run "${DOCKER_E2E_RUN_RESOURCE_ARGS[@]}" "$@" + if [ "${#DOCKER_E2E_RUN_RESOURCE_ARGS[@]}" -gt 0 ]; then + docker_e2e_timeout_cmd "${DOCKER_COMMAND_TIMEOUT:-${OPENCLAW_DOCKER_E2E_RUN_TIMEOUT:-3600s}}" docker run "${DOCKER_E2E_RUN_RESOURCE_ARGS[@]}" "$@" + else + docker_e2e_timeout_cmd "${DOCKER_COMMAND_TIMEOUT:-${OPENCLAW_DOCKER_E2E_RUN_TIMEOUT:-3600s}}" docker run "$@" + fi return fi - set -- run "${DOCKER_E2E_RUN_RESOURCE_ARGS[@]}" "$@" + if [ "${#DOCKER_E2E_RUN_RESOURCE_ARGS[@]}" -gt 0 ]; then + set -- run "${DOCKER_E2E_RUN_RESOURCE_ARGS[@]}" "$@" + else + set -- run "$@" + fi fi if declare -F docker_e2e_timeout_cmd >/dev/null 2>&1; then docker_e2e_timeout_cmd "${DOCKER_COMMAND_TIMEOUT:-${OPENCLAW_DOCKER_E2E_RUN_TIMEOUT:-3600s}}" docker "$@" @@ -333,8 +341,7 @@ docker_e2e_run_with_harness() { rmdir "$cid_dir" 2>/dev/null || true docker_e2e_cleanup_package_mount_args if [ -n "$harness_stdin_fd" ]; then - exec {harness_stdin_fd}<&- - harness_stdin_fd="" + eval "exec ${harness_stdin_fd}<&-" fi restore_harness_traps if [ "$exit_after_cleanup" = "1" ]; then @@ -345,7 +352,19 @@ docker_e2e_run_with_harness() { trap 'cleanup_harness_run 130 1' INT trap 'cleanup_harness_run 143 1' TERM trap 'cleanup_harness_run 129 1' HUP - exec {harness_stdin_fd}<&0 + local candidate_fd + for candidate_fd in 19 18 17 16 15 14 13 12 11 10; do + if ! eval "true <&${candidate_fd}" 2>/dev/null; then + harness_stdin_fd="$candidate_fd" + break + fi + done + if [ -z "$harness_stdin_fd" ]; then + echo "no free file descriptor available for Docker harness stdin" >&2 + cleanup_harness_run 1 + return 1 + fi + eval "exec ${harness_stdin_fd}<&0" docker_e2e_docker_run_cmd run --rm --cidfile "$cidfile" "${DOCKER_E2E_HARNESS_ARGS[@]}" "$@" <&$harness_stdin_fd & docker_run_pid="$!" local had_errexit=0 diff --git a/test/scripts/claude-auth-status.test.ts b/test/scripts/claude-auth-status.test.ts index 0db5441e750..0f41ba9d87e 100644 --- a/test/scripts/claude-auth-status.test.ts +++ b/test/scripts/claude-auth-status.test.ts @@ -3,7 +3,7 @@ import { spawnSync } from "node:child_process"; import { chmodSync, mkdirSync, writeFileSync } from "node:fs"; import path from "node:path"; import { describe, expect, it } from "vitest"; -import { createScriptTestHarness } from "./test-helpers.ts"; +import { createScriptTestHarness, writeNodeBackedJq } from "./test-helpers.ts"; const SCRIPT = "scripts/claude-auth-status.sh"; @@ -16,6 +16,7 @@ describe("claude-auth-status.sh", () => { mkdirSync(bin, { recursive: true }); const openclaw = path.join(bin, "openclaw"); const futureMs = String(Date.now() + 2 * 60 * 60 * 1000); + writeNodeBackedJq(bin); writeFileSync( openclaw, diff --git a/test/scripts/docker-build-helper.test.ts b/test/scripts/docker-build-helper.test.ts index c6e24899c20..ff9f0c15a2f 100644 --- a/test/scripts/docker-build-helper.test.ts +++ b/test/scripts/docker-build-helper.test.ts @@ -2002,6 +2002,72 @@ grep -Fxq 'printf "heredoc reached docker\\n"' "$TMPDIR/docker-stdin-seen" } }); + it("preserves caller-owned file descriptors around harness runs", () => { + const workDir = mkdtempSync(join(tmpdir(), "openclaw-docker-harness-fd-")); + try { + const rootDir = process.cwd(); + const script = String.raw` +set -euo pipefail +ROOT_DIR=${shellQuote(rootDir)} +TMPDIR=${shellQuote(workDir)} +export ROOT_DIR TMPDIR + +mkdir -p "$TMPDIR/bin" +cat >"$TMPDIR/bin/timeout" <<'SH' +#!/usr/bin/env bash +case "$1" in + --kill-after=1s) + exit 0 + ;; + --kill-after=30s) + shift 2 + ;; + *) + shift + ;; +esac +"$@" +SH +chmod +x "$TMPDIR/bin/timeout" +export PATH="$TMPDIR/bin:$PATH" + +source "$ROOT_DIR/scripts/lib/docker-e2e-package.sh" + +docker() { + local cidfile="" + local expect_cidfile=0 + local arg + for arg in "$@"; do + if [[ "$expect_cidfile" == "1" ]]; then + cidfile="$arg" + expect_cidfile=0 + continue + fi + if [[ "$arg" == "--cidfile" ]]; then + expect_cidfile=1 + fi + done + test -n "$cidfile" + printf "container-fd\n" >"$cidfile" + cat >/dev/null +} +export -f docker + +exec 19>"$TMPDIR/caller-fd" +docker_e2e_run_with_harness image-name bash -s <<'SH' +true +SH +printf "preserved\n" >&19 +exec 19>&- +grep -Fxq preserved "$TMPDIR/caller-fd" +`; + + execFileSync("bash", ["-lc", script], { encoding: "utf8" }); + } finally { + rmSync(workDir, { recursive: true, force: true }); + } + }); + it("cleans Codex npm plugin live package artifacts on every exit path", () => { const runner = readFileSync(CODEX_NPM_PLUGIN_LIVE_DOCKER_E2E_PATH, "utf8"); diff --git a/test/scripts/github-activity-helper.test.ts b/test/scripts/github-activity-helper.test.ts index a44620b227a..0272600b218 100644 --- a/test/scripts/github-activity-helper.test.ts +++ b/test/scripts/github-activity-helper.test.ts @@ -4,6 +4,7 @@ import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync import { tmpdir } from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; +import { writeNodeBackedJq } from "./test-helpers.ts"; const repoRoot = path.resolve(import.meta.dirname, "../.."); const helperPath = path.join( @@ -25,6 +26,7 @@ function runHelper(args: string[]) { const logPath = path.join(dir, "gh.log"); const ghPath = path.join(binDir, "gh"); mkdirSync(binDir); + writeNodeBackedJq(binDir); writeFileSync( ghPath, `#!/usr/bin/env bash diff --git a/test/scripts/test-helpers.ts b/test/scripts/test-helpers.ts index 20270250538..9c7b81008d3 100644 --- a/test/scripts/test-helpers.ts +++ b/test/scripts/test-helpers.ts @@ -5,6 +5,44 @@ import os from "node:os"; import path from "node:path"; import { afterEach } from "vitest"; +export function writeNodeBackedJq(binDir: string): void { + const jqPath = path.join(binDir, "jq"); + fs.writeFileSync( + jqPath, + `#!/usr/bin/env node +const fs = require("node:fs"); +const args = process.argv.slice(2); +const query = args.at(-1) ?? ""; +const input = JSON.parse(fs.readFileSync(0, "utf8")); +const print = (value) => process.stdout.write(String(value ?? "") + "\\n"); + +if (query === ".login") print(input.login); +else if (query === ".name // empty") print(input.name ?? ""); +else if (query === ".created_at") print(input.created_at); +else if (query === ".type") print(input.type); +else if (query === ".totalCommitContributions") print(input.totalCommitContributions); +else if (query === ".totalIssueContributions") print(input.totalIssueContributions); +else if (query === ".totalPullRequestContributions") print(input.totalPullRequestContributions); +else if (query === ".totalPullRequestReviewContributions") print(input.totalPullRequestReviewContributions); +else if (query.includes("{id: .profileId")) { + const profiles = input.auth?.oauth?.profiles ?? []; + const profile = profiles.filter((item) => item.provider === "anthropic" && item.type === "oauth").sort((a, b) => (b.expiresAt ?? 0) - (a.expiresAt ?? 0))[0]; + print(profile?.profileId ?? "none"); +} else if (query.includes(".auth.providers[]")) { + const counts = (input.auth?.providers ?? []).filter((item) => item.provider === "anthropic").map((item) => item.profiles?.apiKey ?? 0); + print(Math.max(0, ...counts)); +} else if (query.includes(".auth.oauth.profiles[]")) { + const profiles = (input.auth?.oauth?.profiles ?? []).filter((item) => item.provider === "anthropic" && item.type === "oauth"); + print(Math.max(0, ...profiles.map((item) => item.expiresAt ?? 0))); +} else { + process.stderr.write("unsupported jq query: " + query + "\\n"); + process.exit(2); +} +`, + ); + fs.chmodSync(jqPath, 0o755); +} + export function createScriptTestHarness() { const tempDirs: string[] = []; diff --git a/test/scripts/test-report-utils.test.ts b/test/scripts/test-report-utils.test.ts index ff52c923c28..fa73da36231 100644 --- a/test/scripts/test-report-utils.test.ts +++ b/test/scripts/test-report-utils.test.ts @@ -7,7 +7,6 @@ import { collectVitestAssertionDurations, collectVitestFileDurations, normalizeTrackedRepoPath, - runVitestJsonReport, tryReadJsonFile, } from "../../scripts/test-report-utils.mjs"; @@ -112,10 +111,12 @@ describe("scripts/test-report-utils tryReadJsonFile", () => { describe("scripts/test-report-utils runVitestJsonReport", () => { beforeEach(() => { + vi.resetModules(); spawnSyncMock.mockReset(); }); it("launches Vitest through pnpm exec", async () => { + const { runVitestJsonReport } = await import("../../scripts/test-report-utils.mjs"); const reportPath = path.join(os.tmpdir(), `openclaw-vitest-json-${Date.now()}.json`); spawnSyncMock.mockImplementation(() => { fs.writeFileSync(reportPath, `${JSON.stringify({ testResults: [] })}\n`, "utf8"); @@ -152,7 +153,8 @@ describe("scripts/test-report-utils runVitestJsonReport", () => { ); }); - it("fails when Vitest exits successfully without writing a JSON report", () => { + it("fails when Vitest exits successfully without writing a JSON report", async () => { + const { runVitestJsonReport } = await import("../../scripts/test-report-utils.mjs"); spawnSyncMock.mockReturnValue({ status: 0 }); const reportPath = path.join(os.tmpdir(), `openclaw-vitest-json-missing-${Date.now()}.json`); const exitSpy = vi.spyOn(process, "exit").mockImplementation((code) => {