test: harden path resolution test helpers

This commit is contained in:
Peter Steinberger
2026-03-22 22:46:37 +00:00
parent d91913c207
commit 1ad47b8fa1
11 changed files with 412 additions and 52 deletions

View File

@@ -72,6 +72,9 @@ jobs:
- name: Disallow direct inputs interpolation in composite run blocks
run: python3 scripts/check-composite-action-input-interpolation.py
- name: Disallow tracked merge conflict markers
run: node scripts/check-no-conflict-markers.mjs
generated-doc-baselines:
if: github.event_name == 'workflow_dispatch'
runs-on: blacksmith-16vcpu-ubuntu-2404

View File

@@ -571,13 +571,14 @@
"build:plugin-sdk:dts": "tsc -p tsconfig.plugin-sdk.dts.json",
"build:strict-smoke": "pnpm canvas:a2ui:bundle && node scripts/tsdown-build.mjs && node scripts/runtime-postbuild.mjs && pnpm build:plugin-sdk:dts",
"canvas:a2ui:bundle": "bash scripts/bundle-a2ui.sh",
"check": "pnpm check:host-env-policy:swift && pnpm check:base-config-schema && pnpm check:bundled-plugin-metadata && pnpm check:bundled-provider-auth-env-vars && pnpm format:check && pnpm tsgo && pnpm plugin-sdk:check-exports && pnpm lint && pnpm lint:tmp:no-random-messaging && pnpm lint:tmp:channel-agnostic-boundaries && pnpm lint:tmp:no-raw-channel-fetch && pnpm lint:agent:ingress-owner && pnpm lint:plugins:no-register-http-handler && pnpm lint:plugins:no-monolithic-plugin-sdk-entry-imports && pnpm lint:plugins:no-extension-src-imports && pnpm lint:plugins:no-extension-test-core-imports && pnpm lint:plugins:no-extension-imports && pnpm lint:plugins:plugin-sdk-subpaths-exported && pnpm lint:extensions:no-src-outside-plugin-sdk && pnpm lint:extensions:no-plugin-sdk-internal && pnpm lint:extensions:no-relative-outside-package && pnpm lint:web-search-provider-boundaries && pnpm lint:webhook:no-low-level-body-read && pnpm lint:auth:no-pairing-store-group && pnpm lint:auth:pairing-account-scope",
"check": "pnpm check:host-env-policy:swift && pnpm check:base-config-schema && pnpm check:bundled-plugin-metadata && pnpm check:bundled-provider-auth-env-vars && pnpm check:no-conflict-markers && pnpm format:check && pnpm tsgo && pnpm plugin-sdk:check-exports && pnpm lint && pnpm lint:tmp:no-random-messaging && pnpm lint:tmp:channel-agnostic-boundaries && pnpm lint:tmp:no-raw-channel-fetch && pnpm lint:agent:ingress-owner && pnpm lint:plugins:no-register-http-handler && pnpm lint:plugins:no-monolithic-plugin-sdk-entry-imports && pnpm lint:plugins:no-extension-src-imports && pnpm lint:plugins:no-extension-test-core-imports && pnpm lint:plugins:no-extension-imports && pnpm lint:plugins:plugin-sdk-subpaths-exported && pnpm lint:extensions:no-src-outside-plugin-sdk && pnpm lint:extensions:no-plugin-sdk-internal && pnpm lint:extensions:no-relative-outside-package && pnpm lint:web-search-provider-boundaries && pnpm lint:webhook:no-low-level-body-read && pnpm lint:auth:no-pairing-store-group && pnpm lint:auth:pairing-account-scope",
"check:base-config-schema": "node --import tsx scripts/generate-base-config-schema.ts --check",
"check:bundled-plugin-metadata": "node scripts/generate-bundled-plugin-metadata.mjs --check",
"check:bundled-provider-auth-env-vars": "node scripts/generate-bundled-provider-auth-env-vars.mjs --check",
"check:docs": "pnpm format:docs:check && pnpm lint:docs && pnpm docs:check-i18n-glossary && pnpm docs:check-links",
"check:host-env-policy:swift": "node scripts/generate-host-env-security-policy-swift.mjs --check",
"check:loc": "node --import tsx scripts/check-ts-max-loc.ts --max 500",
"check:no-conflict-markers": "node scripts/check-no-conflict-markers.mjs",
"config:docs:check": "node --import tsx scripts/generate-config-doc-baseline.ts --check",
"config:docs:gen": "node --import tsx scripts/generate-config-doc-baseline.ts --write",
"config:schema:check": "node --import tsx scripts/generate-base-config-schema.ts --check",

