fix(test): harden planner artifact cleanup and profile env fallback

This commit is contained in:
Peter Steinberger
2026-03-29 00:19:57 +00:00
parent c3a0304f63
commit 03826b8075
4 changed files with 107 additions and 20 deletions

View File

@@ -166,6 +166,14 @@ export function createExecutionArtifacts(env = process.env) {
return { ensureTempArtifactDir, writeTempJsonArtifact, cleanupTempArtifacts };
}
export function createTempArtifactWriteStream(filePath) {
const fd = fs.openSync(filePath, "w");
return fs.createWriteStream(filePath, {
fd,
autoClose: true,
});
}
const ensureNodeOptionFlag = (nodeOptions, flagPrefix, nextValue) =>
nodeOptions.includes(flagPrefix) ? nodeOptions : `${nodeOptions} ${nextValue}`.trim();
@@ -420,7 +428,7 @@ export async function executePlan(plan, options = {}) {
.filter(Boolean)
.join("-");
const laneLogPath = path.join(artifacts.ensureTempArtifactDir(), `${artifactStem}.log`);
const laneLogStream = fs.createWriteStream(laneLogPath, { flags: "w" });
const laneLogStream = createTempArtifactWriteStream(laneLogPath);
laneLogStream.write(`[test-parallel] entry=${unit.id}\n`);
laneLogStream.write(`[test-parallel] cwd=${process.cwd()}\n`);
laneLogStream.write(

View File

@@ -3,6 +3,7 @@ import path from "node:path";
import { describe, expect, it } from "vitest";
import {
createExecutionArtifacts,
createTempArtifactWriteStream,
resolvePnpmCommandInvocation,
resolveVitestFsModuleCachePath,
} from "../../scripts/test-planner/executor.mjs";
@@ -348,6 +349,24 @@ describe("test planner", () => {
expect(fs.existsSync(artifactDir)).toBe(false);
});
it("keeps fd-backed artifact streams writable after temp cleanup", async () => {
const artifacts = createExecutionArtifacts({});
const artifactDir = artifacts.ensureTempArtifactDir();
const logPath = path.join(artifactDir, "lane.log");
const stream = createTempArtifactWriteStream(logPath);
stream.write("before cleanup\n");
artifacts.cleanupTempArtifacts();
await expect(
new Promise((resolve, reject) => {
stream.on("error", reject);
stream.end("after cleanup\n", resolve);
}),
).resolves.toBeNull();
expect(fs.existsSync(artifactDir)).toBe(false);
});
it("builds a CI manifest with planner-owned shard counts and matrices", () => {
const manifest = buildCIExecutionManifest(
{

View File

@@ -1,7 +1,8 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import { importFreshModule } from "./helpers/import-fresh.js";
import { installTestEnv } from "./test-env.js";
const ORIGINAL_ENV = { ...process.env };
@@ -136,4 +137,30 @@ describe("installTestEnv", () => {
expect(process.env.HOME).toBe(realHome);
expect(process.env.TEST_PROFILE_ONLY).toBe("from-profile");
});
it("falls back to parsing ~/.profile when bash is unavailable", async () => {
const realHome = createTempHome();
writeFile(path.join(realHome, ".profile"), "export TEST_PROFILE_ONLY=from-profile\n");
process.env.HOME = realHome;
process.env.USERPROFILE = realHome;
process.env.OPENCLAW_LIVE_TEST = "1";
process.env.OPENCLAW_LIVE_USE_REAL_HOME = "1";
process.env.OPENCLAW_LIVE_TEST_QUIET = "1";
vi.doMock("node:child_process", () => ({
execFileSync: () => {
throw Object.assign(new Error("bash missing"), { code: "ENOENT" });
},
}));
const { installTestEnv: installFreshTestEnv } = await importFreshModule<
typeof import("./test-env.js")
>(import.meta.url, "./test-env.js?scope=profile-fallback");
const testEnv = installFreshTestEnv();
expect(testEnv.tempHome).toBe(realHome);
expect(process.env.TEST_PROFILE_ONLY).toBe("from-profile");
});
});

View File

@@ -50,34 +50,67 @@ function loadProfileEnv(homeDir = os.homedir()): void {
if (!fs.existsSync(profilePath)) {
return;
}
const applyEntry = (entry: string) => {
const idx = entry.indexOf("=");
if (idx <= 0) {
return false;
}
const key = entry.slice(0, idx).trim();
if (!/^[A-Za-z_][A-Za-z0-9_]*$/u.test(key) || (process.env[key] ?? "") !== "") {
return false;
}
process.env[key] = entry.slice(idx + 1);
return true;
};
const countAppliedEntries = (entries: Iterable<string>) => {
let applied = 0;
for (const entry of entries) {
if (applyEntry(entry)) {
applied += 1;
}
}
return applied;
};
try {
const output = execFileSync(
"/bin/bash",
["-lc", `set -a; source "${profilePath}" >/dev/null 2>&1; env -0`],
{ encoding: "utf8" },
);
const entries = output.split("\0");
let applied = 0;
for (const entry of entries) {
if (!entry) {
continue;
}
const idx = entry.indexOf("=");
if (idx <= 0) {
continue;
}
const key = entry.slice(0, idx);
if (!key || (process.env[key] ?? "") !== "") {
continue;
}
process.env[key] = entry.slice(idx + 1);
applied += 1;
}
const applied = countAppliedEntries(output.split("\0").filter(Boolean));
if (applied > 0 && !isTruthyEnvValue(process.env.OPENCLAW_LIVE_TEST_QUIET)) {
console.log(`[live] loaded ${applied} env vars from ~/.profile`);
}
} catch {
// ignore profile load failures
try {
const fallbackEntries = fs
.readFileSync(profilePath, "utf8")
.split(/\r?\n/u)
.map((line) => line.trim())
.filter((line) => line && !line.startsWith("#"))
.map((line) => line.replace(/^export\s+/u, ""))
.map((line) => {
const match = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/u);
if (!match) {
return "";
}
let value = match[2].trim();
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1);
}
return `${match[1]}=${value}`;
})
.filter(Boolean);
const applied = countAppliedEntries(fallbackEntries);
if (applied > 0 && !isTruthyEnvValue(process.env.OPENCLAW_LIVE_TEST_QUIET)) {
console.log(`[live] loaded ${applied} env vars from ~/.profile`);
}
} catch {
// ignore profile load failures
}
}
}