mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-20 09:31:54 -06:00
782d52c8b8
Blacksmith's image tracks an older runner-images snapshot. Measured on a leased box 2026-08-16, its toolcache holds Node 20.20.0, 22.22.0 and 24.13.0 while this repo's engines floor is >=22.22.3 and >=24.15.0 -- short by three and two patches. Every candidate is rejected, so all 306 of 306 sampled jobs fell through to a nodejs.org download. GitHub-hosted runners carry 24.19.0 and resolve from the toolcache in about a second, which is why only Blacksmith pays. Normally that download is 2.6s (p99 3.3s), but ~46 jobs fetch the same 50 MB simultaneously and the mirror throttles: three of 53 sampled runs had setup-node medians of 44-93s with maxes to 139s, and because every job pays at once it lands whole on the wall -- those runs went ~210s to 325s. Keep the payload in the Actions cache, which Blacksmith serves from its colocated backend. Measured on Blacksmith: cold 1605ms, warm 77ms. Restores are prefix-keyed and the save carries the resolved patch. An exact key would be worse than nothing: cache entries are immutable and an exact hit suppresses the post-job save, so a floating `24.x` key would pin the first Node it ever saw and, once the floor advanced past it, every job would restore the rejected payload and re-download forever. Keying the save on the installed version lets a newer resolve publish a new entry that later prefix restores pick up. A rejected payload is pruned before the replacement installs, because the entry is saved wholesale and a leftover would ride along in every future save. Windows keeps its existing path. Proven on Blacksmith across cold, warm, stale and truncated-binary cases; both guards are mutation-checked. This stays useful even if Blacksmith refreshes their image: the floor moves independently of the snapshot, so the gap recurs. The image refresh is still the better fix and is worth asking them for.
486 lines
17 KiB
TypeScript
486 lines
17 KiB
TypeScript
// Setup Pnpm Store Cache Ensure Node tests cover setup pnpm store cache ensure node script behavior.
|
|
import { spawnSync } from "node:child_process";
|
|
import {
|
|
chmodSync,
|
|
existsSync,
|
|
mkdirSync,
|
|
mkdtempSync,
|
|
readFileSync,
|
|
rmSync,
|
|
writeFileSync,
|
|
} from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join, resolve } from "node:path";
|
|
import { beforeAll, describe, expect, it } from "vitest";
|
|
|
|
const ensureNodeScript = resolve(".github/actions/setup-pnpm-store-cache/ensure-node.sh");
|
|
let missingToolcacheCase: {
|
|
status: number | null;
|
|
stderr: string;
|
|
};
|
|
|
|
function writeFakeNode(binDir: string, version: string) {
|
|
mkdirSync(binDir, { recursive: true });
|
|
const nodePath = join(binDir, "node");
|
|
writeFileSync(
|
|
nodePath,
|
|
`#!/usr/bin/env bash
|
|
if [[ "$1" == "-p" ]]; then
|
|
echo "${version}"
|
|
exit 0
|
|
fi
|
|
if [[ "$1" == "-v" ]]; then
|
|
echo "v${version}"
|
|
exit 0
|
|
fi
|
|
exit 0
|
|
`,
|
|
);
|
|
chmodSync(nodePath, 0o755);
|
|
return nodePath;
|
|
}
|
|
|
|
function writeFakeCurl(binDir: string) {
|
|
mkdirSync(binDir, { recursive: true });
|
|
const curlPath = join(binDir, "curl");
|
|
writeFileSync(
|
|
curlPath,
|
|
`#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
printf '%s\\n' "$*" >> "$OPENCLAW_FAKE_CURL_LOG"
|
|
output=""
|
|
url=""
|
|
while [[ $# -gt 0 ]]; do
|
|
case "$1" in
|
|
-o)
|
|
output="$2"
|
|
shift 2
|
|
;;
|
|
http://* | https://*)
|
|
url="$1"
|
|
shift
|
|
;;
|
|
*)
|
|
shift
|
|
;;
|
|
esac
|
|
done
|
|
if [[ -n "\${OPENCLAW_FAKE_CURL_FAIL_SUFFIX:-}" && "$url" == *"\${OPENCLAW_FAKE_CURL_FAIL_SUFFIX:-}" ]]; then
|
|
if [[ -n "$output" ]]; then
|
|
printf '%s' 'partial archive' > "$output"
|
|
fi
|
|
exit 28
|
|
fi
|
|
if [[ "$url" == */index.json ]]; then
|
|
printf '%s\\n' '[{"version":"v24.15.0"}]'
|
|
elif [[ -n "$output" ]]; then
|
|
: > "$output"
|
|
else
|
|
printf '%s' 'archive'
|
|
fi
|
|
`,
|
|
);
|
|
chmodSync(curlPath, 0o755);
|
|
}
|
|
|
|
function runEnsureNode(root: string, requested: string, extraEnv: NodeJS.ProcessEnv = {}) {
|
|
const githubPath = join(root, "github-path");
|
|
const pathOverride = extraEnv.PATH;
|
|
const result = spawnSync(
|
|
"bash",
|
|
[
|
|
"-c",
|
|
[
|
|
"set -e",
|
|
...(pathOverride ? [`export PATH=${JSON.stringify(pathOverride)}`] : []),
|
|
`source "${ensureNodeScript}"`,
|
|
`openclaw_ensure_node "${requested}"`,
|
|
"command -v node",
|
|
"node -p 'process.versions.node'",
|
|
].join("; "),
|
|
],
|
|
{
|
|
encoding: "utf8",
|
|
env: {
|
|
...process.env,
|
|
GITHUB_PATH: githubPath,
|
|
...extraEnv,
|
|
},
|
|
},
|
|
);
|
|
return result;
|
|
}
|
|
|
|
function runVersionMatch(actual: string, requested: string) {
|
|
return spawnSync(
|
|
"bash",
|
|
[
|
|
"-c",
|
|
[
|
|
`source "${ensureNodeScript}"`,
|
|
`openclaw_node_version_matches "${actual}" "${requested}"`,
|
|
].join("; "),
|
|
],
|
|
{ encoding: "utf8", env: process.env },
|
|
);
|
|
}
|
|
|
|
describe("setup-pnpm-store-cache ensure-node", () => {
|
|
beforeAll(() => {
|
|
const root = mkdtempSync(join(tmpdir(), "openclaw-ensure-node-"));
|
|
try {
|
|
const result = spawnSync(
|
|
"bash",
|
|
[
|
|
"-c",
|
|
[
|
|
"set -euo pipefail",
|
|
`source "${ensureNodeScript}"`,
|
|
`openclaw_find_toolcache_node "99.99.99"`,
|
|
].join("; "),
|
|
],
|
|
{
|
|
encoding: "utf8",
|
|
env: {
|
|
PATH: process.env.PATH ?? "",
|
|
RUNNER_TOOL_CACHE: join(root, "missing-toolcache"),
|
|
AGENT_TOOLSDIRECTORY: join(root, "missing-agent-tools"),
|
|
ACTIONS_RUNNER_TOOL_CACHE: join(root, "missing-actions-cache"),
|
|
OPENCLAW_CONTAINER_TOOL_CACHE: join(root, "missing-container-cache"),
|
|
},
|
|
},
|
|
);
|
|
missingToolcacheCase = {
|
|
status: result.status,
|
|
stderr: result.stderr,
|
|
};
|
|
} finally {
|
|
rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("uses a matching active node", () => {
|
|
const root = mkdtempSync(join(tmpdir(), "openclaw-ensure-node-"));
|
|
try {
|
|
const activeBin = join(root, "active", "bin");
|
|
const activeNode = writeFakeNode(activeBin, "24.15.0");
|
|
const result = runEnsureNode(root, "24.15.0", {
|
|
PATH: `${activeBin}:${process.env.PATH ?? ""}`,
|
|
RUNNER_TOOL_CACHE: join(root, "missing-toolcache"),
|
|
});
|
|
|
|
expect(result.status).toBe(0);
|
|
expect(result.stdout).toContain(`Using active Node 24.15.0 at ${activeNode}`);
|
|
expect(result.stdout.trim().endsWith("24.15.0")).toBe(true);
|
|
} finally {
|
|
rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("repairs PATH from the toolcache when setup-node leaves an old node active", () => {
|
|
const root = mkdtempSync(join(tmpdir(), "openclaw-ensure-node-"));
|
|
try {
|
|
const activeBin = join(root, "active", "bin");
|
|
writeFakeNode(activeBin, "20.20.0");
|
|
const toolcacheBin = join(root, "toolcache", "node", "24.15.0", "x64", "bin");
|
|
const toolcacheNode = writeFakeNode(toolcacheBin, "24.15.0");
|
|
const result = runEnsureNode(root, "24.15.0", {
|
|
PATH: `${activeBin}:${process.env.PATH ?? ""}`,
|
|
RUNNER_TOOL_CACHE: join(root, "toolcache"),
|
|
});
|
|
|
|
expect(result.status).toBe(0);
|
|
expect(result.stdout).toContain(`Using Node 24.15.0 from ${toolcacheNode}`);
|
|
expect(result.stdout).toContain(`${toolcacheNode}\n24.15.0`);
|
|
} finally {
|
|
rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("normalizes Windows toolcache paths for Git Bash before prepending PATH", () => {
|
|
const root = mkdtempSync(join(tmpdir(), "openclaw-ensure-node-"));
|
|
try {
|
|
const activeBin = join(root, "active", "bin");
|
|
writeFakeNode(activeBin, "22.22.3");
|
|
const toolcacheBin = join(root, "toolcache", "node", "24.15.0", "x64");
|
|
const toolcacheNode = writeFakeNode(toolcacheBin, "24.15.0");
|
|
const helperBin = join(root, "helpers");
|
|
mkdirSync(helperBin, { recursive: true });
|
|
const cygpath = join(helperBin, "cygpath");
|
|
writeFileSync(
|
|
cygpath,
|
|
`#!/usr/bin/env bash
|
|
if [[ "$1" == "-u" ]]; then
|
|
echo "${toolcacheBin}"
|
|
exit 0
|
|
fi
|
|
if [[ "$1" == "-w" ]]; then
|
|
echo "C:\\\\hostedtoolcache\\\\windows\\\\node\\\\24.15.0\\\\x64"
|
|
exit 0
|
|
fi
|
|
exit 1
|
|
`,
|
|
);
|
|
chmodSync(cygpath, 0o755);
|
|
const githubPath = join(root, "github-path");
|
|
const result = spawnSync(
|
|
"bash",
|
|
[
|
|
"-c",
|
|
[
|
|
"set -e",
|
|
`export PATH=${JSON.stringify(`${helperBin}:${activeBin}:${process.env.PATH ?? ""}`)}`,
|
|
`export GITHUB_PATH=${JSON.stringify(githubPath)}`,
|
|
`source "${ensureNodeScript}"`,
|
|
`openclaw_prepend_node_bin "C:\\\\hostedtoolcache\\\\windows/node/24.15.0/x64"`,
|
|
"command -v node",
|
|
"node -p 'process.versions.node'",
|
|
`cat "${githubPath}"`,
|
|
].join("; "),
|
|
],
|
|
{ encoding: "utf8", env: process.env },
|
|
);
|
|
|
|
expect(result.status).toBe(0);
|
|
expect(result.stdout).toContain(`${toolcacheNode}\n24.15.0`);
|
|
expect(result.stdout).toContain("C:\\hostedtoolcache\\windows\\node\\24.15.0\\x64");
|
|
} finally {
|
|
rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("repairs PATH from the container-mounted GitHub Actions toolcache", () => {
|
|
const root = mkdtempSync(join(tmpdir(), "openclaw-ensure-node-"));
|
|
try {
|
|
const activeBin = join(root, "active", "bin");
|
|
writeFakeNode(activeBin, "20.20.0");
|
|
const toolcacheBin = join(root, "__t", "node", "24.99.99", "x64", "bin");
|
|
const toolcacheNode = writeFakeNode(toolcacheBin, "24.99.99");
|
|
const result = runEnsureNode(root, "24.99.99", {
|
|
PATH: `${activeBin}:${process.env.PATH ?? ""}`,
|
|
OPENCLAW_CONTAINER_TOOL_CACHE: join(root, "__t"),
|
|
RUNNER_TOOL_CACHE: join(root, "hostedtoolcache"),
|
|
});
|
|
|
|
expect(result.status).toBe(0);
|
|
expect(result.stdout).toContain(`Using Node 24.99.99 from ${toolcacheNode}`);
|
|
expect(result.stdout).toContain(`${toolcacheNode}\n24.99.99`);
|
|
} finally {
|
|
rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("accepts major wildcard requests when selecting a toolcache node", () => {
|
|
const root = mkdtempSync(join(tmpdir(), "openclaw-ensure-node-"));
|
|
try {
|
|
const activeBin = join(root, "active", "bin");
|
|
writeFakeNode(activeBin, "20.20.0");
|
|
const toolcacheBin = join(root, "toolcache", "node", "24.15.0", "x64", "bin");
|
|
writeFakeNode(toolcacheBin, "24.15.0");
|
|
const result = runEnsureNode(root, "24.x", {
|
|
PATH: `${activeBin}:${process.env.PATH ?? ""}`,
|
|
RUNNER_TOOL_CACHE: join(root, "toolcache"),
|
|
});
|
|
|
|
expect(result.status).toBe(0);
|
|
expect(result.stdout.trim().endsWith("24.15.0")).toBe(true);
|
|
} finally {
|
|
rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("prefers the repo-owned cached toolchain over an image toolcache below the floor", () => {
|
|
// Blacksmith's image ships Node 24.13.0/22.22.0, just under this repo's
|
|
// engines floor, so without a cached toolchain every job re-downloads.
|
|
const root = mkdtempSync(join(tmpdir(), "openclaw-ensure-node-"));
|
|
try {
|
|
const activeBin = join(root, "active", "bin");
|
|
writeFakeNode(activeBin, "20.20.0");
|
|
writeFakeNode(join(root, "toolcache", "node", "24.13.0", "x64", "bin"), "24.13.0");
|
|
const cachedBin = join(root, "cached", "node", "v24.19.0-linux-x64", "bin");
|
|
const cachedNode = writeFakeNode(cachedBin, "24.19.0");
|
|
const result = runEnsureNode(root, "24.x", {
|
|
PATH: `${activeBin}:${process.env.PATH ?? ""}`,
|
|
RUNNER_TOOL_CACHE: join(root, "toolcache"),
|
|
OPENCLAW_NODE_TOOLCHAIN_ROOT: join(root, "cached", "node"),
|
|
});
|
|
|
|
expect(result.status).toBe(0);
|
|
expect(result.stdout).toContain(`Using Node 24.19.0 from ${cachedNode}`);
|
|
expect(result.stdout).not.toContain("Downloading Node");
|
|
expect(result.stdout.trim().endsWith("24.19.0")).toBe(true);
|
|
} finally {
|
|
rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("keeps the Node 22 wildcard at the supported minimum", () => {
|
|
const root = mkdtempSync(join(tmpdir(), "openclaw-ensure-node-"));
|
|
try {
|
|
const activeBin = join(root, "active", "bin");
|
|
writeFakeNode(activeBin, "22.18.0");
|
|
const toolcacheBin = join(root, "toolcache", "node", "22.22.3", "x64", "bin");
|
|
const toolcacheNode = writeFakeNode(toolcacheBin, "22.22.3");
|
|
const result = runEnsureNode(root, "22.x", {
|
|
PATH: `${activeBin}:${process.env.PATH ?? ""}`,
|
|
RUNNER_TOOL_CACHE: join(root, "toolcache"),
|
|
});
|
|
|
|
expect(result.status).toBe(0);
|
|
expect(result.stdout).toContain(`Using Node 22.22.3 from ${toolcacheNode}`);
|
|
expect(result.stdout.trim().endsWith("22.22.3")).toBe(true);
|
|
} finally {
|
|
rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("rejects Node 22 wildcard matches below the supported minimum", () => {
|
|
expect(runVersionMatch("22.18.0", "22.x").status).toBe(1);
|
|
expect(runVersionMatch("22.22.2", "22.x").status).toBe(1);
|
|
expect(runVersionMatch("22.22.3", "22.x").status).toBe(0);
|
|
});
|
|
|
|
it("enforces patched Node 24 and 25 wildcard minimums", () => {
|
|
expect(runVersionMatch("24.14.1", "24.x").status).toBe(1);
|
|
expect(runVersionMatch("24.15.0", "24.x").status).toBe(0);
|
|
expect(runVersionMatch("25.8.1", "25.x").status).toBe(1);
|
|
expect(runVersionMatch("25.9.0", "25.x").status).toBe(0);
|
|
});
|
|
|
|
it("replaces a rejected payload in the cached toolchain root instead of accreting", () => {
|
|
// The cache entry is saved wholesale, so a rejected install left beside the
|
|
// replacement would ride along in every future save and grow without bound.
|
|
const root = mkdtempSync(join(tmpdir(), "openclaw-ensure-node-"));
|
|
try {
|
|
const helperBin = join(root, "bin");
|
|
const toolchainRoot = join(root, "toolchain", "node");
|
|
writeFakeNode(join(toolchainRoot, "v24.13.0-linux-x64", "bin"), "24.13.0");
|
|
writeFakeCurl(helperBin);
|
|
const result = spawnSync(
|
|
"bash",
|
|
[
|
|
"-c",
|
|
[
|
|
"set -euo pipefail",
|
|
`export PATH=${JSON.stringify(`${helperBin}:${process.env.PATH ?? ""}`)}`,
|
|
`export OPENCLAW_FAKE_CURL_LOG=${JSON.stringify(join(root, "curl.log"))}`,
|
|
`export OPENCLAW_NODE_TOOLCHAIN_ROOT=${JSON.stringify(toolchainRoot)}`,
|
|
`source "${ensureNodeScript}"`,
|
|
"openclaw_prepend_node_bin() { :; }",
|
|
'openclaw_node_download_platform() { printf "linux-x64\\n"; }',
|
|
"tar() { :; }",
|
|
`RUNNER_TEMP=${JSON.stringify(root)} openclaw_download_node "24.15.0"`,
|
|
].join("\n"),
|
|
],
|
|
{ encoding: "utf8", env: process.env },
|
|
);
|
|
|
|
expect(result.status, result.stderr).toBe(0);
|
|
expect(existsSync(join(toolchainRoot, "v24.13.0-linux-x64"))).toBe(false);
|
|
expect(existsSync(join(toolchainRoot, "v24.15.0-linux-x64"))).toBe(true);
|
|
} finally {
|
|
rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("bounds every Node distribution request", () => {
|
|
const root = mkdtempSync(join(tmpdir(), "openclaw-ensure-node-"));
|
|
try {
|
|
const helperBin = join(root, "bin");
|
|
const curlLog = join(root, "curl.log");
|
|
writeFakeCurl(helperBin);
|
|
const result = spawnSync(
|
|
"bash",
|
|
[
|
|
"-c",
|
|
[
|
|
"set -euo pipefail",
|
|
`export PATH=${JSON.stringify(`${helperBin}:${process.env.PATH ?? ""}`)}`,
|
|
`export OPENCLAW_FAKE_CURL_LOG=${JSON.stringify(curlLog)}`,
|
|
`source "${ensureNodeScript}"`,
|
|
'openclaw_resolve_node_download_version "24.x"',
|
|
"openclaw_prepend_node_bin() { :; }",
|
|
'openclaw_node_download_platform() { printf "win-x64\\n"; }',
|
|
"pwsh() { :; }",
|
|
`RUNNER_TEMP=${JSON.stringify(root)} openclaw_download_node "24.15.0"`,
|
|
'openclaw_node_download_platform() { printf "linux-x64\\n"; }',
|
|
"tar() { :; }",
|
|
`RUNNER_TEMP=${JSON.stringify(root)} openclaw_download_node "24.15.0"`,
|
|
].join("\n"),
|
|
],
|
|
{ encoding: "utf8", env: process.env },
|
|
);
|
|
|
|
expect(result.status, result.stderr).toBe(0);
|
|
const curlCalls = readFileSync(curlLog, "utf8").trim().split("\n");
|
|
expect(curlCalls).toHaveLength(3);
|
|
for (const call of curlCalls) {
|
|
expect(call).toContain(
|
|
"-fsSL --connect-timeout 10 --max-time 120 --retry 2 --retry-delay 2",
|
|
);
|
|
}
|
|
expect(curlCalls[0]).toContain("https://nodejs.org/dist/index.json");
|
|
expect(curlCalls[1]).toContain("https://nodejs.org/dist/v24.15.0/node-v24.15.0-win-x64.zip");
|
|
expect(curlCalls[2]).toContain(
|
|
"https://nodejs.org/dist/v24.15.0/node-v24.15.0-linux-x64.tar.xz",
|
|
);
|
|
expect(existsSync(join(root, "node-v24.15.0-linux-x64.tar.xz"))).toBe(false);
|
|
} finally {
|
|
rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("removes a partial POSIX archive after a timed-out download", () => {
|
|
const root = mkdtempSync(join(tmpdir(), "openclaw-ensure-node-"));
|
|
try {
|
|
const helperBin = join(root, "bin");
|
|
writeFakeCurl(helperBin);
|
|
const result = spawnSync(
|
|
"bash",
|
|
[
|
|
"-c",
|
|
[
|
|
"set -uo pipefail",
|
|
`export PATH=${JSON.stringify(`${helperBin}:${process.env.PATH ?? ""}`)}`,
|
|
`export OPENCLAW_FAKE_CURL_LOG=${JSON.stringify(join(root, "curl.log"))}`,
|
|
`export OPENCLAW_FAKE_CURL_FAIL_SUFFIX=${JSON.stringify(".tar.xz")}`,
|
|
`source "${ensureNodeScript}"`,
|
|
"openclaw_prepend_node_bin() { :; }",
|
|
'openclaw_node_download_platform() { printf "linux-x64\\n"; }',
|
|
`RUNNER_TEMP=${JSON.stringify(root)} openclaw_download_node "24.15.0"`,
|
|
].join("\n"),
|
|
],
|
|
{ encoding: "utf8", env: process.env },
|
|
);
|
|
|
|
expect(result.status).toBe(1);
|
|
expect(existsSync(join(root, "node-v24.15.0-linux-x64.tar.xz"))).toBe(false);
|
|
} finally {
|
|
rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("fails clearly when no matching node is available", () => {
|
|
const root = mkdtempSync(join(tmpdir(), "openclaw-ensure-node-"));
|
|
try {
|
|
const activeBin = join(root, "active", "bin");
|
|
writeFakeNode(activeBin, "20.20.0");
|
|
const result = runEnsureNode(root, "99.99.99", {
|
|
PATH: `${activeBin}:${process.env.PATH ?? ""}`,
|
|
RUNNER_TOOL_CACHE: join(root, "toolcache"),
|
|
});
|
|
|
|
expect(result.status).toBe(1);
|
|
expect(result.stdout).toContain("::error::Expected Node '99.99.99'");
|
|
expect(result.stdout).toContain("active node is '20.20.0'");
|
|
} finally {
|
|
rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("handles missing toolcache roots under nounset", () => {
|
|
expect(missingToolcacheCase.status).toBe(1);
|
|
expect(missingToolcacheCase.stderr).not.toContain("unbound variable");
|
|
});
|
|
});
|