View File

@@ -0,0 +1,80 @@
#!/usr/bin/env node
import { execFileSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { runAsScript } from "./lib/ts-guard-utils.mjs";
function isBinaryBuffer(buffer) {
return buffer.includes(0);
}
export function findConflictMarkerLines(content) {
const lines = content.split(/\r?\n/u);
const matches = [];
for (const [index, line] of lines.entries()) {
if (
line.startsWith("<<<<<<< ") ||
line.startsWith("||||||| ") ||
line === "=======" ||
line.startsWith(">>>>>>> ")
) {
matches.push(index + 1);
}
}
return matches;
}
export function listTrackedFiles(cwd = process.cwd()) {
const output = execFileSync("git", ["ls-files", "-z"], {
cwd,
encoding: "utf8",
});
return output
.split("\0")
.filter(Boolean)
.map((relativePath) => path.join(cwd, relativePath));
}
export function findConflictMarkersInFiles(filePaths, readFile = fs.readFileSync) {
const violations = [];
for (const filePath of filePaths) {
let content;
try {
content = readFile(filePath);
} catch {
continue;
}
if (!Buffer.isBuffer(content)) {
content = Buffer.from(String(content));
}
if (isBinaryBuffer(content)) {
continue;
}
const lines = findConflictMarkerLines(content.toString("utf8"));
if (lines.length > 0) {
violations.push({
filePath,
lines,
});
}
}
return violations;
}
export async function main() {
const cwd = process.cwd();
const violations = findConflictMarkersInFiles(listTrackedFiles(cwd));
if (violations.length === 0) {
return;
}
console.error("Found unresolved merge conflict markers:");
for (const violation of violations) {
const relativePath = path.relative(cwd, violation.filePath) || violation.filePath;
console.error(`- ${relativePath}:${violation.lines.join(",")}`);
}
process.exitCode = 1;
}
runAsScript(import.meta.url, main);

View File

@@ -1,6 +1,5 @@
import fs from "node:fs/promises";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { readCommandSource } from "./command-source.test-helpers.js";
const SECRET_TARGET_CALLSITES = [
"src/cli/memory-cli.ts",
@@ -14,36 +13,6 @@ const SECRET_TARGET_CALLSITES = [
"src/commands/status.scan.ts",
] as const;
async function readCommandSource(relativePath: string): Promise<string> {
const absolutePath = path.join(process.cwd(), relativePath);
const source = await fs.readFile(absolutePath, "utf8");
const reexportMatch = source.match(/^export \* from "(?<target>[^"]+)";$/m)?.groups?.target;
const runtimeImportMatch = source.match(/import\("(?<target>\.[^"]+\.runtime\.js)"\)/m)?.groups
?.target;
if (runtimeImportMatch) {
const resolvedTarget = path.join(path.dirname(absolutePath), runtimeImportMatch);
const tsResolvedTarget = resolvedTarget.replace(/\.js$/u, ".ts");
const runtimeSource = await fs.readFile(tsResolvedTarget, "utf8");
return `${source}\n${runtimeSource}`;
}
if (!reexportMatch) {
if (source.includes("resolveCommandSecretRefsViaGateway")) {
return source;
}
const runtimeImportMatch = source.match(/import\("(?<target>\.[^"]+\.runtime\.js)"\)/m)?.groups
?.target;
if (!runtimeImportMatch) {
return source;
}
const resolvedTarget = path.join(path.dirname(absolutePath), runtimeImportMatch);
const tsResolvedTarget = resolvedTarget.replace(/\.js$/u, ".ts");
return await fs.readFile(tsResolvedTarget, "utf8");
}
const resolvedTarget = path.join(path.dirname(absolutePath), reexportMatch);
const tsResolvedTarget = resolvedTarget.replace(/\.js$/u, ".ts");
return await fs.readFile(tsResolvedTarget, "utf8");
}
function hasSupportedTargetIdsWiring(source: string): boolean {
return (
/targetIds:\s*get[A-Za-z0-9_]+\(\)/m.test(source) ||

View File

@@ -0,0 +1,63 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { readCommandSource } from "./command-source.test-helpers.js";
const tempDirs: string[] = [];
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true });
}
});
function makeTempDir(): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-command-source-"));
tempDirs.push(dir);
return dir;
}
describe("readCommandSource", () => {
it("follows re-export shims and runtime boundaries", async () => {
const rootDir = makeTempDir();
const cliDir = path.join(rootDir, "src", "cli");
fs.mkdirSync(cliDir, { recursive: true });
fs.writeFileSync(path.join(cliDir, "index.ts"), 'export * from "./command.js";\n');
fs.writeFileSync(
path.join(cliDir, "command.ts"),
[
"async function loadRuntime() {",
' return await import("./command.runtime.js");',
"}",
"export { loadRuntime };",
].join("\n"),
);
fs.writeFileSync(
path.join(cliDir, "command.runtime.ts"),
'export const marker = "resolveCommandSecretRefsViaGateway";\n',
);
const source = await readCommandSource("src/cli/index.ts", rootDir);
expect(source).toContain('export * from "./command.js";');
expect(source).toContain('import("./command.runtime.js")');
expect(source).toContain("resolveCommandSecretRefsViaGateway");
});
it("dedupes repeated runtime imports", async () => {
const rootDir = makeTempDir();
const cliDir = path.join(rootDir, "src", "cli");
fs.mkdirSync(cliDir, { recursive: true });
fs.writeFileSync(
path.join(cliDir, "command.ts"),
['await import("./shared.runtime.js");', 'await import("./shared.runtime.js");'].join("\n"),
);
fs.writeFileSync(path.join(cliDir, "shared.runtime.ts"), "export const shared = true;\n");
const source = await readCommandSource("src/cli/command.ts", rootDir);
const occurrences = source.match(/export const shared = true;/gu) ?? [];
expect(occurrences).toHaveLength(1);
});
});

View File

@@ -0,0 +1,50 @@
import fs from "node:fs/promises";
import path from "node:path";
function resolveImportedTypeScriptPath(importerPath: string, target: string): string {
const resolvedTarget = path.join(path.dirname(importerPath), target);
return resolvedTarget.replace(/\.js$/u, ".ts");
}
async function readModuleSource(modulePath: string, seen: Set<string>): Promise<string> {
const resolvedPath = path.resolve(modulePath);
if (seen.has(resolvedPath)) {
return "";
}
seen.add(resolvedPath);
const source = await fs.readFile(resolvedPath, "utf8");
if (source.includes("resolveCommandSecretRefsViaGateway")) {
return source;
}
const nestedTargets = new Set<string>();
for (const match of source.matchAll(/^export \* from "(?<target>[^"]+)";$/gmu)) {
const target = match.groups?.target;
if (target) {
nestedTargets.add(resolveImportedTypeScriptPath(resolvedPath, target));
}
}
for (const match of source.matchAll(/import\("(?<target>\.[^"]+\.runtime\.js)"\)/gmu)) {
const target = match.groups?.target;
if (target) {
nestedTargets.add(resolveImportedTypeScriptPath(resolvedPath, target));
}
}
const nestedSources = (
await Promise.all(
[...nestedTargets].map(async (targetPath) => await readModuleSource(targetPath, seen)),
)
).filter(Boolean);
return nestedSources.length > 0 ? [source, ...nestedSources].join("\n") : source;
}
export async function readCommandSource(
relativePath: string,
cwd = process.cwd(),
): Promise<string> {
return await readModuleSource(path.join(cwd, relativePath), new Set<string>());
}

View File

@@ -3,6 +3,7 @@ import fs from "node:fs/promises";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { withTempHome } from "../../../test/helpers/temp-home.js";
import { withPathResolutionEnv } from "../../test-utils/env.js";
import type { OpenClawConfig } from "../config.js";
import { resolveStorePath } from "./paths.js";
import {
@@ -87,16 +88,25 @@ describe("resolveSessionStoreTargets", () => {
},
};
const targets = resolveSessionStoreTargets(cfg, { allAgents: true });
const homeDir = path.resolve(path.sep, "tmp", "openclaw-home");
const targets = withPathResolutionEnv(homeDir, {}, (env) =>
resolveSessionStoreTargets(cfg, { allAgents: true }, { env }),
);
expect(targets).toEqual([
{
agentId: "main",
storePath: resolveStorePath(cfg.session?.store, { agentId: "main", env: process.env }),
storePath: resolveStorePath(cfg.session?.store, {
agentId: "main",
env: { HOME: homeDir },
}),
},
{
agentId: "work",
storePath: resolveStorePath(cfg.session?.store, { agentId: "work", env: process.env }),
storePath: resolveStorePath(cfg.session?.store, {
agentId: "work",
env: { HOME: homeDir },
}),
},
]);
});

View File

@@ -1,6 +1,6 @@
import path from "node:path";
import { describe, expect, it } from "vitest";
import { withEnv } from "../test-utils/env.js";
import { withPathResolutionEnv } from "../test-utils/env.js";
import { formatPluginSourceForTable, resolvePluginSourceRoots } from "./source-display.js";
function createPluginSourceRoots() {
@@ -63,24 +63,16 @@ describe("formatPluginSourceForTable", () => {
});
it("resolves source roots from an explicit env override", () => {
const ignoredHome = path.resolve(path.sep, "tmp", "ignored-home");
const homeDir = path.resolve(path.sep, "tmp", "openclaw-home");
const roots = withEnv(
const roots = withPathResolutionEnv(
homeDir,
{
OPENCLAW_BUNDLED_PLUGINS_DIR: path.join(ignoredHome, "ignored-bundled"),
OPENCLAW_STATE_DIR: path.join(ignoredHome, "ignored-state"),
OPENCLAW_HOME: undefined,
HOME: ignoredHome,
OPENCLAW_BUNDLED_PLUGINS_DIR: "~/bundled",
OPENCLAW_STATE_DIR: "~/state",
},
() =>
(env) =>
resolvePluginSourceRoots({
env: {
...process.env,
HOME: homeDir,
OPENCLAW_HOME: undefined,
OPENCLAW_BUNDLED_PLUGINS_DIR: "~/bundled",
OPENCLAW_STATE_DIR: "~/state",
},
env,
workspaceDir: "~/ws",
}),
);

View File

@@ -1,5 +1,13 @@
import path from "node:path";
import { describe, expect, it } from "vitest";
import { captureEnv, captureFullEnv, withEnv, withEnvAsync } from "./env.js";
import {
captureEnv,
captureFullEnv,
createPathResolutionEnv,
withEnv,
withEnvAsync,
withPathResolutionEnv,
} from "./env.js";
function restoreEnvKey(key: string, previous: string | undefined): void {
if (previous === undefined) {
@@ -109,4 +117,58 @@ describe("env test utils", () => {
expect(process.env[key]).toBe("outer");
restoreEnvKey(key, prev);
});
it("createPathResolutionEnv clears leaked path overrides before applying explicit ones", () => {
const homeDir = path.join(path.sep, "tmp", "openclaw-home");
const previousOpenClawHome = process.env.OPENCLAW_HOME;
const previousStateDir = process.env.OPENCLAW_STATE_DIR;
const previousBundledDir = process.env.OPENCLAW_BUNDLED_PLUGINS_DIR;
process.env.OPENCLAW_HOME = "/srv/openclaw-home";
process.env.OPENCLAW_STATE_DIR = "/srv/openclaw-state";
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = "/srv/openclaw-bundled";
try {
const env = createPathResolutionEnv(homeDir, {
OPENCLAW_STATE_DIR: "~/state",
});
expect(env.HOME).toBe(homeDir);
expect(env.OPENCLAW_HOME).toBeUndefined();
expect(env.OPENCLAW_BUNDLED_PLUGINS_DIR).toBeUndefined();
expect(env.OPENCLAW_STATE_DIR).toBe("~/state");
} finally {
restoreEnvKey("OPENCLAW_HOME", previousOpenClawHome);
restoreEnvKey("OPENCLAW_STATE_DIR", previousStateDir);
restoreEnvKey("OPENCLAW_BUNDLED_PLUGINS_DIR", previousBundledDir);
}
});
it("withPathResolutionEnv only applies the explicit path env inside the callback", () => {
const homeDir = path.join(path.sep, "tmp", "openclaw-home");
const previousOpenClawHome = process.env.OPENCLAW_HOME;
process.env.OPENCLAW_HOME = "/srv/openclaw-home";
try {
const seen = withPathResolutionEnv(
homeDir,
{ OPENCLAW_BUNDLED_PLUGINS_DIR: "~/bundled" },
(env) => ({
processHome: process.env.HOME,
processOpenClawHome: process.env.OPENCLAW_HOME,
processBundledDir: process.env.OPENCLAW_BUNDLED_PLUGINS_DIR,
envBundledDir: env.OPENCLAW_BUNDLED_PLUGINS_DIR,
}),
);
expect(seen).toEqual({
processHome: homeDir,
processOpenClawHome: undefined,
processBundledDir: "~/bundled",
envBundledDir: "~/bundled",
});
expect(process.env.OPENCLAW_HOME).toBe("/srv/openclaw-home");
} finally {
restoreEnvKey("OPENCLAW_HOME", previousOpenClawHome);
}
});
});

View File

@@ -1,3 +1,5 @@
import path from "node:path";
export function captureEnv(keys: string[]) {
const snapshot = new Map<string, string | undefined>();
for (const key of keys) {
@@ -27,6 +29,70 @@ function applyEnvValues(env: Record<string, string | undefined>): void {
}
}
const PATH_RESOLUTION_ENV_KEYS = [
"HOME",
"USERPROFILE",
"HOMEDRIVE",
"HOMEPATH",
"OPENCLAW_HOME",
"OPENCLAW_STATE_DIR",
"CLAWDBOT_STATE_DIR",
"OPENCLAW_BUNDLED_PLUGINS_DIR",
] as const;
function resolveWindowsHomeParts(homeDir: string): { homeDrive?: string; homePath?: string } {
if (process.platform !== "win32") {
return {};
}
const match = homeDir.match(/^([A-Za-z]:)(.*)$/);
if (!match) {
return {};
}
return {
homeDrive: match[1],
homePath: match[2] || "\\",
};
}
export function createPathResolutionEnv(
homeDir: string,
env: Record<string, string | undefined> = {},
): NodeJS.ProcessEnv {
const resolvedHome = path.resolve(homeDir);
const nextEnv: NodeJS.ProcessEnv = {
...process.env,
HOME: resolvedHome,
USERPROFILE: resolvedHome,
OPENCLAW_HOME: undefined,
OPENCLAW_STATE_DIR: undefined,
CLAWDBOT_STATE_DIR: undefined,
OPENCLAW_BUNDLED_PLUGINS_DIR: undefined,
};
const windowsHome = resolveWindowsHomeParts(resolvedHome);
nextEnv.HOMEDRIVE = windowsHome.homeDrive;
nextEnv.HOMEPATH = windowsHome.homePath;
for (const [key, value] of Object.entries(env)) {
nextEnv[key] = value;
}
return nextEnv;
}
export function withPathResolutionEnv<T>(
homeDir: string,
env: Record<string, string | undefined>,
fn: (resolvedEnv: NodeJS.ProcessEnv) => T,
): T {
const resolvedEnv = createPathResolutionEnv(homeDir, env);
const scopedEnv: Record<string, string | undefined> = {};
for (const key of new Set([...PATH_RESOLUTION_ENV_KEYS, ...Object.keys(env)])) {
scopedEnv[key] = resolvedEnv[key];
}
return withEnv(scopedEnv, () => fn(resolvedEnv));
}
export function captureFullEnv() {
const snapshot: Record<string, string | undefined> = { ...process.env };

View File

@@ -0,0 +1,64 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
findConflictMarkerLines,
findConflictMarkersInFiles,
} from "../../scripts/check-no-conflict-markers.mjs";
const tempDirs: string[] = [];
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true });
}
});
function makeTempDir(): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-conflict-markers-"));
tempDirs.push(dir);
return dir;
}
describe("check-no-conflict-markers", () => {
it("finds git conflict markers at the start of lines", () => {
expect(
findConflictMarkerLines(
[
"const ok = true;",
"<<<<<<< HEAD",
"value = left;",
"=======",
"value = right;",
">>>>>>> main",
].join("\n"),
),
).toEqual([2, 4, 6]);
});
it("ignores marker-like text when it is indented or inline", () => {
expect(
findConflictMarkerLines(
["Example:", " <<<<<<< HEAD", "const text = '======= not a conflict';"].join("\n"),
),
).toEqual([]);
});
it("scans text files and skips binary files", () => {
const rootDir = makeTempDir();
const textFile = path.join(rootDir, "CHANGELOG.md");
const binaryFile = path.join(rootDir, "image.png");
fs.writeFileSync(textFile, "<<<<<<< HEAD\nconflict\n>>>>>>> main\n");
fs.writeFileSync(binaryFile, Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x00]));
const violations = findConflictMarkersInFiles([textFile, binaryFile]);
expect(violations).toEqual([
{
filePath: textFile,
lines: [1, 3],
},
]);
});
});