mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-09 13:53:18 +08:00
重构主控编排与运行时预热链路,统一工作区提示词/专家调度协议并补齐 wiki 记忆注入与写回闭环。
同时收敛启动与运维脚本默认行为(含 wiki worker)、更新 Admin 可观测性与相关测试,降低首轮时延并提高运行稳定性。 Made-with: Cursor
This commit is contained in:
parent
4a23b715a2
commit
dbbe3add6a
14438 changed files with 2693620 additions and 2546 deletions
149
openclaw/test/scripts/audit-seams.test.ts
Normal file
149
openclaw/test/scripts/audit-seams.test.ts
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
HELP_TEXT,
|
||||
describeSeamKinds,
|
||||
determineSeamTestStatus,
|
||||
} from "../../scripts/audit-seams.mjs";
|
||||
|
||||
describe("audit-seams cron seam classification", () => {
|
||||
it("detects cron agent handoff and outbound delivery boundaries", () => {
|
||||
const source = `
|
||||
import { runCliAgent } from "../../agents/cli-runner.js";
|
||||
import { runWithModelFallback } from "../../agents/model-fallback.js";
|
||||
import { registerAgentRunContext } from "../../infra/agent-events.js";
|
||||
import { deliverOutboundPayloads } from "../../infra/outbound/deliver.js";
|
||||
import { buildOutboundSessionContext } from "../../infra/outbound/session-context.js";
|
||||
|
||||
export async function runCronIsolatedAgentTurn() {
|
||||
registerAgentRunContext({});
|
||||
await runWithModelFallback(() => runCliAgent({}));
|
||||
await deliverOutboundPayloads({ payloads: [{ text: "done" }] });
|
||||
return buildOutboundSessionContext({});
|
||||
}
|
||||
`;
|
||||
|
||||
expect(describeSeamKinds("src/cron/isolated-agent/run.ts", source)).toEqual([
|
||||
"cron-agent-handoff",
|
||||
"cron-outbound-delivery",
|
||||
]);
|
||||
});
|
||||
|
||||
it("detects scheduler-state seams in cron service orchestration", () => {
|
||||
const source = `
|
||||
import { recomputeNextRuns, computeJobNextRunAtMs } from "./jobs.js";
|
||||
import { ensureLoaded, persist } from "./store.js";
|
||||
import { armTimer, runMissedJobs } from "./timer.js";
|
||||
|
||||
export async function start(state) {
|
||||
await ensureLoaded(state);
|
||||
recomputeNextRuns(state);
|
||||
await persist(state);
|
||||
armTimer(state);
|
||||
await runMissedJobs(state);
|
||||
return computeJobNextRunAtMs(state.store.jobs[0], Date.now());
|
||||
}
|
||||
`;
|
||||
|
||||
expect(describeSeamKinds("src/cron/service/ops.ts", source)).toContain("cron-scheduler-state");
|
||||
});
|
||||
});
|
||||
|
||||
describe("audit-seams subagent seam classification", () => {
|
||||
it("detects subagent spawn and cleanup handoff boundaries", () => {
|
||||
const source = `
|
||||
import { callGateway } from "../gateway/call.js";
|
||||
import { emitSessionLifecycleEvent } from "../sessions/session-lifecycle-events.js";
|
||||
import { registerSubagentRun } from "./subagent-registry.js";
|
||||
|
||||
export async function spawnSubagentDirect() {
|
||||
const response = await callGateway({ method: "agent.run", params: { task: "do it" } });
|
||||
registerSubagentRun({ childSessionKey: "agent:main:subagent:child" });
|
||||
await callGateway({ method: "sessions.delete", params: { key: "agent:main:subagent:child" } });
|
||||
emitSessionLifecycleEvent({ sessionKey: "agent:main:subagent:child", type: "spawned" });
|
||||
return response;
|
||||
}
|
||||
`;
|
||||
|
||||
expect(describeSeamKinds("src/agents/subagent-spawn.ts", source)).toEqual([
|
||||
"subagent-lifecycle-registry",
|
||||
"subagent-session-cleanup",
|
||||
"subagent-session-spawn",
|
||||
]);
|
||||
});
|
||||
|
||||
it("detects subagent lifecycle registry and announce delivery seams", () => {
|
||||
const source = `
|
||||
import { resolveContextEngine } from "../context-engine/registry.js";
|
||||
import { captureSubagentCompletionReply, runSubagentAnnounceFlow } from "./subagent-announce.js";
|
||||
import { emitSubagentEndedHookOnce } from "./subagent-registry-completion.js";
|
||||
import { persistSubagentRunsToDisk } from "./subagent-registry-state.js";
|
||||
|
||||
export async function completeRun(entry) {
|
||||
await resolveContextEngine({});
|
||||
await captureSubagentCompletionReply(entry.childSessionKey);
|
||||
await emitSubagentEndedHookOnce({ runId: entry.runId });
|
||||
persistSubagentRunsToDisk(new Map());
|
||||
return runSubagentAnnounceFlow({ childSessionKey: entry.childSessionKey });
|
||||
}
|
||||
`;
|
||||
|
||||
expect(describeSeamKinds("src/agents/subagent-registry.ts", source)).toEqual([
|
||||
"subagent-announce-delivery",
|
||||
"subagent-lifecycle-registry",
|
||||
]);
|
||||
});
|
||||
|
||||
it("detects parent-stream seams for ACP spawn relays", () => {
|
||||
const source = `
|
||||
import { onAgentEvent } from "../infra/agent-events.js";
|
||||
import { requestHeartbeatNow } from "../infra/heartbeat-wake.js";
|
||||
import { enqueueSystemEvent } from "../infra/system-events.js";
|
||||
|
||||
export function startAcpSpawnParentStreamRelay() {
|
||||
onAgentEvent("agent-output", () => {});
|
||||
requestHeartbeatNow({ sessionKey: "agent:main" });
|
||||
enqueueSystemEvent("progress", { sessionKey: "agent:main", contextKey: "stream" });
|
||||
return { streamTo: "parent" };
|
||||
}
|
||||
`;
|
||||
|
||||
expect(describeSeamKinds("src/agents/acp-spawn-parent-stream.ts", source)).toEqual([
|
||||
"subagent-parent-stream",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("audit-seams status/help", () => {
|
||||
it("keeps cron seam statuses conservative when nearby tests exist", () => {
|
||||
expect(
|
||||
determineSeamTestStatus(
|
||||
["cron-agent-handoff"],
|
||||
[{ file: "src/cron/service.issue-regressions.test.ts", matchQuality: "path-nearby" }],
|
||||
),
|
||||
).toEqual({
|
||||
status: "partial",
|
||||
reason:
|
||||
"Nearby tests exist (best match: path-nearby), but this inventory does not prove cross-layer seam coverage end to end.",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps subagent seam statuses conservative when nearby tests exist", () => {
|
||||
expect(
|
||||
determineSeamTestStatus(
|
||||
["subagent-session-spawn"],
|
||||
[{ file: "src/agents/subagent-spawn.workspace.test.ts", matchQuality: "direct-import" }],
|
||||
),
|
||||
).toEqual({
|
||||
status: "partial",
|
||||
reason:
|
||||
"Nearby tests exist (best match: direct-import), but this inventory does not prove cross-layer seam coverage end to end.",
|
||||
});
|
||||
});
|
||||
|
||||
it("documents cron and subagent seam coverage in help text", () => {
|
||||
expect(HELP_TEXT).toContain("cron orchestration seams");
|
||||
expect(HELP_TEXT).toContain("subagent seams");
|
||||
expect(HELP_TEXT).toContain("announce delivery");
|
||||
expect(HELP_TEXT).toContain("parent streaming");
|
||||
});
|
||||
});
|
||||
207
openclaw/test/scripts/build-all.test.ts
Normal file
207
openclaw/test/scripts/build-all.test.ts
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
BUILD_ALL_PROFILES,
|
||||
BUILD_ALL_STEPS,
|
||||
resolveBuildAllStepCacheState,
|
||||
resolveBuildAllStep,
|
||||
resolveBuildAllSteps,
|
||||
restoreBuildAllStepCacheOutputs,
|
||||
writeBuildAllStepCacheStamp,
|
||||
} from "../../scripts/build-all.mjs";
|
||||
|
||||
describe("resolveBuildAllStep", () => {
|
||||
it("routes pnpm steps through the npm_execpath pnpm runner on Windows", () => {
|
||||
const step = BUILD_ALL_STEPS.find((entry) => entry.label === "canvas:a2ui:bundle");
|
||||
expect(step).toBeTruthy();
|
||||
|
||||
const result = resolveBuildAllStep(step, {
|
||||
platform: "win32",
|
||||
nodeExecPath: "C:\\Program Files\\nodejs\\node.exe",
|
||||
npmExecPath: "C:/Users/test/AppData/Local/pnpm/10.32.1/bin/pnpm.cjs",
|
||||
env: {},
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
command: "C:\\Program Files\\nodejs\\node.exe",
|
||||
args: ["C:/Users/test/AppData/Local/pnpm/10.32.1/bin/pnpm.cjs", "canvas:a2ui:bundle"],
|
||||
options: {
|
||||
stdio: "inherit",
|
||||
env: {},
|
||||
shell: false,
|
||||
windowsVerbatimArguments: undefined,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps node steps on the current node binary", () => {
|
||||
const step = BUILD_ALL_STEPS.find((entry) => entry.label === "runtime-postbuild");
|
||||
expect(step).toBeTruthy();
|
||||
|
||||
const result = resolveBuildAllStep(step, {
|
||||
nodeExecPath: "/custom/node",
|
||||
env: { FOO: "bar" },
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
command: "/custom/node",
|
||||
args: ["scripts/runtime-postbuild.mjs"],
|
||||
options: {
|
||||
stdio: "inherit",
|
||||
env: { FOO: "bar" },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("adds heap headroom for plugin-sdk dts on Windows", () => {
|
||||
const step = BUILD_ALL_STEPS.find((entry) => entry.label === "build:plugin-sdk:dts");
|
||||
expect(step).toBeTruthy();
|
||||
|
||||
const result = resolveBuildAllStep(step, {
|
||||
platform: "win32",
|
||||
nodeExecPath: "C:\\Program Files\\nodejs\\node.exe",
|
||||
npmExecPath: "C:/Users/test/AppData/Local/pnpm/10.32.1/bin/pnpm.cjs",
|
||||
env: { FOO: "bar" },
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
command: "C:\\Program Files\\nodejs\\node.exe",
|
||||
args: ["C:/Users/test/AppData/Local/pnpm/10.32.1/bin/pnpm.cjs", "build:plugin-sdk:dts"],
|
||||
options: {
|
||||
stdio: "inherit",
|
||||
env: {
|
||||
FOO: "bar",
|
||||
NODE_OPTIONS: "--max-old-space-size=4096",
|
||||
},
|
||||
shell: false,
|
||||
windowsVerbatimArguments: undefined,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveBuildAllSteps", () => {
|
||||
it("keeps the full profile aligned with the declared steps", () => {
|
||||
expect(resolveBuildAllSteps("full")).toEqual(BUILD_ALL_STEPS);
|
||||
expect(BUILD_ALL_PROFILES.full).toEqual(BUILD_ALL_STEPS.map((step) => step.label));
|
||||
});
|
||||
|
||||
it("uses a runtime-only profile for ci artifacts", () => {
|
||||
expect(resolveBuildAllSteps("ciArtifacts").map((step) => step.label)).toEqual([
|
||||
"canvas:a2ui:bundle",
|
||||
"tsdown",
|
||||
"runtime-postbuild",
|
||||
"write-npm-update-compat-sidecars",
|
||||
"build-stamp",
|
||||
"canvas-a2ui-copy",
|
||||
"copy-hook-metadata",
|
||||
"copy-export-html-templates",
|
||||
"write-build-info",
|
||||
"write-cli-startup-metadata",
|
||||
"write-cli-compat",
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects unknown build profiles", () => {
|
||||
expect(() => resolveBuildAllSteps("wat")).toThrow("Unknown build profile: wat");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveBuildAllStepCacheState", () => {
|
||||
it("marks cacheable steps fresh when the input signature matches", () => {
|
||||
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-build-cache-"));
|
||||
try {
|
||||
const inputPath = path.join(rootDir, "src/input.ts");
|
||||
const outputPath = path.join(rootDir, "dist/output.js");
|
||||
fs.mkdirSync(path.dirname(inputPath), { recursive: true });
|
||||
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
|
||||
fs.writeFileSync(inputPath, "input");
|
||||
fs.writeFileSync(outputPath, "output");
|
||||
const step = {
|
||||
label: "cached",
|
||||
cache: {
|
||||
inputs: ["src"],
|
||||
outputs: ["dist"],
|
||||
},
|
||||
};
|
||||
const cacheState = resolveBuildAllStepCacheState(step, { rootDir });
|
||||
writeBuildAllStepCacheStamp(step, cacheState, { rootDir });
|
||||
|
||||
expect(resolveBuildAllStepCacheState(step, { rootDir })).toMatchObject({
|
||||
cacheable: true,
|
||||
fresh: true,
|
||||
reason: "fresh",
|
||||
inputFiles: 1,
|
||||
outputFiles: 1,
|
||||
});
|
||||
} finally {
|
||||
fs.rmSync(rootDir, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("marks cacheable steps stale when an input changes", () => {
|
||||
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-build-cache-"));
|
||||
try {
|
||||
const inputPath = path.join(rootDir, "src/input.ts");
|
||||
const outputPath = path.join(rootDir, "dist/output.js");
|
||||
fs.mkdirSync(path.dirname(inputPath), { recursive: true });
|
||||
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
|
||||
fs.writeFileSync(inputPath, "input");
|
||||
fs.writeFileSync(outputPath, "output");
|
||||
const step = {
|
||||
label: "cached",
|
||||
cache: {
|
||||
inputs: ["src"],
|
||||
outputs: ["dist"],
|
||||
},
|
||||
};
|
||||
const cacheState = resolveBuildAllStepCacheState(step, { rootDir });
|
||||
writeBuildAllStepCacheStamp(step, cacheState, { rootDir });
|
||||
fs.writeFileSync(inputPath, "changed");
|
||||
|
||||
expect(resolveBuildAllStepCacheState(step, { rootDir })).toMatchObject({
|
||||
cacheable: true,
|
||||
fresh: false,
|
||||
reason: "stale",
|
||||
});
|
||||
} finally {
|
||||
fs.rmSync(rootDir, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("restores cached outputs when generated files were removed", () => {
|
||||
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-build-cache-"));
|
||||
try {
|
||||
const inputPath = path.join(rootDir, "src/input.ts");
|
||||
const outputPath = path.join(rootDir, "dist/output.js");
|
||||
fs.mkdirSync(path.dirname(inputPath), { recursive: true });
|
||||
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
|
||||
fs.writeFileSync(inputPath, "input");
|
||||
fs.writeFileSync(outputPath, "output");
|
||||
const step = {
|
||||
label: "cached",
|
||||
cache: {
|
||||
inputs: ["src"],
|
||||
outputs: ["dist"],
|
||||
},
|
||||
};
|
||||
const cacheState = resolveBuildAllStepCacheState(step, { rootDir });
|
||||
writeBuildAllStepCacheStamp(step, cacheState, { rootDir });
|
||||
fs.rmSync(path.join(rootDir, "dist"), { force: true, recursive: true });
|
||||
|
||||
const restorable = resolveBuildAllStepCacheState(step, { rootDir });
|
||||
expect(restorable).toMatchObject({
|
||||
cacheable: true,
|
||||
fresh: true,
|
||||
reason: "fresh-cache",
|
||||
restorable: true,
|
||||
});
|
||||
expect(restoreBuildAllStepCacheOutputs(restorable, { rootDir })).toBe(true);
|
||||
expect(fs.readFileSync(outputPath, "utf8")).toBe("output");
|
||||
} finally {
|
||||
fs.rmSync(rootDir, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
85
openclaw/test/scripts/bundle-a2ui.test.ts
Normal file
85
openclaw/test/scripts/bundle-a2ui.test.ts
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
compareNormalizedPaths,
|
||||
getBundleHashInputPaths,
|
||||
getBundleHashRepoInputPaths,
|
||||
getLocalRolldownCliCandidates,
|
||||
getResolvedBundleDependencyPackageJsonPaths,
|
||||
isBundleHashInputPath,
|
||||
} from "../../scripts/bundle-a2ui.mjs";
|
||||
|
||||
describe("scripts/bundle-a2ui.mjs", () => {
|
||||
it("keeps generated renderer output out of bundle hash inputs", () => {
|
||||
const repoRoot = path.resolve("repo-root");
|
||||
|
||||
expect(
|
||||
isBundleHashInputPath(
|
||||
path.join(repoRoot, "vendor", "a2ui", "renderers", "lit", "src", "index.ts"),
|
||||
repoRoot,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isBundleHashInputPath(
|
||||
path.join(repoRoot, "vendor", "a2ui", "renderers", "lit", "dist"),
|
||||
repoRoot,
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isBundleHashInputPath(
|
||||
path.join(repoRoot, "vendor", "a2ui", "renderers", "lit", "dist", "src", "index.js"),
|
||||
repoRoot,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("prefers the installed rolldown CLI over a network dlx fallback", () => {
|
||||
const repoRoot = path.resolve("repo-root");
|
||||
|
||||
expect(getLocalRolldownCliCandidates(repoRoot)[0]).toBe(
|
||||
path.join(repoRoot, "node_modules", "rolldown", "bin", "cli.mjs"),
|
||||
);
|
||||
});
|
||||
|
||||
it("sorts hash inputs without locale-dependent collation", () => {
|
||||
const paths = ["repo/Z.ts", "repo/a.ts", "repo/ä.ts", "repo/A.ts"];
|
||||
|
||||
expect([...paths].toSorted(compareNormalizedPaths)).toEqual([
|
||||
"repo/A.ts",
|
||||
"repo/Z.ts",
|
||||
"repo/a.ts",
|
||||
"repo/ä.ts",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps repo-root package churn out of bundle hash inputs", () => {
|
||||
const repoRoot = path.resolve("repo-root");
|
||||
const inputPaths = getBundleHashRepoInputPaths(repoRoot);
|
||||
|
||||
expect(inputPaths).toContain(path.join(repoRoot, "ui", "package.json"));
|
||||
expect(inputPaths).not.toContain(path.join(repoRoot, "package.json"));
|
||||
expect(inputPaths).not.toContain(path.join(repoRoot, "pnpm-lock.yaml"));
|
||||
});
|
||||
|
||||
it("tracks only the resolved bundle dependency manifests from node_modules", () => {
|
||||
const repoRoot = process.cwd();
|
||||
const dependencyPaths = getResolvedBundleDependencyPackageJsonPaths(repoRoot);
|
||||
const relativeDependencyPaths = dependencyPaths.map((dependencyPath) =>
|
||||
path.relative(repoRoot, dependencyPath).replaceAll(path.sep, "/"),
|
||||
);
|
||||
|
||||
expect(
|
||||
relativeDependencyPaths.map((relativePath) => relativePath.replace(/^ui\//u, "")),
|
||||
).toEqual([
|
||||
path.posix.join("node_modules", "lit", "package.json"),
|
||||
path.posix.join("node_modules", "@lit/context", "package.json"),
|
||||
path.posix.join("node_modules", "@lit-labs/signals", "package.json"),
|
||||
path.posix.join("node_modules", "signal-utils", "package.json"),
|
||||
]);
|
||||
expect(
|
||||
relativeDependencyPaths.every((relativePath) => /^(ui\/)?node_modules\//u.test(relativePath)),
|
||||
).toBe(true);
|
||||
expect(getBundleHashInputPaths(repoRoot)).not.toContain(path.join(repoRoot, "package.json"));
|
||||
expect(getBundleHashInputPaths(repoRoot)).not.toContain(path.join(repoRoot, "pnpm-lock.yaml"));
|
||||
});
|
||||
});
|
||||
132
openclaw/test/scripts/bundled-plugin-build-entries.test.ts
Normal file
132
openclaw/test/scripts/bundled-plugin-build-entries.test.ts
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
listBundledPluginBuildEntries,
|
||||
listBundledPluginPackArtifacts,
|
||||
} from "../../scripts/lib/bundled-plugin-build-entries.mjs";
|
||||
|
||||
describe("bundled plugin build entries", () => {
|
||||
const bundledChannelEntrySources = ["index.ts", "channel-entry.ts", "setup-entry.ts"];
|
||||
const forEachBundledChannelEntry = (
|
||||
visit: (params: { entryPath: string; entry: string; pluginId: string }) => void,
|
||||
) => {
|
||||
for (const dirent of fs.readdirSync("extensions", { withFileTypes: true })) {
|
||||
if (!dirent.isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const sourceEntry of bundledChannelEntrySources) {
|
||||
const entryPath = path.join("extensions", dirent.name, sourceEntry);
|
||||
if (!fs.existsSync(entryPath)) {
|
||||
continue;
|
||||
}
|
||||
visit({
|
||||
entryPath,
|
||||
entry: fs.readFileSync(entryPath, "utf8"),
|
||||
pluginId: dirent.name,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
it("includes manifest-less runtime core support packages in dist build entries", () => {
|
||||
const entries = listBundledPluginBuildEntries();
|
||||
|
||||
expect(entries).toMatchObject({
|
||||
"extensions/image-generation-core/api": "extensions/image-generation-core/api.ts",
|
||||
"extensions/image-generation-core/runtime-api":
|
||||
"extensions/image-generation-core/runtime-api.ts",
|
||||
"extensions/media-understanding-core/runtime-api":
|
||||
"extensions/media-understanding-core/runtime-api.ts",
|
||||
"extensions/speech-core/api": "extensions/speech-core/api.ts",
|
||||
"extensions/speech-core/runtime-api": "extensions/speech-core/runtime-api.ts",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the Matrix packaged runtime shim in bundled plugin build entries", () => {
|
||||
const entries = listBundledPluginBuildEntries();
|
||||
|
||||
expect(entries).toMatchObject({
|
||||
"extensions/matrix/plugin-entry.handlers.runtime":
|
||||
"extensions/matrix/plugin-entry.handlers.runtime.ts",
|
||||
});
|
||||
});
|
||||
|
||||
it("packs runtime core support packages without requiring plugin manifests", () => {
|
||||
const artifacts = listBundledPluginPackArtifacts();
|
||||
|
||||
expect(artifacts).toContain("dist/extensions/image-generation-core/package.json");
|
||||
expect(artifacts).toContain("dist/extensions/image-generation-core/runtime-api.js");
|
||||
expect(artifacts).not.toContain("dist/extensions/image-generation-core/openclaw.plugin.json");
|
||||
expect(artifacts).toContain("dist/extensions/media-understanding-core/runtime-api.js");
|
||||
expect(artifacts).not.toContain(
|
||||
"dist/extensions/media-understanding-core/openclaw.plugin.json",
|
||||
);
|
||||
expect(artifacts).toContain("dist/extensions/speech-core/runtime-api.js");
|
||||
expect(artifacts).not.toContain("dist/extensions/speech-core/openclaw.plugin.json");
|
||||
});
|
||||
|
||||
it("packs the Matrix packaged runtime shim", () => {
|
||||
const artifacts = listBundledPluginPackArtifacts();
|
||||
|
||||
expect(artifacts).toContain("dist/extensions/matrix/plugin-entry.handlers.runtime.js");
|
||||
});
|
||||
|
||||
it("keeps private QA bundles out of required npm pack artifacts", () => {
|
||||
const artifacts = listBundledPluginPackArtifacts();
|
||||
|
||||
expect(artifacts.some((artifact) => artifact.startsWith("dist/extensions/qa-channel/"))).toBe(
|
||||
false,
|
||||
);
|
||||
expect(artifacts.some((artifact) => artifact.startsWith("dist/extensions/qa-lab/"))).toBe(
|
||||
false,
|
||||
);
|
||||
expect(artifacts.some((artifact) => artifact.startsWith("dist/extensions/qa-matrix/"))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps bundled channel secret contracts on packed top-level sidecars", () => {
|
||||
const artifacts = listBundledPluginPackArtifacts();
|
||||
const offenders: string[] = [];
|
||||
const secretBackedPluginIds = new Set<string>();
|
||||
|
||||
forEachBundledChannelEntry(({ entryPath, entry, pluginId }) => {
|
||||
if (!entry.includes('exportName: "channelSecrets"')) {
|
||||
return;
|
||||
}
|
||||
secretBackedPluginIds.add(pluginId);
|
||||
if (entry.includes("./src/secret-contract.js")) {
|
||||
offenders.push(entryPath);
|
||||
}
|
||||
expect(entry).toContain('specifier: "./secret-contract-api.js"');
|
||||
});
|
||||
|
||||
expect(offenders).toEqual([]);
|
||||
|
||||
for (const pluginId of [...secretBackedPluginIds].toSorted()) {
|
||||
const secretApiPath = path.join("extensions", pluginId, "secret-contract-api.ts");
|
||||
expect(fs.readFileSync(secretApiPath, "utf8")).toContain("channelSecrets");
|
||||
expect(artifacts).toContain(`dist/extensions/${pluginId}/secret-contract-api.js`);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps bundled channel entry metadata on packed top-level sidecars", () => {
|
||||
const offenders: string[] = [];
|
||||
|
||||
forEachBundledChannelEntry(({ entryPath, entry }) => {
|
||||
if (
|
||||
!entry.includes("defineBundledChannelEntry") &&
|
||||
!entry.includes("defineBundledChannelSetupEntry")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (/specifier:\s*["']\.\/src\//u.test(entry)) {
|
||||
offenders.push(entryPath);
|
||||
}
|
||||
});
|
||||
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { collectBuiltBundledPluginStagedRuntimeDependencyErrors } from "../../scripts/lib/bundled-plugin-root-runtime-mirrors.mjs";
|
||||
import { createScriptTestHarness } from "./test-helpers.js";
|
||||
|
||||
const { createTempDir } = createScriptTestHarness();
|
||||
|
||||
function writeJson(root: string, relativePath: string, value: unknown) {
|
||||
const fullPath = path.join(root, relativePath);
|
||||
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
||||
fs.writeFileSync(fullPath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
||||
}
|
||||
|
||||
describe("collectBuiltBundledPluginStagedRuntimeDependencyErrors", () => {
|
||||
it("flags built staged plugins whose dist node_modules are missing runtime deps", () => {
|
||||
const repoRoot = createTempDir("openclaw-runtime-contracts-");
|
||||
|
||||
writeJson(repoRoot, "dist/extensions/diffs/package.json", {
|
||||
name: "@openclaw/diffs",
|
||||
dependencies: {
|
||||
"@pierre/diffs": "^0.1.0",
|
||||
},
|
||||
openclaw: {
|
||||
bundle: {
|
||||
stageRuntimeDependencies: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
collectBuiltBundledPluginStagedRuntimeDependencyErrors({
|
||||
bundledPluginsDir: path.join(repoRoot, "dist/extensions"),
|
||||
}),
|
||||
).toEqual([
|
||||
"built bundled plugin 'diffs' is missing staged runtime dependency '@pierre/diffs: ^0.1.0' under dist/extensions/diffs/node_modules.",
|
||||
]);
|
||||
});
|
||||
|
||||
it("accepts built staged plugins when their staged runtime deps are present", () => {
|
||||
const repoRoot = createTempDir("openclaw-runtime-contracts-");
|
||||
|
||||
writeJson(repoRoot, "dist/extensions/diffs/package.json", {
|
||||
name: "@openclaw/diffs",
|
||||
dependencies: {
|
||||
"@pierre/diffs": "^0.1.0",
|
||||
},
|
||||
openclaw: {
|
||||
bundle: {
|
||||
stageRuntimeDependencies: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
writeJson(repoRoot, "dist/extensions/diffs/node_modules/@pierre/diffs/package.json", {
|
||||
name: "@pierre/diffs",
|
||||
version: "0.1.0",
|
||||
});
|
||||
|
||||
expect(
|
||||
collectBuiltBundledPluginStagedRuntimeDependencyErrors({
|
||||
bundledPluginsDir: path.join(repoRoot, "dist/extensions"),
|
||||
}),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps the WhatsApp bundled plugin opted into staged runtime dependencies", () => {
|
||||
const packageJson = JSON.parse(
|
||||
fs.readFileSync(path.join(process.cwd(), "extensions/whatsapp/package.json"), "utf8"),
|
||||
) as {
|
||||
dependencies?: Record<string, string>;
|
||||
openclaw?: {
|
||||
bundle?: {
|
||||
stageRuntimeDependencies?: boolean;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
expect(packageJson.dependencies?.["@whiskeysockets/baileys"]).toBe("7.0.0-rc.9");
|
||||
expect(packageJson.openclaw?.bundle?.stageRuntimeDependencies).toBe(true);
|
||||
});
|
||||
});
|
||||
127
openclaw/test/scripts/check-channel-agnostic-boundaries.test.ts
Normal file
127
openclaw/test/scripts/check-channel-agnostic-boundaries.test.ts
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
findChannelAgnosticBoundaryViolations,
|
||||
findAcpUserFacingChannelNameViolations,
|
||||
findChannelCoreReverseDependencyViolations,
|
||||
findSystemMarkLiteralViolations,
|
||||
} from "../../scripts/check-channel-agnostic-boundaries.mjs";
|
||||
|
||||
describe("check-channel-agnostic-boundaries", () => {
|
||||
it("flags direct channel module imports", () => {
|
||||
const source = `
|
||||
import { getThreadBindingManager } from "../discord/monitor/thread-bindings.js";
|
||||
const x = 1;
|
||||
`;
|
||||
expect(findChannelAgnosticBoundaryViolations(source)).toEqual([
|
||||
{
|
||||
line: 2,
|
||||
reason: 'imports channel module "../discord/monitor/thread-bindings.js"',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("flags channel config path access", () => {
|
||||
const source = `
|
||||
const x = cfg.channels.discord?.threadBindings?.enabled;
|
||||
`;
|
||||
expect(findChannelAgnosticBoundaryViolations(source)).toEqual([
|
||||
{
|
||||
line: 2,
|
||||
reason: 'references config path "channels.discord"',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("flags channel-literal comparisons", () => {
|
||||
const source = `
|
||||
if (channel === "discord") {
|
||||
return true;
|
||||
}
|
||||
`;
|
||||
expect(findChannelAgnosticBoundaryViolations(source)).toEqual([
|
||||
{
|
||||
line: 2,
|
||||
reason: 'compares with channel id literal (channel === "discord")',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("flags object literals with explicit channel ids", () => {
|
||||
const source = `
|
||||
const payload = { channel: "telegram" };
|
||||
`;
|
||||
expect(findChannelAgnosticBoundaryViolations(source)).toEqual([
|
||||
{
|
||||
line: 2,
|
||||
reason: 'assigns channel id literal to "channel" ("telegram")',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("ignores non-channel literals and unrelated text", () => {
|
||||
const source = `
|
||||
const msg = "discord";
|
||||
const payload = { mode: "persistent" };
|
||||
const x = cfg.session.threadBindings?.enabled;
|
||||
`;
|
||||
expect(findChannelAgnosticBoundaryViolations(source)).toEqual([]);
|
||||
});
|
||||
|
||||
it("reverse-deps mode flags channel module re-exports", () => {
|
||||
const source = `
|
||||
export { resolveThreadBindingIntroText } from "../discord/monitor/thread-bindings.messages.js";
|
||||
`;
|
||||
expect(findChannelCoreReverseDependencyViolations(source)).toEqual([
|
||||
{
|
||||
line: 2,
|
||||
reason: 're-exports channel module "../discord/monitor/thread-bindings.messages.js"',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("reverse-deps mode ignores channel literals when no imports are present", () => {
|
||||
const source = `
|
||||
const channel = "discord";
|
||||
const x = cfg.channels.discord?.threadBindings?.enabled;
|
||||
`;
|
||||
expect(findChannelCoreReverseDependencyViolations(source)).toEqual([]);
|
||||
});
|
||||
|
||||
it("user-facing text mode flags channel names in string literals", () => {
|
||||
const source = `
|
||||
const message = "Bind a Discord thread first.";
|
||||
`;
|
||||
expect(findAcpUserFacingChannelNameViolations(source)).toEqual([
|
||||
{
|
||||
line: 2,
|
||||
reason: 'user-facing text references channel name ("Bind a Discord thread first.")',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("user-facing text mode ignores channel names in import specifiers", () => {
|
||||
const source = `
|
||||
import { x } from "../discord/monitor/thread-bindings.js";
|
||||
`;
|
||||
expect(findAcpUserFacingChannelNameViolations(source)).toEqual([]);
|
||||
});
|
||||
|
||||
it("system-mark guard flags hardcoded gear literals", () => {
|
||||
const source = `
|
||||
const line = "⚙️ Thread bindings enabled.";
|
||||
`;
|
||||
expect(findSystemMarkLiteralViolations(source)).toEqual([
|
||||
{
|
||||
line: 2,
|
||||
reason: 'hardcoded system mark literal ("⚙️ Thread bindings enabled.")',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("system-mark guard ignores module import specifiers", () => {
|
||||
const source = `
|
||||
import { x } from "../infra/system-message.js";
|
||||
`;
|
||||
expect(findSystemMarkLiteralViolations(source)).toEqual([]);
|
||||
});
|
||||
});
|
||||
92
openclaw/test/scripts/check-dynamic-import-warts.test.ts
Normal file
92
openclaw/test/scripts/check-dynamic-import-warts.test.ts
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { findDynamicImportAdvisories } from "../../scripts/check-dynamic-import-warts.mjs";
|
||||
|
||||
describe("check-dynamic-import-warts", () => {
|
||||
it("flags runtime static plus dynamic imports of the same module", () => {
|
||||
const source = `
|
||||
import { run } from "./runtime.js";
|
||||
export async function start() {
|
||||
return await import("./runtime.js");
|
||||
}
|
||||
`;
|
||||
expect(findDynamicImportAdvisories(source)).toEqual([
|
||||
{
|
||||
line: 4,
|
||||
reason: 'runtime static + dynamic import of "./runtime.js" (static line 2)',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("ignores type-only static imports", () => {
|
||||
const source = `
|
||||
import { type Runtime } from "./runtime.js";
|
||||
export async function start(): Promise<Runtime> {
|
||||
return (await import("./runtime.js")).createRuntime();
|
||||
}
|
||||
`;
|
||||
expect(findDynamicImportAdvisories(source)).toEqual([]);
|
||||
});
|
||||
|
||||
it("flags repeated direct dynamic imports", () => {
|
||||
const source = `
|
||||
export async function one() {
|
||||
return await import("./runtime.js");
|
||||
}
|
||||
export async function two() {
|
||||
return await import("./runtime.js");
|
||||
}
|
||||
`;
|
||||
expect(findDynamicImportAdvisories(source)).toEqual([
|
||||
{
|
||||
line: 3,
|
||||
reason: 'repeated direct dynamic import of "./runtime.js" (2 callsites: 3, 6)',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("ignores cached loader patterns", () => {
|
||||
const source = `
|
||||
let runtimePromise: Promise<typeof import("./runtime.js")> | undefined;
|
||||
function loadRuntime() {
|
||||
runtimePromise ??= import("./runtime.js");
|
||||
return runtimePromise;
|
||||
}
|
||||
`;
|
||||
expect(findDynamicImportAdvisories(source)).toEqual([]);
|
||||
});
|
||||
|
||||
it("flags direct dynamic imports inside execute paths", () => {
|
||||
const source = `
|
||||
export function createTool() {
|
||||
return {
|
||||
execute: async () => {
|
||||
return await import("./runtime.js");
|
||||
},
|
||||
};
|
||||
}
|
||||
`;
|
||||
expect(findDynamicImportAdvisories(source)).toEqual([
|
||||
{
|
||||
line: 5,
|
||||
reason:
|
||||
'direct dynamic import of "./runtime.js" inside execute path; move it behind a cached loader',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("allows execute paths that call cached loaders", () => {
|
||||
const source = `
|
||||
let runtimePromise: Promise<typeof import("./runtime.js")> | undefined;
|
||||
function loadRuntime() {
|
||||
runtimePromise ??= import("./runtime.js");
|
||||
return runtimePromise;
|
||||
}
|
||||
export function createTool() {
|
||||
return {
|
||||
execute: async () => await loadRuntime(),
|
||||
};
|
||||
}
|
||||
`;
|
||||
expect(findDynamicImportAdvisories(source)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,371 @@
|
|||
import { EventEmitter } from "node:events";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
acquireBoundaryCheckLock,
|
||||
cleanupCanaryArtifactsForExtensions,
|
||||
formatBoundaryCheckSuccessSummary,
|
||||
formatSlowCompileSummary,
|
||||
formatSkippedCompileProgress,
|
||||
formatStepFailure,
|
||||
installCanaryArtifactCleanup,
|
||||
isBoundaryCompileFresh,
|
||||
resolveBoundaryCheckLockPath,
|
||||
resolveCanaryArtifactPaths,
|
||||
runNodeStepAsync,
|
||||
runNodeStepsWithConcurrency,
|
||||
} from "../../scripts/check-extension-package-tsc-boundary.mjs";
|
||||
|
||||
const tempRoots = new Set<string>();
|
||||
|
||||
function createTempExtensionRoot(extensionId = "demo") {
|
||||
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-boundary-canary-"));
|
||||
tempRoots.add(rootDir);
|
||||
const extensionRoot = path.join(rootDir, "extensions", extensionId);
|
||||
fs.mkdirSync(extensionRoot, { recursive: true });
|
||||
return { rootDir, extensionRoot };
|
||||
}
|
||||
|
||||
function writeCanaryArtifacts(rootDir: string, extensionId = "demo") {
|
||||
const { canaryPath, tsconfigPath } = resolveCanaryArtifactPaths(extensionId, rootDir);
|
||||
fs.writeFileSync(canaryPath, "export {};\n", "utf8");
|
||||
fs.writeFileSync(tsconfigPath, '{ "extends": "./tsconfig.json" }\n', "utf8");
|
||||
return { canaryPath, tsconfigPath };
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const rootDir of tempRoots) {
|
||||
fs.rmSync(rootDir, { force: true, recursive: true });
|
||||
}
|
||||
tempRoots.clear();
|
||||
});
|
||||
|
||||
describe("check-extension-package-tsc-boundary", () => {
|
||||
it("removes stale canary artifacts across extensions", () => {
|
||||
const { rootDir } = createTempExtensionRoot();
|
||||
const { canaryPath, tsconfigPath } = writeCanaryArtifacts(rootDir);
|
||||
|
||||
cleanupCanaryArtifactsForExtensions(["demo"], rootDir);
|
||||
|
||||
expect(fs.existsSync(canaryPath)).toBe(false);
|
||||
expect(fs.existsSync(tsconfigPath)).toBe(false);
|
||||
});
|
||||
|
||||
it("cleans canary artifacts again on process exit", () => {
|
||||
const { rootDir } = createTempExtensionRoot();
|
||||
const { canaryPath, tsconfigPath } = writeCanaryArtifacts(rootDir);
|
||||
const processObject = new EventEmitter();
|
||||
const teardown = installCanaryArtifactCleanup(["demo"], { processObject, rootDir });
|
||||
|
||||
processObject.emit("exit");
|
||||
teardown();
|
||||
|
||||
expect(fs.existsSync(canaryPath)).toBe(false);
|
||||
expect(fs.existsSync(tsconfigPath)).toBe(false);
|
||||
});
|
||||
|
||||
it("cleans stale artifacts for every extension id passed to the cleanup hook", () => {
|
||||
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-boundary-canary-"));
|
||||
tempRoots.add(rootDir);
|
||||
fs.mkdirSync(path.join(rootDir, "extensions", "demo-a"), { recursive: true });
|
||||
fs.mkdirSync(path.join(rootDir, "extensions", "demo-b"), { recursive: true });
|
||||
const demoA = writeCanaryArtifacts(rootDir, "demo-a");
|
||||
const demoB = writeCanaryArtifacts(rootDir, "demo-b");
|
||||
const processObject = new EventEmitter();
|
||||
const teardown = installCanaryArtifactCleanup(["demo-a", "demo-b"], {
|
||||
processObject,
|
||||
rootDir,
|
||||
});
|
||||
|
||||
processObject.emit("exit");
|
||||
teardown();
|
||||
|
||||
expect(fs.existsSync(demoA.canaryPath)).toBe(false);
|
||||
expect(fs.existsSync(demoA.tsconfigPath)).toBe(false);
|
||||
expect(fs.existsSync(demoB.canaryPath)).toBe(false);
|
||||
expect(fs.existsSync(demoB.tsconfigPath)).toBe(false);
|
||||
});
|
||||
|
||||
it("blocks concurrent boundary checks in the same checkout", () => {
|
||||
const { rootDir } = createTempExtensionRoot();
|
||||
const processObject = new EventEmitter();
|
||||
const release = acquireBoundaryCheckLock({ processObject, rootDir });
|
||||
|
||||
let thrownError = null;
|
||||
try {
|
||||
acquireBoundaryCheckLock({ rootDir });
|
||||
} catch (error) {
|
||||
thrownError = error;
|
||||
}
|
||||
|
||||
expect(thrownError).toMatchObject({
|
||||
message: expect.stringContaining("kind: lock-contention"),
|
||||
fullOutput: expect.stringContaining(
|
||||
"another extension package boundary check is already running",
|
||||
),
|
||||
kind: "lock-contention",
|
||||
});
|
||||
|
||||
release();
|
||||
|
||||
const lockPath = resolveBoundaryCheckLockPath(rootDir);
|
||||
expect(fs.existsSync(lockPath)).toBe(false);
|
||||
});
|
||||
|
||||
it("summarizes long failure output with the useful tail", () => {
|
||||
const stdout = Array.from({ length: 45 }, (_, index) => `stdout ${index + 1}`).join("\n");
|
||||
const stderr = Array.from({ length: 3 }, (_, index) => `stderr ${index + 1}`).join("\n");
|
||||
|
||||
const message = formatStepFailure("demo-plugin", {
|
||||
stdout,
|
||||
stderr,
|
||||
kind: "timeout",
|
||||
elapsedMs: 4_321,
|
||||
note: "demo-plugin timed out after 5000ms",
|
||||
});
|
||||
const messageLines = message.split("\n");
|
||||
|
||||
expect(message).toContain("demo-plugin");
|
||||
expect(message).toContain("[... 5 earlier lines omitted ...]");
|
||||
expect(message).toContain("kind: timeout");
|
||||
expect(message).toContain("elapsed: 4321ms");
|
||||
expect(message).toContain("stdout 45");
|
||||
expect(messageLines).not.toContain("stdout 1");
|
||||
expect(message).toContain("stderr:\nstderr 1\nstderr 2\nstderr 3");
|
||||
expect(message).toContain("demo-plugin timed out after 5000ms");
|
||||
});
|
||||
|
||||
it("formats a success summary with counts and elapsed time", () => {
|
||||
expect(
|
||||
formatBoundaryCheckSuccessSummary({
|
||||
mode: "all",
|
||||
compileCount: 84,
|
||||
skippedCompileCount: 13,
|
||||
canaryCount: 12,
|
||||
prepElapsedMs: 12_345,
|
||||
compileElapsedMs: 54_321,
|
||||
canaryElapsedMs: 6_789,
|
||||
elapsedMs: 54_321,
|
||||
}),
|
||||
).toBe(
|
||||
[
|
||||
"extension package boundary check passed",
|
||||
"mode: all",
|
||||
"compiled plugins: 84",
|
||||
"skipped plugins: 13",
|
||||
"canary plugins: 12",
|
||||
"prep elapsed: 12345ms",
|
||||
"compile elapsed: 54321ms",
|
||||
"canary elapsed: 6789ms",
|
||||
"elapsed: 54321ms",
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
});
|
||||
|
||||
it("omits phase timings that never ran", () => {
|
||||
expect(
|
||||
formatBoundaryCheckSuccessSummary({
|
||||
mode: "compile",
|
||||
compileCount: 97,
|
||||
skippedCompileCount: 0,
|
||||
canaryCount: 0,
|
||||
prepElapsedMs: 12_345,
|
||||
compileElapsedMs: 54_321,
|
||||
canaryElapsedMs: 0,
|
||||
elapsedMs: 66_666,
|
||||
}),
|
||||
).toBe(
|
||||
[
|
||||
"extension package boundary check passed",
|
||||
"mode: compile",
|
||||
"compiled plugins: 97",
|
||||
"canary plugins: 0",
|
||||
"prep elapsed: 12345ms",
|
||||
"compile elapsed: 54321ms",
|
||||
"elapsed: 66666ms",
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
});
|
||||
|
||||
it("formats skipped compile progress concisely", () => {
|
||||
expect(
|
||||
formatSkippedCompileProgress({
|
||||
skippedCount: 13,
|
||||
totalCount: 97,
|
||||
}),
|
||||
).toBe("skipped 13 fresh plugin compiles before running 84 stale plugin checks\n");
|
||||
|
||||
expect(
|
||||
formatSkippedCompileProgress({
|
||||
skippedCount: 97,
|
||||
totalCount: 97,
|
||||
}),
|
||||
).toBe("skipped 97 fresh plugin compiles\n");
|
||||
});
|
||||
|
||||
it("formats the slowest plugin compiles in descending order", () => {
|
||||
expect(
|
||||
formatSlowCompileSummary({
|
||||
compileTimings: [
|
||||
{ extensionId: "quick", elapsedMs: 40 },
|
||||
{ extensionId: "slow", elapsedMs: 900 },
|
||||
{ extensionId: "medium", elapsedMs: 250 },
|
||||
],
|
||||
limit: 2,
|
||||
}),
|
||||
).toBe(["slowest plugin compiles:", "- slow: 900ms", "- medium: 250ms", ""].join("\n"));
|
||||
});
|
||||
|
||||
it("treats a plugin compile as fresh only when its outputs are newer than plugin and shared sdk inputs", () => {
|
||||
const { rootDir, extensionRoot } = createTempExtensionRoot();
|
||||
const extensionSourcePath = path.join(extensionRoot, "index.ts");
|
||||
const extensionTsconfigPath = path.join(extensionRoot, "tsconfig.json");
|
||||
const stampPath = path.join(extensionRoot, "dist", ".boundary-tsc.stamp");
|
||||
const rootSdkTypePath = path.join(rootDir, "dist", "plugin-sdk", "core.d.ts");
|
||||
const packageSdkTypePath = path.join(
|
||||
rootDir,
|
||||
"packages",
|
||||
"plugin-sdk",
|
||||
"dist",
|
||||
"src",
|
||||
"plugin-sdk",
|
||||
"core.d.ts",
|
||||
);
|
||||
|
||||
fs.mkdirSync(path.dirname(extensionSourcePath), { recursive: true });
|
||||
fs.mkdirSync(path.dirname(stampPath), { recursive: true });
|
||||
fs.mkdirSync(path.dirname(rootSdkTypePath), { recursive: true });
|
||||
fs.mkdirSync(path.dirname(packageSdkTypePath), { recursive: true });
|
||||
|
||||
fs.writeFileSync(extensionSourcePath, "export const demo = 1;\n", "utf8");
|
||||
fs.writeFileSync(
|
||||
extensionTsconfigPath,
|
||||
'{ "extends": "../tsconfig.package-boundary.base.json" }\n',
|
||||
"utf8",
|
||||
);
|
||||
fs.writeFileSync(stampPath, "ok\n", "utf8");
|
||||
fs.writeFileSync(rootSdkTypePath, "export {};\n", "utf8");
|
||||
fs.writeFileSync(packageSdkTypePath, "export {};\n", "utf8");
|
||||
|
||||
fs.utimesSync(extensionSourcePath, new Date(1_000), new Date(1_000));
|
||||
fs.utimesSync(extensionTsconfigPath, new Date(1_000), new Date(1_000));
|
||||
fs.utimesSync(rootSdkTypePath, new Date(500), new Date(500));
|
||||
fs.utimesSync(packageSdkTypePath, new Date(2_000), new Date(2_000));
|
||||
fs.utimesSync(stampPath, new Date(3_000), new Date(3_000));
|
||||
|
||||
expect(isBoundaryCompileFresh("demo", { rootDir })).toBe(true);
|
||||
|
||||
fs.utimesSync(rootSdkTypePath, new Date(500), new Date(500));
|
||||
fs.utimesSync(packageSdkTypePath, new Date(500), new Date(500));
|
||||
|
||||
expect(isBoundaryCompileFresh("demo", { rootDir })).toBe(true);
|
||||
|
||||
fs.utimesSync(rootSdkTypePath, new Date(4_000), new Date(4_000));
|
||||
|
||||
expect(isBoundaryCompileFresh("demo", { rootDir })).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts cached input mtimes for freshness checks", () => {
|
||||
const { rootDir, extensionRoot } = createTempExtensionRoot();
|
||||
const extensionSourcePath = path.join(extensionRoot, "index.ts");
|
||||
const stampPath = path.join(extensionRoot, "dist", ".boundary-tsc.stamp");
|
||||
|
||||
fs.mkdirSync(path.dirname(extensionSourcePath), { recursive: true });
|
||||
fs.mkdirSync(path.dirname(stampPath), { recursive: true });
|
||||
fs.writeFileSync(extensionSourcePath, "export const demo = 1;\n", "utf8");
|
||||
fs.writeFileSync(stampPath, "ok\n", "utf8");
|
||||
|
||||
fs.utimesSync(extensionSourcePath, new Date(1_000), new Date(1_000));
|
||||
fs.utimesSync(stampPath, new Date(3_000), new Date(3_000));
|
||||
|
||||
expect(
|
||||
isBoundaryCompileFresh("demo", {
|
||||
rootDir,
|
||||
extensionNewestInputMtimeMs: 1_000,
|
||||
sharedNewestInputMtimeMs: 2_000,
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
isBoundaryCompileFresh("demo", {
|
||||
rootDir,
|
||||
extensionNewestInputMtimeMs: 1_000,
|
||||
sharedNewestInputMtimeMs: 4_000,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps full failure output on the thrown error for canary detection", async () => {
|
||||
await expect(
|
||||
runNodeStepAsync(
|
||||
"demo-plugin",
|
||||
[
|
||||
"--eval",
|
||||
[
|
||||
"console.log('src/plugins/contracts/rootdir-boundary-canary.ts');",
|
||||
"for (let index = 1; index <= 45; index += 1) console.log(`stdout ${index}`);",
|
||||
"console.error('TS6059');",
|
||||
"process.exit(2);",
|
||||
].join(" "),
|
||||
],
|
||||
20_000,
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
message: expect.stringContaining("[... 6 earlier lines omitted ...]"),
|
||||
fullOutput: expect.stringContaining("src/plugins/contracts/rootdir-boundary-canary.ts"),
|
||||
kind: "nonzero-exit",
|
||||
elapsedMs: expect.any(Number),
|
||||
});
|
||||
}, 30_000);
|
||||
|
||||
it("aborts concurrent sibling steps after the first failure", async () => {
|
||||
const startedAt = Date.now();
|
||||
const slowStepTimeoutMs = 60_000;
|
||||
const abortBudgetMs = 30_000;
|
||||
|
||||
await expect(
|
||||
runNodeStepsWithConcurrency(
|
||||
[
|
||||
{
|
||||
label: "fail-fast",
|
||||
args: ["--eval", "process.exit(2)"],
|
||||
timeoutMs: slowStepTimeoutMs,
|
||||
},
|
||||
{
|
||||
label: "slow-step",
|
||||
args: ["--eval", "setTimeout(() => {}, 60_000)"],
|
||||
timeoutMs: slowStepTimeoutMs,
|
||||
},
|
||||
],
|
||||
2,
|
||||
),
|
||||
).rejects.toThrow("fail-fast");
|
||||
|
||||
expect(Date.now() - startedAt).toBeLessThan(abortBudgetMs);
|
||||
}, 45_000);
|
||||
|
||||
it("passes successful step timing metadata to onSuccess handlers", async () => {
|
||||
const elapsedTimes: number[] = [];
|
||||
|
||||
await runNodeStepsWithConcurrency(
|
||||
[
|
||||
{
|
||||
label: "demo-step",
|
||||
args: ["--eval", "process.exit(0)"],
|
||||
timeoutMs: 20_000,
|
||||
onSuccess(result: { elapsedMs: number }) {
|
||||
elapsedTimes.push(result.elapsedMs);
|
||||
},
|
||||
},
|
||||
],
|
||||
1,
|
||||
);
|
||||
|
||||
expect(elapsedTimes).toHaveLength(1);
|
||||
expect(elapsedTimes[0]).toBeGreaterThanOrEqual(0);
|
||||
}, 30_000);
|
||||
});
|
||||
65
openclaw/test/scripts/check-file-utils.test.ts
Normal file
65
openclaw/test/scripts/check-file-utils.test.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
collectFilesSync,
|
||||
isCodeFile,
|
||||
relativeToCwd,
|
||||
toPosixPath,
|
||||
} from "../../scripts/check-file-utils.js";
|
||||
import { createScriptTestHarness } from "./test-helpers.js";
|
||||
|
||||
const { createTempDir } = createScriptTestHarness();
|
||||
|
||||
describe("scripts/check-file-utils isCodeFile", () => {
|
||||
it("accepts source files and skips declarations", () => {
|
||||
expect(isCodeFile("example.ts")).toBe(true);
|
||||
expect(isCodeFile("example.mjs")).toBe(true);
|
||||
expect(isCodeFile("example.d.ts")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("scripts/check-file-utils collectFilesSync", () => {
|
||||
it("collects matching files while skipping common generated dirs", () => {
|
||||
const rootDir = createTempDir("openclaw-check-file-utils-");
|
||||
fs.mkdirSync(path.join(rootDir, "src", "nested"), { recursive: true });
|
||||
fs.mkdirSync(path.join(rootDir, "dist"), { recursive: true });
|
||||
fs.mkdirSync(path.join(rootDir, "docs", ".generated"), { recursive: true });
|
||||
fs.writeFileSync(path.join(rootDir, "src", "keep.ts"), "");
|
||||
fs.writeFileSync(path.join(rootDir, "src", "nested", "keep.test.ts"), "");
|
||||
fs.writeFileSync(path.join(rootDir, "dist", "skip.ts"), "");
|
||||
fs.writeFileSync(path.join(rootDir, "docs", ".generated", "skip.ts"), "");
|
||||
|
||||
const files = collectFilesSync(rootDir, {
|
||||
includeFile: (filePath) => filePath.endsWith(".ts"),
|
||||
}).map((filePath) => toPosixPath(path.relative(rootDir, filePath)));
|
||||
|
||||
expect(files.toSorted((left, right) => left.localeCompare(right))).toEqual([
|
||||
"src/keep.ts",
|
||||
"src/nested/keep.test.ts",
|
||||
]);
|
||||
});
|
||||
|
||||
it("supports custom skipped directories", () => {
|
||||
const rootDir = createTempDir("openclaw-check-file-utils-");
|
||||
fs.mkdirSync(path.join(rootDir, "fixtures"), { recursive: true });
|
||||
fs.mkdirSync(path.join(rootDir, "src"), { recursive: true });
|
||||
fs.writeFileSync(path.join(rootDir, "fixtures", "skip.ts"), "");
|
||||
fs.writeFileSync(path.join(rootDir, "src", "keep.ts"), "");
|
||||
|
||||
const files = collectFilesSync(rootDir, {
|
||||
includeFile: (filePath) => filePath.endsWith(".ts"),
|
||||
skipDirNames: new Set(["fixtures"]),
|
||||
}).map((filePath) => toPosixPath(path.relative(rootDir, filePath)));
|
||||
|
||||
expect(files).toEqual(["src/keep.ts"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("scripts/check-file-utils relativeToCwd", () => {
|
||||
it("renders repo-relative paths when possible", () => {
|
||||
expect(relativeToCwd(path.join(process.cwd(), "scripts", "check-file-utils.ts"))).toBe(
|
||||
"scripts/check-file-utils.ts",
|
||||
);
|
||||
});
|
||||
});
|
||||
91
openclaw/test/scripts/check-no-conflict-markers.test.ts
Normal file
91
openclaw/test/scripts/check-no-conflict-markers.test.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
import { execFileSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
findConflictMarkerLines,
|
||||
findConflictMarkersInFiles,
|
||||
listTrackedFiles,
|
||||
} from "../../scripts/check-no-conflict-markers.mjs";
|
||||
import { createScriptTestHarness } from "./test-helpers.js";
|
||||
|
||||
const { createTempDir } = createScriptTestHarness();
|
||||
|
||||
function git(cwd: string, ...args: string[]): string {
|
||||
return execFileSync("git", args, {
|
||||
cwd,
|
||||
encoding: "utf8",
|
||||
}).trim();
|
||||
}
|
||||
|
||||
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 = createTempDir("openclaw-conflict-markers-");
|
||||
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],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("finds conflict markers in tracked script files", () => {
|
||||
const rootDir = createTempDir("openclaw-conflict-markers-");
|
||||
git(rootDir, "init", "-q");
|
||||
git(rootDir, "config", "user.email", "test@example.com");
|
||||
git(rootDir, "config", "user.name", "Test User");
|
||||
|
||||
const scriptFile = path.join(rootDir, "scripts", "bundled-plugin-metadata-runtime.mjs");
|
||||
fs.mkdirSync(path.dirname(scriptFile), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
scriptFile,
|
||||
[
|
||||
"<<<<<<< HEAD",
|
||||
'const left = "left";',
|
||||
"=======",
|
||||
'const right = "right";',
|
||||
">>>>>>> branch",
|
||||
].join("\n"),
|
||||
);
|
||||
git(rootDir, "add", "scripts/bundled-plugin-metadata-runtime.mjs");
|
||||
|
||||
const violations = findConflictMarkersInFiles(listTrackedFiles(rootDir));
|
||||
|
||||
expect(violations).toEqual([
|
||||
{
|
||||
filePath: scriptFile,
|
||||
lines: [1, 3, 5],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
51
openclaw/test/scripts/check-no-random-messaging-tmp.test.ts
Normal file
51
openclaw/test/scripts/check-no-random-messaging-tmp.test.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
findMessagingTmpdirCallLines,
|
||||
messagingTmpdirGuardSourceRoots,
|
||||
} from "../../scripts/check-no-random-messaging-tmp.mjs";
|
||||
|
||||
describe("check-no-random-messaging-tmp", () => {
|
||||
it("finds os.tmpdir calls imported from node:os", () => {
|
||||
const source = `
|
||||
import os from "node:os";
|
||||
const dir = os.tmpdir();
|
||||
`;
|
||||
expect(findMessagingTmpdirCallLines(source)).toEqual([3]);
|
||||
});
|
||||
|
||||
it("finds tmpdir named import calls from node:os", () => {
|
||||
const source = `
|
||||
import { tmpdir } from "node:os";
|
||||
const dir = tmpdir();
|
||||
`;
|
||||
expect(findMessagingTmpdirCallLines(source)).toEqual([3]);
|
||||
});
|
||||
|
||||
it("finds tmpdir calls imported from os", () => {
|
||||
const source = `
|
||||
import os from "os";
|
||||
const dir = os.tmpdir();
|
||||
`;
|
||||
expect(findMessagingTmpdirCallLines(source)).toEqual([3]);
|
||||
});
|
||||
|
||||
it("ignores mentions in comments and strings", () => {
|
||||
const source = `
|
||||
// os.tmpdir()
|
||||
const text = "tmpdir()";
|
||||
`;
|
||||
expect(findMessagingTmpdirCallLines(source)).toEqual([]);
|
||||
});
|
||||
|
||||
it("ignores tmpdir symbols that are not imported from node:os", () => {
|
||||
const source = `
|
||||
const tmpdir = () => "/tmp";
|
||||
const dir = tmpdir();
|
||||
`;
|
||||
expect(findMessagingTmpdirCallLines(source)).toEqual([]);
|
||||
});
|
||||
|
||||
it("guards src/media against host tmpdir usage", () => {
|
||||
expect(messagingTmpdirGuardSourceRoots).toContain("src/media");
|
||||
});
|
||||
});
|
||||
39
openclaw/test/scripts/check-no-raw-window-open.test.ts
Normal file
39
openclaw/test/scripts/check-no-raw-window-open.test.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { findRawWindowOpenLines } from "../../scripts/check-no-raw-window-open.mjs";
|
||||
|
||||
describe("check-no-raw-window-open", () => {
|
||||
it("finds direct window.open calls", () => {
|
||||
const source = `
|
||||
function openDocs() {
|
||||
window.open("https://docs.openclaw.ai");
|
||||
}
|
||||
`;
|
||||
expect(findRawWindowOpenLines(source)).toEqual([3]);
|
||||
});
|
||||
|
||||
it("finds globalThis.open calls", () => {
|
||||
const source = `
|
||||
function openDocs() {
|
||||
globalThis.open("https://docs.openclaw.ai");
|
||||
}
|
||||
`;
|
||||
expect(findRawWindowOpenLines(source)).toEqual([3]);
|
||||
});
|
||||
|
||||
it("ignores mentions in strings and comments", () => {
|
||||
const source = `
|
||||
// window.open("https://example.com")
|
||||
const text = "window.open('https://example.com')";
|
||||
`;
|
||||
expect(findRawWindowOpenLines(source)).toEqual([]);
|
||||
});
|
||||
|
||||
it("handles parenthesized and asserted window references", () => {
|
||||
const source = `
|
||||
const openRef = (window as Window).open;
|
||||
openRef("https://example.com");
|
||||
(window as Window).open("https://example.com");
|
||||
`;
|
||||
expect(findRawWindowOpenLines(source)).toEqual([4]);
|
||||
});
|
||||
});
|
||||
80
openclaw/test/scripts/ci-node-test-plan.test.ts
Normal file
80
openclaw/test/scripts/ci-node-test-plan.test.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { createNodeTestShards } from "../../scripts/lib/ci-node-test-plan.mjs";
|
||||
|
||||
describe("scripts/lib/ci-node-test-plan.mjs", () => {
|
||||
it("names the node shard checks as core test lanes", () => {
|
||||
const shards = createNodeTestShards();
|
||||
|
||||
expect(shards).not.toHaveLength(0);
|
||||
expect(shards.map((shard) => shard.checkName)).toEqual(
|
||||
shards.map((shard) =>
|
||||
shard.shardName.startsWith("core-unit-")
|
||||
? `checks-node-core-${shard.shardName.slice("core-unit-".length)}`
|
||||
: `checks-node-${shard.shardName}`,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps extension, bundled, contracts, and channels configs out of the core node lane", () => {
|
||||
const configs = createNodeTestShards().flatMap((shard) => shard.configs);
|
||||
|
||||
expect(configs).not.toContain("test/vitest/vitest.channels.config.ts");
|
||||
expect(configs).not.toContain("test/vitest/vitest.contracts.config.ts");
|
||||
expect(configs).not.toContain("test/vitest/vitest.bundled.config.ts");
|
||||
expect(configs).not.toContain("test/vitest/vitest.full-extensions.config.ts");
|
||||
expect(configs).not.toContain("test/vitest/vitest.extension-telegram.config.ts");
|
||||
});
|
||||
|
||||
it("marks only dist-dependent shards for built artifact restore", () => {
|
||||
const requiresDistShardNames = createNodeTestShards()
|
||||
.filter((shard) => shard.requiresDist)
|
||||
.map((shard) => shard.shardName);
|
||||
|
||||
expect(requiresDistShardNames).toEqual([
|
||||
"core-support-boundary",
|
||||
"core-runtime",
|
||||
"agentic-agents-plugins",
|
||||
]);
|
||||
});
|
||||
|
||||
it("splits the agentic lane into control-plane, commands, and agent/plugin shards", () => {
|
||||
const shards = createNodeTestShards();
|
||||
const controlPlaneShard = shards.find((shard) => shard.shardName === "agentic-control-plane");
|
||||
const commandsShard = shards.find((shard) => shard.shardName === "agentic-commands");
|
||||
const agentPluginShard = shards.find((shard) => shard.shardName === "agentic-agents-plugins");
|
||||
|
||||
expect(controlPlaneShard).toEqual({
|
||||
checkName: "checks-node-agentic-control-plane",
|
||||
shardName: "agentic-control-plane",
|
||||
configs: [
|
||||
"test/vitest/vitest.gateway-core.config.ts",
|
||||
"test/vitest/vitest.gateway-client.config.ts",
|
||||
"test/vitest/vitest.gateway-methods.config.ts",
|
||||
"test/vitest/vitest.gateway-server.config.ts",
|
||||
"test/vitest/vitest.daemon.config.ts",
|
||||
],
|
||||
requiresDist: false,
|
||||
});
|
||||
expect(commandsShard).toEqual({
|
||||
checkName: "checks-node-agentic-commands",
|
||||
shardName: "agentic-commands",
|
||||
configs: [
|
||||
"test/vitest/vitest.cli.config.ts",
|
||||
"test/vitest/vitest.commands-light.config.ts",
|
||||
"test/vitest/vitest.commands.config.ts",
|
||||
],
|
||||
requiresDist: false,
|
||||
});
|
||||
expect(agentPluginShard).toEqual({
|
||||
checkName: "checks-node-agentic-agents-plugins",
|
||||
shardName: "agentic-agents-plugins",
|
||||
configs: [
|
||||
"test/vitest/vitest.agents.config.ts",
|
||||
"test/vitest/vitest.plugin-sdk-light.config.ts",
|
||||
"test/vitest/vitest.plugin-sdk.config.ts",
|
||||
"test/vitest/vitest.plugins.config.ts",
|
||||
],
|
||||
requiresDist: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
199
openclaw/test/scripts/committer.test.ts
Normal file
199
openclaw/test/scripts/committer.test.ts
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
import { execFileSync } from "node:child_process";
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createScriptTestHarness } from "./test-helpers.js";
|
||||
|
||||
const scriptPath = path.join(process.cwd(), "scripts", "committer");
|
||||
const { createTempDir } = createScriptTestHarness();
|
||||
|
||||
function run(cwd: string, command: string, args: string[]) {
|
||||
return execFileSync(command, args, {
|
||||
cwd,
|
||||
encoding: "utf8",
|
||||
}).trim();
|
||||
}
|
||||
|
||||
function git(cwd: string, ...args: string[]) {
|
||||
return run(cwd, "git", args);
|
||||
}
|
||||
|
||||
function createRepo() {
|
||||
const repo = createTempDir("committer-test-");
|
||||
|
||||
git(repo, "init", "-q");
|
||||
git(repo, "config", "user.email", "test@example.com");
|
||||
git(repo, "config", "user.name", "Test User");
|
||||
writeFileSync(path.join(repo, "seed.txt"), "seed\n");
|
||||
git(repo, "add", "seed.txt");
|
||||
git(repo, "commit", "-qm", "seed");
|
||||
|
||||
return repo;
|
||||
}
|
||||
|
||||
function writeRepoFile(repo: string, relativePath: string, contents: string) {
|
||||
const fullPath = path.join(repo, relativePath);
|
||||
mkdirSync(path.dirname(fullPath), { recursive: true });
|
||||
writeFileSync(fullPath, contents);
|
||||
}
|
||||
|
||||
function installHook(repo: string, relativePath: string, contents: string) {
|
||||
const fullPath = path.join(repo, relativePath);
|
||||
mkdirSync(path.dirname(fullPath), { recursive: true });
|
||||
writeFileSync(fullPath, contents, {
|
||||
encoding: "utf8",
|
||||
mode: 0o755,
|
||||
});
|
||||
git(repo, "config", "core.hooksPath", path.dirname(relativePath));
|
||||
}
|
||||
|
||||
function commitWithHelper(repo: string, commitMessage: string, ...args: string[]) {
|
||||
return run(repo, "bash", [scriptPath, commitMessage, ...args]);
|
||||
}
|
||||
|
||||
function commitWithHelperArgs(repo: string, ...args: string[]) {
|
||||
return run(repo, "bash", [scriptPath, ...args]);
|
||||
}
|
||||
|
||||
function committedPaths(repo: string) {
|
||||
const output = git(repo, "diff-tree", "--no-commit-id", "--name-only", "-r", "HEAD");
|
||||
return output.split("\n").filter(Boolean).toSorted();
|
||||
}
|
||||
|
||||
function committedFileContents(repo: string, relativePath: string) {
|
||||
return git(repo, "show", `HEAD:${relativePath}`);
|
||||
}
|
||||
|
||||
describe("scripts/committer", () => {
|
||||
it("accepts supported path argument shapes", () => {
|
||||
const cases = [
|
||||
{
|
||||
commitMessage: "test: plain argv",
|
||||
files: [
|
||||
["alpha.txt", "alpha\n"],
|
||||
["nested/file with spaces.txt", "beta\n"],
|
||||
] as const,
|
||||
args: ["alpha.txt", "nested/file with spaces.txt"],
|
||||
expected: ["alpha.txt", "nested/file with spaces.txt"],
|
||||
},
|
||||
{
|
||||
commitMessage: "test: space blob",
|
||||
files: [
|
||||
["alpha.txt", "alpha\n"],
|
||||
["beta.txt", "beta\n"],
|
||||
] as const,
|
||||
args: ["alpha.txt beta.txt"],
|
||||
expected: ["alpha.txt", "beta.txt"],
|
||||
},
|
||||
{
|
||||
commitMessage: "test: newline blob",
|
||||
files: [
|
||||
["alpha.txt", "alpha\n"],
|
||||
["nested/file with spaces.txt", "beta\n"],
|
||||
] as const,
|
||||
args: ["alpha.txt\nnested/file with spaces.txt"],
|
||||
expected: ["alpha.txt", "nested/file with spaces.txt"],
|
||||
},
|
||||
] as const;
|
||||
|
||||
for (const testCase of cases) {
|
||||
const repo = createRepo();
|
||||
for (const [file, contents] of testCase.files) {
|
||||
writeRepoFile(repo, file, contents);
|
||||
}
|
||||
|
||||
commitWithHelper(repo, testCase.commitMessage, ...testCase.args);
|
||||
|
||||
expect(committedPaths(repo)).toEqual(testCase.expected);
|
||||
}
|
||||
});
|
||||
|
||||
it("commits changelog-only changes without pulling in unrelated dirty files", () => {
|
||||
const repo = createRepo();
|
||||
writeRepoFile(repo, "CHANGELOG.md", "initial\n");
|
||||
writeRepoFile(repo, "unrelated.ts", "export const ok = true;\n");
|
||||
git(repo, "add", "CHANGELOG.md", "unrelated.ts");
|
||||
git(repo, "commit", "-qm", "seed extra files");
|
||||
|
||||
writeRepoFile(repo, "CHANGELOG.md", "breaking note\n");
|
||||
writeRepoFile(repo, "unrelated.ts", "<<<<<<< HEAD\nleft\n=======\nright\n>>>>>>> branch\n");
|
||||
|
||||
commitWithHelper(repo, "docs(changelog): note breaking change", "CHANGELOG.md");
|
||||
|
||||
expect(committedPaths(repo)).toEqual(["CHANGELOG.md"]);
|
||||
expect(git(repo, "status", "--short")).toContain("M unrelated.ts");
|
||||
});
|
||||
|
||||
it("supports --fast before the commit message", () => {
|
||||
const repo = createRepo();
|
||||
writeRepoFile(repo, "note.txt", "hello\n");
|
||||
|
||||
const output = commitWithHelperArgs(repo, "--fast", "test: fast helper", "note.txt");
|
||||
|
||||
expect(output).toContain('Committed "test: fast helper" with 1 files');
|
||||
expect(committedPaths(repo)).toEqual(["note.txt"]);
|
||||
});
|
||||
|
||||
it("supports combining --force and --fast", () => {
|
||||
const repo = createRepo();
|
||||
writeRepoFile(repo, "note.txt", "hello\n");
|
||||
|
||||
const output = commitWithHelperArgs(
|
||||
repo,
|
||||
"--force",
|
||||
"--fast",
|
||||
"test: fast forced helper",
|
||||
"note.txt",
|
||||
);
|
||||
|
||||
expect(output).toContain('Committed "test: fast forced helper" with 1 files');
|
||||
expect(committedPaths(repo)).toEqual(["note.txt"]);
|
||||
});
|
||||
|
||||
it("passes FAST_COMMIT through to git hooks when using --fast", () => {
|
||||
const repo = createRepo();
|
||||
installHook(
|
||||
repo,
|
||||
".githooks/pre-commit",
|
||||
'#!/usr/bin/env bash\nset -euo pipefail\n[ "${FAST_COMMIT:-}" = "1" ] || exit 91\n',
|
||||
);
|
||||
writeRepoFile(repo, "note.txt", "hello\n");
|
||||
|
||||
const output = commitWithHelperArgs(repo, "--fast", "test: fast hook env", "note.txt");
|
||||
|
||||
expect(output).toContain('Committed "test: fast hook env" with 1 files');
|
||||
expect(committedPaths(repo)).toEqual(["note.txt"]);
|
||||
});
|
||||
|
||||
it("commits the hook-restaged file contents and leaves the tree clean", () => {
|
||||
const repo = createRepo();
|
||||
installHook(
|
||||
repo,
|
||||
".githooks/pre-commit",
|
||||
[
|
||||
"#!/usr/bin/env bash",
|
||||
"set -euo pipefail",
|
||||
"printf 'formatted\\n' > note.txt",
|
||||
"git add note.txt",
|
||||
].join("\n") + "\n",
|
||||
);
|
||||
writeRepoFile(repo, "note.txt", "raw\n");
|
||||
|
||||
const output = commitWithHelperArgs(repo, "test: hook rewrite", "note.txt");
|
||||
|
||||
expect(output).toContain('Committed "test: hook rewrite" with 1 files');
|
||||
expect(committedPaths(repo)).toEqual(["note.txt"]);
|
||||
expect(committedFileContents(repo, "note.txt")).toBe("formatted");
|
||||
expect(git(repo, "status", "--short", "--untracked-files=no")).toBe("");
|
||||
});
|
||||
|
||||
it("prints usage for --help", () => {
|
||||
const repo = createRepo();
|
||||
|
||||
const output = commitWithHelperArgs(repo, "--help");
|
||||
|
||||
expect(output).toContain(
|
||||
'Usage: committer [--force] [--fast] "commit message" "file" ["file" ...]',
|
||||
);
|
||||
});
|
||||
});
|
||||
49
openclaw/test/scripts/extension-source-classifier.test.ts
Normal file
49
openclaw/test/scripts/extension-source-classifier.test.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { classifyBundledExtensionSourcePath } from "../../scripts/lib/extension-source-classifier.mjs";
|
||||
|
||||
describe("classifyBundledExtensionSourcePath", () => {
|
||||
it("treats runtime barrels as non-production source", () => {
|
||||
expect(classifyBundledExtensionSourcePath("extensions/msteams/runtime-api.ts")).toMatchObject({
|
||||
isCodeFile: true,
|
||||
isRuntimeApiBarrel: true,
|
||||
isTestLike: false,
|
||||
isProductionSource: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("treats extension tests and fixtures as test-like across naming styles", () => {
|
||||
expect(
|
||||
classifyBundledExtensionSourcePath("extensions/feishu/src/monitor-handler.test.ts"),
|
||||
).toMatchObject({
|
||||
isTestLike: true,
|
||||
isProductionSource: false,
|
||||
});
|
||||
expect(
|
||||
classifyBundledExtensionSourcePath("extensions/discord/src/test-fixtures/message.ts"),
|
||||
).toMatchObject({
|
||||
isTestLike: true,
|
||||
isProductionSource: false,
|
||||
});
|
||||
expect(
|
||||
classifyBundledExtensionSourcePath("extensions/telegram/src/bot.test-harness.ts"),
|
||||
).toMatchObject({
|
||||
isTestLike: true,
|
||||
isProductionSource: false,
|
||||
});
|
||||
expect(
|
||||
classifyBundledExtensionSourcePath("extensions/telegram/src/target-writeback.test-shared.ts"),
|
||||
).toMatchObject({
|
||||
isTestLike: true,
|
||||
isProductionSource: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps normal extension production files eligible for guardrails", () => {
|
||||
expect(classifyBundledExtensionSourcePath("extensions/msteams/src/send.ts")).toMatchObject({
|
||||
isCodeFile: true,
|
||||
isRuntimeApiBarrel: false,
|
||||
isTestLike: false,
|
||||
isProductionSource: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
52
openclaw/test/scripts/gh-read.test.ts
Normal file
52
openclaw/test/scripts/gh-read.test.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildReadPermissions,
|
||||
normalizeRepo,
|
||||
parsePermissionKeys,
|
||||
parseRepoArg,
|
||||
} from "../../scripts/gh-read.js";
|
||||
|
||||
describe("gh-read helpers", () => {
|
||||
it("finds repo from gh args", () => {
|
||||
expect(parseRepoArg(["pr", "view", "42", "-R", "openclaw/openclaw"])).toBe("openclaw/openclaw");
|
||||
expect(parseRepoArg(["run", "list", "--repo=openclaw/docs"])).toBe("openclaw/docs");
|
||||
expect(parseRepoArg(["pr", "view", "42"])).toBeNull();
|
||||
});
|
||||
|
||||
it("normalizes repo strings from common git formats", () => {
|
||||
expect(normalizeRepo("openclaw/openclaw")).toBe("openclaw/openclaw");
|
||||
expect(normalizeRepo("github.com/openclaw/openclaw")).toBe("openclaw/openclaw");
|
||||
expect(normalizeRepo("https://github.com/openclaw/openclaw.git")).toBe("openclaw/openclaw");
|
||||
expect(normalizeRepo("git@github.com:openclaw/openclaw.git")).toBe("openclaw/openclaw");
|
||||
expect(normalizeRepo("invalid")).toBeNull();
|
||||
});
|
||||
|
||||
it("builds a read-only permission subset from granted permissions", () => {
|
||||
expect(
|
||||
buildReadPermissions(
|
||||
{
|
||||
actions: "write",
|
||||
issues: "read",
|
||||
administration: "write",
|
||||
metadata: "read",
|
||||
statuses: null,
|
||||
},
|
||||
["actions", "issues", "metadata", "statuses", "administration"],
|
||||
),
|
||||
).toEqual({
|
||||
administration: "read",
|
||||
actions: "read",
|
||||
issues: "read",
|
||||
metadata: "read",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses permission key overrides", () => {
|
||||
expect(parsePermissionKeys(undefined)).toContain("pull_requests");
|
||||
expect(parsePermissionKeys("actions, contents ,issues")).toEqual([
|
||||
"actions",
|
||||
"contents",
|
||||
"issues",
|
||||
]);
|
||||
});
|
||||
});
|
||||
112
openclaw/test/scripts/ios-pin-version.test.ts
Normal file
112
openclaw/test/scripts/ios-pin-version.test.ts
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { pinIosVersion, parseArgs } from "../../scripts/ios-pin-version.ts";
|
||||
import { resolveIosVersion } from "../../scripts/lib/ios-version.ts";
|
||||
import { installIosFixtureCleanup, writeIosFixture } from "./ios-version.test-support.ts";
|
||||
|
||||
installIosFixtureCleanup();
|
||||
|
||||
describe("parseArgs", () => {
|
||||
it("requires exactly one pin source", () => {
|
||||
expect(() => parseArgs([])).toThrow(
|
||||
"Choose exactly one of --from-gateway or --version <YYYY.M.D>",
|
||||
);
|
||||
expect(() => parseArgs(["--from-gateway", "--version", "2026.4.7"])).toThrow(
|
||||
"Choose exactly one of --from-gateway or --version <YYYY.M.D>",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pinIosVersion", () => {
|
||||
it("pins an explicit iOS release version and syncs generated artifacts", () => {
|
||||
const rootDir = writeIosFixture({
|
||||
version: "2026.4.6",
|
||||
changelog: `# OpenClaw iOS Changelog
|
||||
|
||||
## Unreleased
|
||||
|
||||
- Draft release notes.
|
||||
`,
|
||||
prefix: "openclaw-ios-pin-",
|
||||
});
|
||||
|
||||
const result = pinIosVersion({
|
||||
explicitVersion: "2026.4.7",
|
||||
fromGateway: false,
|
||||
rootDir,
|
||||
sync: true,
|
||||
});
|
||||
|
||||
expect(result.previousVersion).toBe("2026.4.6");
|
||||
expect(result.nextVersion).toBe("2026.4.7");
|
||||
expect(result.packageVersion).toBeNull();
|
||||
expect(resolveIosVersion(rootDir).canonicalVersion).toBe("2026.4.7");
|
||||
expect(fs.readFileSync(path.join(rootDir, "apps", "ios", "version.json"), "utf8")).toContain(
|
||||
'"version": "2026.4.7"',
|
||||
);
|
||||
expect(
|
||||
fs.readFileSync(path.join(rootDir, "apps", "ios", "Config", "Version.xcconfig"), "utf8"),
|
||||
).toContain("OPENCLAW_MARKETING_VERSION = 2026.4.7");
|
||||
expect(
|
||||
fs.readFileSync(
|
||||
path.join(rootDir, "apps", "ios", "fastlane", "metadata", "en-US", "release_notes.txt"),
|
||||
"utf8",
|
||||
),
|
||||
).toContain("- Draft release notes.");
|
||||
expect(result.syncedPaths).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("pins from the current gateway version without carrying prerelease suffixes", () => {
|
||||
const rootDir = writeIosFixture({
|
||||
version: "2026.4.6",
|
||||
packageVersion: "2026.4.10-beta.3",
|
||||
changelog: `# OpenClaw iOS Changelog
|
||||
|
||||
## Unreleased
|
||||
|
||||
- Candidate release notes.
|
||||
`,
|
||||
prefix: "openclaw-ios-pin-",
|
||||
});
|
||||
|
||||
const result = pinIosVersion({
|
||||
explicitVersion: null,
|
||||
fromGateway: true,
|
||||
rootDir,
|
||||
sync: true,
|
||||
});
|
||||
|
||||
expect(result.previousVersion).toBe("2026.4.6");
|
||||
expect(result.nextVersion).toBe("2026.4.10");
|
||||
expect(result.packageVersion).toBe("2026.4.10-beta.3");
|
||||
expect(resolveIosVersion(rootDir).marketingVersion).toBe("2026.4.10");
|
||||
});
|
||||
|
||||
it("can skip syncing checked-in artifacts when requested", () => {
|
||||
const rootDir = writeIosFixture({
|
||||
version: "2026.4.6",
|
||||
changelog: `# OpenClaw iOS Changelog
|
||||
|
||||
## Unreleased
|
||||
|
||||
- Candidate release notes.
|
||||
`,
|
||||
versionXcconfig: "stale\n",
|
||||
releaseNotes: "stale\n",
|
||||
prefix: "openclaw-ios-pin-",
|
||||
});
|
||||
|
||||
const result = pinIosVersion({
|
||||
explicitVersion: "2026.4.8",
|
||||
fromGateway: false,
|
||||
rootDir,
|
||||
sync: false,
|
||||
});
|
||||
|
||||
expect(result.syncedPaths).toHaveLength(0);
|
||||
expect(
|
||||
fs.readFileSync(path.join(rootDir, "apps", "ios", "Config", "Version.xcconfig"), "utf8"),
|
||||
).toBe("stale\n");
|
||||
});
|
||||
});
|
||||
236
openclaw/test/scripts/ios-team-id.test.ts
Normal file
236
openclaw/test/scripts/ios-team-id.test.ts
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
import { execFileSync } from "node:child_process";
|
||||
import { chmodSync } from "node:fs";
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { cleanupTempDirs, makeTempDir } from "../helpers/temp-dir.js";
|
||||
|
||||
const SCRIPT = path.join(process.cwd(), "scripts", "ios-team-id.sh");
|
||||
const BASH_BIN = process.platform === "win32" ? "bash" : "/bin/bash";
|
||||
const BASH_ARGS = process.platform === "win32" ? [SCRIPT] : ["--noprofile", "--norc", SCRIPT];
|
||||
const BASE_PATH = process.env.PATH ?? "/usr/bin:/bin";
|
||||
const BASE_LANG = process.env.LANG ?? "C";
|
||||
let fixtureRoot = "";
|
||||
let sharedBinDir = "";
|
||||
let sharedHomeDir = "";
|
||||
let sharedHomeBinDir = "";
|
||||
let sharedFakePythonPath = "";
|
||||
const tempDirs: string[] = [];
|
||||
const runScriptCache = new Map<string, { ok: boolean; stdout: string; stderr: string }>();
|
||||
type TeamCandidate = {
|
||||
teamId: string;
|
||||
isFree: boolean;
|
||||
teamName: string;
|
||||
};
|
||||
|
||||
function parseTeamCandidateRows(raw: string): TeamCandidate[] {
|
||||
return raw
|
||||
.split("\n")
|
||||
.map((line) => line.replace(/\r/g, "").trim())
|
||||
.filter(Boolean)
|
||||
.map((line) => line.split("\t"))
|
||||
.filter((parts) => parts.length >= 3)
|
||||
.map((parts) => ({
|
||||
teamId: parts[0] ?? "",
|
||||
isFree: (parts[1] ?? "0") === "1",
|
||||
teamName: parts[2] ?? "",
|
||||
}))
|
||||
.filter((candidate) => candidate.teamId.length > 0);
|
||||
}
|
||||
|
||||
function pickTeamIdFromCandidates(params: {
|
||||
candidates: TeamCandidate[];
|
||||
preferredTeamId?: string;
|
||||
preferredTeamName?: string;
|
||||
preferNonFreeTeam?: boolean;
|
||||
}): string | undefined {
|
||||
const preferredTeamId = (params.preferredTeamId ?? "").trim();
|
||||
if (preferredTeamId) {
|
||||
const preferred = params.candidates.find((candidate) => candidate.teamId === preferredTeamId);
|
||||
if (preferred) {
|
||||
return preferred.teamId;
|
||||
}
|
||||
}
|
||||
|
||||
const preferredTeamName = (params.preferredTeamName ?? "").trim().toLowerCase();
|
||||
if (preferredTeamName) {
|
||||
const preferredByName = params.candidates.find(
|
||||
(candidate) => candidate.teamName.trim().toLowerCase() === preferredTeamName,
|
||||
);
|
||||
if (preferredByName) {
|
||||
return preferredByName.teamId;
|
||||
}
|
||||
}
|
||||
|
||||
if (params.preferNonFreeTeam !== false) {
|
||||
const paid = params.candidates.find((candidate) => !candidate.isFree);
|
||||
if (paid) {
|
||||
return paid.teamId;
|
||||
}
|
||||
}
|
||||
|
||||
return params.candidates[0]?.teamId;
|
||||
}
|
||||
|
||||
async function writeExecutable(filePath: string, body: string): Promise<void> {
|
||||
await writeFile(filePath, body, "utf8");
|
||||
chmodSync(filePath, 0o755);
|
||||
}
|
||||
|
||||
function runScript(
|
||||
homeDir: string,
|
||||
extraEnv: Record<string, string> = {},
|
||||
): {
|
||||
ok: boolean;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
} {
|
||||
const extraEnvKey = Object.keys(extraEnv)
|
||||
.toSorted((a, b) => a.localeCompare(b))
|
||||
.map((key) => `${key}=${extraEnv[key] ?? ""}`)
|
||||
.join("\u0001");
|
||||
const cacheKey = `${homeDir}\u0000${extraEnvKey}`;
|
||||
const cached = runScriptCache.get(cacheKey);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
const binDir = path.join(homeDir, "bin");
|
||||
const env = {
|
||||
HOME: homeDir,
|
||||
PATH: `${binDir}${path.delimiter}${sharedBinDir}${path.delimiter}${BASE_PATH}`,
|
||||
LANG: BASE_LANG,
|
||||
...extraEnv,
|
||||
};
|
||||
try {
|
||||
const stdout = execFileSync(BASH_BIN, BASH_ARGS, {
|
||||
env,
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
const result = { ok: true, stdout: stdout.trim(), stderr: "" };
|
||||
runScriptCache.set(cacheKey, result);
|
||||
return result;
|
||||
} catch (error) {
|
||||
const e = error as { stdout?: unknown; stderr?: unknown };
|
||||
const stdout =
|
||||
typeof e.stdout === "string"
|
||||
? e.stdout
|
||||
: Buffer.isBuffer(e.stdout)
|
||||
? e.stdout.toString("utf8")
|
||||
: "";
|
||||
const stderr =
|
||||
typeof e.stderr === "string"
|
||||
? e.stderr
|
||||
: Buffer.isBuffer(e.stderr)
|
||||
? e.stderr.toString("utf8")
|
||||
: "";
|
||||
const result = { ok: false, stdout: stdout.trim(), stderr: stderr.trim() };
|
||||
runScriptCache.set(cacheKey, result);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
describe("scripts/ios-team-id.sh", () => {
|
||||
beforeAll(async () => {
|
||||
fixtureRoot = makeTempDir(tempDirs, "openclaw-ios-team-id-");
|
||||
sharedBinDir = path.join(fixtureRoot, "shared-bin");
|
||||
await mkdir(sharedBinDir, { recursive: true });
|
||||
sharedHomeDir = path.join(fixtureRoot, "home");
|
||||
sharedHomeBinDir = path.join(sharedHomeDir, "bin");
|
||||
await mkdir(sharedHomeBinDir, { recursive: true });
|
||||
await mkdir(path.join(sharedHomeDir, "Library", "Preferences"), { recursive: true });
|
||||
await writeFile(
|
||||
path.join(sharedHomeDir, "Library", "Preferences", "com.apple.dt.Xcode.plist"),
|
||||
"",
|
||||
);
|
||||
await writeExecutable(
|
||||
path.join(sharedBinDir, "plutil"),
|
||||
`#!/usr/bin/env bash
|
||||
echo '{}'`,
|
||||
);
|
||||
await writeExecutable(
|
||||
path.join(sharedBinDir, "defaults"),
|
||||
`#!/usr/bin/env bash
|
||||
if [[ "$3" == "DVTDeveloperAccountManagerAppleIDLists" ]]; then
|
||||
echo '(identifier = "dev@example.com";)'
|
||||
exit 0
|
||||
fi
|
||||
exit 0`,
|
||||
);
|
||||
await writeExecutable(
|
||||
path.join(sharedBinDir, "security"),
|
||||
`#!/usr/bin/env bash
|
||||
if [[ "$1" == "cms" && "$2" == "-D" ]]; then
|
||||
if [[ "$4" == *"one.mobileprovision" ]]; then
|
||||
cat <<'PLIST'
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0"><dict><key>TeamIdentifier</key><array><string>AAAAA11111</string></array></dict></plist>
|
||||
PLIST
|
||||
exit 0
|
||||
fi
|
||||
if [[ "$4" == *"two.mobileprovision" ]]; then
|
||||
cat <<'PLIST'
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0"><dict><key>TeamIdentifier</key><array><string>BBBBB22222</string></array></dict></plist>
|
||||
PLIST
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
exit 1`,
|
||||
);
|
||||
sharedFakePythonPath = path.join(sharedHomeBinDir, "fake-python");
|
||||
await writeExecutable(
|
||||
sharedFakePythonPath,
|
||||
`#!/usr/bin/env bash
|
||||
printf 'AAAAA11111\\t0\\tAlpha Team\\r\\n'
|
||||
printf 'BBBBB22222\\t0\\tBeta Team\\r\\n'`,
|
||||
);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
cleanupTempDirs(tempDirs);
|
||||
});
|
||||
|
||||
it("parses team listings and prioritizes preferred IDs without shelling out", () => {
|
||||
const rows = parseTeamCandidateRows(
|
||||
"AAAAA11111\t1\tAlpha Team\r\nBBBBB22222\t0\tBeta Team\r\n",
|
||||
);
|
||||
expect(rows).toStrictEqual([
|
||||
{ teamId: "AAAAA11111", isFree: true, teamName: "Alpha Team" },
|
||||
{ teamId: "BBBBB22222", isFree: false, teamName: "Beta Team" },
|
||||
]);
|
||||
|
||||
const preferred = pickTeamIdFromCandidates({
|
||||
candidates: rows,
|
||||
preferredTeamId: "BBBBB22222",
|
||||
});
|
||||
expect(preferred).toBe("BBBBB22222");
|
||||
|
||||
const fallback = pickTeamIdFromCandidates({
|
||||
candidates: rows,
|
||||
preferredTeamId: "CCCCCC3333",
|
||||
});
|
||||
expect(fallback).toBe("BBBBB22222");
|
||||
});
|
||||
|
||||
it("resolves a fallback team ID from Xcode team listings (smoke)", async () => {
|
||||
const fallbackResult = runScript(sharedHomeDir, { IOS_PYTHON_BIN: sharedFakePythonPath });
|
||||
expect(fallbackResult.ok).toBe(true);
|
||||
expect(fallbackResult.stdout).toBe("AAAAA11111");
|
||||
});
|
||||
|
||||
it("prints actionable guidance when Xcode account exists but no Team ID is resolvable", async () => {
|
||||
const result = runScript(sharedHomeDir);
|
||||
expect(result.ok).toBe(false);
|
||||
expect(
|
||||
result.stderr.includes("An Apple account is signed in to Xcode") ||
|
||||
result.stderr.includes("No Apple Team ID found in Xcode accounts"),
|
||||
).toBe(true);
|
||||
expect(
|
||||
result.stderr.includes("IOS_DEVELOPMENT_TEAM") ||
|
||||
result.stderr.includes("IOS_ALLOW_KEYCHAIN_TEAM_FALLBACK"),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
49
openclaw/test/scripts/ios-version.test-support.ts
Normal file
49
openclaw/test/scripts/ios-version.test-support.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { afterEach } from "vitest";
|
||||
import { cleanupTempDirs, makeTempDir } from "../helpers/temp-dir.js";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
export function installIosFixtureCleanup(): void {
|
||||
afterEach(() => {
|
||||
cleanupTempDirs(tempDirs);
|
||||
});
|
||||
}
|
||||
|
||||
export function writeIosFixture(params: {
|
||||
version: string;
|
||||
changelog: string;
|
||||
packageVersion?: string;
|
||||
releaseNotes?: string;
|
||||
versionXcconfig?: string;
|
||||
prefix?: string;
|
||||
}): string {
|
||||
const rootDir = makeTempDir(tempDirs, params.prefix ?? "openclaw-ios-version-");
|
||||
fs.mkdirSync(path.join(rootDir, "apps", "ios", "Config"), { recursive: true });
|
||||
fs.mkdirSync(path.join(rootDir, "apps", "ios", "fastlane", "metadata", "en-US"), {
|
||||
recursive: true,
|
||||
});
|
||||
fs.writeFileSync(
|
||||
path.join(rootDir, "package.json"),
|
||||
`${JSON.stringify({ version: params.packageVersion ?? "2026.4.6" }, null, 2)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(rootDir, "apps", "ios", "version.json"),
|
||||
`${JSON.stringify({ version: params.version }, null, 2)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
fs.writeFileSync(path.join(rootDir, "apps", "ios", "CHANGELOG.md"), params.changelog, "utf8");
|
||||
fs.writeFileSync(
|
||||
path.join(rootDir, "apps", "ios", "Config", "Version.xcconfig"),
|
||||
params.versionXcconfig ?? "",
|
||||
"utf8",
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(rootDir, "apps", "ios", "fastlane", "metadata", "en-US", "release_notes.txt"),
|
||||
params.releaseNotes ?? "",
|
||||
"utf8",
|
||||
);
|
||||
return rootDir;
|
||||
}
|
||||
138
openclaw/test/scripts/ios-version.test.ts
Normal file
138
openclaw/test/scripts/ios-version.test.ts
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
extractChangelogSection,
|
||||
normalizeGatewayVersionToPinnedIosVersion,
|
||||
renderIosReleaseNotes,
|
||||
renderIosVersionXcconfig,
|
||||
resolveGatewayVersionForIosRelease,
|
||||
resolveIosVersion,
|
||||
} from "../../scripts/lib/ios-version.ts";
|
||||
import { installIosFixtureCleanup, writeIosFixture } from "./ios-version.test-support.ts";
|
||||
|
||||
installIosFixtureCleanup();
|
||||
|
||||
describe("resolveIosVersion", () => {
|
||||
it("parses pinned CalVer versions and derives Apple marketing fields", () => {
|
||||
const rootDir = writeIosFixture({
|
||||
version: "2026.4.6",
|
||||
changelog: "# OpenClaw iOS Changelog\n\n## 2026.4.6\n\nStable notes.\n",
|
||||
});
|
||||
|
||||
expect(resolveIosVersion(rootDir)).toMatchObject({
|
||||
canonicalVersion: "2026.4.6",
|
||||
marketingVersion: "2026.4.6",
|
||||
buildVersion: "1",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects semver-only versions", () => {
|
||||
const rootDir = writeIosFixture({
|
||||
version: "1.2.3",
|
||||
changelog: "# OpenClaw iOS Changelog\n\n## Unreleased\n\nNotes.\n",
|
||||
});
|
||||
|
||||
expect(() => resolveIosVersion(rootDir)).toThrow("Expected pinned CalVer like 2026.4.6");
|
||||
});
|
||||
|
||||
it("rejects prerelease suffixes in the pinned iOS version file", () => {
|
||||
const rootDir = writeIosFixture({
|
||||
version: "2026.4.6-beta.1",
|
||||
changelog: "# OpenClaw iOS Changelog\n\n## Unreleased\n\nNotes.\n",
|
||||
});
|
||||
|
||||
expect(() => resolveIosVersion(rootDir)).toThrow("Expected pinned CalVer like 2026.4.6");
|
||||
});
|
||||
});
|
||||
|
||||
describe("gateway version normalization", () => {
|
||||
it("keeps stable gateway CalVer values", () => {
|
||||
expect(normalizeGatewayVersionToPinnedIosVersion("2026.4.6")).toBe("2026.4.6");
|
||||
});
|
||||
|
||||
it("strips beta suffixes when pinning from gateway version", () => {
|
||||
expect(normalizeGatewayVersionToPinnedIosVersion("2026.4.6-beta.2")).toBe("2026.4.6");
|
||||
});
|
||||
|
||||
it("strips fallback correction suffixes when pinning from gateway version", () => {
|
||||
expect(normalizeGatewayVersionToPinnedIosVersion("2026.4.6-3")).toBe("2026.4.6");
|
||||
});
|
||||
|
||||
it("reads and normalizes the root package version for iOS releases", () => {
|
||||
const rootDir = writeIosFixture({
|
||||
version: "2026.4.6",
|
||||
packageVersion: "2026.4.7-beta.5",
|
||||
changelog: "# OpenClaw iOS Changelog\n\n## Unreleased\n\nNotes.\n",
|
||||
});
|
||||
|
||||
expect(resolveGatewayVersionForIosRelease(rootDir)).toEqual({
|
||||
packageVersion: "2026.4.7-beta.5",
|
||||
pinnedIosVersion: "2026.4.7",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("renderIosVersionXcconfig", () => {
|
||||
it("renders checked-in defaults from the pinned iOS version", () => {
|
||||
const rootDir = writeIosFixture({
|
||||
version: "2026.4.8",
|
||||
changelog: "# OpenClaw iOS Changelog\n\n## 2026.4.8\n\nNotes.\n",
|
||||
});
|
||||
const version = resolveIosVersion(rootDir);
|
||||
|
||||
expect(renderIosVersionXcconfig(version)).toContain("OPENCLAW_IOS_VERSION = 2026.4.8");
|
||||
expect(renderIosVersionXcconfig(version)).toContain("OPENCLAW_MARKETING_VERSION = 2026.4.8");
|
||||
expect(renderIosVersionXcconfig(version)).toContain("OPENCLAW_BUILD_VERSION = 1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("release note extraction", () => {
|
||||
it("extracts exact pinned version sections first", () => {
|
||||
const rootDir = writeIosFixture({
|
||||
version: "2026.4.6",
|
||||
changelog: `# OpenClaw iOS Changelog
|
||||
|
||||
## Unreleased
|
||||
|
||||
Draft notes.
|
||||
|
||||
## 2026.4.6
|
||||
|
||||
- Exact release notes.
|
||||
`,
|
||||
});
|
||||
const version = resolveIosVersion(rootDir);
|
||||
const changelog = fs.readFileSync(path.join(rootDir, "apps", "ios", "CHANGELOG.md"), "utf8");
|
||||
|
||||
expect(renderIosReleaseNotes(version, changelog)).toBe("- Exact release notes.\n");
|
||||
});
|
||||
|
||||
it("falls back to Unreleased when the release section does not exist yet", () => {
|
||||
const rootDir = writeIosFixture({
|
||||
version: "2026.4.6",
|
||||
changelog: `# OpenClaw iOS Changelog
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Added
|
||||
|
||||
- New iOS feature.
|
||||
`,
|
||||
});
|
||||
const version = resolveIosVersion(rootDir);
|
||||
const changelog = fs.readFileSync(path.join(rootDir, "apps", "ios", "CHANGELOG.md"), "utf8");
|
||||
|
||||
expect(renderIosReleaseNotes(version, changelog)).toContain("### Added");
|
||||
expect(renderIosReleaseNotes(version, changelog)).toContain("- New iOS feature.");
|
||||
});
|
||||
|
||||
it("extracts markdown bodies without the version heading", () => {
|
||||
expect(
|
||||
extractChangelogSection(
|
||||
`# OpenClaw iOS Changelog\n\n## 2026.4.6 - 2026-04-06\n\nLine one.\n\n## 2026.4.5\n`,
|
||||
"2026.4.6",
|
||||
),
|
||||
).toBe("Line one.");
|
||||
});
|
||||
});
|
||||
154
openclaw/test/scripts/lint-suppressions.test.ts
Normal file
154
openclaw/test/scripts/lint-suppressions.test.ts
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const repoRoot = path.resolve(import.meta.dirname, "../..");
|
||||
const CODE_EXTENSIONS = new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"]);
|
||||
const IGNORED_DIRS = new Set([".cache", ".git", "build", "coverage", "dist", "node_modules"]);
|
||||
const ROOTS = ["src", "extensions", "scripts", "ui"] as const;
|
||||
const SUPPRESSION_PATTERN = /(?:oxlint|eslint)-disable(?:-next-line)?\s+([@/\w-]+)(?:\s+--|$)/u;
|
||||
|
||||
type SuppressionEntry = {
|
||||
file: string;
|
||||
rule: string;
|
||||
};
|
||||
|
||||
function walkCodeFiles(dir: string, files: string[] = []): string[] {
|
||||
if (!fs.existsSync(dir)) {
|
||||
return files;
|
||||
}
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
if (IGNORED_DIRS.has(entry.name)) {
|
||||
continue;
|
||||
}
|
||||
walkCodeFiles(fullPath, files);
|
||||
continue;
|
||||
}
|
||||
if (!CODE_EXTENSIONS.has(path.extname(entry.name))) {
|
||||
continue;
|
||||
}
|
||||
if (entry.name.startsWith("__rootdir_boundary_canary__.")) {
|
||||
continue;
|
||||
}
|
||||
const relativePath = path.relative(repoRoot, fullPath).replaceAll(path.sep, "/");
|
||||
if (
|
||||
relativePath.includes("/test/") ||
|
||||
relativePath.endsWith(".test.ts") ||
|
||||
relativePath.endsWith(".test.tsx") ||
|
||||
relativePath.endsWith(".spec.ts") ||
|
||||
relativePath.endsWith(".spec.tsx")
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
files.push(relativePath);
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
function collectProductionLintSuppressions(): SuppressionEntry[] {
|
||||
const entries: SuppressionEntry[] = [];
|
||||
const files = ROOTS.flatMap((root) => walkCodeFiles(path.join(repoRoot, root))).toSorted();
|
||||
for (const relativePath of files) {
|
||||
const source = fs.readFileSync(path.join(repoRoot, relativePath), "utf8");
|
||||
for (const line of source.split("\n")) {
|
||||
const match = line.match(SUPPRESSION_PATTERN);
|
||||
if (!match) {
|
||||
continue;
|
||||
}
|
||||
entries.push({
|
||||
file: relativePath,
|
||||
rule: match[1],
|
||||
});
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
function summarizeSuppressions(entries: readonly SuppressionEntry[]): string[] {
|
||||
const counts = new Map<string, number>();
|
||||
for (const entry of entries) {
|
||||
const key = `${entry.file}|${entry.rule}`;
|
||||
counts.set(key, (counts.get(key) ?? 0) + 1);
|
||||
}
|
||||
return [...counts.entries()].map(([key, count]) => `${key}|${count}`).toSorted();
|
||||
}
|
||||
|
||||
describe("production lint suppressions", () => {
|
||||
it("keeps the intentional production suppression tail on an explicit allowlist", () => {
|
||||
expect(summarizeSuppressions(collectProductionLintSuppressions())).toEqual([
|
||||
"extensions/browser/src/browser/pw-tools-core.interactions.ts|@typescript-eslint/no-implied-eval|2",
|
||||
"extensions/browser/src/cli/browser-cli-actions-input/register.files-downloads.ts|typescript/no-unnecessary-type-parameters|1",
|
||||
"extensions/browser/src/node-host/invoke-browser.ts|typescript/no-unnecessary-type-parameters|1",
|
||||
"extensions/discord/src/monitor.tool-result.test-helpers.ts|typescript/no-unnecessary-type-parameters|1",
|
||||
"extensions/discord/src/outbound-adapter.test-harness.ts|typescript/no-unnecessary-type-parameters|1",
|
||||
"extensions/discord/src/test-support/provider.test-support.ts|typescript/no-unnecessary-type-parameters|1",
|
||||
"extensions/feishu/src/bitable.ts|typescript/no-unnecessary-type-parameters|1",
|
||||
"extensions/matrix/src/onboarding.test-harness.ts|typescript/no-unnecessary-type-parameters|1",
|
||||
"extensions/slack/src/monitor/provider.ts|typescript/no-unnecessary-type-parameters|1",
|
||||
"scripts/e2e/mcp-channels-harness.ts|unicorn/prefer-add-event-listener|1",
|
||||
"scripts/lib/extension-package-boundary.ts|typescript/no-unnecessary-type-parameters|1",
|
||||
"scripts/lib/plugin-npm-release.ts|typescript/no-unnecessary-type-parameters|1",
|
||||
"src/agents/agent-scope.ts|no-control-regex|1",
|
||||
"src/agents/pi-embedded-runner/run/images.ts|no-control-regex|1",
|
||||
"src/agents/skills-clawhub.ts|no-control-regex|1",
|
||||
"src/agents/subagent-attachments.ts|no-control-regex|1",
|
||||
"src/agents/subagent-spawn.ts|no-control-regex|1",
|
||||
"src/agents/tools/common.ts|typescript/no-explicit-any|1",
|
||||
"src/channels/plugins/channel-runtime-surface.types.ts|typescript/no-unnecessary-type-parameters|1",
|
||||
"src/channels/plugins/contracts/test-helpers.ts|typescript/no-unnecessary-type-parameters|1",
|
||||
"src/channels/plugins/types.plugin.ts|typescript/no-explicit-any|1",
|
||||
"src/cli/cli-utils.ts|typescript/no-unnecessary-type-parameters|1",
|
||||
"src/cli/command-options.ts|typescript/no-unnecessary-type-parameters|1",
|
||||
"src/cli/plugins-cli-test-helpers.ts|typescript/no-unnecessary-type-parameters|1",
|
||||
"src/cli/test-runtime-capture.ts|typescript/no-unnecessary-type-parameters|1",
|
||||
"src/config/types.channels.ts|@typescript-eslint/no-explicit-any|1",
|
||||
"src/gateway/test-helpers.server.ts|typescript/no-unnecessary-type-parameters|1",
|
||||
"src/hooks/module-loader.ts|typescript/no-unnecessary-type-parameters|1",
|
||||
"src/infra/channel-runtime-context.ts|typescript/no-unnecessary-type-parameters|1",
|
||||
"src/infra/exec-approvals-effective.ts|typescript/no-unnecessary-type-parameters|1",
|
||||
"src/infra/json-file.ts|typescript/no-unnecessary-type-parameters|1",
|
||||
"src/infra/outbound/send-deps.ts|typescript/no-unnecessary-type-parameters|1",
|
||||
"src/node-host/invoke.ts|typescript/no-unnecessary-type-parameters|1",
|
||||
"src/plugin-sdk/channel-config-helpers.ts|typescript/no-unnecessary-type-parameters|1",
|
||||
"src/plugin-sdk/channel-entry-contract.ts|typescript/no-unnecessary-type-parameters|1",
|
||||
"src/plugin-sdk/facade-loader.ts|typescript/no-unnecessary-type-parameters|1",
|
||||
"src/plugin-sdk/facade-runtime.ts|typescript/no-unnecessary-type-parameters|3",
|
||||
"src/plugin-sdk/qa-runner-runtime.ts|typescript/no-unnecessary-type-parameters|1",
|
||||
"src/plugins/hooks.ts|typescript/no-unnecessary-type-parameters|1",
|
||||
"src/plugins/lazy-service-module.ts|typescript/no-unnecessary-type-parameters|1",
|
||||
"src/plugins/public-surface-loader.ts|typescript/no-unnecessary-type-parameters|1",
|
||||
"src/plugins/runtime/runtime-channel.ts|typescript/no-unnecessary-type-parameters|1",
|
||||
"src/plugins/runtime/runtime-plugin-boundary.ts|typescript/no-unnecessary-type-parameters|2",
|
||||
"src/plugins/runtime/types-channel.ts|typescript/no-unnecessary-type-parameters|1",
|
||||
"src/tasks/task-flow-registry.store.sqlite.ts|typescript/no-unnecessary-type-parameters|1",
|
||||
"src/tasks/task-registry.store.sqlite.ts|typescript/no-unnecessary-type-parameters|1",
|
||||
"src/test-utils/bundled-plugin-public-surface.ts|typescript/no-unnecessary-type-parameters|2",
|
||||
"src/test-utils/vitest-mock-fn.ts|typescript/no-explicit-any|1",
|
||||
"src/utils.ts|typescript/no-unnecessary-type-parameters|1",
|
||||
"ui/src/ui/views/overview-log-tail.ts|no-control-regex|1",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps production no-explicit-any suppressions on an explicit allowlist", () => {
|
||||
const anySuppressions = collectProductionLintSuppressions().filter(
|
||||
(entry) => entry.rule === "typescript/no-explicit-any",
|
||||
);
|
||||
|
||||
expect(anySuppressions).toEqual([
|
||||
{
|
||||
file: "src/agents/tools/common.ts",
|
||||
rule: "typescript/no-explicit-any",
|
||||
},
|
||||
{
|
||||
file: "src/channels/plugins/types.plugin.ts",
|
||||
rule: "typescript/no-explicit-any",
|
||||
},
|
||||
{
|
||||
file: "src/test-utils/vitest-mock-fn.ts",
|
||||
rule: "typescript/no-explicit-any",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
346
openclaw/test/scripts/local-heavy-check-runtime.test.ts
Normal file
346
openclaw/test/scripts/local-heavy-check-runtime.test.ts
Normal file
|
|
@ -0,0 +1,346 @@
|
|||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
acquireLocalHeavyCheckLockSync,
|
||||
applyLocalOxlintPolicy,
|
||||
applyLocalTsgoPolicy,
|
||||
shouldAcquireLocalHeavyCheckLockForOxlint,
|
||||
shouldAcquireLocalHeavyCheckLockForTsgo,
|
||||
} from "../../scripts/lib/local-heavy-check-runtime.mjs";
|
||||
import { createScriptTestHarness } from "./test-helpers.js";
|
||||
|
||||
const { createTempDir } = createScriptTestHarness();
|
||||
const GIB = 1024 ** 3;
|
||||
const CONSTRAINED_HOST = {
|
||||
totalMemoryBytes: 16 * GIB,
|
||||
logicalCpuCount: 8,
|
||||
};
|
||||
const ROOMY_HOST = {
|
||||
totalMemoryBytes: 128 * GIB,
|
||||
logicalCpuCount: 16,
|
||||
};
|
||||
|
||||
function makeEnv(overrides: Record<string, string | undefined> = {}) {
|
||||
return {
|
||||
...process.env,
|
||||
OPENCLAW_LOCAL_CHECK: "1",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("local-heavy-check-runtime", () => {
|
||||
it("tightens local tsgo runs on constrained hosts", () => {
|
||||
const { args, env } = applyLocalTsgoPolicy([], makeEnv(), CONSTRAINED_HOST);
|
||||
|
||||
expect(args).toEqual([
|
||||
"--declaration",
|
||||
"false",
|
||||
"--incremental",
|
||||
"--tsBuildInfoFile",
|
||||
".artifacts/tsgo-cache/root.tsbuildinfo",
|
||||
"--singleThreaded",
|
||||
"--checkers",
|
||||
"1",
|
||||
]);
|
||||
expect(env.GOGC).toBe("30");
|
||||
expect(env.GOMEMLIMIT).toBe("3GiB");
|
||||
});
|
||||
|
||||
it("skips declaration transforms for no-emit tsgo checks", () => {
|
||||
const { args } = applyLocalTsgoPolicy([], makeEnv({ OPENCLAW_LOCAL_CHECK: "0" }), ROOMY_HOST);
|
||||
|
||||
expect(args).toEqual(["--declaration", "false"]);
|
||||
});
|
||||
|
||||
it("keeps explicit tsgo flags and Go env overrides intact when throttled", () => {
|
||||
const { args, env } = applyLocalTsgoPolicy(
|
||||
["--checkers", "4", "--singleThreaded", "--pprofDir", "/tmp/existing"],
|
||||
makeEnv({
|
||||
GOGC: "80",
|
||||
GOMEMLIMIT: "5GiB",
|
||||
OPENCLAW_TSGO_PPROF_DIR: "/tmp/profile",
|
||||
}),
|
||||
CONSTRAINED_HOST,
|
||||
);
|
||||
|
||||
expect(args).toEqual([
|
||||
"--checkers",
|
||||
"4",
|
||||
"--singleThreaded",
|
||||
"--pprofDir",
|
||||
"/tmp/existing",
|
||||
"--declaration",
|
||||
"false",
|
||||
]);
|
||||
expect(env.GOGC).toBe("80");
|
||||
expect(env.GOMEMLIMIT).toBe("5GiB");
|
||||
});
|
||||
|
||||
it("keeps explicit tsgo declaration flags intact", () => {
|
||||
const env = makeEnv({ OPENCLAW_LOCAL_CHECK_MODE: "full" });
|
||||
const longFlag = applyLocalTsgoPolicy(["--declaration"], env, ROOMY_HOST);
|
||||
const shortFlag = applyLocalTsgoPolicy(["-d"], env, ROOMY_HOST);
|
||||
|
||||
expect(longFlag.args).toEqual(["--declaration"]);
|
||||
expect(shortFlag.args).toEqual(["-d"]);
|
||||
});
|
||||
|
||||
it("defaults local tsgo to throttled mode on roomy hosts", () => {
|
||||
const { args, env } = applyLocalTsgoPolicy([], makeEnv(), ROOMY_HOST);
|
||||
|
||||
expect(args).toEqual([
|
||||
"--declaration",
|
||||
"false",
|
||||
"--incremental",
|
||||
"--tsBuildInfoFile",
|
||||
".artifacts/tsgo-cache/root.tsbuildinfo",
|
||||
"--singleThreaded",
|
||||
"--checkers",
|
||||
"1",
|
||||
]);
|
||||
expect(env.GOGC).toBe("30");
|
||||
expect(env.GOMEMLIMIT).toBe("3GiB");
|
||||
});
|
||||
|
||||
it("uses the configured local tsgo build info file", () => {
|
||||
const { args } = applyLocalTsgoPolicy(
|
||||
[],
|
||||
makeEnv({
|
||||
OPENCLAW_LOCAL_CHECK_MODE: "full",
|
||||
OPENCLAW_TSGO_BUILD_INFO_FILE: ".artifacts/custom/tsgo.tsbuildinfo",
|
||||
}),
|
||||
ROOMY_HOST,
|
||||
);
|
||||
|
||||
expect(args).toEqual([
|
||||
"--declaration",
|
||||
"false",
|
||||
"--incremental",
|
||||
"--tsBuildInfoFile",
|
||||
".artifacts/custom/tsgo.tsbuildinfo",
|
||||
]);
|
||||
});
|
||||
|
||||
it("avoids incremental cache reuse for ad hoc tsgo runs", () => {
|
||||
const { args } = applyLocalTsgoPolicy(
|
||||
["--extendedDiagnostics"],
|
||||
makeEnv({ OPENCLAW_LOCAL_CHECK_MODE: "full" }),
|
||||
ROOMY_HOST,
|
||||
);
|
||||
|
||||
expect(args).toEqual(["--extendedDiagnostics", "--declaration", "false"]);
|
||||
});
|
||||
|
||||
it("allows forcing the throttled tsgo policy on roomy hosts", () => {
|
||||
const { args, env } = applyLocalTsgoPolicy(
|
||||
[],
|
||||
makeEnv({
|
||||
OPENCLAW_LOCAL_CHECK_MODE: "throttled",
|
||||
}),
|
||||
ROOMY_HOST,
|
||||
);
|
||||
|
||||
expect(args).toEqual([
|
||||
"--declaration",
|
||||
"false",
|
||||
"--incremental",
|
||||
"--tsBuildInfoFile",
|
||||
".artifacts/tsgo-cache/root.tsbuildinfo",
|
||||
"--singleThreaded",
|
||||
"--checkers",
|
||||
"1",
|
||||
]);
|
||||
expect(env.GOGC).toBe("30");
|
||||
expect(env.GOMEMLIMIT).toBe("3GiB");
|
||||
});
|
||||
|
||||
it("allows forcing full-speed tsgo runs on roomy hosts", () => {
|
||||
const { args, env } = applyLocalTsgoPolicy(
|
||||
[],
|
||||
makeEnv({
|
||||
OPENCLAW_LOCAL_CHECK_MODE: "full",
|
||||
}),
|
||||
ROOMY_HOST,
|
||||
);
|
||||
|
||||
expect(args).toEqual([
|
||||
"--declaration",
|
||||
"false",
|
||||
"--incremental",
|
||||
"--tsBuildInfoFile",
|
||||
".artifacts/tsgo-cache/root.tsbuildinfo",
|
||||
]);
|
||||
expect(env.GOGC).toBeUndefined();
|
||||
expect(env.GOMEMLIMIT).toBeUndefined();
|
||||
});
|
||||
|
||||
it("skips the heavy-check lock for tsgo metadata commands", () => {
|
||||
expect(shouldAcquireLocalHeavyCheckLockForTsgo(["--help"])).toBe(false);
|
||||
expect(shouldAcquireLocalHeavyCheckLockForTsgo(["-h"])).toBe(false);
|
||||
expect(shouldAcquireLocalHeavyCheckLockForTsgo(["--version"])).toBe(false);
|
||||
expect(shouldAcquireLocalHeavyCheckLockForTsgo(["-v"])).toBe(false);
|
||||
expect(shouldAcquireLocalHeavyCheckLockForTsgo(["--init"])).toBe(false);
|
||||
expect(shouldAcquireLocalHeavyCheckLockForTsgo(["--showConfig"])).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps the heavy-check lock for real tsgo runs", () => {
|
||||
expect(shouldAcquireLocalHeavyCheckLockForTsgo([])).toBe(true);
|
||||
expect(shouldAcquireLocalHeavyCheckLockForTsgo(["--extendedDiagnostics"])).toBe(true);
|
||||
});
|
||||
|
||||
it("allows forcing the tsgo lock back on", () => {
|
||||
expect(
|
||||
shouldAcquireLocalHeavyCheckLockForTsgo(
|
||||
["--help"],
|
||||
makeEnv({ OPENCLAW_TSGO_FORCE_LOCK: "1" }),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("serializes local oxlint runs onto one thread on constrained hosts", () => {
|
||||
const { args } = applyLocalOxlintPolicy([], makeEnv(), CONSTRAINED_HOST);
|
||||
|
||||
expect(args).toEqual([
|
||||
"--type-aware",
|
||||
"--tsconfig",
|
||||
"tsconfig.oxlint.json",
|
||||
"--report-unused-disable-directives-severity",
|
||||
"error",
|
||||
"--threads=1",
|
||||
]);
|
||||
});
|
||||
|
||||
it("defaults local oxlint to one thread on roomy hosts", () => {
|
||||
const { args } = applyLocalOxlintPolicy([], makeEnv(), ROOMY_HOST);
|
||||
|
||||
expect(args).toEqual([
|
||||
"--type-aware",
|
||||
"--tsconfig",
|
||||
"tsconfig.oxlint.json",
|
||||
"--report-unused-disable-directives-severity",
|
||||
"error",
|
||||
"--threads=1",
|
||||
]);
|
||||
});
|
||||
|
||||
it("allows forcing full-speed oxlint runs on roomy hosts", () => {
|
||||
const { args } = applyLocalOxlintPolicy(
|
||||
[],
|
||||
makeEnv({
|
||||
OPENCLAW_LOCAL_CHECK_MODE: "full",
|
||||
}),
|
||||
ROOMY_HOST,
|
||||
);
|
||||
|
||||
expect(args).toEqual([
|
||||
"--type-aware",
|
||||
"--tsconfig",
|
||||
"tsconfig.oxlint.json",
|
||||
"--report-unused-disable-directives-severity",
|
||||
"error",
|
||||
]);
|
||||
});
|
||||
|
||||
it("skips the heavy-check lock for explicit oxlint file targets", () => {
|
||||
const cwd = createTempDir("openclaw-oxlint-lock-skip-");
|
||||
const target = path.join(cwd, "sample.ts");
|
||||
fs.writeFileSync(target, "export const ok = true;\n", "utf8");
|
||||
|
||||
expect(
|
||||
shouldAcquireLocalHeavyCheckLockForOxlint(["--type-aware", "--", "sample.ts"], { cwd }),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("skips the heavy-check lock for oxlint metadata commands", () => {
|
||||
expect(shouldAcquireLocalHeavyCheckLockForOxlint(["--help"])).toBe(false);
|
||||
expect(shouldAcquireLocalHeavyCheckLockForOxlint(["-h"])).toBe(false);
|
||||
expect(shouldAcquireLocalHeavyCheckLockForOxlint(["--version"])).toBe(false);
|
||||
expect(shouldAcquireLocalHeavyCheckLockForOxlint(["-V"])).toBe(false);
|
||||
expect(shouldAcquireLocalHeavyCheckLockForOxlint(["--rules"])).toBe(false);
|
||||
expect(shouldAcquireLocalHeavyCheckLockForOxlint(["--print-config"])).toBe(false);
|
||||
expect(shouldAcquireLocalHeavyCheckLockForOxlint(["--init"])).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps the heavy-check lock for directory targets and broad oxlint runs", () => {
|
||||
const cwd = createTempDir("openclaw-oxlint-lock-keep-");
|
||||
fs.mkdirSync(path.join(cwd, "src"), { recursive: true });
|
||||
fs.writeFileSync(path.join(cwd, "src", "sample.ts"), "export const ok = true;\n", "utf8");
|
||||
|
||||
expect(shouldAcquireLocalHeavyCheckLockForOxlint(["--type-aware", "--", "src"], { cwd })).toBe(
|
||||
true,
|
||||
);
|
||||
expect(shouldAcquireLocalHeavyCheckLockForOxlint(["--type-aware"], { cwd })).toBe(true);
|
||||
});
|
||||
|
||||
it("allows forcing the oxlint lock back on", () => {
|
||||
const cwd = createTempDir("openclaw-oxlint-lock-force-");
|
||||
fs.writeFileSync(path.join(cwd, "sample.ts"), "export const ok = true;\n", "utf8");
|
||||
|
||||
expect(
|
||||
shouldAcquireLocalHeavyCheckLockForOxlint(["--type-aware", "--", "sample.ts"], {
|
||||
cwd,
|
||||
env: makeEnv({ OPENCLAW_OXLINT_FORCE_LOCK: "1" }),
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("reclaims stale local heavy-check locks from dead pids", () => {
|
||||
const cwd = createTempDir("openclaw-local-heavy-check-");
|
||||
const commonDir = path.join(cwd, ".git");
|
||||
const lockDir = path.join(commonDir, "openclaw-local-checks", "heavy-check.lock");
|
||||
fs.mkdirSync(lockDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(lockDir, "owner.json"),
|
||||
`${JSON.stringify({
|
||||
pid: 999_999_999,
|
||||
tool: "tsgo",
|
||||
cwd,
|
||||
})}\n`,
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const release = acquireLocalHeavyCheckLockSync({
|
||||
cwd,
|
||||
env: makeEnv(),
|
||||
toolName: "oxlint",
|
||||
});
|
||||
|
||||
const owner = JSON.parse(fs.readFileSync(path.join(lockDir, "owner.json"), "utf8"));
|
||||
expect(owner.pid).toBe(process.pid);
|
||||
expect(owner.tool).toBe("oxlint");
|
||||
|
||||
release();
|
||||
expect(fs.existsSync(lockDir)).toBe(false);
|
||||
});
|
||||
|
||||
it("cleans up stale legacy test locks when acquiring the shared heavy-check lock", () => {
|
||||
const cwd = createTempDir("openclaw-local-heavy-check-legacy-");
|
||||
const commonDir = path.join(cwd, ".git");
|
||||
const locksDir = path.join(commonDir, "openclaw-local-checks");
|
||||
const legacyLockDir = path.join(locksDir, "test.lock");
|
||||
const heavyCheckLockDir = path.join(locksDir, "heavy-check.lock");
|
||||
fs.mkdirSync(legacyLockDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(legacyLockDir, "owner.json"),
|
||||
`${JSON.stringify({
|
||||
pid: 999_999_999,
|
||||
tool: "test",
|
||||
cwd,
|
||||
})}\n`,
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const release = acquireLocalHeavyCheckLockSync({
|
||||
cwd,
|
||||
env: makeEnv(),
|
||||
toolName: "oxlint",
|
||||
});
|
||||
|
||||
expect(fs.existsSync(legacyLockDir)).toBe(false);
|
||||
expect(fs.existsSync(heavyCheckLockDir)).toBe(true);
|
||||
|
||||
release();
|
||||
expect(fs.existsSync(heavyCheckLockDir)).toBe(false);
|
||||
});
|
||||
});
|
||||
141
openclaw/test/scripts/npm-runner.test.ts
Normal file
141
openclaw/test/scripts/npm-runner.test.ts
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveNpmRunner } from "../../scripts/npm-runner.mjs";
|
||||
|
||||
describe("resolveNpmRunner", () => {
|
||||
it("anchors npm staging to the active node toolchain when npm-cli.js exists", () => {
|
||||
const execPath = "/Users/test/.nodenv/versions/24.13.0/bin/node";
|
||||
const expectedNpmCliPath = path.posix.resolve(
|
||||
path.posix.dirname(execPath),
|
||||
"../lib/node_modules/npm/bin/npm-cli.js",
|
||||
);
|
||||
|
||||
const runner = resolveNpmRunner({
|
||||
execPath,
|
||||
env: {},
|
||||
existsSync: (candidate) => candidate === expectedNpmCliPath,
|
||||
platform: "darwin",
|
||||
});
|
||||
|
||||
expect(runner).toEqual({
|
||||
command: execPath,
|
||||
args: [expectedNpmCliPath],
|
||||
shell: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("anchors Windows npm staging to the adjacent npm-cli.js without a shell", () => {
|
||||
const execPath = "C:\\nodejs\\node.exe";
|
||||
const expectedNpmCliPath = path.win32.resolve(
|
||||
path.win32.dirname(execPath),
|
||||
"node_modules/npm/bin/npm-cli.js",
|
||||
);
|
||||
|
||||
const runner = resolveNpmRunner({
|
||||
execPath,
|
||||
env: {},
|
||||
existsSync: (candidate) => candidate === expectedNpmCliPath,
|
||||
platform: "win32",
|
||||
});
|
||||
|
||||
expect(runner).toEqual({
|
||||
command: execPath,
|
||||
args: [expectedNpmCliPath],
|
||||
shell: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses an adjacent npm.exe on Windows without a shell", () => {
|
||||
const execPath = "C:\\nodejs\\node.exe";
|
||||
const expectedNpmExePath = path.win32.resolve(path.win32.dirname(execPath), "npm.exe");
|
||||
|
||||
const runner = resolveNpmRunner({
|
||||
execPath,
|
||||
env: {},
|
||||
existsSync: (candidate) => candidate === expectedNpmExePath,
|
||||
npmArgs: ["install", "--silent"],
|
||||
platform: "win32",
|
||||
});
|
||||
|
||||
expect(runner).toEqual({
|
||||
command: expectedNpmExePath,
|
||||
args: ["install", "--silent"],
|
||||
shell: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("wraps an adjacent npm.cmd via cmd.exe without enabling shell mode", () => {
|
||||
const execPath = "C:\\nodejs\\node.exe";
|
||||
const npmCmdPath = path.win32.resolve(path.win32.dirname(execPath), "npm.cmd");
|
||||
|
||||
const runner = resolveNpmRunner({
|
||||
comSpec: "C:\\Windows\\System32\\cmd.exe",
|
||||
execPath,
|
||||
env: {},
|
||||
existsSync: (candidate) => candidate === npmCmdPath,
|
||||
npmArgs: ["install", "--omit=dev"],
|
||||
platform: "win32",
|
||||
});
|
||||
|
||||
expect(runner).toEqual({
|
||||
command: "C:\\Windows\\System32\\cmd.exe",
|
||||
args: ["/d", "/s", "/c", `${npmCmdPath} install --omit=dev`],
|
||||
shell: false,
|
||||
windowsVerbatimArguments: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("escapes caret semver specs when invoking npm.cmd through cmd.exe", () => {
|
||||
const execPath = "C:\\nodejs\\node.exe";
|
||||
const npmCmdPath = path.win32.resolve(path.win32.dirname(execPath), "npm.cmd");
|
||||
|
||||
const runner = resolveNpmRunner({
|
||||
comSpec: "C:\\Windows\\System32\\cmd.exe",
|
||||
execPath,
|
||||
env: {},
|
||||
existsSync: (candidate) => candidate === npmCmdPath,
|
||||
npmArgs: ["install", "@slack/bolt@^4.6.0"],
|
||||
platform: "win32",
|
||||
});
|
||||
|
||||
expect(runner).toEqual({
|
||||
command: "C:\\Windows\\System32\\cmd.exe",
|
||||
args: ["/d", "/s", "/c", `${npmCmdPath} install @slack/bolt@^^4.6.0`],
|
||||
shell: false,
|
||||
windowsVerbatimArguments: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("prefixes PATH with the active node dir when falling back to bare npm", () => {
|
||||
expect(
|
||||
resolveNpmRunner({
|
||||
execPath: "/tmp/node",
|
||||
env: {
|
||||
PATH: "/usr/bin:/bin",
|
||||
},
|
||||
existsSync: () => false,
|
||||
platform: "linux",
|
||||
}),
|
||||
).toEqual({
|
||||
command: "npm",
|
||||
args: [],
|
||||
shell: false,
|
||||
env: {
|
||||
PATH: `/tmp${path.delimiter}/usr/bin:/bin`,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("fails closed on Windows when no toolchain-local npm CLI exists", () => {
|
||||
expect(() =>
|
||||
resolveNpmRunner({
|
||||
execPath: "C:\\node\\node.exe",
|
||||
env: {
|
||||
Path: "C:\\Windows\\System32",
|
||||
},
|
||||
existsSync: () => false,
|
||||
platform: "win32",
|
||||
}),
|
||||
).toThrow("OpenClaw refuses to shell out to bare npm on Windows");
|
||||
});
|
||||
});
|
||||
486
openclaw/test/scripts/openclaw-cross-os-release-checks.test.ts
Normal file
486
openclaw/test/scripts/openclaw-cross-os-release-checks.test.ts
Normal file
|
|
@ -0,0 +1,486 @@
|
|||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { createServer as createNetServer } from "node:net";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildWindowsDevUpdateToolchainCheckScript,
|
||||
buildWindowsFreshShellVersionCheckScript,
|
||||
buildWindowsPathBootstrapScript,
|
||||
canConnectToLoopbackPort,
|
||||
buildDiscordSmokeGuildsConfig,
|
||||
buildRealUpdateEnv,
|
||||
isImmutableReleaseRef,
|
||||
looksLikeReleaseVersionRef,
|
||||
normalizeRequestedRef,
|
||||
normalizeWindowsCommandShimPath,
|
||||
normalizeWindowsInstalledCliPath,
|
||||
parseArgs,
|
||||
packageHasScript,
|
||||
readInstalledVersion,
|
||||
readRunnerOverrideEnv,
|
||||
resolveExplicitBaselineVersion,
|
||||
resolveDevUpdateVerificationRef,
|
||||
resolveInstalledPrefixDirFromCliPath,
|
||||
resolvePublishedInstallerUrl,
|
||||
resolveRequestedSuites,
|
||||
resolveRunnerMatrix,
|
||||
resolveStaticFileContentType,
|
||||
shouldExerciseManagedGatewayLifecycleAfterInstall,
|
||||
shouldSkipInstallerDaemonHealthCheck,
|
||||
shouldStopManagedGatewayBeforeManualFallback,
|
||||
shouldRunMainChannelDevUpdate,
|
||||
shouldUseManagedGatewayForInstallerRuntime,
|
||||
shouldUseManagedGatewayService,
|
||||
verifyDevUpdateStatus,
|
||||
} from "../../scripts/openclaw-cross-os-release-checks.ts";
|
||||
|
||||
describe("scripts/openclaw-cross-os-release-checks", () => {
|
||||
it("treats explicit empty-string args as values instead of boolean flags", () => {
|
||||
expect(parseArgs(["--ubuntu-runner", "", "--mode", "both"])).toEqual({
|
||||
"ubuntu-runner": "",
|
||||
mode: "both",
|
||||
});
|
||||
});
|
||||
|
||||
it("detects release refs and keeps branch refs out of release-only logic", () => {
|
||||
expect(looksLikeReleaseVersionRef("2026.4.5")).toBe(true);
|
||||
expect(looksLikeReleaseVersionRef("refs/tags/v2026.4.5-beta.1")).toBe(true);
|
||||
expect(looksLikeReleaseVersionRef("v2026.4.5-beta.1")).toBe(true);
|
||||
expect(looksLikeReleaseVersionRef("v2026.4.7-1")).toBe(true);
|
||||
expect(looksLikeReleaseVersionRef("main")).toBe(false);
|
||||
expect(looksLikeReleaseVersionRef("codex/cross-os-release-checks")).toBe(false);
|
||||
});
|
||||
|
||||
it("normalizes full Git refs before suite and update decisions", () => {
|
||||
expect(normalizeRequestedRef(" refs/heads/main ")).toBe("main");
|
||||
expect(normalizeRequestedRef("refs/tags/v2026.4.14")).toBe("v2026.4.14");
|
||||
expect(isImmutableReleaseRef("refs/tags/test-tag")).toBe(true);
|
||||
expect(resolveRequestedSuites("both", "refs/tags/v2026.4.14")).toEqual([
|
||||
"packaged-fresh",
|
||||
"installer-fresh",
|
||||
"packaged-upgrade",
|
||||
]);
|
||||
expect(resolveRequestedSuites("both", "refs/tags/test-tag")).toEqual([
|
||||
"packaged-fresh",
|
||||
"installer-fresh",
|
||||
"packaged-upgrade",
|
||||
]);
|
||||
expect(shouldRunMainChannelDevUpdate("refs/heads/main")).toBe(true);
|
||||
expect(shouldRunMainChannelDevUpdate("refs/tags/main")).toBe(false);
|
||||
});
|
||||
|
||||
it("skips the dev-update suite for immutable release refs", () => {
|
||||
expect(resolveRequestedSuites("both", "v2026.4.5")).toEqual([
|
||||
"packaged-fresh",
|
||||
"installer-fresh",
|
||||
"packaged-upgrade",
|
||||
]);
|
||||
});
|
||||
|
||||
it("skips dev-update for non-main branch validation refs", () => {
|
||||
expect(resolveRequestedSuites("both", "codex/cross-os-release-checks")).toEqual([
|
||||
"packaged-fresh",
|
||||
"installer-fresh",
|
||||
"packaged-upgrade",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps dev-update enabled for main validation refs", () => {
|
||||
expect(resolveRequestedSuites("both", "main")).toEqual([
|
||||
"packaged-fresh",
|
||||
"installer-fresh",
|
||||
"packaged-upgrade",
|
||||
"dev-update",
|
||||
]);
|
||||
});
|
||||
|
||||
it("skips dev-update for pinned commit refs", () => {
|
||||
expect(resolveRequestedSuites("both", "08753a1d793c040b101c8a26c43445dbbab14995")).toEqual([
|
||||
"packaged-fresh",
|
||||
"installer-fresh",
|
||||
"packaged-upgrade",
|
||||
]);
|
||||
});
|
||||
|
||||
it("builds a suite-aware runner matrix with the beefy Windows default", () => {
|
||||
const matrix = resolveRunnerMatrix({
|
||||
mode: "both",
|
||||
ref: "main",
|
||||
ubuntuRunner: "",
|
||||
windowsRunner: "",
|
||||
macosRunner: "",
|
||||
varUbuntuRunner: "",
|
||||
varWindowsRunner: "",
|
||||
varMacosRunner: "",
|
||||
});
|
||||
|
||||
expect(matrix.include).toHaveLength(12);
|
||||
expect(matrix.include).toContainEqual(
|
||||
expect.objectContaining({
|
||||
os_id: "windows",
|
||||
runner: "blacksmith-32vcpu-windows-2025",
|
||||
suite: "dev-update",
|
||||
lane: "upgrade",
|
||||
}),
|
||||
);
|
||||
expect(matrix.include).toContainEqual(
|
||||
expect.objectContaining({
|
||||
os_id: "ubuntu",
|
||||
suite: "installer-fresh",
|
||||
lane: "fresh",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("can rebuild the Windows PATH with or without current-process entries", () => {
|
||||
expect(buildWindowsPathBootstrapScript()).toContain("@($userPath, $machinePath, $env:Path)");
|
||||
const persistedOnlyScript = buildWindowsPathBootstrapScript({
|
||||
includeCurrentProcessPath: false,
|
||||
});
|
||||
expect(persistedOnlyScript).toContain("@($userPath, $machinePath)");
|
||||
expect(persistedOnlyScript).not.toContain("@($userPath, $machinePath, $env:Path)");
|
||||
});
|
||||
|
||||
it("prefers the freshly installed Windows CLI under npm's prefix before PATH lookup", () => {
|
||||
const script = buildWindowsFreshShellVersionCheckScript({
|
||||
expectedNeedle: "2026.4.14",
|
||||
});
|
||||
expect(script).toContain(buildWindowsPathBootstrapScript());
|
||||
expect(script).not.toContain(
|
||||
buildWindowsPathBootstrapScript({ includeCurrentProcessPath: false }),
|
||||
);
|
||||
expect(script).toContain("Get-Command npm.cmd -ErrorAction SilentlyContinue");
|
||||
expect(script).toContain('$env:Path = "$npmPrefix;$env:Path"');
|
||||
expect(script).toContain("(Join-Path $npmPrefix 'openclaw.cmd')");
|
||||
expect(script).toContain("$cmd = Get-Command openclaw -ErrorAction Stop");
|
||||
});
|
||||
|
||||
it("keeps Windows dev-update toolchain checks compatible with setup-node PATH shims", () => {
|
||||
const script = buildWindowsDevUpdateToolchainCheckScript();
|
||||
expect(script).toContain(buildWindowsPathBootstrapScript());
|
||||
expect(script).not.toContain(
|
||||
buildWindowsPathBootstrapScript({ includeCurrentProcessPath: false }),
|
||||
);
|
||||
expect(script).toContain("$pnpmPath = Resolve-CommandPath 'pnpm'");
|
||||
expect(script).toContain("$corepackPath = Resolve-CommandPath 'corepack'");
|
||||
expect(script).toContain("$npmPath = Resolve-CommandPath 'npm'");
|
||||
});
|
||||
|
||||
it("prefers workflow-injected runner override env names over legacy ones", () => {
|
||||
expect(
|
||||
readRunnerOverrideEnv({
|
||||
VAR_UBUNTU_RUNNER: "workflow-linux",
|
||||
VAR_WINDOWS_RUNNER: "workflow-windows",
|
||||
VAR_MACOS_RUNNER: "workflow-macos",
|
||||
OPENCLAW_RELEASE_CHECKS_UBUNTU_RUNNER: "legacy-linux",
|
||||
OPENCLAW_RELEASE_CHECKS_WINDOWS_RUNNER: "legacy-windows",
|
||||
OPENCLAW_RELEASE_CHECKS_MACOS_RUNNER: "legacy-macos",
|
||||
}),
|
||||
).toEqual({
|
||||
varUbuntuRunner: "workflow-linux",
|
||||
varWindowsRunner: "workflow-windows",
|
||||
varMacosRunner: "workflow-macos",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to legacy runner override env names when workflow vars are blank", () => {
|
||||
expect(
|
||||
readRunnerOverrideEnv({
|
||||
VAR_UBUNTU_RUNNER: "",
|
||||
VAR_WINDOWS_RUNNER: " ",
|
||||
VAR_MACOS_RUNNER: "",
|
||||
OPENCLAW_RELEASE_CHECKS_UBUNTU_RUNNER: "legacy-linux",
|
||||
OPENCLAW_RELEASE_CHECKS_WINDOWS_RUNNER: "legacy-windows",
|
||||
OPENCLAW_RELEASE_CHECKS_MACOS_RUNNER: "legacy-macos",
|
||||
}),
|
||||
).toEqual({
|
||||
varUbuntuRunner: "legacy-linux",
|
||||
varWindowsRunner: "legacy-windows",
|
||||
varMacosRunner: "legacy-macos",
|
||||
});
|
||||
});
|
||||
|
||||
it("serves installer scripts as UTF-8 text and package payloads as binary", () => {
|
||||
expect(resolveStaticFileContentType("scripts/install.sh")).toBe("text/plain; charset=utf-8");
|
||||
expect(resolveStaticFileContentType("scripts/install.ps1")).toBe("text/plain; charset=utf-8");
|
||||
expect(resolveStaticFileContentType("openclaw-2026.4.14.tgz")).toBe("application/octet-stream");
|
||||
});
|
||||
|
||||
it("uses the published installer URLs for native installer lanes", () => {
|
||||
expect(resolvePublishedInstallerUrl("darwin")).toBe("https://openclaw.ai/install.sh");
|
||||
expect(resolvePublishedInstallerUrl("linux")).toBe("https://openclaw.ai/install.sh");
|
||||
expect(resolvePublishedInstallerUrl("win32")).toBe("https://openclaw.ai/install.ps1");
|
||||
});
|
||||
|
||||
it("uses managed gateway services only on native Windows runners", () => {
|
||||
expect(shouldUseManagedGatewayService("win32")).toBe(true);
|
||||
expect(shouldUseManagedGatewayService("darwin")).toBe(false);
|
||||
expect(shouldUseManagedGatewayService("linux")).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps the Windows installer runtime on the manual gateway after managed lifecycle checks", () => {
|
||||
expect(shouldExerciseManagedGatewayLifecycleAfterInstall("win32")).toBe(true);
|
||||
expect(shouldUseManagedGatewayForInstallerRuntime("win32")).toBe(false);
|
||||
expect(shouldExerciseManagedGatewayLifecycleAfterInstall("darwin")).toBe(false);
|
||||
expect(shouldUseManagedGatewayForInstallerRuntime("darwin")).toBe(false);
|
||||
});
|
||||
|
||||
it("stops the managed gateway before the manual fallback only on Windows", () => {
|
||||
expect(shouldStopManagedGatewayBeforeManualFallback("win32")).toBe(true);
|
||||
expect(shouldStopManagedGatewayBeforeManualFallback("darwin")).toBe(false);
|
||||
expect(shouldStopManagedGatewayBeforeManualFallback("linux")).toBe(false);
|
||||
});
|
||||
|
||||
it("skips daemon health during installed onboarding only on native Windows", () => {
|
||||
expect(shouldSkipInstallerDaemonHealthCheck("win32")).toBe(true);
|
||||
expect(shouldSkipInstallerDaemonHealthCheck("darwin")).toBe(false);
|
||||
expect(shouldSkipInstallerDaemonHealthCheck("linux")).toBe(false);
|
||||
});
|
||||
|
||||
it("normalizes Windows installed CLI paths to the cmd shim", () => {
|
||||
expect(
|
||||
normalizeWindowsInstalledCliPath(
|
||||
String.raw`C:\Users\runner\AppData\Roaming\npm\openclaw.ps1`,
|
||||
),
|
||||
).toBe(String.raw`C:\Users\runner\AppData\Roaming\npm\openclaw.cmd`);
|
||||
expect(
|
||||
normalizeWindowsInstalledCliPath(
|
||||
String.raw`C:\Users\runner\AppData\Roaming\npm\openclaw.cmd`,
|
||||
),
|
||||
).toBe(String.raw`C:\Users\runner\AppData\Roaming\npm\openclaw.cmd`);
|
||||
});
|
||||
|
||||
it("normalizes generic Windows PowerShell shims to cmd shims", () => {
|
||||
expect(normalizeWindowsCommandShimPath(String.raw`C:\Program Files\nodejs\pnpm.ps1`)).toBe(
|
||||
String.raw`C:\Program Files\nodejs\pnpm.cmd`,
|
||||
);
|
||||
expect(normalizeWindowsCommandShimPath(String.raw`C:\Program Files\nodejs\corepack.ps1`)).toBe(
|
||||
String.raw`C:\Program Files\nodejs\corepack.cmd`,
|
||||
);
|
||||
expect(normalizeWindowsCommandShimPath(String.raw`C:\Program Files\nodejs\node.exe`)).toBe(
|
||||
String.raw`C:\Program Files\nodejs\node.exe`,
|
||||
);
|
||||
});
|
||||
|
||||
it("derives the installed prefix from resolved CLI paths", () => {
|
||||
expect(
|
||||
resolveInstalledPrefixDirFromCliPath(
|
||||
String.raw`C:\Users\runner\AppData\Roaming\npm\openclaw.ps1`,
|
||||
"win32",
|
||||
),
|
||||
).toBe(String.raw`C:\Users\runner\AppData\Roaming\npm`);
|
||||
expect(
|
||||
resolveInstalledPrefixDirFromCliPath("/Users/runner/.npm-global/bin/openclaw", "darwin"),
|
||||
).toBe("/Users/runner/.npm-global");
|
||||
});
|
||||
|
||||
it("detects whether a managed gateway listener is still reachable on loopback", async () => {
|
||||
const server = createNetServer();
|
||||
await new Promise((resolvePromise) => {
|
||||
server.listen(0, "127.0.0.1", resolvePromise);
|
||||
});
|
||||
const address = server.address();
|
||||
const port = typeof address === "object" && address ? address.port : 0;
|
||||
expect(await canConnectToLoopbackPort(port)).toBe(true);
|
||||
await new Promise((resolvePromise) => {
|
||||
server.close(resolvePromise);
|
||||
});
|
||||
expect(await canConnectToLoopbackPort(port)).toBe(false);
|
||||
});
|
||||
|
||||
it("writes Discord smoke config using the strict guild channel schema", () => {
|
||||
expect(buildDiscordSmokeGuildsConfig("guild-123", "channel-456")).toEqual({
|
||||
"guild-123": {
|
||||
channels: {
|
||||
"channel-456": {
|
||||
enabled: true,
|
||||
requireMention: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the dev-update lane for main only", () => {
|
||||
expect(shouldRunMainChannelDevUpdate("main")).toBe(true);
|
||||
expect(shouldRunMainChannelDevUpdate("08753a1d793c040b101c8a26c43445dbbab14995")).toBe(false);
|
||||
expect(shouldRunMainChannelDevUpdate(" codex/cross-os-release-checks-full-native-e2e ")).toBe(
|
||||
false,
|
||||
);
|
||||
expect(shouldRunMainChannelDevUpdate("v2026.4.14")).toBe(false);
|
||||
});
|
||||
|
||||
it("verifies main dev updates against the prepared source sha when available", () => {
|
||||
expect(resolveDevUpdateVerificationRef("main")).toBe("main");
|
||||
expect(
|
||||
resolveDevUpdateVerificationRef("main", "08753a1d793c040b101c8a26c43445dbbab14995"),
|
||||
).toBe("08753a1d793c040b101c8a26c43445dbbab14995");
|
||||
expect(
|
||||
resolveDevUpdateVerificationRef(
|
||||
"refs/heads/main",
|
||||
"08753a1d793c040b101c8a26c43445dbbab14995",
|
||||
),
|
||||
).toBe("08753a1d793c040b101c8a26c43445dbbab14995");
|
||||
expect(resolveDevUpdateVerificationRef("codex/cross-os-release-checks-full-native-e2e")).toBe(
|
||||
"codex/cross-os-release-checks-full-native-e2e",
|
||||
);
|
||||
});
|
||||
|
||||
it("drops the bundled plugin postinstall disable flag for real updater calls", () => {
|
||||
expect(
|
||||
buildRealUpdateEnv({
|
||||
FOO: "bar",
|
||||
OPENCLAW_DISABLE_BUNDLED_PLUGIN_POSTINSTALL: "1",
|
||||
}),
|
||||
).toEqual({
|
||||
FOO: "bar",
|
||||
});
|
||||
});
|
||||
|
||||
it("only treats pinned baseline specs as exact installer version assertions", () => {
|
||||
expect(resolveExplicitBaselineVersion("")).toBe("");
|
||||
expect(resolveExplicitBaselineVersion("openclaw@latest")).toBe("");
|
||||
expect(resolveExplicitBaselineVersion("openclaw@2026.4.10")).toBe("2026.4.10");
|
||||
expect(resolveExplicitBaselineVersion("2026.4.10")).toBe("2026.4.10");
|
||||
});
|
||||
|
||||
it("reads an installed baseline version without requiring build metadata", () => {
|
||||
const prefixDir = mkdtempSync(join(tmpdir(), "openclaw-cross-os-installed-version-"));
|
||||
try {
|
||||
const packageRoot =
|
||||
process.platform === "win32"
|
||||
? join(prefixDir, "node_modules", "openclaw")
|
||||
: join(prefixDir, "lib", "node_modules", "openclaw");
|
||||
mkdirSync(packageRoot, { recursive: true });
|
||||
writeFileSync(
|
||||
join(packageRoot, "package.json"),
|
||||
JSON.stringify({
|
||||
name: "openclaw",
|
||||
version: "2026.4.10",
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
expect(readInstalledVersion(prefixDir)).toBe("2026.4.10");
|
||||
} finally {
|
||||
rmSync(prefixDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("treats missing package scripts as optional in older refs", () => {
|
||||
const packageRoot = mkdtempSync(join(tmpdir(), "openclaw-cross-os-scripts-"));
|
||||
try {
|
||||
writeFileSync(
|
||||
join(packageRoot, "package.json"),
|
||||
JSON.stringify({
|
||||
name: "openclaw",
|
||||
scripts: {
|
||||
build: "pnpm build",
|
||||
},
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
expect(packageHasScript(packageRoot, "build")).toBe(true);
|
||||
expect(packageHasScript(packageRoot, "ui:build")).toBe(false);
|
||||
} finally {
|
||||
rmSync(packageRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts a git main dev-channel update status payload", () => {
|
||||
expect(() =>
|
||||
verifyDevUpdateStatus(
|
||||
JSON.stringify({
|
||||
update: {
|
||||
installKind: "git",
|
||||
git: {
|
||||
branch: "main",
|
||||
},
|
||||
},
|
||||
channel: {
|
||||
value: "dev",
|
||||
},
|
||||
}),
|
||||
),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("accepts a git dev-channel payload for a requested non-main branch", () => {
|
||||
expect(() =>
|
||||
verifyDevUpdateStatus(
|
||||
JSON.stringify({
|
||||
update: {
|
||||
installKind: "git",
|
||||
git: {
|
||||
branch: "codex/cross-os-release-checks-full-native-e2e",
|
||||
sha: "08753a1d793c040b101c8a26c43445dbbab14995",
|
||||
},
|
||||
},
|
||||
channel: {
|
||||
value: "dev",
|
||||
},
|
||||
}),
|
||||
{ ref: "codex/cross-os-release-checks-full-native-e2e" },
|
||||
),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("accepts a git dev-channel payload pinned to a prepared source sha", () => {
|
||||
expect(() =>
|
||||
verifyDevUpdateStatus(
|
||||
JSON.stringify({
|
||||
update: {
|
||||
installKind: "git",
|
||||
git: {
|
||||
branch: "main",
|
||||
sha: "08753a1d793c040b101c8a26c43445dbbab14995",
|
||||
},
|
||||
},
|
||||
channel: {
|
||||
value: "dev",
|
||||
},
|
||||
}),
|
||||
{ ref: "08753a1d793c040b101c8a26c43445dbbab14995" },
|
||||
),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("accepts uppercase requested commit shas when update status reports lowercase", () => {
|
||||
expect(() =>
|
||||
verifyDevUpdateStatus(
|
||||
JSON.stringify({
|
||||
update: {
|
||||
installKind: "git",
|
||||
git: {
|
||||
sha: "08753a1d793c040b101c8a26c43445dbbab14995",
|
||||
},
|
||||
},
|
||||
channel: {
|
||||
value: "dev",
|
||||
},
|
||||
}),
|
||||
{ ref: "08753A1D793C040B101C8A26C43445DBBAB14995" },
|
||||
),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("rejects update status payloads that are not on dev/main git", () => {
|
||||
expect(() =>
|
||||
verifyDevUpdateStatus(
|
||||
JSON.stringify({
|
||||
update: {
|
||||
installKind: "package",
|
||||
git: {
|
||||
branch: "release",
|
||||
},
|
||||
},
|
||||
channel: {
|
||||
value: "stable",
|
||||
},
|
||||
}),
|
||||
),
|
||||
).toThrow("git install");
|
||||
});
|
||||
});
|
||||
61
openclaw/test/scripts/oxlint-config.test.ts
Normal file
61
openclaw/test/scripts/oxlint-config.test.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import fs from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
type OxlintConfig = {
|
||||
ignorePatterns?: string[];
|
||||
};
|
||||
|
||||
type OxlintTsconfig = {
|
||||
include?: string[];
|
||||
exclude?: string[];
|
||||
};
|
||||
|
||||
function readJson(path: string): unknown {
|
||||
return JSON.parse(fs.readFileSync(path, "utf8")) as unknown;
|
||||
}
|
||||
|
||||
describe("oxlint config", () => {
|
||||
it("includes bundled extensions in type-aware lint coverage", () => {
|
||||
const tsconfig = readJson<OxlintTsconfig>("tsconfig.oxlint.json");
|
||||
|
||||
expect(tsconfig.include).toContain("extensions/**/*");
|
||||
expect(tsconfig.exclude ?? []).not.toContain("extensions");
|
||||
});
|
||||
|
||||
it("includes scripts in root type-aware lint coverage", () => {
|
||||
const tsconfig = readJson<OxlintTsconfig>("tsconfig.oxlint.json");
|
||||
|
||||
expect(tsconfig.include).toContain("scripts/**/*");
|
||||
});
|
||||
|
||||
it("has a discoverable scripts tsconfig for type-aware linting", () => {
|
||||
const tsconfig = readJson<OxlintTsconfig>("scripts/tsconfig.json");
|
||||
|
||||
expect(tsconfig.include).toContain("**/*.ts");
|
||||
expect(tsconfig.exclude ?? []).not.toContain("**/*.ts");
|
||||
});
|
||||
|
||||
it("has a discoverable test tsconfig for type-aware linting", () => {
|
||||
const tsconfig = readJson<OxlintTsconfig>("test/tsconfig.json");
|
||||
|
||||
expect(tsconfig.include).toContain("**/*.ts");
|
||||
expect(tsconfig.exclude ?? []).not.toContain("**/*.ts");
|
||||
});
|
||||
|
||||
it("does not ignore the bundled extensions tree", () => {
|
||||
const config = readJson<OxlintConfig>(".oxlintrc.json");
|
||||
|
||||
expect(config.ignorePatterns ?? []).not.toContain("extensions/");
|
||||
});
|
||||
|
||||
it("keeps generated and vendored extension outputs ignored", () => {
|
||||
const config = readJson<OxlintConfig>(".oxlintrc.json");
|
||||
const ignorePatterns = config.ignorePatterns ?? [];
|
||||
|
||||
expect(ignorePatterns).toContain("**/node_modules/**");
|
||||
expect(ignorePatterns).toContain("**/dist/**");
|
||||
expect(ignorePatterns).toContain("**/build/**");
|
||||
expect(ignorePatterns).toContain("**/coverage/**");
|
||||
expect(ignorePatterns).toContain("**/.cache/**");
|
||||
});
|
||||
});
|
||||
14
openclaw/test/scripts/parallels-npm-update-smoke.test.ts
Normal file
14
openclaw/test/scripts/parallels-npm-update-smoke.test.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const SCRIPT_PATH = "scripts/e2e/parallels-npm-update-smoke.sh";
|
||||
|
||||
describe("parallels npm update smoke", () => {
|
||||
it("does not leave guard/server children attached to the wrapper", () => {
|
||||
const script = readFileSync(SCRIPT_PATH, "utf8");
|
||||
|
||||
expect(script).toContain('wait "$SERVER_PID" 2>/dev/null || true');
|
||||
expect(script).toContain(") >&2 &");
|
||||
expect(script).toContain('wait "$pid" 2>/dev/null || true');
|
||||
});
|
||||
});
|
||||
233
openclaw/test/scripts/pnpm-audit-prod.test.ts
Normal file
233
openclaw/test/scripts/pnpm-audit-prod.test.ts
Normal file
|
|
@ -0,0 +1,233 @@
|
|||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
collectProdResolvedPackagesFromLockfile,
|
||||
createBulkAdvisoryPayload,
|
||||
filterFindingsBySeverity,
|
||||
parseSnapshotKey,
|
||||
runPnpmAuditProd,
|
||||
stripVersionDecorators,
|
||||
} from "../../scripts/pre-commit/pnpm-audit-prod.mjs";
|
||||
|
||||
describe("pnpm-audit-prod", () => {
|
||||
it("parses scoped snapshot keys with peer suffixes", () => {
|
||||
expect(parseSnapshotKey("@scope/pkg@1.2.3(peer@4.5.6)")).toEqual({
|
||||
packageName: "@scope/pkg",
|
||||
reference: "1.2.3(peer@4.5.6)",
|
||||
version: "1.2.3",
|
||||
});
|
||||
});
|
||||
|
||||
it("strips peer and patch decorators from resolved versions", () => {
|
||||
expect(stripVersionDecorators("7.0.0-rc.9(patch_hash=abc123)(sharp@0.34.5)")).toBe(
|
||||
"7.0.0-rc.9",
|
||||
);
|
||||
expect(stripVersionDecorators("1.2.3")).toBe("1.2.3");
|
||||
});
|
||||
|
||||
it("collects the production graph from pnpm lockfile snapshots", () => {
|
||||
const lockfile = `lockfileVersion: '9.0'
|
||||
|
||||
importers:
|
||||
.:
|
||||
dependencies:
|
||||
pkg-a:
|
||||
version: 1.0.0
|
||||
devDependencies:
|
||||
dev-only:
|
||||
version: 9.9.9
|
||||
extensions/demo:
|
||||
dependencies:
|
||||
'@scope/pkg':
|
||||
version: 2.0.0(peer@4.0.0)
|
||||
workspace-lib:
|
||||
version: link:../../packages/workspace-lib
|
||||
|
||||
snapshots:
|
||||
pkg-a@1.0.0:
|
||||
dependencies:
|
||||
transitive: 3.0.0(patch_hash=abc123)
|
||||
transitive@3.0.0(patch_hash=abc123): {}
|
||||
'@scope/pkg@2.0.0(peer@4.0.0)':
|
||||
optionalDependencies:
|
||||
opt-dep: 4.0.0
|
||||
opt-dep@4.0.0: {}
|
||||
`;
|
||||
|
||||
const payload = createBulkAdvisoryPayload(collectProdResolvedPackagesFromLockfile(lockfile));
|
||||
expect(payload).toEqual({
|
||||
"@scope/pkg": ["2.0.0"],
|
||||
"opt-dep": ["4.0.0"],
|
||||
"pkg-a": ["1.0.0"],
|
||||
transitive: ["3.0.0"],
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves npm alias snapshots to the real package name", () => {
|
||||
const lockfile = `lockfileVersion: '9.0'
|
||||
|
||||
importers:
|
||||
.:
|
||||
dependencies:
|
||||
request:
|
||||
version: npm:@cypress/request@3.0.10
|
||||
|
||||
snapshots:
|
||||
'@cypress/request@3.0.10': {}
|
||||
`;
|
||||
|
||||
const payload = createBulkAdvisoryPayload(collectProdResolvedPackagesFromLockfile(lockfile));
|
||||
expect(payload).toEqual({
|
||||
"@cypress/request": ["3.0.10"],
|
||||
});
|
||||
});
|
||||
|
||||
it("reads inline importer dependency maps without repo dependencies", () => {
|
||||
const lockfile = `lockfileVersion: '9.0'
|
||||
|
||||
importers:
|
||||
.:
|
||||
dependencies:
|
||||
axios: {specifier: ^1.0.0, version: 1.0.0}
|
||||
'@scope/pkg': {'version': '2.0.0(peer@4.0.0)'}
|
||||
|
||||
snapshots:
|
||||
axios@1.0.0: {}
|
||||
'@scope/pkg@2.0.0(peer@4.0.0)': {}
|
||||
`;
|
||||
|
||||
const payload = createBulkAdvisoryPayload(collectProdResolvedPackagesFromLockfile(lockfile));
|
||||
expect(payload).toEqual({
|
||||
"@scope/pkg": ["2.0.0"],
|
||||
axios: ["1.0.0"],
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves quoted snapshot keys that contain tarball URLs", () => {
|
||||
const lockfile = `lockfileVersion: '9.0'
|
||||
|
||||
importers:
|
||||
.:
|
||||
dependencies:
|
||||
wrapper:
|
||||
version: 1.0.0
|
||||
|
||||
snapshots:
|
||||
wrapper@1.0.0:
|
||||
dependencies:
|
||||
libsignal: '@whiskeysockets/libsignal-node@https://codeload.github.com/whiskeysockets/libsignal-node/tar.gz/abc123'
|
||||
'@whiskeysockets/libsignal-node@https://codeload.github.com/whiskeysockets/libsignal-node/tar.gz/abc123':
|
||||
dependencies:
|
||||
curve25519-js: 0.0.4
|
||||
curve25519-js@0.0.4: {}
|
||||
`;
|
||||
|
||||
const payload = createBulkAdvisoryPayload(collectProdResolvedPackagesFromLockfile(lockfile));
|
||||
expect(payload).toEqual({
|
||||
"@whiskeysockets/libsignal-node": [
|
||||
"https://codeload.github.com/whiskeysockets/libsignal-node/tar.gz/abc123",
|
||||
],
|
||||
"curve25519-js": ["0.0.4"],
|
||||
wrapper: ["1.0.0"],
|
||||
});
|
||||
});
|
||||
|
||||
it("filters advisory findings by minimum severity", () => {
|
||||
const findings = filterFindingsBySeverity(
|
||||
{
|
||||
axios: [
|
||||
{
|
||||
id: "GHSA-low",
|
||||
severity: "moderate",
|
||||
title: "moderate issue",
|
||||
},
|
||||
{
|
||||
id: "GHSA-high",
|
||||
severity: "high",
|
||||
title: "high issue",
|
||||
url: "https://github.com/advisories/GHSA-high",
|
||||
},
|
||||
],
|
||||
},
|
||||
"high",
|
||||
);
|
||||
|
||||
expect(findings).toEqual([
|
||||
{
|
||||
id: "GHSA-high",
|
||||
packageName: "axios",
|
||||
severity: "high",
|
||||
title: "high issue",
|
||||
url: "https://github.com/advisories/GHSA-high",
|
||||
vulnerableVersions: null,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns a failing exit code when bulk advisories include high severity findings", async () => {
|
||||
const tempDir = await mkdtemp(path.join(tmpdir(), "openclaw-audit-prod-"));
|
||||
await writeFile(
|
||||
path.join(tempDir, "pnpm-lock.yaml"),
|
||||
`lockfileVersion: '9.0'
|
||||
|
||||
importers:
|
||||
.:
|
||||
dependencies:
|
||||
axios:
|
||||
version: 1.0.0
|
||||
|
||||
snapshots:
|
||||
axios@1.0.0: {}
|
||||
`,
|
||||
"utf8",
|
||||
);
|
||||
|
||||
try {
|
||||
const stdoutChunks: string[] = [];
|
||||
const stderrChunks: string[] = [];
|
||||
const exitCode = await runPnpmAuditProd({
|
||||
rootDir: tempDir,
|
||||
fetchImpl: async () =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
axios: [
|
||||
{
|
||||
id: "GHSA-test",
|
||||
severity: "high",
|
||||
title: "test issue",
|
||||
vulnerable_versions: "<=1.0.0",
|
||||
url: "https://github.com/advisories/GHSA-test",
|
||||
},
|
||||
],
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
},
|
||||
),
|
||||
stdout: {
|
||||
write(chunk: string) {
|
||||
stdoutChunks.push(chunk);
|
||||
return true;
|
||||
},
|
||||
} as NodeJS.WriteStream,
|
||||
stderr: {
|
||||
write(chunk: string) {
|
||||
stderrChunks.push(chunk);
|
||||
return true;
|
||||
},
|
||||
} as NodeJS.WriteStream,
|
||||
});
|
||||
|
||||
expect(exitCode).toBe(1);
|
||||
expect(stdoutChunks).toEqual([]);
|
||||
expect(stderrChunks.join("")).toContain("Found 1 high or higher advisories");
|
||||
} finally {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
165
openclaw/test/scripts/pnpm-runner.test.ts
Normal file
165
openclaw/test/scripts/pnpm-runner.test.ts
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createPnpmRunnerSpawnSpec, resolvePnpmRunner } from "../../scripts/pnpm-runner.mjs";
|
||||
|
||||
describe("resolvePnpmRunner", () => {
|
||||
it("uses npm_execpath when it points to a JS pnpm entrypoint", () => {
|
||||
expect(
|
||||
resolvePnpmRunner({
|
||||
npmExecPath: "/home/test/.cache/node/corepack/v1/pnpm/10.32.1/bin/pnpm.cjs",
|
||||
nodeExecPath: "/usr/local/bin/node",
|
||||
pnpmArgs: ["exec", "vitest", "run"],
|
||||
platform: "linux",
|
||||
}),
|
||||
).toEqual({
|
||||
command: "/usr/local/bin/node",
|
||||
args: [
|
||||
"/home/test/.cache/node/corepack/v1/pnpm/10.32.1/bin/pnpm.cjs",
|
||||
"exec",
|
||||
"vitest",
|
||||
"run",
|
||||
],
|
||||
shell: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses npm_execpath when it points to a shebang pnpm script", () => {
|
||||
const tempDir = mkdtempSync(path.join(os.tmpdir(), "pnpm-runner-"));
|
||||
const npmExecPath = path.join(tempDir, "pnpm");
|
||||
writeFileSync(npmExecPath, "#!/usr/bin/env node\nconsole.log('pnpm');\n");
|
||||
|
||||
try {
|
||||
expect(
|
||||
resolvePnpmRunner({
|
||||
npmExecPath,
|
||||
nodeExecPath: "/usr/local/bin/node",
|
||||
pnpmArgs: ["exec", "vitest", "run"],
|
||||
platform: "linux",
|
||||
}),
|
||||
).toEqual({
|
||||
command: "/usr/local/bin/node",
|
||||
args: [npmExecPath, "exec", "vitest", "run"],
|
||||
shell: false,
|
||||
});
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("prepends node args when launching pnpm through node", () => {
|
||||
expect(
|
||||
resolvePnpmRunner({
|
||||
npmExecPath: "/home/test/.cache/node/corepack/v1/pnpm/10.32.1/bin/pnpm.cjs",
|
||||
nodeArgs: ["--no-maglev"],
|
||||
nodeExecPath: "/usr/local/bin/node",
|
||||
pnpmArgs: ["exec", "vitest", "run"],
|
||||
platform: "linux",
|
||||
}),
|
||||
).toEqual({
|
||||
command: "/usr/local/bin/node",
|
||||
args: [
|
||||
"--no-maglev",
|
||||
"/home/test/.cache/node/corepack/v1/pnpm/10.32.1/bin/pnpm.cjs",
|
||||
"exec",
|
||||
"vitest",
|
||||
"run",
|
||||
],
|
||||
shell: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to bare pnpm when npm_execpath points to a native pnpm binary", () => {
|
||||
const tempDir = mkdtempSync(path.join(os.tmpdir(), "pnpm-runner-"));
|
||||
const npmExecPath = path.join(tempDir, "pnpm");
|
||||
writeFileSync(npmExecPath, Buffer.from([0x7f, 0x45, 0x4c, 0x46]));
|
||||
|
||||
try {
|
||||
expect(
|
||||
resolvePnpmRunner({
|
||||
npmExecPath,
|
||||
pnpmArgs: ["exec", "vitest", "run"],
|
||||
platform: "linux",
|
||||
}),
|
||||
).toEqual({
|
||||
command: "pnpm",
|
||||
args: ["exec", "vitest", "run"],
|
||||
shell: false,
|
||||
});
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("falls back to bare pnpm on non-Windows when npm_execpath is missing", () => {
|
||||
expect(
|
||||
resolvePnpmRunner({
|
||||
npmExecPath: "",
|
||||
pnpmArgs: ["exec", "vitest", "run"],
|
||||
platform: "linux",
|
||||
}),
|
||||
).toEqual({
|
||||
command: "pnpm",
|
||||
args: ["exec", "vitest", "run"],
|
||||
shell: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("wraps pnpm.cmd via cmd.exe on Windows when npm_execpath is unavailable", () => {
|
||||
expect(
|
||||
resolvePnpmRunner({
|
||||
comSpec: "C:\\Windows\\System32\\cmd.exe",
|
||||
npmExecPath: "",
|
||||
pnpmArgs: ["exec", "vitest", "run", "-t", "path with spaces"],
|
||||
platform: "win32",
|
||||
}),
|
||||
).toEqual({
|
||||
command: "C:\\Windows\\System32\\cmd.exe",
|
||||
args: ["/d", "/s", "/c", 'pnpm.cmd exec vitest run -t "path with spaces"'],
|
||||
shell: false,
|
||||
windowsVerbatimArguments: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("escapes caret arguments for Windows cmd.exe", () => {
|
||||
expect(
|
||||
resolvePnpmRunner({
|
||||
comSpec: "C:\\Windows\\System32\\cmd.exe",
|
||||
npmExecPath: "",
|
||||
pnpmArgs: ["exec", "vitest", "-t", "@scope/pkg@^1.2.3"],
|
||||
platform: "win32",
|
||||
}),
|
||||
).toEqual({
|
||||
command: "C:\\Windows\\System32\\cmd.exe",
|
||||
args: ["/d", "/s", "/c", "pnpm.cmd exec vitest -t @scope/pkg@^^1.2.3"],
|
||||
shell: false,
|
||||
windowsVerbatimArguments: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("builds a shared spawn spec with inherited stdio and env overrides", () => {
|
||||
const env = { PATH: "/custom/bin", FOO: "bar" };
|
||||
expect(
|
||||
createPnpmRunnerSpawnSpec({
|
||||
cwd: "/repo",
|
||||
detached: true,
|
||||
npmExecPath: "",
|
||||
pnpmArgs: ["exec", "vitest", "run"],
|
||||
platform: "linux",
|
||||
env,
|
||||
}),
|
||||
).toEqual({
|
||||
command: "pnpm",
|
||||
args: ["exec", "vitest", "run"],
|
||||
options: {
|
||||
cwd: "/repo",
|
||||
detached: true,
|
||||
stdio: "inherit",
|
||||
env,
|
||||
shell: false,
|
||||
windowsVerbatimArguments: undefined,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
736
openclaw/test/scripts/postinstall-bundled-plugins.test.ts
Normal file
736
openclaw/test/scripts/postinstall-bundled-plugins.test.ts
Normal file
|
|
@ -0,0 +1,736 @@
|
|||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
createNestedNpmInstallEnv,
|
||||
pruneInstalledPackageDist,
|
||||
discoverBundledPluginRuntimeDeps,
|
||||
pruneBundledPluginSourceNodeModules,
|
||||
restoreLegacyUpdaterCompatSidecars,
|
||||
runBundledPluginPostinstall,
|
||||
} from "../../scripts/postinstall-bundled-plugins.mjs";
|
||||
import { writePackageDistInventory } from "../../src/infra/package-dist-inventory.ts";
|
||||
import { createScriptTestHarness } from "./test-helpers.js";
|
||||
|
||||
const { createTempDirAsync } = createScriptTestHarness();
|
||||
|
||||
async function createExtensionsDir() {
|
||||
const root = await createTempDirAsync("openclaw-postinstall-");
|
||||
const extensionsDir = path.join(root, "dist", "extensions");
|
||||
await fs.mkdir(extensionsDir, { recursive: true });
|
||||
return extensionsDir;
|
||||
}
|
||||
|
||||
async function writePluginPackage(
|
||||
extensionsDir: string,
|
||||
pluginId: string,
|
||||
packageJson: Record<string, unknown>,
|
||||
) {
|
||||
const pluginDir = path.join(extensionsDir, pluginId);
|
||||
await fs.mkdir(pluginDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(pluginDir, "package.json"),
|
||||
`${JSON.stringify(packageJson, null, 2)}\n`,
|
||||
);
|
||||
const packageRoot =
|
||||
path.basename(path.dirname(extensionsDir)) === "dist"
|
||||
? path.dirname(path.dirname(extensionsDir))
|
||||
: path.dirname(extensionsDir);
|
||||
try {
|
||||
await writePackageDistInventory(packageRoot);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe("bundled plugin postinstall", () => {
|
||||
function createNpmInstallArgs(...packages: string[]) {
|
||||
return [
|
||||
"install",
|
||||
"--omit=dev",
|
||||
"--no-save",
|
||||
"--package-lock=false",
|
||||
"--legacy-peer-deps",
|
||||
...packages,
|
||||
];
|
||||
}
|
||||
|
||||
function createBareNpmRunner(packages: string[]) {
|
||||
return {
|
||||
command: "npm",
|
||||
args: createNpmInstallArgs(...packages),
|
||||
env: {
|
||||
HOME: "/tmp/home",
|
||||
PATH: "/tmp/node/bin",
|
||||
},
|
||||
shell: false as const,
|
||||
};
|
||||
}
|
||||
|
||||
function expectNpmInstallSpawn(
|
||||
spawnSync: ReturnType<typeof vi.fn>,
|
||||
packageRoot: string,
|
||||
packages: string[],
|
||||
) {
|
||||
expect(spawnSync).toHaveBeenCalledWith("npm", createNpmInstallArgs(...packages), {
|
||||
cwd: packageRoot,
|
||||
encoding: "utf8",
|
||||
env: {
|
||||
HOME: "/tmp/home",
|
||||
PATH: "/tmp/node/bin",
|
||||
},
|
||||
shell: false,
|
||||
stdio: "pipe",
|
||||
windowsVerbatimArguments: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
it("clears global npm config before nested installs", () => {
|
||||
expect(
|
||||
createNestedNpmInstallEnv({
|
||||
npm_config_global: "true",
|
||||
npm_config_location: "global",
|
||||
npm_config_prefix: "/opt/homebrew",
|
||||
HOME: "/tmp/home",
|
||||
}),
|
||||
).toEqual({
|
||||
HOME: "/tmp/home",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not install bundled plugin deps outside of source checkouts by default", async () => {
|
||||
const extensionsDir = await createExtensionsDir();
|
||||
const packageRoot = path.dirname(path.dirname(extensionsDir));
|
||||
await writePluginPackage(extensionsDir, "acpx", {
|
||||
dependencies: {
|
||||
acpx: "0.4.1",
|
||||
},
|
||||
});
|
||||
const spawnSync = vi.fn();
|
||||
|
||||
runBundledPluginPostinstall({
|
||||
env: { HOME: "/tmp/home" },
|
||||
extensionsDir,
|
||||
packageRoot,
|
||||
npmRunner: createBareNpmRunner(["acpx@0.4.1"]),
|
||||
spawnSync,
|
||||
log: { log: vi.fn(), warn: vi.fn() },
|
||||
});
|
||||
|
||||
expect(spawnSync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("prunes source-checkout bundled plugin node_modules", async () => {
|
||||
const packageRoot = await createTempDirAsync("openclaw-source-checkout-");
|
||||
const extensionsDir = path.join(packageRoot, "extensions");
|
||||
await fs.mkdir(path.join(packageRoot, ".git"), { recursive: true });
|
||||
await fs.mkdir(path.join(packageRoot, "src"), { recursive: true });
|
||||
await fs.mkdir(extensionsDir, { recursive: true });
|
||||
await writePluginPackage(extensionsDir, "acpx", {
|
||||
dependencies: {
|
||||
acpx: "0.5.2",
|
||||
},
|
||||
});
|
||||
await fs.mkdir(path.join(extensionsDir, "acpx", "node_modules", "acpx"), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(extensionsDir, "acpx", "node_modules", "acpx", "package.json"),
|
||||
JSON.stringify({ name: "acpx", version: "0.4.1" }),
|
||||
);
|
||||
const spawnSync = vi.fn();
|
||||
|
||||
runBundledPluginPostinstall({
|
||||
env: { HOME: "/tmp/home" },
|
||||
packageRoot,
|
||||
spawnSync,
|
||||
log: { log: vi.fn(), warn: vi.fn() },
|
||||
});
|
||||
|
||||
await expect(fs.stat(path.join(extensionsDir, "acpx", "node_modules"))).rejects.toMatchObject({
|
||||
code: "ENOENT",
|
||||
});
|
||||
expect(spawnSync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps source-checkout prune non-fatal", async () => {
|
||||
const packageRoot = await createTempDirAsync("openclaw-source-checkout-prune-error-");
|
||||
const extensionsDir = path.join(packageRoot, "extensions");
|
||||
await fs.mkdir(path.join(packageRoot, ".git"), { recursive: true });
|
||||
await fs.mkdir(path.join(packageRoot, "src"), { recursive: true });
|
||||
await fs.mkdir(path.join(extensionsDir, "acpx"), { recursive: true });
|
||||
await fs.writeFile(path.join(extensionsDir, "acpx", "package.json"), "{}\n");
|
||||
const warn = vi.fn();
|
||||
|
||||
expect(() =>
|
||||
runBundledPluginPostinstall({
|
||||
env: { HOME: "/tmp/home" },
|
||||
packageRoot,
|
||||
rmSync: vi.fn(() => {
|
||||
throw new Error("locked");
|
||||
}),
|
||||
log: { log: vi.fn(), warn },
|
||||
}),
|
||||
).not.toThrow();
|
||||
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
"[postinstall] could not prune bundled plugin source node_modules: Error: locked",
|
||||
);
|
||||
});
|
||||
|
||||
it("honors disable env before source-checkout pruning", async () => {
|
||||
const packageRoot = await createTempDirAsync("openclaw-source-checkout-disabled-");
|
||||
const extensionsDir = path.join(packageRoot, "extensions");
|
||||
await fs.mkdir(path.join(packageRoot, ".git"), { recursive: true });
|
||||
await fs.mkdir(path.join(packageRoot, "src"), { recursive: true });
|
||||
await fs.mkdir(path.join(extensionsDir, "acpx", "node_modules"), { recursive: true });
|
||||
await fs.writeFile(path.join(extensionsDir, "acpx", "package.json"), "{}\n");
|
||||
|
||||
runBundledPluginPostinstall({
|
||||
env: { OPENCLAW_DISABLE_BUNDLED_PLUGIN_POSTINSTALL: "1" },
|
||||
packageRoot,
|
||||
log: { log: vi.fn(), warn: vi.fn() },
|
||||
});
|
||||
|
||||
await expect(fs.stat(path.join(extensionsDir, "acpx", "node_modules"))).resolves.toBeTruthy();
|
||||
});
|
||||
|
||||
it("prunes stale dist files from packaged installs", async () => {
|
||||
const packageRoot = await createTempDirAsync("openclaw-packaged-install-");
|
||||
const currentFile = path.join(packageRoot, "dist", "channel-BOa4MfoC.js");
|
||||
const staleFile = path.join(packageRoot, "dist", "channel-CJUAgRQR.js");
|
||||
await fs.mkdir(path.dirname(currentFile), { recursive: true });
|
||||
await fs.writeFile(currentFile, "export {};\n");
|
||||
await writePackageDistInventory(packageRoot);
|
||||
await fs.writeFile(staleFile, "export {};\n");
|
||||
|
||||
expect(
|
||||
pruneInstalledPackageDist({
|
||||
packageRoot,
|
||||
log: { log: vi.fn(), warn: vi.fn() },
|
||||
}),
|
||||
).toEqual(["dist/channel-CJUAgRQR.js"]);
|
||||
|
||||
await expect(fs.stat(currentFile)).resolves.toBeTruthy();
|
||||
await expect(fs.stat(staleFile)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
});
|
||||
|
||||
it("restores only postinstall-generated QA compat sidecars after pruning old installs", async () => {
|
||||
const packageRoot = await createTempDirAsync("openclaw-packaged-install-qa-compat-");
|
||||
const currentFile = path.join(packageRoot, "dist", "entry.js");
|
||||
const stalePackage = path.join(packageRoot, "dist", "extensions", "qa-lab", "package.json");
|
||||
const staleManifest = path.join(
|
||||
packageRoot,
|
||||
"dist",
|
||||
"extensions",
|
||||
"qa-lab",
|
||||
"openclaw.plugin.json",
|
||||
);
|
||||
await fs.mkdir(path.dirname(stalePackage), { recursive: true });
|
||||
await fs.writeFile(currentFile, "export {};\n");
|
||||
await writePackageDistInventory(packageRoot);
|
||||
await fs.writeFile(stalePackage, "{}\n");
|
||||
await fs.writeFile(staleManifest, "{}\n");
|
||||
|
||||
runBundledPluginPostinstall({
|
||||
packageRoot,
|
||||
spawnSync: vi.fn(),
|
||||
log: { log: vi.fn(), warn: vi.fn() },
|
||||
});
|
||||
|
||||
await expect(fs.stat(stalePackage)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
await expect(fs.stat(staleManifest)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
await expect(
|
||||
fs.readFile(
|
||||
path.join(packageRoot, "dist", "extensions", "qa-channel", "runtime-api.js"),
|
||||
"utf8",
|
||||
),
|
||||
).resolves.toContain("QA channel implementation is not packaged");
|
||||
await expect(
|
||||
fs.readFile(path.join(packageRoot, "dist", "extensions", "qa-lab", "runtime-api.js"), "utf8"),
|
||||
).resolves.toContain("QA lab implementation is not packaged");
|
||||
});
|
||||
|
||||
it("creates only empty QA compat sidecars for fresh installs", async () => {
|
||||
const packageRoot = await createTempDirAsync("openclaw-packaged-install-no-qa-compat-");
|
||||
await fs.mkdir(path.join(packageRoot, "dist"), { recursive: true });
|
||||
await fs.writeFile(path.join(packageRoot, "dist", "entry.js"), "export {};\n");
|
||||
await writePackageDistInventory(packageRoot);
|
||||
|
||||
expect(
|
||||
restoreLegacyUpdaterCompatSidecars({
|
||||
packageRoot,
|
||||
removedFiles: ["dist/entry-old.js"],
|
||||
log: { log: vi.fn(), warn: vi.fn() },
|
||||
}),
|
||||
).toEqual([
|
||||
"dist/extensions/qa-channel/runtime-api.js",
|
||||
"dist/extensions/qa-lab/runtime-api.js",
|
||||
]);
|
||||
|
||||
await expect(
|
||||
fs.readFile(
|
||||
path.join(packageRoot, "dist", "extensions", "qa-channel", "runtime-api.js"),
|
||||
"utf8",
|
||||
),
|
||||
).resolves.toBe(
|
||||
"// Compatibility stub for older OpenClaw updaters. The QA channel implementation is not packaged.\nexport {};\n",
|
||||
);
|
||||
await expect(
|
||||
fs.readFile(path.join(packageRoot, "dist", "extensions", "qa-lab", "runtime-api.js"), "utf8"),
|
||||
).resolves.toBe(
|
||||
"// Compatibility stub for older OpenClaw updaters. The QA lab implementation is not packaged.\nexport {};\n",
|
||||
);
|
||||
await expect(
|
||||
fs.stat(path.join(packageRoot, "dist", "extensions", "qa-channel", "package.json")),
|
||||
).rejects.toMatchObject({ code: "ENOENT" });
|
||||
await expect(
|
||||
fs.stat(path.join(packageRoot, "dist", "extensions", "qa-channel", "openclaw.plugin.json")),
|
||||
).rejects.toMatchObject({ code: "ENOENT" });
|
||||
await expect(
|
||||
fs.stat(path.join(packageRoot, "dist", "extensions", "qa-lab", "package.json")),
|
||||
).rejects.toMatchObject({ code: "ENOENT" });
|
||||
await expect(
|
||||
fs.stat(path.join(packageRoot, "dist", "extensions", "qa-lab", "openclaw.plugin.json")),
|
||||
).rejects.toMatchObject({ code: "ENOENT" });
|
||||
});
|
||||
|
||||
it("keeps packaged postinstall non-fatal when the dist inventory is missing", async () => {
|
||||
const packageRoot = await createTempDirAsync("openclaw-packaged-install-missing-inventory-");
|
||||
const staleFile = path.join(packageRoot, "dist", "channel-CJUAgRQR.js");
|
||||
await fs.mkdir(path.dirname(staleFile), { recursive: true });
|
||||
await fs.writeFile(staleFile, "export {};\n");
|
||||
const warn = vi.fn();
|
||||
|
||||
expect(() =>
|
||||
runBundledPluginPostinstall({
|
||||
packageRoot,
|
||||
log: { log: vi.fn(), warn },
|
||||
}),
|
||||
).not.toThrow();
|
||||
|
||||
await expect(fs.stat(staleFile)).resolves.toBeTruthy();
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
"[postinstall] skipping dist prune: missing dist inventory: dist/postinstall-inventory.json",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps packaged postinstall non-fatal when the dist inventory is invalid", async () => {
|
||||
const packageRoot = await createTempDirAsync("openclaw-packaged-install-invalid-inventory-");
|
||||
const currentFile = path.join(packageRoot, "dist", "channel-BOa4MfoC.js");
|
||||
const inventoryPath = path.join(packageRoot, "dist", "postinstall-inventory.json");
|
||||
await fs.mkdir(path.dirname(currentFile), { recursive: true });
|
||||
await fs.writeFile(currentFile, "export {};\n");
|
||||
await fs.writeFile(inventoryPath, "{not-json}\n");
|
||||
const warn = vi.fn();
|
||||
|
||||
expect(() =>
|
||||
runBundledPluginPostinstall({
|
||||
packageRoot,
|
||||
log: { log: vi.fn(), warn },
|
||||
}),
|
||||
).not.toThrow();
|
||||
|
||||
await expect(fs.stat(currentFile)).resolves.toBeTruthy();
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
"[postinstall] skipping dist prune: invalid dist inventory: dist/postinstall-inventory.json",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects symlinked dist roots in packaged installs", () => {
|
||||
expect(() =>
|
||||
pruneInstalledPackageDist({
|
||||
packageRoot: "/pkg",
|
||||
expectedFiles: new Set(),
|
||||
existsSync: vi.fn(() => true),
|
||||
lstatSync: vi.fn((filePath) => ({
|
||||
isDirectory: () => filePath === "/pkg/dist",
|
||||
isSymbolicLink: () => filePath === "/pkg/dist",
|
||||
})),
|
||||
realpathSync: vi.fn((filePath) => filePath),
|
||||
readdirSync: vi.fn(),
|
||||
rmSync: vi.fn(),
|
||||
log: { log: vi.fn(), warn: vi.fn() },
|
||||
}),
|
||||
).toThrow("unsafe dist root: dist must be a real directory");
|
||||
});
|
||||
|
||||
it("rejects symlink entries in packaged dist trees", () => {
|
||||
expect(() =>
|
||||
pruneInstalledPackageDist({
|
||||
packageRoot: "/pkg",
|
||||
expectedFiles: new Set(),
|
||||
existsSync: vi.fn(() => true),
|
||||
lstatSync: vi.fn(() => ({
|
||||
isDirectory: () => true,
|
||||
isSymbolicLink: () => false,
|
||||
})),
|
||||
realpathSync: vi.fn((filePath) => filePath),
|
||||
readdirSync: vi.fn((filePath) => {
|
||||
if (filePath === "/pkg/dist") {
|
||||
return [
|
||||
{
|
||||
name: "escape",
|
||||
isDirectory: () => false,
|
||||
isFile: () => false,
|
||||
isSymbolicLink: () => true,
|
||||
},
|
||||
];
|
||||
}
|
||||
return [];
|
||||
}),
|
||||
rmSync: vi.fn(),
|
||||
log: { log: vi.fn(), warn: vi.fn() },
|
||||
}),
|
||||
).toThrow("unsafe dist entry: dist/escape");
|
||||
});
|
||||
|
||||
it("unlinks stale files instead of recursive pruning them", () => {
|
||||
const unlinkSync = vi.fn();
|
||||
|
||||
expect(
|
||||
pruneInstalledPackageDist({
|
||||
packageRoot: "/pkg",
|
||||
expectedFiles: new Set(),
|
||||
existsSync: vi.fn(() => true),
|
||||
lstatSync: vi.fn(() => ({
|
||||
isDirectory: () => true,
|
||||
isSymbolicLink: () => false,
|
||||
})),
|
||||
realpathSync: vi.fn((filePath) => filePath),
|
||||
readdirSync: vi.fn((filePath, options) => {
|
||||
if (filePath === "/pkg/dist" && options?.withFileTypes) {
|
||||
return [
|
||||
{
|
||||
name: "stale.js",
|
||||
isDirectory: () => false,
|
||||
isFile: () => true,
|
||||
isSymbolicLink: () => false,
|
||||
},
|
||||
];
|
||||
}
|
||||
return [];
|
||||
}),
|
||||
unlinkSync,
|
||||
log: { log: vi.fn(), warn: vi.fn() },
|
||||
}),
|
||||
).toEqual(["dist/stale.js"]);
|
||||
|
||||
expect(unlinkSync).toHaveBeenCalledWith("/pkg/dist/stale.js");
|
||||
});
|
||||
|
||||
it("runs nested local installs with sanitized env when the sentinel package is missing", async () => {
|
||||
const extensionsDir = await createExtensionsDir();
|
||||
const packageRoot = path.dirname(path.dirname(extensionsDir));
|
||||
await writePluginPackage(extensionsDir, "acpx", {
|
||||
dependencies: {
|
||||
acpx: "0.4.1",
|
||||
},
|
||||
});
|
||||
const spawnSync = vi.fn(() => ({ status: 0, stderr: "", stdout: "" }));
|
||||
|
||||
runBundledPluginPostinstall({
|
||||
env: {
|
||||
OPENCLAW_EAGER_BUNDLED_PLUGIN_DEPS: "1",
|
||||
npm_config_global: "true",
|
||||
npm_config_location: "global",
|
||||
npm_config_prefix: "/opt/homebrew",
|
||||
HOME: "/tmp/home",
|
||||
},
|
||||
extensionsDir,
|
||||
packageRoot,
|
||||
npmRunner: createBareNpmRunner(["acpx@0.4.1"]),
|
||||
spawnSync,
|
||||
log: { log: vi.fn(), warn: vi.fn() },
|
||||
});
|
||||
|
||||
expectNpmInstallSpawn(spawnSync, packageRoot, ["acpx@0.4.1"]);
|
||||
});
|
||||
|
||||
it("skips reinstall when the bundled sentinel package already exists", async () => {
|
||||
const extensionsDir = await createExtensionsDir();
|
||||
const packageRoot = path.dirname(path.dirname(extensionsDir));
|
||||
await writePluginPackage(extensionsDir, "acpx", {
|
||||
dependencies: {
|
||||
acpx: "0.4.1",
|
||||
},
|
||||
});
|
||||
await fs.mkdir(path.join(packageRoot, "node_modules", "acpx"), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(packageRoot, "node_modules", "acpx", "package.json"),
|
||||
"{}\n",
|
||||
"utf8",
|
||||
);
|
||||
const spawnSync = vi.fn();
|
||||
|
||||
runBundledPluginPostinstall({
|
||||
env: { npm_config_global: "true" },
|
||||
extensionsDir,
|
||||
packageRoot,
|
||||
spawnSync,
|
||||
});
|
||||
|
||||
expect(spawnSync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reinstalls bundled runtime deps when optional native children are missing", async () => {
|
||||
const extensionsDir = await createExtensionsDir();
|
||||
const packageRoot = path.dirname(path.dirname(extensionsDir));
|
||||
await writePluginPackage(extensionsDir, "discord", {
|
||||
dependencies: {
|
||||
"@snazzah/davey": "0.1.11",
|
||||
},
|
||||
});
|
||||
await fs.mkdir(path.join(packageRoot, "node_modules", "@snazzah", "davey"), {
|
||||
recursive: true,
|
||||
});
|
||||
await fs.writeFile(
|
||||
path.join(packageRoot, "node_modules", "@snazzah", "davey", "package.json"),
|
||||
JSON.stringify({
|
||||
optionalDependencies: {
|
||||
"@snazzah/davey-win32-arm64-msvc": "0.1.11",
|
||||
},
|
||||
}),
|
||||
);
|
||||
const spawnSync = vi.fn(() => ({ status: 0, stderr: "", stdout: "" }));
|
||||
|
||||
runBundledPluginPostinstall({
|
||||
env: { HOME: "/tmp/home", OPENCLAW_EAGER_BUNDLED_PLUGIN_DEPS: "1" },
|
||||
extensionsDir,
|
||||
packageRoot,
|
||||
arch: "arm64",
|
||||
npmRunner: createBareNpmRunner(["@snazzah/davey@0.1.11"]),
|
||||
platform: "win32",
|
||||
spawnSync,
|
||||
log: { log: vi.fn(), warn: vi.fn() },
|
||||
});
|
||||
|
||||
expectNpmInstallSpawn(spawnSync, packageRoot, ["@snazzah/davey@0.1.11"]);
|
||||
});
|
||||
|
||||
it("does not reinstall when only another platform optional native child is missing", async () => {
|
||||
const extensionsDir = await createExtensionsDir();
|
||||
const packageRoot = path.dirname(path.dirname(extensionsDir));
|
||||
await writePluginPackage(extensionsDir, "discord", {
|
||||
dependencies: {
|
||||
"@snazzah/davey": "0.1.11",
|
||||
},
|
||||
});
|
||||
await fs.mkdir(path.join(packageRoot, "node_modules", "@snazzah", "davey"), {
|
||||
recursive: true,
|
||||
});
|
||||
await fs.writeFile(
|
||||
path.join(packageRoot, "node_modules", "@snazzah", "davey", "package.json"),
|
||||
JSON.stringify({
|
||||
optionalDependencies: {
|
||||
"@snazzah/davey-win32-arm64-msvc": "0.1.11",
|
||||
},
|
||||
}),
|
||||
);
|
||||
const spawnSync = vi.fn();
|
||||
|
||||
runBundledPluginPostinstall({
|
||||
env: { HOME: "/tmp/home" },
|
||||
extensionsDir,
|
||||
packageRoot,
|
||||
arch: "arm64",
|
||||
platform: "darwin",
|
||||
spawnSync,
|
||||
log: { log: vi.fn(), warn: vi.fn() },
|
||||
});
|
||||
|
||||
expect(spawnSync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("discovers bundled plugin runtime deps from extension manifests", async () => {
|
||||
const extensionsDir = await createExtensionsDir();
|
||||
await writePluginPackage(extensionsDir, "slack", {
|
||||
dependencies: {
|
||||
"@slack/web-api": "7.11.0",
|
||||
},
|
||||
});
|
||||
await writePluginPackage(extensionsDir, "amazon-bedrock", {
|
||||
dependencies: {
|
||||
"@aws-sdk/client-bedrock": "3.1020.0",
|
||||
},
|
||||
});
|
||||
|
||||
expect(discoverBundledPluginRuntimeDeps({ extensionsDir })).toEqual(
|
||||
expect.arrayContaining([
|
||||
{
|
||||
name: "@slack/web-api",
|
||||
pluginIds: ["slack"],
|
||||
sentinelPath: path.join("node_modules", "@slack", "web-api", "package.json"),
|
||||
version: "7.11.0",
|
||||
},
|
||||
{
|
||||
name: "@aws-sdk/client-bedrock",
|
||||
pluginIds: ["amazon-bedrock"],
|
||||
sentinelPath: path.join("node_modules", "@aws-sdk", "client-bedrock", "package.json"),
|
||||
version: "3.1020.0",
|
||||
},
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("merges duplicate bundled runtime deps across plugins", async () => {
|
||||
const extensionsDir = await createExtensionsDir();
|
||||
await writePluginPackage(extensionsDir, "slack", {
|
||||
dependencies: {
|
||||
"https-proxy-agent": "^8.0.0",
|
||||
},
|
||||
});
|
||||
await writePluginPackage(extensionsDir, "feishu", {
|
||||
dependencies: {
|
||||
"https-proxy-agent": "^8.0.0",
|
||||
},
|
||||
});
|
||||
|
||||
expect(discoverBundledPluginRuntimeDeps({ extensionsDir })).toEqual(
|
||||
expect.arrayContaining([
|
||||
{
|
||||
name: "https-proxy-agent",
|
||||
pluginIds: ["feishu", "slack"],
|
||||
sentinelPath: path.join("node_modules", "https-proxy-agent", "package.json"),
|
||||
version: "^8.0.0",
|
||||
},
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("installs missing bundled plugin runtime deps during global installs", async () => {
|
||||
const extensionsDir = await createExtensionsDir();
|
||||
const packageRoot = path.dirname(path.dirname(extensionsDir));
|
||||
await writePluginPackage(extensionsDir, "slack", {
|
||||
dependencies: {
|
||||
"@slack/web-api": "7.11.0",
|
||||
},
|
||||
});
|
||||
await writePluginPackage(extensionsDir, "telegram", {
|
||||
dependencies: {
|
||||
grammy: "1.38.4",
|
||||
},
|
||||
});
|
||||
const spawnSync = vi.fn(() => ({ status: 0, stderr: "", stdout: "" }));
|
||||
|
||||
runBundledPluginPostinstall({
|
||||
env: {
|
||||
OPENCLAW_EAGER_BUNDLED_PLUGIN_DEPS: "1",
|
||||
npm_config_global: "true",
|
||||
npm_config_location: "global",
|
||||
npm_config_prefix: "/opt/homebrew",
|
||||
HOME: "/tmp/home",
|
||||
},
|
||||
extensionsDir,
|
||||
packageRoot,
|
||||
npmRunner: createBareNpmRunner(["@slack/web-api@7.11.0", "grammy@1.38.4"]),
|
||||
spawnSync,
|
||||
log: { log: vi.fn(), warn: vi.fn() },
|
||||
});
|
||||
|
||||
expectNpmInstallSpawn(spawnSync, packageRoot, ["@slack/web-api@7.11.0", "grammy@1.38.4"]);
|
||||
});
|
||||
|
||||
it("installs only missing bundled plugin runtime deps", async () => {
|
||||
const extensionsDir = await createExtensionsDir();
|
||||
const packageRoot = path.dirname(path.dirname(extensionsDir));
|
||||
await writePluginPackage(extensionsDir, "slack", {
|
||||
dependencies: {
|
||||
"@slack/web-api": "7.11.0",
|
||||
},
|
||||
});
|
||||
await writePluginPackage(extensionsDir, "telegram", {
|
||||
dependencies: {
|
||||
grammy: "1.38.4",
|
||||
},
|
||||
});
|
||||
await fs.mkdir(path.join(packageRoot, "node_modules", "@slack", "web-api"), {
|
||||
recursive: true,
|
||||
});
|
||||
await fs.writeFile(
|
||||
path.join(packageRoot, "node_modules", "@slack", "web-api", "package.json"),
|
||||
"{}\n",
|
||||
);
|
||||
const spawnSync = vi.fn(() => ({ status: 0, stderr: "", stdout: "" }));
|
||||
|
||||
runBundledPluginPostinstall({
|
||||
env: {
|
||||
OPENCLAW_EAGER_BUNDLED_PLUGIN_DEPS: "1",
|
||||
HOME: "/tmp/home",
|
||||
},
|
||||
extensionsDir,
|
||||
packageRoot,
|
||||
npmRunner: createBareNpmRunner(["grammy@1.38.4"]),
|
||||
spawnSync,
|
||||
log: { log: vi.fn(), warn: vi.fn() },
|
||||
});
|
||||
|
||||
expectNpmInstallSpawn(spawnSync, packageRoot, ["grammy@1.38.4"]);
|
||||
});
|
||||
|
||||
it("installs bundled plugin deps when npm location is global", async () => {
|
||||
const extensionsDir = await createExtensionsDir();
|
||||
const packageRoot = path.dirname(path.dirname(extensionsDir));
|
||||
await writePluginPackage(extensionsDir, "telegram", {
|
||||
dependencies: {
|
||||
grammy: "1.38.4",
|
||||
},
|
||||
});
|
||||
const spawnSync = vi.fn(() => ({ status: 0, stderr: "", stdout: "" }));
|
||||
|
||||
runBundledPluginPostinstall({
|
||||
env: {
|
||||
OPENCLAW_EAGER_BUNDLED_PLUGIN_DEPS: "1",
|
||||
npm_config_location: "global",
|
||||
npm_config_prefix: "/opt/homebrew",
|
||||
HOME: "/tmp/home",
|
||||
},
|
||||
extensionsDir,
|
||||
packageRoot,
|
||||
npmRunner: createBareNpmRunner(["grammy@1.38.4"]),
|
||||
spawnSync,
|
||||
log: { log: vi.fn(), warn: vi.fn() },
|
||||
});
|
||||
|
||||
expectNpmInstallSpawn(spawnSync, packageRoot, ["grammy@1.38.4"]);
|
||||
});
|
||||
|
||||
it("prunes only bundled plugin package node_modules in source checkouts", async () => {
|
||||
const packageRoot = await createTempDirAsync("openclaw-source-prune-");
|
||||
const extensionsDir = path.join(packageRoot, "extensions");
|
||||
await fs.mkdir(path.join(extensionsDir, "acpx", "node_modules"), { recursive: true });
|
||||
await fs.mkdir(path.join(extensionsDir, "fixtures", "node_modules"), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(extensionsDir, "acpx", "package.json"),
|
||||
JSON.stringify({ name: "@openclaw/acpx" }),
|
||||
);
|
||||
|
||||
pruneBundledPluginSourceNodeModules({ extensionsDir });
|
||||
|
||||
await expect(fs.stat(path.join(extensionsDir, "acpx", "node_modules"))).rejects.toMatchObject({
|
||||
code: "ENOENT",
|
||||
});
|
||||
await expect(
|
||||
fs.stat(path.join(extensionsDir, "fixtures", "node_modules")),
|
||||
).resolves.toBeTruthy();
|
||||
});
|
||||
|
||||
it("skips symlink entries when pruning source-checkout bundled plugin node_modules", () => {
|
||||
const removePath = vi.fn();
|
||||
|
||||
pruneBundledPluginSourceNodeModules({
|
||||
extensionsDir: "/repo/extensions",
|
||||
existsSync: vi.fn((value) => value === "/repo/extensions"),
|
||||
readdirSync: vi.fn(() => [
|
||||
{
|
||||
name: "acpx",
|
||||
isDirectory: () => true,
|
||||
isSymbolicLink: () => true,
|
||||
},
|
||||
]),
|
||||
rmSync: removePath,
|
||||
});
|
||||
|
||||
expect(removePath).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
createPackageManagerWarningMessage,
|
||||
detectLifecyclePackageManager,
|
||||
warnIfNonPnpmLifecycle,
|
||||
} from "../../scripts/preinstall-package-manager-warning.mjs";
|
||||
|
||||
describe("detectLifecyclePackageManager", () => {
|
||||
it("prefers npm_config_user_agent when present", () => {
|
||||
expect(
|
||||
detectLifecyclePackageManager({
|
||||
npm_config_user_agent: "npm/11.4.1 node/v22.20.0 darwin arm64",
|
||||
}),
|
||||
).toBe("npm");
|
||||
});
|
||||
|
||||
it("falls back to npm_execpath when user agent is missing", () => {
|
||||
expect(
|
||||
detectLifecyclePackageManager({
|
||||
npm_execpath: "/Users/test/.cache/node/corepack/v1/pnpm/10.32.1/bin/pnpm.cjs",
|
||||
}),
|
||||
).toBe("pnpm");
|
||||
});
|
||||
|
||||
it("ignores untrusted user-agent tokens with control characters", () => {
|
||||
expect(
|
||||
detectLifecyclePackageManager({
|
||||
npm_config_user_agent: "\u001bnpm/11.4.1 node/v22.20.0 darwin arm64",
|
||||
npm_execpath: "/Users/test/.cache/node/corepack/v1/pnpm/10.32.1/bin/pnpm.cjs",
|
||||
}),
|
||||
).toBe("pnpm");
|
||||
});
|
||||
});
|
||||
|
||||
describe("createPackageManagerWarningMessage", () => {
|
||||
it("returns null for pnpm", () => {
|
||||
expect(createPackageManagerWarningMessage("pnpm")).toBeNull();
|
||||
});
|
||||
|
||||
it("warns for npm installs", () => {
|
||||
expect(createPackageManagerWarningMessage("npm")).toContain("prefer: corepack pnpm install");
|
||||
});
|
||||
});
|
||||
|
||||
describe("warnIfNonPnpmLifecycle", () => {
|
||||
it("warns once for npm lifecycle runs", () => {
|
||||
const warn = vi.fn();
|
||||
expect(
|
||||
warnIfNonPnpmLifecycle(
|
||||
{
|
||||
npm_config_user_agent: "npm/11.4.1 node/v22.20.0 darwin arm64",
|
||||
},
|
||||
warn,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(warn).toHaveBeenCalledTimes(1);
|
||||
expect(warn.mock.calls[0]?.[0]).toContain("detected npm");
|
||||
});
|
||||
|
||||
it("stays quiet for pnpm", () => {
|
||||
const warn = vi.fn();
|
||||
expect(
|
||||
warnIfNonPnpmLifecycle(
|
||||
{
|
||||
npm_config_user_agent: "pnpm/10.32.1 npm/? node/v22.20.0 darwin arm64",
|
||||
},
|
||||
warn,
|
||||
),
|
||||
).toBe(false);
|
||||
expect(warn).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,97 @@
|
|||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
createPrefixedOutputWriter,
|
||||
isArtifactSetFresh,
|
||||
parseMode,
|
||||
runNodeStepsInParallel,
|
||||
} from "../../scripts/prepare-extension-package-boundary-artifacts.mjs";
|
||||
|
||||
const tempRoots = new Set<string>();
|
||||
|
||||
afterEach(() => {
|
||||
for (const rootDir of tempRoots) {
|
||||
fs.rmSync(rootDir, { force: true, recursive: true });
|
||||
}
|
||||
tempRoots.clear();
|
||||
});
|
||||
|
||||
describe("prepare-extension-package-boundary-artifacts", () => {
|
||||
it("prefixes each completed line and flushes the trailing partial line", () => {
|
||||
let output = "";
|
||||
const writer = createPrefixedOutputWriter("boundary", {
|
||||
write(chunk: string) {
|
||||
output += chunk;
|
||||
},
|
||||
});
|
||||
|
||||
writer.write("first line\nsecond");
|
||||
writer.write(" line\nthird");
|
||||
writer.flush();
|
||||
|
||||
expect(output).toBe("[boundary] first line\n[boundary] second line\n[boundary] third");
|
||||
});
|
||||
|
||||
it("aborts sibling steps after the first failure", async () => {
|
||||
const startedAt = Date.now();
|
||||
const slowStepTimeoutMs = 60_000;
|
||||
const abortBudgetMs = 30_000;
|
||||
|
||||
await expect(
|
||||
runNodeStepsInParallel([
|
||||
{
|
||||
label: "fail-fast",
|
||||
args: ["--eval", "process.exit(2)"],
|
||||
timeoutMs: slowStepTimeoutMs,
|
||||
},
|
||||
{
|
||||
label: "slow-step",
|
||||
args: ["--eval", "setTimeout(() => {}, 60_000)"],
|
||||
timeoutMs: slowStepTimeoutMs,
|
||||
},
|
||||
]),
|
||||
).rejects.toThrow("fail-fast failed with exit code 2");
|
||||
|
||||
expect(Date.now() - startedAt).toBeLessThan(abortBudgetMs);
|
||||
}, 45_000);
|
||||
|
||||
it("treats artifacts as fresh only when outputs are newer than inputs", () => {
|
||||
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-boundary-prep-"));
|
||||
tempRoots.add(rootDir);
|
||||
const inputPath = path.join(rootDir, "src", "demo.ts");
|
||||
const outputPath = path.join(rootDir, "dist", "demo.tsbuildinfo");
|
||||
fs.mkdirSync(path.dirname(inputPath), { recursive: true });
|
||||
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
|
||||
fs.writeFileSync(inputPath, "export const demo = 1;\n", "utf8");
|
||||
fs.writeFileSync(outputPath, "ok\n", "utf8");
|
||||
|
||||
fs.utimesSync(inputPath, new Date(1_000), new Date(1_000));
|
||||
fs.utimesSync(outputPath, new Date(2_000), new Date(2_000));
|
||||
|
||||
expect(
|
||||
isArtifactSetFresh({
|
||||
rootDir,
|
||||
inputPaths: ["src"],
|
||||
outputPaths: ["dist/demo.tsbuildinfo"],
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
fs.utimesSync(inputPath, new Date(3_000), new Date(3_000));
|
||||
|
||||
expect(
|
||||
isArtifactSetFresh({
|
||||
rootDir,
|
||||
inputPaths: ["src"],
|
||||
outputPaths: ["dist/demo.tsbuildinfo"],
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("parses prep mode and rejects unknown values", () => {
|
||||
expect(parseMode([])).toBe("all");
|
||||
expect(parseMode(["--mode=package-boundary"])).toBe("package-boundary");
|
||||
expect(() => parseMode(["--mode=nope"])).toThrow("Unknown mode: nope");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
classifyRootDependencyOwnership,
|
||||
collectModuleSpecifiers,
|
||||
} from "../../scripts/root-dependency-ownership-audit.mjs";
|
||||
|
||||
describe("collectModuleSpecifiers", () => {
|
||||
it("captures require.resolve package lookups used by runtime shims and bundled plugins", () => {
|
||||
expect([
|
||||
...collectModuleSpecifiers(`
|
||||
const require = createRequire(import.meta.url);
|
||||
const runtimeRequire = createRequire(runtimePackagePath);
|
||||
require.resolve("gaxios");
|
||||
runtimeRequire.resolve("openshell/package.json");
|
||||
`),
|
||||
]).toEqual(["gaxios", "openshell/package.json"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("classifyRootDependencyOwnership", () => {
|
||||
it("treats root-dist bundled runtime imports as localizable extension deps", () => {
|
||||
expect(
|
||||
classifyRootDependencyOwnership({
|
||||
sections: ["extensions"],
|
||||
rootMirrorImporters: ["discovery-DZDwKJdJ.js"],
|
||||
}),
|
||||
).toEqual({
|
||||
category: "extension_only_localizable",
|
||||
recommendation:
|
||||
"remove from root package.json and rely on owning extension manifests plus doctor --fix",
|
||||
});
|
||||
});
|
||||
|
||||
it("treats scripts and tests as dev-only candidates", () => {
|
||||
expect(
|
||||
classifyRootDependencyOwnership({
|
||||
sections: ["scripts", "test"],
|
||||
rootMirrorImporters: [],
|
||||
}),
|
||||
).toEqual({
|
||||
category: "script_or_test_only",
|
||||
recommendation: "consider moving from dependencies to devDependencies",
|
||||
});
|
||||
});
|
||||
|
||||
it("treats extension-only deps as localizable when no root mirror exists", () => {
|
||||
expect(
|
||||
classifyRootDependencyOwnership({
|
||||
sections: ["extensions", "test"],
|
||||
rootMirrorImporters: [],
|
||||
}),
|
||||
).toEqual({
|
||||
category: "extension_only_localizable",
|
||||
recommendation:
|
||||
"remove from root package.json and rely on owning extension manifests plus doctor --fix",
|
||||
});
|
||||
});
|
||||
|
||||
it("treats src-owned deps as core runtime", () => {
|
||||
expect(
|
||||
classifyRootDependencyOwnership({
|
||||
sections: ["src"],
|
||||
rootMirrorImporters: [],
|
||||
}),
|
||||
).toEqual({
|
||||
category: "core_runtime",
|
||||
recommendation: "keep at root",
|
||||
});
|
||||
});
|
||||
|
||||
it("treats unreferenced deps as removal candidates", () => {
|
||||
expect(
|
||||
classifyRootDependencyOwnership({
|
||||
sections: [],
|
||||
rootMirrorImporters: [],
|
||||
}),
|
||||
).toEqual({
|
||||
category: "unreferenced",
|
||||
recommendation: "investigate removal; no direct source imports found in scanned files",
|
||||
});
|
||||
});
|
||||
});
|
||||
33
openclaw/test/scripts/run-oxlint.test.ts
Normal file
33
openclaw/test/scripts/run-oxlint.test.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { shouldPrepareExtensionPackageBoundaryArtifacts } from "../../scripts/run-oxlint.mjs";
|
||||
|
||||
describe("run-oxlint", () => {
|
||||
it("prepares extension package boundary artifacts for normal lint runs", () => {
|
||||
expect(shouldPrepareExtensionPackageBoundaryArtifacts([])).toBe(true);
|
||||
expect(shouldPrepareExtensionPackageBoundaryArtifacts(["src/index.ts"])).toBe(true);
|
||||
expect(shouldPrepareExtensionPackageBoundaryArtifacts(["--type-aware"])).toBe(true);
|
||||
});
|
||||
|
||||
it("skips artifact preparation for metadata-only oxlint commands", () => {
|
||||
expect(shouldPrepareExtensionPackageBoundaryArtifacts(["--help"])).toBe(false);
|
||||
expect(shouldPrepareExtensionPackageBoundaryArtifacts(["--version"])).toBe(false);
|
||||
expect(shouldPrepareExtensionPackageBoundaryArtifacts(["--print-config"])).toBe(false);
|
||||
expect(shouldPrepareExtensionPackageBoundaryArtifacts(["--rules"])).toBe(false);
|
||||
});
|
||||
|
||||
it("does not run package-boundary artifact prep twice in pnpm check", () => {
|
||||
const packageJson = JSON.parse(readFileSync("package.json", "utf8")) as {
|
||||
scripts: Record<string, string>;
|
||||
};
|
||||
const shardedLintRunner = readFileSync("scripts/run-oxlint-shards.mjs", "utf8");
|
||||
|
||||
expect(packageJson.scripts.check).toBe("node scripts/check.mjs");
|
||||
expect(packageJson.scripts.lint).toBe("node scripts/run-oxlint-shards.mjs");
|
||||
expect(packageJson.scripts.check).not.toContain(
|
||||
"node scripts/prepare-extension-package-boundary-artifacts.mjs",
|
||||
);
|
||||
expect(shardedLintRunner).toContain("prepare-extension-package-boundary-artifacts.mjs");
|
||||
expect(shardedLintRunner).toContain('OPENCLAW_OXLINT_SKIP_PREPARE: "1"');
|
||||
});
|
||||
});
|
||||
67
openclaw/test/scripts/run-vitest-profile.test.ts
Normal file
67
openclaw/test/scripts/run-vitest-profile.test.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildVitestProfileCommand,
|
||||
parseArgs,
|
||||
resolveVitestProfileDir,
|
||||
} from "../../scripts/run-vitest-profile.mjs";
|
||||
import { createScriptTestHarness } from "./test-helpers.js";
|
||||
|
||||
describe("scripts/run-vitest-profile", () => {
|
||||
const { trackTempDir } = createScriptTestHarness();
|
||||
|
||||
it("defaults profile output outside the repo", () => {
|
||||
const outputDir = trackTempDir(resolveVitestProfileDir({ mode: "main", outputDir: "" }));
|
||||
|
||||
expect(outputDir.startsWith(os.tmpdir())).toBe(true);
|
||||
expect(outputDir.startsWith(process.cwd())).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps explicit output directories", () => {
|
||||
expect(
|
||||
resolveVitestProfileDir({ mode: "runner", outputDir: ".artifacts/custom-profile" }),
|
||||
).toBe(path.resolve(".artifacts/custom-profile"));
|
||||
});
|
||||
|
||||
it("builds main-thread cpu profiling args", () => {
|
||||
expect(buildVitestProfileCommand({ mode: "main", outputDir: "/tmp/profile-main" })).toEqual({
|
||||
command: process.execPath,
|
||||
args: [
|
||||
"--cpu-prof",
|
||||
"--cpu-prof-dir=/tmp/profile-main",
|
||||
"./node_modules/vitest/vitest.mjs",
|
||||
"run",
|
||||
"--config",
|
||||
"test/vitest/vitest.unit.config.ts",
|
||||
"--no-file-parallelism",
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("builds runner cpu and heap profiling args", () => {
|
||||
expect(buildVitestProfileCommand({ mode: "runner", outputDir: "/tmp/profile-runner" })).toEqual(
|
||||
{
|
||||
command: "pnpm",
|
||||
args: [
|
||||
"vitest",
|
||||
"run",
|
||||
"--config",
|
||||
"test/vitest/vitest.unit.config.ts",
|
||||
"--no-file-parallelism",
|
||||
"--execArgv=--cpu-prof",
|
||||
"--execArgv=--cpu-prof-dir=/tmp/profile-runner",
|
||||
"--execArgv=--heap-prof",
|
||||
"--execArgv=--heap-prof-dir=/tmp/profile-runner",
|
||||
],
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("parses mode and explicit output dir", () => {
|
||||
expect(parseArgs(["runner", "--output-dir", "/tmp/out"])).toEqual({
|
||||
mode: "runner",
|
||||
outputDir: "/tmp/out",
|
||||
});
|
||||
});
|
||||
});
|
||||
124
openclaw/test/scripts/run-vitest.test.ts
Normal file
124
openclaw/test/scripts/run-vitest.test.ts
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
import { EventEmitter } from "node:events";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
installVitestNoOutputWatchdog,
|
||||
resolveVitestNodeArgs,
|
||||
resolveVitestNoOutputTimeoutMs,
|
||||
resolveVitestSpawnParams,
|
||||
shouldSuppressVitestStderrLine,
|
||||
} from "../../scripts/run-vitest.mjs";
|
||||
|
||||
describe("scripts/run-vitest", () => {
|
||||
it("adds --no-maglev to vitest child processes by default", () => {
|
||||
expect(resolveVitestNodeArgs({ PATH: "/usr/bin" })).toEqual(["--no-maglev"]);
|
||||
});
|
||||
|
||||
it("allows opting back into Maglev explicitly", () => {
|
||||
expect(
|
||||
resolveVitestNodeArgs({
|
||||
OPENCLAW_VITEST_ENABLE_MAGLEV: "1",
|
||||
PATH: "/usr/bin",
|
||||
}),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("parses the optional no-output timeout env", () => {
|
||||
expect(resolveVitestNoOutputTimeoutMs({})).toBeNull();
|
||||
expect(resolveVitestNoOutputTimeoutMs({ OPENCLAW_VITEST_NO_OUTPUT_TIMEOUT_MS: "2500" })).toBe(
|
||||
2500,
|
||||
);
|
||||
expect(
|
||||
resolveVitestNoOutputTimeoutMs({ OPENCLAW_VITEST_NO_OUTPUT_TIMEOUT_MS: "0" }),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("spawns vitest in a detached process group on Unix hosts", () => {
|
||||
expect(resolveVitestSpawnParams({ PATH: "/usr/bin" }, "darwin")).toEqual({
|
||||
env: { PATH: "/usr/bin" },
|
||||
detached: true,
|
||||
stdio: ["inherit", "pipe", "pipe"],
|
||||
});
|
||||
expect(resolveVitestSpawnParams({ PATH: "/usr/bin" }, "win32")).toEqual({
|
||||
env: { PATH: "/usr/bin" },
|
||||
detached: false,
|
||||
stdio: ["inherit", "pipe", "pipe"],
|
||||
});
|
||||
});
|
||||
|
||||
it("suppresses rolldown plugin timing noise while keeping other stderr intact", () => {
|
||||
expect(
|
||||
shouldSuppressVitestStderrLine(
|
||||
"\u001b[33m[PLUGIN_TIMINGS] Warning:\u001b[0m plugin `foo` was slow\n",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(shouldSuppressVitestStderrLine("real failure output\n")).toBe(false);
|
||||
});
|
||||
|
||||
it("kills silent vitest runs after the configured idle timeout", () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const stdout = new EventEmitter();
|
||||
const timeoutSpy = vi.fn();
|
||||
const forceKillSpy = vi.fn();
|
||||
const logSpy = vi.fn();
|
||||
|
||||
const teardown = installVitestNoOutputWatchdog({
|
||||
streams: [stdout],
|
||||
timeoutMs: 1000,
|
||||
forceKillAfterMs: 5000,
|
||||
log: logSpy,
|
||||
onTimeout: timeoutSpy,
|
||||
onForceKill: forceKillSpy,
|
||||
setTimeoutFn: setTimeout,
|
||||
clearTimeoutFn: clearTimeout,
|
||||
});
|
||||
|
||||
vi.advanceTimersByTime(900);
|
||||
expect(timeoutSpy).not.toHaveBeenCalled();
|
||||
|
||||
stdout.emit("data", "still alive");
|
||||
vi.advanceTimersByTime(900);
|
||||
expect(timeoutSpy).not.toHaveBeenCalled();
|
||||
|
||||
vi.advanceTimersByTime(100);
|
||||
expect(timeoutSpy).toHaveBeenCalledTimes(1);
|
||||
expect(logSpy).toHaveBeenCalledWith(
|
||||
"[vitest] no output for 1000ms; terminating stalled Vitest process group.",
|
||||
);
|
||||
|
||||
vi.advanceTimersByTime(5000);
|
||||
expect(forceKillSpy).toHaveBeenCalledTimes(1);
|
||||
expect(logSpy).toHaveBeenCalledWith(
|
||||
"[vitest] process group still alive after 5000ms; sending SIGKILL.",
|
||||
);
|
||||
|
||||
teardown();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("includes the runner label in watchdog logs when provided", () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const stdout = new EventEmitter();
|
||||
const logSpy = vi.fn();
|
||||
|
||||
installVitestNoOutputWatchdog({
|
||||
streams: [stdout],
|
||||
timeoutMs: 1000,
|
||||
forceKillAfterMs: 0,
|
||||
label: "run --config test/vitest/vitest.secrets.config.ts",
|
||||
log: logSpy,
|
||||
onTimeout: () => {},
|
||||
});
|
||||
|
||||
vi.advanceTimersByTime(1000);
|
||||
expect(logSpy).toHaveBeenCalledWith(
|
||||
"[vitest] no output for 1000ms; terminating stalled Vitest process group (run --config test/vitest/vitest.secrets.config.ts).",
|
||||
);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
82
openclaw/test/scripts/runtime-postbuild.test.ts
Normal file
82
openclaw/test/scripts/runtime-postbuild.test.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
copyStaticExtensionAssets,
|
||||
listStaticExtensionAssetOutputs,
|
||||
writeStableRootRuntimeAliases,
|
||||
} from "../../scripts/runtime-postbuild.mjs";
|
||||
import { createScriptTestHarness } from "./test-helpers.js";
|
||||
|
||||
const { createTempDir } = createScriptTestHarness();
|
||||
|
||||
describe("runtime postbuild static assets", () => {
|
||||
it("tracks plugin-owned static assets that release packaging must ship", () => {
|
||||
expect(listStaticExtensionAssetOutputs()).toContain(
|
||||
"dist/extensions/diffs/assets/viewer-runtime.js",
|
||||
);
|
||||
});
|
||||
|
||||
it("copies declared static assets into dist", async () => {
|
||||
const rootDir = createTempDir("openclaw-runtime-postbuild-");
|
||||
const src = "extensions/acpx/src/runtime-internals/mcp-proxy.mjs";
|
||||
const dest = "dist/extensions/acpx/mcp-proxy.mjs";
|
||||
const sourcePath = path.join(rootDir, src);
|
||||
const destPath = path.join(rootDir, dest);
|
||||
await fs.mkdir(path.dirname(sourcePath), { recursive: true });
|
||||
await fs.writeFile(sourcePath, "proxy-data\n", "utf8");
|
||||
|
||||
copyStaticExtensionAssets({
|
||||
rootDir,
|
||||
assets: [{ src, dest }],
|
||||
});
|
||||
|
||||
expect(await fs.readFile(destPath, "utf8")).toBe("proxy-data\n");
|
||||
});
|
||||
|
||||
it("warns when a declared static asset is missing", async () => {
|
||||
const rootDir = createTempDir("openclaw-runtime-postbuild-");
|
||||
const warn = vi.fn();
|
||||
|
||||
copyStaticExtensionAssets({
|
||||
rootDir,
|
||||
assets: [{ src: "missing/file.mjs", dest: "dist/file.mjs" }],
|
||||
warn,
|
||||
});
|
||||
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
"[runtime-postbuild] static asset not found, skipping: missing/file.mjs",
|
||||
);
|
||||
});
|
||||
|
||||
it("writes stable aliases for hashed root runtime modules", async () => {
|
||||
const rootDir = createTempDir("openclaw-runtime-postbuild-");
|
||||
const distDir = path.join(rootDir, "dist");
|
||||
await fs.mkdir(distDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(distDir, "runtime-model-auth.runtime-XyZ987.js"),
|
||||
"export const auth = true;\n",
|
||||
"utf8",
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(distDir, "runtime-tts.runtime-AbCd1234.js"),
|
||||
"export const tts = true;\n",
|
||||
"utf8",
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(distDir, "library-Other123.js"),
|
||||
"export const x = true;\n",
|
||||
"utf8",
|
||||
);
|
||||
|
||||
writeStableRootRuntimeAliases({ rootDir });
|
||||
|
||||
expect(await fs.readFile(path.join(distDir, "runtime-model-auth.runtime.js"), "utf8")).toBe(
|
||||
'export * from "./runtime-model-auth.runtime-XyZ987.js";\n',
|
||||
);
|
||||
expect(await fs.readFile(path.join(distDir, "runtime-tts.runtime.js"), "utf8")).toBe(
|
||||
'export * from "./runtime-tts.runtime-AbCd1234.js";\n',
|
||||
);
|
||||
await expect(fs.stat(path.join(distDir, "library.js"))).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
1145
openclaw/test/scripts/stage-bundled-plugin-runtime-deps.test.ts
Normal file
1145
openclaw/test/scripts/stage-bundled-plugin-runtime-deps.test.ts
Normal file
File diff suppressed because it is too large
Load diff
66
openclaw/test/scripts/stage-bundled-plugin-runtime.test.ts
Normal file
66
openclaw/test/scripts/stage-bundled-plugin-runtime.test.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { stageBundledPluginRuntime } from "../../scripts/stage-bundled-plugin-runtime.mjs";
|
||||
|
||||
async function withTempDir(run: (dir: string) => Promise<void>) {
|
||||
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "openclaw-stage-runtime-"));
|
||||
try {
|
||||
await run(dir);
|
||||
} finally {
|
||||
await fs.promises.rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
describe("stageBundledPluginRuntime", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("copies files when Windows rejects runtime overlay symlinks", async () => {
|
||||
await withTempDir(async (repoRoot) => {
|
||||
const sourceFile = path.join(
|
||||
repoRoot,
|
||||
"dist",
|
||||
"extensions",
|
||||
"acpx",
|
||||
"skills",
|
||||
"acp-router",
|
||||
"fixture.txt",
|
||||
);
|
||||
await fs.promises.mkdir(path.dirname(sourceFile), { recursive: true });
|
||||
await fs.promises.writeFile(sourceFile, "skill-body\n", "utf8");
|
||||
|
||||
vi.spyOn(process, "platform", "get").mockReturnValue("win32");
|
||||
const symlinkSpy = vi
|
||||
.spyOn(fs, "symlinkSync")
|
||||
.mockImplementation((target, targetPath, type) => {
|
||||
if (
|
||||
String(targetPath).includes(`${path.sep}dist-runtime${path.sep}`) &&
|
||||
type !== "junction"
|
||||
) {
|
||||
const error = new Error("no symlink privilege");
|
||||
Object.assign(error, { code: "EPERM" });
|
||||
throw error;
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
stageBundledPluginRuntime({ repoRoot });
|
||||
|
||||
const runtimeFile = path.join(
|
||||
repoRoot,
|
||||
"dist-runtime",
|
||||
"extensions",
|
||||
"acpx",
|
||||
"skills",
|
||||
"acp-router",
|
||||
"fixture.txt",
|
||||
);
|
||||
expect(await fs.promises.readFile(runtimeFile, "utf8")).toBe("skill-body\n");
|
||||
expect(fs.lstatSync(runtimeFile).isSymbolicLink()).toBe(false);
|
||||
expect(symlinkSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
439
openclaw/test/scripts/test-extension.test.ts
Normal file
439
openclaw/test/scripts/test-extension.test.ts
Normal file
|
|
@ -0,0 +1,439 @@
|
|||
import { execFileSync } from "node:child_process";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
detectChangedExtensionIds,
|
||||
listAvailableExtensionIds,
|
||||
listChangedExtensionIds,
|
||||
} from "../../scripts/lib/changed-extensions.mjs";
|
||||
import {
|
||||
DEFAULT_EXTENSION_TEST_SHARD_COUNT,
|
||||
createExtensionTestShards,
|
||||
resolveExtensionBatchPlan,
|
||||
resolveExtensionTestPlan,
|
||||
} from "../../scripts/lib/extension-test-plan.mjs";
|
||||
import { bundledPluginFile, bundledPluginRoot } from "../helpers/bundled-plugin-paths.js";
|
||||
|
||||
const scriptPath = path.join(process.cwd(), "scripts", "test-extension.mjs");
|
||||
|
||||
function runScript(args: string[], cwd = process.cwd()) {
|
||||
return execFileSync(process.execPath, [scriptPath, ...args], {
|
||||
cwd,
|
||||
encoding: "utf8",
|
||||
});
|
||||
}
|
||||
|
||||
function findExtensionWithoutTests() {
|
||||
const extensionId = listAvailableExtensionIds().find(
|
||||
(candidate) => !resolveExtensionTestPlan({ targetArg: candidate, cwd: process.cwd() }).hasTests,
|
||||
);
|
||||
|
||||
expect(extensionId).toBeDefined();
|
||||
return extensionId ?? "missing-no-test-extension";
|
||||
}
|
||||
|
||||
describe("scripts/test-extension.mjs", () => {
|
||||
it("resolves channel-root extensions onto the channel vitest config", () => {
|
||||
const plan = resolveExtensionTestPlan({ targetArg: "slack", cwd: process.cwd() });
|
||||
|
||||
expect(plan.extensionId).toBe("slack");
|
||||
expect(plan.extensionDir).toBe(bundledPluginRoot("slack"));
|
||||
expect(plan.config).toBe("test/vitest/vitest.extension-channels.config.ts");
|
||||
expect(plan.roots).toContain(bundledPluginRoot("slack"));
|
||||
expect(plan.hasTests).toBe(true);
|
||||
});
|
||||
|
||||
it("resolves bluebubbles onto the bluebubbles vitest config", () => {
|
||||
const plan = resolveExtensionTestPlan({ targetArg: "bluebubbles", cwd: process.cwd() });
|
||||
|
||||
expect(plan.extensionId).toBe("bluebubbles");
|
||||
expect(plan.config).toBe("test/vitest/vitest.extension-bluebubbles.config.ts");
|
||||
expect(plan.roots).toContain(bundledPluginRoot("bluebubbles"));
|
||||
expect(plan.hasTests).toBe(true);
|
||||
});
|
||||
|
||||
it("resolves acpx onto the acpx vitest config", () => {
|
||||
const plan = resolveExtensionTestPlan({ targetArg: "acpx", cwd: process.cwd() });
|
||||
|
||||
expect(plan.extensionId).toBe("acpx");
|
||||
expect(plan.config).toBe("test/vitest/vitest.extension-acpx.config.ts");
|
||||
expect(plan.roots).toContain(bundledPluginRoot("acpx"));
|
||||
expect(plan.hasTests).toBe(true);
|
||||
});
|
||||
|
||||
it("resolves diffs onto the diffs vitest config", () => {
|
||||
const plan = resolveExtensionTestPlan({ targetArg: "diffs", cwd: process.cwd() });
|
||||
|
||||
expect(plan.extensionId).toBe("diffs");
|
||||
expect(plan.config).toBe("test/vitest/vitest.extension-diffs.config.ts");
|
||||
expect(plan.roots).toContain(bundledPluginRoot("diffs"));
|
||||
expect(plan.hasTests).toBe(true);
|
||||
});
|
||||
|
||||
it("resolves feishu onto the feishu vitest config", () => {
|
||||
const plan = resolveExtensionTestPlan({ targetArg: "feishu", cwd: process.cwd() });
|
||||
|
||||
expect(plan.extensionId).toBe("feishu");
|
||||
expect(plan.config).toBe("test/vitest/vitest.extension-feishu.config.ts");
|
||||
expect(plan.roots).toContain(bundledPluginRoot("feishu"));
|
||||
expect(plan.hasTests).toBe(true);
|
||||
});
|
||||
|
||||
it("resolves provider extensions onto the provider vitest config", () => {
|
||||
const plan = resolveExtensionTestPlan({ targetArg: "openai", cwd: process.cwd() });
|
||||
|
||||
expect(plan.extensionId).toBe("openai");
|
||||
expect(plan.config).toBe("test/vitest/vitest.extension-providers.config.ts");
|
||||
expect(plan.roots).toContain(bundledPluginRoot("openai"));
|
||||
expect(plan.hasTests).toBe(true);
|
||||
});
|
||||
|
||||
it("resolves matrix onto the matrix vitest config", () => {
|
||||
const plan = resolveExtensionTestPlan({ targetArg: "matrix", cwd: process.cwd() });
|
||||
|
||||
expect(plan.extensionId).toBe("matrix");
|
||||
expect(plan.config).toBe("test/vitest/vitest.extension-matrix.config.ts");
|
||||
expect(plan.roots).toContain(bundledPluginRoot("matrix"));
|
||||
expect(plan.hasTests).toBe(true);
|
||||
});
|
||||
|
||||
it("resolves telegram onto the telegram vitest config", () => {
|
||||
const plan = resolveExtensionTestPlan({ targetArg: "telegram", cwd: process.cwd() });
|
||||
|
||||
expect(plan.extensionId).toBe("telegram");
|
||||
expect(plan.config).toBe("test/vitest/vitest.extension-telegram.config.ts");
|
||||
expect(plan.roots).toContain(bundledPluginRoot("telegram"));
|
||||
expect(plan.hasTests).toBe(true);
|
||||
});
|
||||
|
||||
it("resolves whatsapp onto the whatsapp vitest config", () => {
|
||||
const plan = resolveExtensionTestPlan({ targetArg: "whatsapp", cwd: process.cwd() });
|
||||
|
||||
expect(plan.extensionId).toBe("whatsapp");
|
||||
expect(plan.config).toBe("test/vitest/vitest.extension-whatsapp.config.ts");
|
||||
expect(plan.roots).toContain(bundledPluginRoot("whatsapp"));
|
||||
expect(plan.hasTests).toBe(true);
|
||||
});
|
||||
|
||||
it("resolves voice-call onto the voice-call vitest config", () => {
|
||||
const plan = resolveExtensionTestPlan({ targetArg: "voice-call", cwd: process.cwd() });
|
||||
|
||||
expect(plan.extensionId).toBe("voice-call");
|
||||
expect(plan.config).toBe("test/vitest/vitest.extension-voice-call.config.ts");
|
||||
expect(plan.roots).toContain(bundledPluginRoot("voice-call"));
|
||||
expect(plan.hasTests).toBe(true);
|
||||
});
|
||||
|
||||
it("resolves mattermost onto the mattermost vitest config", () => {
|
||||
const plan = resolveExtensionTestPlan({ targetArg: "mattermost", cwd: process.cwd() });
|
||||
|
||||
expect(plan.extensionId).toBe("mattermost");
|
||||
expect(plan.config).toBe("test/vitest/vitest.extension-mattermost.config.ts");
|
||||
expect(plan.roots).toContain(bundledPluginRoot("mattermost"));
|
||||
expect(plan.hasTests).toBe(true);
|
||||
});
|
||||
|
||||
it("resolves irc onto the irc vitest config", () => {
|
||||
const plan = resolveExtensionTestPlan({ targetArg: "irc", cwd: process.cwd() });
|
||||
|
||||
expect(plan.extensionId).toBe("irc");
|
||||
expect(plan.config).toBe("test/vitest/vitest.extension-irc.config.ts");
|
||||
expect(plan.roots).toContain(bundledPluginRoot("irc"));
|
||||
expect(plan.hasTests).toBe(true);
|
||||
});
|
||||
|
||||
it("resolves zalo onto the zalo vitest config", () => {
|
||||
const plan = resolveExtensionTestPlan({ targetArg: "zalo", cwd: process.cwd() });
|
||||
|
||||
expect(plan.extensionId).toBe("zalo");
|
||||
expect(plan.config).toBe("test/vitest/vitest.extension-zalo.config.ts");
|
||||
expect(plan.roots).toContain(bundledPluginRoot("zalo"));
|
||||
expect(plan.hasTests).toBe(true);
|
||||
});
|
||||
|
||||
it("resolves memory extensions onto the memory vitest config", () => {
|
||||
const plan = resolveExtensionTestPlan({ targetArg: "memory-core", cwd: process.cwd() });
|
||||
|
||||
expect(plan.extensionId).toBe("memory-core");
|
||||
expect(plan.config).toBe("test/vitest/vitest.extension-memory.config.ts");
|
||||
expect(plan.roots).toContain(bundledPluginRoot("memory-core"));
|
||||
expect(plan.hasTests).toBe(true);
|
||||
});
|
||||
|
||||
it("resolves msteams onto the msteams vitest config", () => {
|
||||
const plan = resolveExtensionTestPlan({ targetArg: "msteams", cwd: process.cwd() });
|
||||
|
||||
expect(plan.extensionId).toBe("msteams");
|
||||
expect(plan.config).toBe("test/vitest/vitest.extension-msteams.config.ts");
|
||||
expect(plan.roots).toContain(bundledPluginRoot("msteams"));
|
||||
expect(plan.hasTests).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps non-provider extensions on the shared extensions vitest config", () => {
|
||||
const plan = resolveExtensionTestPlan({ targetArg: "firecrawl", cwd: process.cwd() });
|
||||
|
||||
expect(plan.extensionId).toBe("firecrawl");
|
||||
expect(plan.config).toBe("test/vitest/vitest.extensions.config.ts");
|
||||
expect(plan.roots).toContain(bundledPluginRoot("firecrawl"));
|
||||
expect(plan.hasTests).toBe(true);
|
||||
});
|
||||
|
||||
it("omits src/<extension> when no paired core root exists", () => {
|
||||
const plan = resolveExtensionTestPlan({ targetArg: "line", cwd: process.cwd() });
|
||||
|
||||
expect(plan.roots).toContain(bundledPluginRoot("line"));
|
||||
expect(plan.roots).not.toContain("src/line");
|
||||
expect(plan.config).toBe("test/vitest/vitest.extension-channels.config.ts");
|
||||
expect(plan.hasTests).toBe(true);
|
||||
});
|
||||
|
||||
it("infers the extension from the current working directory", () => {
|
||||
const cwd = path.join(process.cwd(), "extensions", "slack");
|
||||
const plan = resolveExtensionTestPlan({ cwd });
|
||||
|
||||
expect(plan.extensionId).toBe("slack");
|
||||
expect(plan.extensionDir).toBe(bundledPluginRoot("slack"));
|
||||
});
|
||||
|
||||
it("maps changed paths back to extension ids", () => {
|
||||
const extensionIds = detectChangedExtensionIds([
|
||||
bundledPluginFile("slack", "src/channel.ts"),
|
||||
"src/line/message.test.ts",
|
||||
bundledPluginFile("firecrawl", "package.json"),
|
||||
"src/not-a-plugin/file.ts",
|
||||
]);
|
||||
|
||||
expect(extensionIds).toEqual(["firecrawl", "line", "slack"]);
|
||||
});
|
||||
|
||||
it("lists available extension ids", () => {
|
||||
const extensionIds = listAvailableExtensionIds();
|
||||
|
||||
expect(extensionIds).toContain("slack");
|
||||
expect(extensionIds).toContain("firecrawl");
|
||||
expect(extensionIds).toEqual(
|
||||
[...extensionIds].toSorted((left, right) => left.localeCompare(right)),
|
||||
);
|
||||
});
|
||||
|
||||
it("can fail safe to all extensions when the base revision is unavailable", () => {
|
||||
const extensionIds = listChangedExtensionIds({
|
||||
base: "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef",
|
||||
unavailableBaseBehavior: "all",
|
||||
});
|
||||
|
||||
expect(extensionIds).toEqual(listAvailableExtensionIds());
|
||||
});
|
||||
|
||||
it("resolves a plan for extensions without tests", () => {
|
||||
const extensionId = findExtensionWithoutTests();
|
||||
const plan = resolveExtensionTestPlan({ cwd: process.cwd(), targetArg: extensionId });
|
||||
|
||||
expect(plan.extensionId).toBe(extensionId);
|
||||
expect(plan.hasTests).toBe(false);
|
||||
expect(plan.testFileCount).toBe(0);
|
||||
});
|
||||
|
||||
it("batches extensions into config-specific vitest invocations", () => {
|
||||
const batch = resolveExtensionBatchPlan({
|
||||
cwd: process.cwd(),
|
||||
extensionIds: [
|
||||
"slack",
|
||||
"firecrawl",
|
||||
"line",
|
||||
"openai",
|
||||
"matrix",
|
||||
"telegram",
|
||||
"mattermost",
|
||||
"voice-call",
|
||||
"whatsapp",
|
||||
"zalo",
|
||||
"zalouser",
|
||||
"memory-core",
|
||||
"msteams",
|
||||
"feishu",
|
||||
"irc",
|
||||
"bluebubbles",
|
||||
"acpx",
|
||||
"diffs",
|
||||
],
|
||||
});
|
||||
|
||||
expect(batch.extensionIds).toEqual([
|
||||
"acpx",
|
||||
"bluebubbles",
|
||||
"diffs",
|
||||
"feishu",
|
||||
"firecrawl",
|
||||
"irc",
|
||||
"line",
|
||||
"matrix",
|
||||
"mattermost",
|
||||
"memory-core",
|
||||
"msteams",
|
||||
"openai",
|
||||
"slack",
|
||||
"telegram",
|
||||
"voice-call",
|
||||
"whatsapp",
|
||||
"zalo",
|
||||
"zalouser",
|
||||
]);
|
||||
expect(batch.planGroups).toEqual([
|
||||
{
|
||||
config: "test/vitest/vitest.extension-acpx.config.ts",
|
||||
estimatedCost: expect.any(Number),
|
||||
extensionIds: ["acpx"],
|
||||
roots: [bundledPluginRoot("acpx")],
|
||||
testFileCount: expect.any(Number),
|
||||
},
|
||||
{
|
||||
config: "test/vitest/vitest.extension-bluebubbles.config.ts",
|
||||
estimatedCost: expect.any(Number),
|
||||
extensionIds: ["bluebubbles"],
|
||||
roots: [bundledPluginRoot("bluebubbles")],
|
||||
testFileCount: expect.any(Number),
|
||||
},
|
||||
{
|
||||
config: "test/vitest/vitest.extension-channels.config.ts",
|
||||
estimatedCost: expect.any(Number),
|
||||
extensionIds: ["line", "slack"],
|
||||
roots: [bundledPluginRoot("slack"), bundledPluginRoot("line")],
|
||||
testFileCount: expect.any(Number),
|
||||
},
|
||||
{
|
||||
config: "test/vitest/vitest.extension-diffs.config.ts",
|
||||
estimatedCost: expect.any(Number),
|
||||
extensionIds: ["diffs"],
|
||||
roots: [bundledPluginRoot("diffs")],
|
||||
testFileCount: expect.any(Number),
|
||||
},
|
||||
{
|
||||
config: "test/vitest/vitest.extension-feishu.config.ts",
|
||||
estimatedCost: expect.any(Number),
|
||||
extensionIds: ["feishu"],
|
||||
roots: [bundledPluginRoot("feishu")],
|
||||
testFileCount: expect.any(Number),
|
||||
},
|
||||
{
|
||||
config: "test/vitest/vitest.extension-irc.config.ts",
|
||||
estimatedCost: expect.any(Number),
|
||||
extensionIds: ["irc"],
|
||||
roots: [bundledPluginRoot("irc")],
|
||||
testFileCount: expect.any(Number),
|
||||
},
|
||||
{
|
||||
config: "test/vitest/vitest.extension-matrix.config.ts",
|
||||
estimatedCost: expect.any(Number),
|
||||
extensionIds: ["matrix"],
|
||||
roots: [bundledPluginRoot("matrix")],
|
||||
testFileCount: expect.any(Number),
|
||||
},
|
||||
{
|
||||
config: "test/vitest/vitest.extension-mattermost.config.ts",
|
||||
estimatedCost: expect.any(Number),
|
||||
extensionIds: ["mattermost"],
|
||||
roots: [bundledPluginRoot("mattermost")],
|
||||
testFileCount: expect.any(Number),
|
||||
},
|
||||
{
|
||||
config: "test/vitest/vitest.extension-memory.config.ts",
|
||||
estimatedCost: expect.any(Number),
|
||||
extensionIds: ["memory-core"],
|
||||
roots: [bundledPluginRoot("memory-core")],
|
||||
testFileCount: expect.any(Number),
|
||||
},
|
||||
{
|
||||
config: "test/vitest/vitest.extension-msteams.config.ts",
|
||||
estimatedCost: expect.any(Number),
|
||||
extensionIds: ["msteams"],
|
||||
roots: [bundledPluginRoot("msteams")],
|
||||
testFileCount: expect.any(Number),
|
||||
},
|
||||
{
|
||||
config: "test/vitest/vitest.extension-providers.config.ts",
|
||||
estimatedCost: expect.any(Number),
|
||||
extensionIds: ["openai"],
|
||||
roots: [bundledPluginRoot("openai")],
|
||||
testFileCount: expect.any(Number),
|
||||
},
|
||||
{
|
||||
config: "test/vitest/vitest.extension-telegram.config.ts",
|
||||
estimatedCost: expect.any(Number),
|
||||
extensionIds: ["telegram"],
|
||||
roots: [bundledPluginRoot("telegram")],
|
||||
testFileCount: expect.any(Number),
|
||||
},
|
||||
{
|
||||
config: "test/vitest/vitest.extension-voice-call.config.ts",
|
||||
estimatedCost: expect.any(Number),
|
||||
extensionIds: ["voice-call"],
|
||||
roots: [bundledPluginRoot("voice-call")],
|
||||
testFileCount: expect.any(Number),
|
||||
},
|
||||
{
|
||||
config: "test/vitest/vitest.extension-whatsapp.config.ts",
|
||||
estimatedCost: expect.any(Number),
|
||||
extensionIds: ["whatsapp"],
|
||||
roots: [bundledPluginRoot("whatsapp")],
|
||||
testFileCount: expect.any(Number),
|
||||
},
|
||||
{
|
||||
config: "test/vitest/vitest.extension-zalo.config.ts",
|
||||
estimatedCost: expect.any(Number),
|
||||
extensionIds: ["zalo", "zalouser"],
|
||||
roots: [bundledPluginRoot("zalo"), bundledPluginRoot("zalouser")],
|
||||
testFileCount: expect.any(Number),
|
||||
},
|
||||
{
|
||||
config: "test/vitest/vitest.extensions.config.ts",
|
||||
estimatedCost: expect.any(Number),
|
||||
extensionIds: ["firecrawl"],
|
||||
roots: [bundledPluginRoot("firecrawl")],
|
||||
testFileCount: expect.any(Number),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("balances extension test shards by estimated CI cost", () => {
|
||||
const shards = createExtensionTestShards({
|
||||
cwd: process.cwd(),
|
||||
shardCount: DEFAULT_EXTENSION_TEST_SHARD_COUNT,
|
||||
});
|
||||
|
||||
expect(shards).toHaveLength(DEFAULT_EXTENSION_TEST_SHARD_COUNT);
|
||||
expect(shards.map((shard) => shard.checkName)).toEqual(
|
||||
shards.map((shard, index) => `checks-node-extensions-shard-${index + 1}`),
|
||||
);
|
||||
|
||||
const assigned = shards.flatMap((shard) => shard.extensionIds);
|
||||
const uniqueAssigned = [...new Set(assigned)];
|
||||
const expected = listAvailableExtensionIds().filter(
|
||||
(extensionId) =>
|
||||
resolveExtensionTestPlan({ cwd: process.cwd(), targetArg: extensionId }).hasTests,
|
||||
);
|
||||
|
||||
expect(uniqueAssigned.toSorted((left, right) => left.localeCompare(right))).toEqual(
|
||||
expected.toSorted((left, right) => left.localeCompare(right)),
|
||||
);
|
||||
expect(assigned).toHaveLength(expected.length);
|
||||
|
||||
const totals = shards.map((shard) => shard.estimatedCost);
|
||||
expect(Math.max(...totals) - Math.min(...totals)).toBeLessThanOrEqual(1);
|
||||
|
||||
const msTeamsShardIndex = shards.findIndex((shard) => shard.extensionIds.includes("msteams"));
|
||||
const feishuShardIndex = shards.findIndex((shard) => shard.extensionIds.includes("feishu"));
|
||||
|
||||
expect(msTeamsShardIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(feishuShardIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(msTeamsShardIndex).not.toBe(feishuShardIndex);
|
||||
});
|
||||
|
||||
it("treats extensions without tests as a no-op by default", () => {
|
||||
const extensionId = findExtensionWithoutTests();
|
||||
const stdout = runScript([extensionId]);
|
||||
|
||||
expect(stdout).toContain(`No tests found for ${bundledPluginRoot(extensionId)}.`);
|
||||
expect(stdout).toContain("Skipping.");
|
||||
});
|
||||
});
|
||||
215
openclaw/test/scripts/test-group-report.test.ts
Normal file
215
openclaw/test/scripts/test-group-report.test.ts
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildGroupedTestComparison,
|
||||
buildGroupedTestReport,
|
||||
renderGroupedTestComparison,
|
||||
resolveGroupKey,
|
||||
resolveTestArea,
|
||||
} from "../../scripts/lib/test-group-report.mjs";
|
||||
import {
|
||||
parseTestGroupReportArgs,
|
||||
resolveReportArtifactDirs,
|
||||
} from "../../scripts/test-group-report.mjs";
|
||||
|
||||
describe("scripts/test-group-report grouping", () => {
|
||||
it("groups repo files by stable product area", () => {
|
||||
expect(resolveTestArea("extensions/discord/src/send.test.ts")).toBe("extensions/discord");
|
||||
expect(resolveTestArea("src/commands/agent.test.ts")).toBe("src/commands");
|
||||
expect(resolveTestArea("packages/plugin-sdk/src/index.test.ts")).toBe("packages/plugin-sdk");
|
||||
expect(resolveTestArea("ui/src/ui/views/chat.test.ts")).toBe("ui/views");
|
||||
expect(resolveTestArea("test/scripts/test-group-report.test.ts")).toBe("test/scripts");
|
||||
});
|
||||
|
||||
it("supports folder and top-level grouping modes", () => {
|
||||
expect(resolveGroupKey("src/commands/agent.test.ts", "folder")).toBe("src/commands");
|
||||
expect(resolveGroupKey("extensions/browser/src/browser/pw.test.ts", "folder")).toBe(
|
||||
"extensions/browser/src",
|
||||
);
|
||||
expect(resolveGroupKey("extensions/browser/src/browser/pw.test.ts", "top")).toBe("extensions");
|
||||
});
|
||||
});
|
||||
|
||||
describe("scripts/test-group-report aggregation", () => {
|
||||
it("aggregates file durations by group and config", () => {
|
||||
const report = buildGroupedTestReport({
|
||||
groupBy: "area",
|
||||
reports: [
|
||||
{
|
||||
config: "test/vitest/vitest.commands.config.ts",
|
||||
report: {
|
||||
testResults: [
|
||||
{
|
||||
name: path.join(process.cwd(), "src", "commands", "agent.test.ts"),
|
||||
startTime: 100,
|
||||
endTime: 700,
|
||||
assertionResults: [{}, {}],
|
||||
},
|
||||
{
|
||||
name: path.join(process.cwd(), "extensions", "discord", "src", "send.test.ts"),
|
||||
startTime: 200,
|
||||
endTime: 450,
|
||||
assertionResults: [{}],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(report.totals).toEqual({ durationMs: 850, fileCount: 2, testCount: 3 });
|
||||
expect(report.groups.map((group) => [group.key, group.durationMs])).toEqual([
|
||||
["src/commands", 600],
|
||||
["extensions/discord", 250],
|
||||
]);
|
||||
expect(report.configs).toMatchObject([
|
||||
{
|
||||
key: "commands",
|
||||
durationMs: 850,
|
||||
fileCount: 2,
|
||||
testCount: 3,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("scripts/test-group-report comparison", () => {
|
||||
it("compares grouped reports by group, file, config, and run metrics", () => {
|
||||
const comparison = buildGroupedTestComparison({
|
||||
beforePath: "before.json",
|
||||
afterPath: "after.json",
|
||||
before: {
|
||||
groupBy: "area",
|
||||
totals: { durationMs: 1000, fileCount: 2, testCount: 4 },
|
||||
groups: [
|
||||
{ key: "src/commands", durationMs: 700, fileCount: 1, testCount: 2 },
|
||||
{ key: "extensions/discord", durationMs: 300, fileCount: 1, testCount: 2 },
|
||||
],
|
||||
configs: [{ key: "commands", durationMs: 1000, fileCount: 2, testCount: 4 }],
|
||||
topFiles: [
|
||||
{
|
||||
config: "commands",
|
||||
file: "src/commands/agent.test.ts",
|
||||
group: "src/commands",
|
||||
durationMs: 700,
|
||||
testCount: 2,
|
||||
},
|
||||
{
|
||||
config: "commands",
|
||||
file: "extensions/discord/src/send.test.ts",
|
||||
group: "extensions/discord",
|
||||
durationMs: 300,
|
||||
testCount: 2,
|
||||
},
|
||||
],
|
||||
runs: [
|
||||
{
|
||||
config: "test/vitest/vitest.commands.config.ts",
|
||||
elapsedMs: 2000,
|
||||
maxRssBytes: 1024 * 1024 * 100,
|
||||
status: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
after: {
|
||||
groupBy: "area",
|
||||
totals: { durationMs: 900, fileCount: 2, testCount: 5 },
|
||||
groups: [{ key: "src/commands", durationMs: 900, fileCount: 2, testCount: 5 }],
|
||||
configs: [{ key: "commands", durationMs: 900, fileCount: 2, testCount: 5 }],
|
||||
topFiles: [
|
||||
{
|
||||
config: "commands",
|
||||
file: "src/commands/agent.test.ts",
|
||||
group: "src/commands",
|
||||
durationMs: 800,
|
||||
testCount: 3,
|
||||
},
|
||||
{
|
||||
config: "commands",
|
||||
file: "src/commands/new.test.ts",
|
||||
group: "src/commands",
|
||||
durationMs: 100,
|
||||
testCount: 2,
|
||||
},
|
||||
],
|
||||
runs: [
|
||||
{
|
||||
config: "test/vitest/vitest.commands.config.ts",
|
||||
elapsedMs: 1800,
|
||||
maxRssBytes: 1024 * 1024 * 80,
|
||||
status: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(comparison.totals.delta).toEqual({ durationMs: -100, fileCount: 0, testCount: 1 });
|
||||
expect(comparison.groups.find((group) => group.key === "src/commands")).toMatchObject({
|
||||
delta: { durationMs: 200, fileCount: 1, testCount: 3 },
|
||||
});
|
||||
expect(
|
||||
comparison.files.find((file) => file.file === "extensions/discord/src/send.test.ts"),
|
||||
).toMatchObject({
|
||||
status: "removed",
|
||||
delta: { durationMs: -300, testCount: -2 },
|
||||
});
|
||||
expect(comparison.runs[0]).toMatchObject({
|
||||
key: "commands",
|
||||
delta: { elapsedMs: -200, maxRssBytes: -1024 * 1024 * 20 },
|
||||
});
|
||||
|
||||
expect(renderGroupedTestComparison(comparison, { limit: 2, topFiles: 2 })).toContain(
|
||||
"Top group regressions",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("scripts/test-group-report arg parsing", () => {
|
||||
it("parses repeatable config and passthrough args", () => {
|
||||
expect(
|
||||
parseTestGroupReportArgs([
|
||||
"--config",
|
||||
"a.ts",
|
||||
"--config",
|
||||
"b.ts",
|
||||
"--group-by",
|
||||
"folder",
|
||||
"--allow-failures",
|
||||
"--",
|
||||
"--maxWorkers=1",
|
||||
]),
|
||||
).toMatchObject({
|
||||
allowFailures: true,
|
||||
configs: ["a.ts", "b.ts"],
|
||||
groupBy: "folder",
|
||||
vitestArgs: ["--maxWorkers=1"],
|
||||
});
|
||||
});
|
||||
|
||||
it("parses compare mode", () => {
|
||||
expect(
|
||||
parseTestGroupReportArgs([
|
||||
"--compare",
|
||||
"before.json",
|
||||
"after.json",
|
||||
"--limit",
|
||||
"5",
|
||||
"--top-files",
|
||||
"3",
|
||||
]),
|
||||
).toMatchObject({
|
||||
compare: { before: "before.json", after: "after.json" },
|
||||
limit: 5,
|
||||
topFiles: 3,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("scripts/test-group-report artifact paths", () => {
|
||||
it("keeps raw Vitest reports scoped to the output file stem", () => {
|
||||
expect(resolveReportArtifactDirs(".artifacts/test-perf/baseline-before.json")).toEqual({
|
||||
reportDir: path.join(".artifacts", "test-perf", "baseline-before", "vitest-json"),
|
||||
logDir: path.join(".artifacts", "test-perf", "baseline-before", "logs"),
|
||||
});
|
||||
});
|
||||
});
|
||||
41
openclaw/test/scripts/test-helpers.ts
Normal file
41
openclaw/test/scripts/test-helpers.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import fs from "node:fs";
|
||||
import fsPromises from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach } from "vitest";
|
||||
|
||||
export function createScriptTestHarness() {
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
while (tempDirs.length > 0) {
|
||||
const dir = tempDirs.pop();
|
||||
if (dir) {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function createTempDir(prefix: string): string {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||
tempDirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
async function createTempDirAsync(prefix: string): Promise<string> {
|
||||
const dir = await fsPromises.mkdtemp(path.join(os.tmpdir(), prefix));
|
||||
tempDirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
function trackTempDir(dir: string): string {
|
||||
tempDirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
return {
|
||||
createTempDir,
|
||||
createTempDirAsync,
|
||||
trackTempDir,
|
||||
};
|
||||
}
|
||||
95
openclaw/test/scripts/test-install-sh-docker.test.ts
Normal file
95
openclaw/test/scripts/test-install-sh-docker.test.ts
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const SCRIPT_PATH = "scripts/test-install-sh-docker.sh";
|
||||
const SMOKE_RUNNER_PATH = "scripts/docker/install-sh-smoke/run.sh";
|
||||
|
||||
describe("test-install-sh-docker", () => {
|
||||
it("defaults local Apple Silicon smoke runs to native arm64 while keeping CI on amd64", () => {
|
||||
const script = readFileSync(SCRIPT_PATH, "utf8");
|
||||
|
||||
expect(script).toContain("resolve_default_smoke_platform");
|
||||
expect(script).toContain('printf "linux/amd64"');
|
||||
expect(script).toContain('[[ "$host_os" == "Darwin" && "$host_arch" == "arm64" ]]');
|
||||
expect(script).toContain('printf "linux/arm64"');
|
||||
});
|
||||
|
||||
it("supports npm update package specs without a separate expected-version env", () => {
|
||||
const script = readFileSync(SCRIPT_PATH, "utf8");
|
||||
|
||||
expect(script).toContain(
|
||||
'UPDATE_EXPECT_VERSION="${OPENCLAW_INSTALL_SMOKE_UPDATE_EXPECT_VERSION:-}"',
|
||||
);
|
||||
expect(script).toContain('if [[ -z "$UPDATE_EXPECT_VERSION" ]]; then');
|
||||
expect(script).toContain('UPDATE_EXPECT_VERSION="$packed_update_version"');
|
||||
expect(script).toContain(
|
||||
"packed update version ${packed_update_version} does not match expected ${UPDATE_EXPECT_VERSION}",
|
||||
);
|
||||
});
|
||||
|
||||
it("can reuse dist from the already-built root Docker smoke image", () => {
|
||||
const script = readFileSync(SCRIPT_PATH, "utf8");
|
||||
|
||||
expect(script).toContain('UPDATE_DIST_IMAGE="${OPENCLAW_INSTALL_SMOKE_UPDATE_DIST_IMAGE:-}"');
|
||||
expect(script).toContain("restore_local_dist_from_image");
|
||||
expect(script).toContain('docker cp "${container_id}:/app/dist" "$ROOT_DIR/dist"');
|
||||
expect(script).toContain('echo "==> Reuse local dist/ from Docker image: $image"');
|
||||
});
|
||||
|
||||
it("prints package size audits for release smoke tarballs", () => {
|
||||
const script = readFileSync(SCRIPT_PATH, "utf8");
|
||||
|
||||
expect(script).toContain("print_pack_audit");
|
||||
expect(script).toContain("print_pack_delta_audit");
|
||||
expect(script).toContain("==> Pack audit");
|
||||
expect(script).toContain("==> Pack audit delta");
|
||||
});
|
||||
|
||||
it("fails the update smoke when the candidate npm pack exceeds the release budget", () => {
|
||||
const script = readFileSync(SCRIPT_PATH, "utf8");
|
||||
|
||||
expect(script).toContain("assert_pack_unpacked_size_budget");
|
||||
expect(script).toContain('assert_pack_unpacked_size_budget "update" "$pack_json_file"');
|
||||
expect(script).toContain('from "./scripts/lib/npm-pack-budget.mjs"');
|
||||
expect(script).toContain("install smoke cannot verify pack budget");
|
||||
});
|
||||
|
||||
it("writes the package dist inventory before packing ignore-scripts tarballs", () => {
|
||||
const script = readFileSync(SCRIPT_PATH, "utf8");
|
||||
|
||||
expect(script).toContain("node --import tsx scripts/write-package-dist-inventory.ts");
|
||||
expect(script).toContain("quiet_npm pack --ignore-scripts");
|
||||
});
|
||||
});
|
||||
|
||||
describe("install-sh smoke runner", () => {
|
||||
it("wraps long npm/update operations with heartbeat and install-size audits", () => {
|
||||
const script = readFileSync(SMOKE_RUNNER_PATH, "utf8");
|
||||
|
||||
expect(script).toContain(
|
||||
'HEARTBEAT_INTERVAL="${OPENCLAW_INSTALL_SMOKE_HEARTBEAT_INTERVAL:-60}"',
|
||||
);
|
||||
expect(script).toContain(
|
||||
'INSTALL_COMMAND_TIMEOUT="${OPENCLAW_INSTALL_SMOKE_COMMAND_TIMEOUT:-300}"',
|
||||
);
|
||||
expect(script).toContain("run_with_heartbeat");
|
||||
expect(script).toContain("npm_install_global");
|
||||
expect(script).toContain('timeout --foreground "${INSTALL_COMMAND_TIMEOUT}s"');
|
||||
expect(script).toContain("==> Still running");
|
||||
expect(script).toContain("print_install_audit");
|
||||
expect(script).toContain('install -g "$@"');
|
||||
expect(script).toContain("openclaw update --tag");
|
||||
});
|
||||
|
||||
it("covers plain npm global installs and npm-driven updates", () => {
|
||||
const script = readFileSync(SCRIPT_PATH, "utf8");
|
||||
const runner = readFileSync(SMOKE_RUNNER_PATH, "utf8");
|
||||
|
||||
expect(script).toContain('SKIP_NPM_GLOBAL="${OPENCLAW_INSTALL_SMOKE_SKIP_NPM_GLOBAL:-0}"');
|
||||
expect(script).toContain("==> Run direct npm global smoke");
|
||||
expect(script).toContain("OPENCLAW_INSTALL_SMOKE_MODE=npm-global");
|
||||
expect(runner).toContain("run_npm_global_smoke");
|
||||
expect(runner).toContain("==> Direct npm global install candidate");
|
||||
expect(runner).toContain("==> Direct npm global update candidate");
|
||||
});
|
||||
});
|
||||
22
openclaw/test/scripts/test-live-cli-backend-docker.test.ts
Normal file
22
openclaw/test/scripts/test-live-cli-backend-docker.test.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const SCRIPT_PATH = path.resolve(
|
||||
import.meta.dirname,
|
||||
"../../scripts/test-live-cli-backend-docker.sh",
|
||||
);
|
||||
|
||||
function readForwardedDockerEnvVars(): string[] {
|
||||
const script = fs.readFileSync(SCRIPT_PATH, "utf8");
|
||||
return Array.from(script.matchAll(/-e\s+([A-Z0-9_]+)=/g), (match) => match[1] ?? "");
|
||||
}
|
||||
|
||||
describe("scripts/test-live-cli-backend-docker.sh", () => {
|
||||
it("forwards both fresh and resume CLI arg overrides into the Docker container", () => {
|
||||
const forwardedVars = readForwardedDockerEnvVars();
|
||||
|
||||
expect(forwardedVars).toContain("OPENCLAW_LIVE_CLI_BACKEND_ARGS");
|
||||
expect(forwardedVars).toContain("OPENCLAW_LIVE_CLI_BACKEND_RESUME_ARGS");
|
||||
});
|
||||
});
|
||||
630
openclaw/test/scripts/test-projects.test.ts
Normal file
630
openclaw/test/scripts/test-projects.test.ts
Normal file
|
|
@ -0,0 +1,630 @@
|
|||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
applyParallelVitestCachePaths,
|
||||
buildFullSuiteVitestRunPlans,
|
||||
buildVitestRunPlans,
|
||||
shouldAcquireLocalHeavyCheckLock,
|
||||
resolveChangedTargetArgs,
|
||||
resolveParallelFullSuiteConcurrency,
|
||||
} from "../../scripts/test-projects.test-support.mjs";
|
||||
|
||||
describe("scripts/test-projects changed-target routing", () => {
|
||||
it("maps changed source files into scoped lane targets", () => {
|
||||
expect(
|
||||
resolveChangedTargetArgs(["--changed", "origin/main"], process.cwd(), () => [
|
||||
"src/shared/string-normalization.ts",
|
||||
"src/utils/provider-utils.ts",
|
||||
]),
|
||||
).toEqual(["src/shared/string-normalization.ts", "src/utils/provider-utils.ts"]);
|
||||
});
|
||||
|
||||
it("keeps the broad changed run for Vitest wiring edits", () => {
|
||||
expect(
|
||||
resolveChangedTargetArgs(["--changed", "origin/main"], process.cwd(), () => [
|
||||
"test/vitest/vitest.shared.config.ts",
|
||||
"src/utils/provider-utils.ts",
|
||||
]),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("ignores changed files that cannot map to test lanes", () => {
|
||||
expect(
|
||||
resolveChangedTargetArgs(["--changed", "origin/main"], process.cwd(), () => [
|
||||
"docs/help/testing.md",
|
||||
]),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("narrows default-lane changed source files to include globs", () => {
|
||||
const plans = buildVitestRunPlans(["--changed", "origin/main"], process.cwd(), () => [
|
||||
"packages/sdk/src/index.ts",
|
||||
]);
|
||||
|
||||
expect(plans).toEqual([
|
||||
{
|
||||
config: "test/vitest/vitest.unit.config.ts",
|
||||
forwardedArgs: [],
|
||||
includePatterns: ["packages/sdk/src/**/*.test.ts"],
|
||||
watchMode: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("routes changed utils and shared files to their light scoped lanes", () => {
|
||||
const plans = buildVitestRunPlans(["--changed", "origin/main"], process.cwd(), () => [
|
||||
"src/shared/string-normalization.ts",
|
||||
"src/utils/provider-utils.ts",
|
||||
]);
|
||||
|
||||
expect(plans).toEqual([
|
||||
{
|
||||
config: "test/vitest/vitest.unit-fast.config.ts",
|
||||
forwardedArgs: [],
|
||||
includePatterns: ["src/shared/string-normalization.test.ts"],
|
||||
watchMode: false,
|
||||
},
|
||||
{
|
||||
config: "test/vitest/vitest.utils.config.ts",
|
||||
forwardedArgs: [],
|
||||
includePatterns: ["src/utils/**/*.test.ts"],
|
||||
watchMode: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("routes explicit plugin-sdk light tests to the lighter plugin-sdk lane", () => {
|
||||
const plans = buildVitestRunPlans(["src/plugin-sdk/temp-path.test.ts"], process.cwd());
|
||||
|
||||
expect(plans).toEqual([
|
||||
{
|
||||
config: "test/vitest/vitest.plugin-sdk-light.config.ts",
|
||||
forwardedArgs: [],
|
||||
includePatterns: ["src/plugin-sdk/temp-path.test.ts"],
|
||||
watchMode: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("routes explicit commands light tests to the lighter commands lane", () => {
|
||||
const plans = buildVitestRunPlans(["src/commands/status-json-runtime.test.ts"], process.cwd());
|
||||
|
||||
expect(plans).toEqual([
|
||||
{
|
||||
config: "test/vitest/vitest.commands-light.config.ts",
|
||||
forwardedArgs: [],
|
||||
includePatterns: ["src/commands/status-json-runtime.test.ts"],
|
||||
watchMode: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("routes unit-fast light tests to the cache-friendly unit-fast lane", () => {
|
||||
const plans = buildVitestRunPlans(
|
||||
["src/commands/status-overview-values.test.ts"],
|
||||
process.cwd(),
|
||||
);
|
||||
|
||||
expect(plans).toEqual([
|
||||
{
|
||||
config: "test/vitest/vitest.unit-fast.config.ts",
|
||||
forwardedArgs: [],
|
||||
includePatterns: ["src/commands/status-overview-values.test.ts"],
|
||||
watchMode: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("routes changed plugin-sdk source allowlist files to sibling light tests", () => {
|
||||
const plans = buildVitestRunPlans(["--changed", "origin/main"], process.cwd(), () => [
|
||||
"src/plugin-sdk/provider-entry.ts",
|
||||
]);
|
||||
|
||||
expect(plans).toEqual([
|
||||
{
|
||||
config: "test/vitest/vitest.unit-fast.config.ts",
|
||||
forwardedArgs: [],
|
||||
includePatterns: ["src/plugin-sdk/provider-entry.test.ts"],
|
||||
watchMode: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("routes changed commands source allowlist files to sibling light tests", () => {
|
||||
const plans = buildVitestRunPlans(["--changed", "origin/main"], process.cwd(), () => [
|
||||
"src/commands/status-overview-values.ts",
|
||||
"src/commands/gateway-status/helpers.ts",
|
||||
]);
|
||||
|
||||
expect(plans).toEqual([
|
||||
{
|
||||
config: "test/vitest/vitest.unit-fast.config.ts",
|
||||
forwardedArgs: [],
|
||||
includePatterns: [
|
||||
"src/commands/status-overview-values.test.ts",
|
||||
"src/commands/gateway-status/helpers.test.ts",
|
||||
],
|
||||
watchMode: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps non-allowlisted plugin-sdk source files on the heavy lane", () => {
|
||||
const plans = buildVitestRunPlans(["--changed", "origin/main"], process.cwd(), () => [
|
||||
"src/plugin-sdk/facade-runtime.ts",
|
||||
]);
|
||||
|
||||
expect(plans).toEqual([
|
||||
{
|
||||
config: "test/vitest/vitest.plugin-sdk.config.ts",
|
||||
forwardedArgs: [],
|
||||
includePatterns: ["src/plugin-sdk/**/*.test.ts"],
|
||||
watchMode: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps non-allowlisted commands source files on the heavy lane", () => {
|
||||
const plans = buildVitestRunPlans(["--changed", "origin/main"], process.cwd(), () => [
|
||||
"src/commands/channels.add.ts",
|
||||
]);
|
||||
|
||||
expect(plans).toEqual([
|
||||
{
|
||||
config: "test/vitest/vitest.commands.config.ts",
|
||||
forwardedArgs: [],
|
||||
includePatterns: ["src/commands/**/*.test.ts"],
|
||||
watchMode: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"src/gateway/gateway.test.ts",
|
||||
"src/gateway/server.startup-matrix-migration.integration.test.ts",
|
||||
"src/gateway/sessions-history-http.test.ts",
|
||||
])("routes gateway integration fixture %s to the e2e lane", (target) => {
|
||||
const plans = buildVitestRunPlans([target], process.cwd());
|
||||
|
||||
expect(plans).toEqual([
|
||||
{
|
||||
config: "test/vitest/vitest.e2e.config.ts",
|
||||
forwardedArgs: [target],
|
||||
includePatterns: null,
|
||||
watchMode: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("scripts/test-projects local heavy-check lock", () => {
|
||||
it("skips the lock for a single scoped tooling run", () => {
|
||||
expect(
|
||||
shouldAcquireLocalHeavyCheckLock(
|
||||
[
|
||||
{
|
||||
config: "test/vitest/vitest.tooling.config.ts",
|
||||
includePatterns: ["test/scripts/committer.test.ts"],
|
||||
watchMode: false,
|
||||
},
|
||||
],
|
||||
process.env,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps the lock for non-tooling runs", () => {
|
||||
expect(
|
||||
shouldAcquireLocalHeavyCheckLock(
|
||||
[
|
||||
{
|
||||
config: "test/vitest/vitest.unit.config.ts",
|
||||
includePatterns: ["src/infra/vitest-config.test.ts"],
|
||||
watchMode: false,
|
||||
},
|
||||
],
|
||||
process.env,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("allows forcing the lock back on", () => {
|
||||
expect(
|
||||
shouldAcquireLocalHeavyCheckLock(
|
||||
[
|
||||
{
|
||||
config: "test/vitest/vitest.tooling.config.ts",
|
||||
includePatterns: ["test/scripts/committer.test.ts"],
|
||||
watchMode: false,
|
||||
},
|
||||
],
|
||||
{
|
||||
...process.env,
|
||||
OPENCLAW_TEST_PROJECTS_FORCE_LOCK: "1",
|
||||
},
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("scripts/test-projects full-suite sharding", () => {
|
||||
it("uses the large host-aware local profile on roomy local hosts", () => {
|
||||
expect(
|
||||
resolveParallelFullSuiteConcurrency(
|
||||
61,
|
||||
{},
|
||||
{
|
||||
cpuCount: 14,
|
||||
loadAverage1m: 0,
|
||||
totalMemoryBytes: 48 * 1024 ** 3,
|
||||
},
|
||||
),
|
||||
).toBe(10);
|
||||
});
|
||||
|
||||
it("keeps CI full-suite runs serial even on roomy hosts", () => {
|
||||
expect(
|
||||
resolveParallelFullSuiteConcurrency(
|
||||
61,
|
||||
{
|
||||
CI: "true",
|
||||
},
|
||||
{
|
||||
cpuCount: 14,
|
||||
loadAverage1m: 0,
|
||||
totalMemoryBytes: 48 * 1024 ** 3,
|
||||
},
|
||||
),
|
||||
).toBe(1);
|
||||
});
|
||||
|
||||
it("keeps explicit parallel overrides ahead of the host-aware profile", () => {
|
||||
expect(
|
||||
resolveParallelFullSuiteConcurrency(
|
||||
61,
|
||||
{
|
||||
OPENCLAW_TEST_PROJECTS_PARALLEL: "3",
|
||||
},
|
||||
{
|
||||
cpuCount: 14,
|
||||
loadAverage1m: 0,
|
||||
totalMemoryBytes: 48 * 1024 ** 3,
|
||||
},
|
||||
),
|
||||
).toBe(3);
|
||||
});
|
||||
|
||||
it("splits untargeted runs into fixed core shards and per-extension configs", () => {
|
||||
const previousParallel = process.env.OPENCLAW_TEST_PROJECTS_PARALLEL;
|
||||
const previousSerial = process.env.OPENCLAW_TEST_PROJECTS_SERIAL;
|
||||
delete process.env.OPENCLAW_TEST_PROJECTS_LEAF_SHARDS;
|
||||
delete process.env.OPENCLAW_TEST_SKIP_FULL_EXTENSIONS_SHARD;
|
||||
delete process.env.OPENCLAW_TEST_PROJECTS_PARALLEL;
|
||||
process.env.OPENCLAW_TEST_PROJECTS_SERIAL = "1";
|
||||
try {
|
||||
expect(buildFullSuiteVitestRunPlans([], process.cwd()).map((plan) => plan.config)).toEqual([
|
||||
"test/vitest/vitest.full-core-unit-fast.config.ts",
|
||||
"test/vitest/vitest.full-core-unit-src.config.ts",
|
||||
"test/vitest/vitest.full-core-unit-security.config.ts",
|
||||
"test/vitest/vitest.full-core-unit-ui.config.ts",
|
||||
"test/vitest/vitest.full-core-unit-support.config.ts",
|
||||
"test/vitest/vitest.full-core-support-boundary.config.ts",
|
||||
"test/vitest/vitest.full-core-contracts.config.ts",
|
||||
"test/vitest/vitest.full-core-bundled.config.ts",
|
||||
"test/vitest/vitest.full-core-runtime.config.ts",
|
||||
"test/vitest/vitest.full-agentic.config.ts",
|
||||
"test/vitest/vitest.full-auto-reply.config.ts",
|
||||
"test/vitest/vitest.extension-acpx.config.ts",
|
||||
"test/vitest/vitest.extension-bluebubbles.config.ts",
|
||||
"test/vitest/vitest.extension-channels.config.ts",
|
||||
"test/vitest/vitest.extension-diffs.config.ts",
|
||||
"test/vitest/vitest.extension-feishu.config.ts",
|
||||
"test/vitest/vitest.extension-irc.config.ts",
|
||||
"test/vitest/vitest.extension-mattermost.config.ts",
|
||||
"test/vitest/vitest.extension-matrix.config.ts",
|
||||
"test/vitest/vitest.extension-memory.config.ts",
|
||||
"test/vitest/vitest.extension-messaging.config.ts",
|
||||
"test/vitest/vitest.extension-msteams.config.ts",
|
||||
"test/vitest/vitest.extension-providers.config.ts",
|
||||
"test/vitest/vitest.extension-telegram.config.ts",
|
||||
"test/vitest/vitest.extension-voice-call.config.ts",
|
||||
"test/vitest/vitest.extension-whatsapp.config.ts",
|
||||
"test/vitest/vitest.extension-zalo.config.ts",
|
||||
"test/vitest/vitest.extension-browser.config.ts",
|
||||
"test/vitest/vitest.extension-qa.config.ts",
|
||||
"test/vitest/vitest.extension-media.config.ts",
|
||||
"test/vitest/vitest.extension-misc.config.ts",
|
||||
]);
|
||||
} finally {
|
||||
if (previousParallel === undefined) {
|
||||
delete process.env.OPENCLAW_TEST_PROJECTS_PARALLEL;
|
||||
} else {
|
||||
process.env.OPENCLAW_TEST_PROJECTS_PARALLEL = previousParallel;
|
||||
}
|
||||
if (previousSerial === undefined) {
|
||||
delete process.env.OPENCLAW_TEST_PROJECTS_SERIAL;
|
||||
} else {
|
||||
process.env.OPENCLAW_TEST_PROJECTS_SERIAL = previousSerial;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("expands untargeted local runs to leaf project configs by default", () => {
|
||||
const previousLeafShards = process.env.OPENCLAW_TEST_PROJECTS_LEAF_SHARDS;
|
||||
const previousParallel = process.env.OPENCLAW_TEST_PROJECTS_PARALLEL;
|
||||
const previousSerial = process.env.OPENCLAW_TEST_PROJECTS_SERIAL;
|
||||
const previousCi = process.env.CI;
|
||||
const previousActions = process.env.GITHUB_ACTIONS;
|
||||
const previousVitestMaxWorkers = process.env.OPENCLAW_VITEST_MAX_WORKERS;
|
||||
const previousTestWorkers = process.env.OPENCLAW_TEST_WORKERS;
|
||||
delete process.env.OPENCLAW_TEST_PROJECTS_LEAF_SHARDS;
|
||||
delete process.env.OPENCLAW_TEST_PROJECTS_PARALLEL;
|
||||
delete process.env.OPENCLAW_TEST_PROJECTS_SERIAL;
|
||||
delete process.env.CI;
|
||||
delete process.env.GITHUB_ACTIONS;
|
||||
delete process.env.OPENCLAW_VITEST_MAX_WORKERS;
|
||||
delete process.env.OPENCLAW_TEST_WORKERS;
|
||||
try {
|
||||
const configs = buildFullSuiteVitestRunPlans([], process.cwd()).map((plan) => plan.config);
|
||||
|
||||
expect(configs).toContain("test/vitest/vitest.gateway-server.config.ts");
|
||||
expect(configs).toContain("test/vitest/vitest.extension-telegram.config.ts");
|
||||
expect(configs).not.toContain("test/vitest/vitest.full-agentic.config.ts");
|
||||
expect(configs).not.toContain("test/vitest/vitest.full-core-unit-fast.config.ts");
|
||||
} finally {
|
||||
if (previousLeafShards === undefined) {
|
||||
delete process.env.OPENCLAW_TEST_PROJECTS_LEAF_SHARDS;
|
||||
} else {
|
||||
process.env.OPENCLAW_TEST_PROJECTS_LEAF_SHARDS = previousLeafShards;
|
||||
}
|
||||
if (previousParallel === undefined) {
|
||||
delete process.env.OPENCLAW_TEST_PROJECTS_PARALLEL;
|
||||
} else {
|
||||
process.env.OPENCLAW_TEST_PROJECTS_PARALLEL = previousParallel;
|
||||
}
|
||||
if (previousSerial === undefined) {
|
||||
delete process.env.OPENCLAW_TEST_PROJECTS_SERIAL;
|
||||
} else {
|
||||
process.env.OPENCLAW_TEST_PROJECTS_SERIAL = previousSerial;
|
||||
}
|
||||
if (previousCi === undefined) {
|
||||
delete process.env.CI;
|
||||
} else {
|
||||
process.env.CI = previousCi;
|
||||
}
|
||||
if (previousActions === undefined) {
|
||||
delete process.env.GITHUB_ACTIONS;
|
||||
} else {
|
||||
process.env.GITHUB_ACTIONS = previousActions;
|
||||
}
|
||||
if (previousVitestMaxWorkers === undefined) {
|
||||
delete process.env.OPENCLAW_VITEST_MAX_WORKERS;
|
||||
} else {
|
||||
process.env.OPENCLAW_VITEST_MAX_WORKERS = previousVitestMaxWorkers;
|
||||
}
|
||||
if (previousTestWorkers === undefined) {
|
||||
delete process.env.OPENCLAW_TEST_WORKERS;
|
||||
} else {
|
||||
process.env.OPENCLAW_TEST_WORKERS = previousTestWorkers;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("can skip the aggregate extension shard when CI runs dedicated extension shards", () => {
|
||||
const previous = process.env.OPENCLAW_TEST_SKIP_FULL_EXTENSIONS_SHARD;
|
||||
const previousParallel = process.env.OPENCLAW_TEST_PROJECTS_PARALLEL;
|
||||
const previousSerial = process.env.OPENCLAW_TEST_PROJECTS_SERIAL;
|
||||
delete process.env.OPENCLAW_TEST_PROJECTS_PARALLEL;
|
||||
process.env.OPENCLAW_TEST_PROJECTS_SERIAL = "1";
|
||||
process.env.OPENCLAW_TEST_SKIP_FULL_EXTENSIONS_SHARD = "1";
|
||||
try {
|
||||
const configs = buildFullSuiteVitestRunPlans([], process.cwd()).map((plan) => plan.config);
|
||||
|
||||
expect(configs).not.toContain("test/vitest/vitest.full-extensions.config.ts");
|
||||
expect(configs).toContain("test/vitest/vitest.full-auto-reply.config.ts");
|
||||
} finally {
|
||||
if (previous === undefined) {
|
||||
delete process.env.OPENCLAW_TEST_SKIP_FULL_EXTENSIONS_SHARD;
|
||||
} else {
|
||||
process.env.OPENCLAW_TEST_SKIP_FULL_EXTENSIONS_SHARD = previous;
|
||||
}
|
||||
if (previousParallel === undefined) {
|
||||
delete process.env.OPENCLAW_TEST_PROJECTS_PARALLEL;
|
||||
} else {
|
||||
process.env.OPENCLAW_TEST_PROJECTS_PARALLEL = previousParallel;
|
||||
}
|
||||
if (previousSerial === undefined) {
|
||||
delete process.env.OPENCLAW_TEST_PROJECTS_SERIAL;
|
||||
} else {
|
||||
process.env.OPENCLAW_TEST_PROJECTS_SERIAL = previousSerial;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("can expand full-suite shards to project configs for perf experiments", () => {
|
||||
const previous = process.env.OPENCLAW_TEST_PROJECTS_LEAF_SHARDS;
|
||||
process.env.OPENCLAW_TEST_PROJECTS_LEAF_SHARDS = "1";
|
||||
let plans: ReturnType<typeof buildFullSuiteVitestRunPlans>;
|
||||
try {
|
||||
plans = buildFullSuiteVitestRunPlans([], process.cwd());
|
||||
} finally {
|
||||
if (previous === undefined) {
|
||||
delete process.env.OPENCLAW_TEST_PROJECTS_LEAF_SHARDS;
|
||||
} else {
|
||||
process.env.OPENCLAW_TEST_PROJECTS_LEAF_SHARDS = previous;
|
||||
}
|
||||
}
|
||||
|
||||
expect(plans.map((plan) => plan.config)).toEqual([
|
||||
"test/vitest/vitest.unit-fast.config.ts",
|
||||
"test/vitest/vitest.unit-src.config.ts",
|
||||
"test/vitest/vitest.unit-security.config.ts",
|
||||
"test/vitest/vitest.unit-ui.config.ts",
|
||||
"test/vitest/vitest.unit-support.config.ts",
|
||||
"test/vitest/vitest.boundary.config.ts",
|
||||
"test/vitest/vitest.tooling.config.ts",
|
||||
"test/vitest/vitest.contracts.config.ts",
|
||||
"test/vitest/vitest.bundled.config.ts",
|
||||
"test/vitest/vitest.infra.config.ts",
|
||||
"test/vitest/vitest.hooks.config.ts",
|
||||
"test/vitest/vitest.acp.config.ts",
|
||||
"test/vitest/vitest.runtime-config.config.ts",
|
||||
"test/vitest/vitest.secrets.config.ts",
|
||||
"test/vitest/vitest.logging.config.ts",
|
||||
"test/vitest/vitest.process.config.ts",
|
||||
"test/vitest/vitest.cron.config.ts",
|
||||
"test/vitest/vitest.media.config.ts",
|
||||
"test/vitest/vitest.media-understanding.config.ts",
|
||||
"test/vitest/vitest.shared-core.config.ts",
|
||||
"test/vitest/vitest.tasks.config.ts",
|
||||
"test/vitest/vitest.tui.config.ts",
|
||||
"test/vitest/vitest.ui.config.ts",
|
||||
"test/vitest/vitest.utils.config.ts",
|
||||
"test/vitest/vitest.wizard.config.ts",
|
||||
"test/vitest/vitest.gateway-core.config.ts",
|
||||
"test/vitest/vitest.gateway-client.config.ts",
|
||||
"test/vitest/vitest.gateway-methods.config.ts",
|
||||
"test/vitest/vitest.gateway-server.config.ts",
|
||||
"test/vitest/vitest.cli.config.ts",
|
||||
"test/vitest/vitest.commands-light.config.ts",
|
||||
"test/vitest/vitest.commands.config.ts",
|
||||
"test/vitest/vitest.agents.config.ts",
|
||||
"test/vitest/vitest.daemon.config.ts",
|
||||
"test/vitest/vitest.plugin-sdk-light.config.ts",
|
||||
"test/vitest/vitest.plugin-sdk.config.ts",
|
||||
"test/vitest/vitest.plugins.config.ts",
|
||||
"test/vitest/vitest.channels.config.ts",
|
||||
"test/vitest/vitest.auto-reply-core.config.ts",
|
||||
"test/vitest/vitest.auto-reply-top-level.config.ts",
|
||||
"test/vitest/vitest.auto-reply-reply.config.ts",
|
||||
"test/vitest/vitest.extension-acpx.config.ts",
|
||||
"test/vitest/vitest.extension-bluebubbles.config.ts",
|
||||
"test/vitest/vitest.extension-channels.config.ts",
|
||||
"test/vitest/vitest.extension-diffs.config.ts",
|
||||
"test/vitest/vitest.extension-feishu.config.ts",
|
||||
"test/vitest/vitest.extension-irc.config.ts",
|
||||
"test/vitest/vitest.extension-mattermost.config.ts",
|
||||
"test/vitest/vitest.extension-matrix.config.ts",
|
||||
"test/vitest/vitest.extension-memory.config.ts",
|
||||
"test/vitest/vitest.extension-messaging.config.ts",
|
||||
"test/vitest/vitest.extension-msteams.config.ts",
|
||||
"test/vitest/vitest.extension-providers.config.ts",
|
||||
"test/vitest/vitest.extension-telegram.config.ts",
|
||||
"test/vitest/vitest.extension-voice-call.config.ts",
|
||||
"test/vitest/vitest.extension-whatsapp.config.ts",
|
||||
"test/vitest/vitest.extension-zalo.config.ts",
|
||||
"test/vitest/vitest.extension-browser.config.ts",
|
||||
"test/vitest/vitest.extension-qa.config.ts",
|
||||
"test/vitest/vitest.extension-media.config.ts",
|
||||
"test/vitest/vitest.extension-misc.config.ts",
|
||||
]);
|
||||
expect(plans).toEqual(
|
||||
plans.map((plan) => ({
|
||||
config: plan.config,
|
||||
forwardedArgs: [],
|
||||
includePatterns: null,
|
||||
watchMode: false,
|
||||
})),
|
||||
);
|
||||
});
|
||||
|
||||
it("skips extension project configs when leaf sharding and the aggregate extension shard is disabled", () => {
|
||||
const previousLeafShards = process.env.OPENCLAW_TEST_PROJECTS_LEAF_SHARDS;
|
||||
const previousSkipExtensions = process.env.OPENCLAW_TEST_SKIP_FULL_EXTENSIONS_SHARD;
|
||||
process.env.OPENCLAW_TEST_PROJECTS_LEAF_SHARDS = "1";
|
||||
process.env.OPENCLAW_TEST_SKIP_FULL_EXTENSIONS_SHARD = "1";
|
||||
try {
|
||||
const configs = buildFullSuiteVitestRunPlans([], process.cwd()).map((plan) => plan.config);
|
||||
|
||||
expect(configs).not.toContain("test/vitest/vitest.extensions.config.ts");
|
||||
expect(configs).not.toContain("test/vitest/vitest.extension-providers.config.ts");
|
||||
expect(configs).toContain("test/vitest/vitest.auto-reply-reply.config.ts");
|
||||
} finally {
|
||||
if (previousLeafShards === undefined) {
|
||||
delete process.env.OPENCLAW_TEST_PROJECTS_LEAF_SHARDS;
|
||||
} else {
|
||||
process.env.OPENCLAW_TEST_PROJECTS_LEAF_SHARDS = previousLeafShards;
|
||||
}
|
||||
if (previousSkipExtensions === undefined) {
|
||||
delete process.env.OPENCLAW_TEST_SKIP_FULL_EXTENSIONS_SHARD;
|
||||
} else {
|
||||
process.env.OPENCLAW_TEST_SKIP_FULL_EXTENSIONS_SHARD = previousSkipExtensions;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("expands full-suite shards before running them in parallel", () => {
|
||||
const previousLeafShards = process.env.OPENCLAW_TEST_PROJECTS_LEAF_SHARDS;
|
||||
const previousParallel = process.env.OPENCLAW_TEST_PROJECTS_PARALLEL;
|
||||
delete process.env.OPENCLAW_TEST_PROJECTS_LEAF_SHARDS;
|
||||
process.env.OPENCLAW_TEST_PROJECTS_PARALLEL = "6";
|
||||
try {
|
||||
const configs = buildFullSuiteVitestRunPlans([], process.cwd()).map((plan) => plan.config);
|
||||
|
||||
expect(configs).toContain("test/vitest/vitest.extension-telegram.config.ts");
|
||||
expect(configs).not.toContain("test/vitest/vitest.full-extensions.config.ts");
|
||||
} finally {
|
||||
if (previousLeafShards === undefined) {
|
||||
delete process.env.OPENCLAW_TEST_PROJECTS_LEAF_SHARDS;
|
||||
} else {
|
||||
process.env.OPENCLAW_TEST_PROJECTS_LEAF_SHARDS = previousLeafShards;
|
||||
}
|
||||
if (previousParallel === undefined) {
|
||||
delete process.env.OPENCLAW_TEST_PROJECTS_PARALLEL;
|
||||
} else {
|
||||
process.env.OPENCLAW_TEST_PROJECTS_PARALLEL = previousParallel;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps untargeted watch mode on the native root config", () => {
|
||||
expect(buildFullSuiteVitestRunPlans(["--watch"], process.cwd())).toEqual([
|
||||
{
|
||||
config: "vitest.config.ts",
|
||||
forwardedArgs: [],
|
||||
includePatterns: null,
|
||||
watchMode: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("scripts/test-projects parallel cache paths", () => {
|
||||
it("assigns isolated Vitest fs-module cache paths per parallel shard", () => {
|
||||
const specs = applyParallelVitestCachePaths(
|
||||
[
|
||||
{ config: "test/vitest/vitest.gateway.config.ts", env: {}, pnpmArgs: [] },
|
||||
{ config: "test/vitest/vitest.extension-matrix.config.ts", env: {}, pnpmArgs: [] },
|
||||
],
|
||||
{ cwd: "/repo", env: {} },
|
||||
);
|
||||
|
||||
expect(specs.map((spec) => spec.env)).toEqual([
|
||||
{
|
||||
OPENCLAW_VITEST_FS_MODULE_CACHE_PATH: path.join(
|
||||
"/repo",
|
||||
"node_modules",
|
||||
".experimental-vitest-cache",
|
||||
"0-test-vitest-vitest.gateway.config.ts",
|
||||
),
|
||||
},
|
||||
{
|
||||
OPENCLAW_VITEST_FS_MODULE_CACHE_PATH: path.join(
|
||||
"/repo",
|
||||
"node_modules",
|
||||
".experimental-vitest-cache",
|
||||
"1-test-vitest-vitest.extension-matrix.config.ts",
|
||||
),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps an explicit global cache path", () => {
|
||||
const [spec] = applyParallelVitestCachePaths(
|
||||
[{ config: "test/vitest/vitest.gateway.config.ts", env: {}, pnpmArgs: [] }],
|
||||
{ cwd: "/repo", env: { OPENCLAW_VITEST_FS_MODULE_CACHE_PATH: "/tmp/cache" } },
|
||||
);
|
||||
|
||||
expect(spec?.env.OPENCLAW_VITEST_FS_MODULE_CACHE_PATH).toBeUndefined();
|
||||
});
|
||||
});
|
||||
121
openclaw/test/scripts/test-report-utils.test.ts
Normal file
121
openclaw/test/scripts/test-report-utils.test.ts
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
collectVitestFileDurations,
|
||||
normalizeTrackedRepoPath,
|
||||
tryReadJsonFile,
|
||||
} from "../../scripts/test-report-utils.mjs";
|
||||
|
||||
const { spawnSyncMock } = vi.hoisted(() => ({
|
||||
spawnSyncMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("node:child_process", async () => {
|
||||
const actual = await vi.importActual<typeof import("node:child_process")>("node:child_process");
|
||||
return {
|
||||
...actual,
|
||||
spawnSync: spawnSyncMock,
|
||||
};
|
||||
});
|
||||
|
||||
describe("scripts/test-report-utils normalizeTrackedRepoPath", () => {
|
||||
it("normalizes repo-local absolute paths to repo-relative slash paths", () => {
|
||||
const absoluteFile = path.join(process.cwd(), "src", "tools", "example.test.ts");
|
||||
|
||||
expect(normalizeTrackedRepoPath(absoluteFile)).toBe("src/tools/example.test.ts");
|
||||
});
|
||||
|
||||
it("preserves external absolute paths as normalized absolute paths", () => {
|
||||
const externalFile = path.join(path.parse(process.cwd()).root, "tmp", "outside.test.ts");
|
||||
|
||||
expect(normalizeTrackedRepoPath(externalFile)).toBe(externalFile.split(path.sep).join("/"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("scripts/test-report-utils collectVitestFileDurations", () => {
|
||||
it("extracts per-file durations and applies file normalization", () => {
|
||||
const report = {
|
||||
testResults: [
|
||||
{
|
||||
name: path.join(process.cwd(), "src", "alpha.test.ts"),
|
||||
startTime: 100,
|
||||
endTime: 460,
|
||||
assertionResults: [{}, {}],
|
||||
},
|
||||
{
|
||||
name: "src/zero.test.ts",
|
||||
startTime: 300,
|
||||
endTime: 300,
|
||||
assertionResults: [{}],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(collectVitestFileDurations(report, normalizeTrackedRepoPath)).toEqual([
|
||||
{
|
||||
file: "src/alpha.test.ts",
|
||||
durationMs: 360,
|
||||
testCount: 2,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("scripts/test-report-utils tryReadJsonFile", () => {
|
||||
it("returns the fallback when the file is missing", () => {
|
||||
const missingPath = path.join(os.tmpdir(), `openclaw-missing-${Date.now()}.json`);
|
||||
|
||||
expect(tryReadJsonFile(missingPath, { ok: true })).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it("reads valid JSON files", () => {
|
||||
const tempPath = path.join(os.tmpdir(), `openclaw-json-${Date.now()}.json`);
|
||||
fs.writeFileSync(tempPath, JSON.stringify({ ok: true }));
|
||||
|
||||
try {
|
||||
expect(tryReadJsonFile(tempPath, null)).toEqual({ ok: true });
|
||||
} finally {
|
||||
fs.unlinkSync(tempPath);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("scripts/test-report-utils runVitestJsonReport", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
spawnSyncMock.mockReset();
|
||||
});
|
||||
|
||||
it("launches Vitest through pnpm exec", async () => {
|
||||
spawnSyncMock.mockReturnValue({ status: 0 });
|
||||
const reportPath = path.join(os.tmpdir(), `openclaw-vitest-json-${Date.now()}.json`);
|
||||
const { runVitestJsonReport } = await import("../../scripts/test-report-utils.mjs");
|
||||
|
||||
expect(
|
||||
runVitestJsonReport({
|
||||
config: "test/vitest/vitest.unit.config.ts",
|
||||
reportPath,
|
||||
}),
|
||||
).toBe(reportPath);
|
||||
|
||||
expect(spawnSyncMock).toHaveBeenCalledWith(
|
||||
"pnpm",
|
||||
[
|
||||
"exec",
|
||||
"vitest",
|
||||
"run",
|
||||
"--config",
|
||||
"test/vitest/vitest.unit.config.ts",
|
||||
"--reporter=json",
|
||||
"--outputFile",
|
||||
reportPath,
|
||||
],
|
||||
expect.objectContaining({
|
||||
stdio: "inherit",
|
||||
env: process.env,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
51
openclaw/test/scripts/ts-guard-utils.test.ts
Normal file
51
openclaw/test/scripts/ts-guard-utils.test.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import { existsSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveRepoRoot } from "../../scripts/lib/ts-guard-utils.mjs";
|
||||
|
||||
/**
|
||||
* Regression tests for resolveRepoRoot().
|
||||
*
|
||||
* The original implementation went up exactly two levels from the caller's
|
||||
* import.meta.url, which broke for scripts at scripts/*.mjs (one level below
|
||||
* root) — it overshot to the repo's parent directory.
|
||||
*/
|
||||
describe("resolveRepoRoot", () => {
|
||||
it("resolves correctly from a scripts/lib/*.mjs path (two levels below root)", () => {
|
||||
const fakeUrl = pathToFileURL(path.resolve("scripts", "lib", "some-guard-utils.mjs")).href;
|
||||
const root = resolveRepoRoot(fakeUrl);
|
||||
|
||||
expect(existsSync(path.join(root, ".git"))).toBe(true);
|
||||
expect(existsSync(path.join(root, "package.json"))).toBe(true);
|
||||
});
|
||||
|
||||
it("resolves correctly from a scripts/*.mjs path (one level below root)", () => {
|
||||
const fakeUrl = pathToFileURL(path.resolve("scripts", "check-no-raw-channel-fetch.mjs")).href;
|
||||
const root = resolveRepoRoot(fakeUrl);
|
||||
|
||||
expect(existsSync(path.join(root, ".git"))).toBe(true);
|
||||
expect(existsSync(path.join(root, "package.json"))).toBe(true);
|
||||
});
|
||||
|
||||
it("resolves correctly from a deeply nested extension path", () => {
|
||||
const fakeUrl = pathToFileURL(
|
||||
path.resolve("extensions", "qqbot", "src", "utils", "hypothetical.mjs"),
|
||||
).href;
|
||||
const root = resolveRepoRoot(fakeUrl);
|
||||
|
||||
expect(existsSync(path.join(root, ".git"))).toBe(true);
|
||||
expect(existsSync(path.join(root, "package.json"))).toBe(true);
|
||||
});
|
||||
|
||||
it("all caller depths resolve to the same root", () => {
|
||||
const fromLib = resolveRepoRoot(pathToFileURL(path.resolve("scripts", "lib", "a.mjs")).href);
|
||||
const fromScripts = resolveRepoRoot(pathToFileURL(path.resolve("scripts", "b.mjs")).href);
|
||||
const fromExtension = resolveRepoRoot(
|
||||
pathToFileURL(path.resolve("extensions", "qqbot", "c.mjs")).href,
|
||||
);
|
||||
|
||||
expect(fromLib).toBe(fromScripts);
|
||||
expect(fromScripts).toBe(fromExtension);
|
||||
});
|
||||
});
|
||||
154
openclaw/test/scripts/ts-topology.test.ts
Normal file
154
openclaw/test/scripts/ts-topology.test.ts
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { analyzeTopology } from "../../scripts/lib/ts-topology/analyze.js";
|
||||
import { renderTextReport } from "../../scripts/lib/ts-topology/reports.js";
|
||||
import { createFilesystemPublicSurfaceScope } from "../../scripts/lib/ts-topology/scope.js";
|
||||
import { main } from "../../scripts/ts-topology.ts";
|
||||
import { createCapturedIo } from "../helpers/captured-io.js";
|
||||
|
||||
const repoRoot = path.join(process.cwd(), "test", "fixtures", "ts-topology", "basic");
|
||||
|
||||
function buildFixtureScope() {
|
||||
return createFilesystemPublicSurfaceScope(repoRoot, {
|
||||
id: "custom",
|
||||
entrypointRoot: "src/public",
|
||||
importPrefix: "fixture-sdk",
|
||||
});
|
||||
}
|
||||
|
||||
describe("ts-topology", () => {
|
||||
it("collapses canonical symbols exported by multiple public subpaths", () => {
|
||||
const envelope = analyzeTopology({
|
||||
repoRoot,
|
||||
scope: buildFixtureScope(),
|
||||
report: "public-surface-usage",
|
||||
});
|
||||
const sharedThing = envelope.records.find((record) =>
|
||||
record.exportNames.includes("sharedThing"),
|
||||
);
|
||||
|
||||
expect(sharedThing).toMatchObject({
|
||||
declarationPath: "src/lib/shared.ts",
|
||||
declarationLine: 1,
|
||||
productionExtensions: ["alpha", "beta"],
|
||||
productionPackages: ["src"],
|
||||
productionOwners: ["extension:alpha", "extension:beta", "src"],
|
||||
});
|
||||
expect(sharedThing?.publicSpecifiers).toEqual(["fixture-sdk", "fixture-sdk/extra"]);
|
||||
});
|
||||
|
||||
it("counts renamed imports, namespace imports, type-only imports, and test-only consumers", () => {
|
||||
const envelope = analyzeTopology({
|
||||
repoRoot,
|
||||
scope: buildFixtureScope(),
|
||||
report: "public-surface-usage",
|
||||
});
|
||||
const aliasedThing = envelope.records.find((record) =>
|
||||
record.exportNames.includes("aliasedThing"),
|
||||
);
|
||||
const sharedType = envelope.records.find((record) => record.exportNames.includes("SharedType"));
|
||||
const testOnlyThing = envelope.records.find((record) =>
|
||||
record.exportNames.includes("testOnlyThing"),
|
||||
);
|
||||
|
||||
expect(aliasedThing?.productionRefCount).toBe(1);
|
||||
expect(sharedType).toMatchObject({
|
||||
isTypeOnlyCandidate: true,
|
||||
productionExtensions: ["alpha", "beta"],
|
||||
productionRefCount: 2,
|
||||
});
|
||||
expect(testOnlyThing).toMatchObject({
|
||||
productionRefCount: 0,
|
||||
testRefCount: 1,
|
||||
testConsumers: ["tests/public.test.ts"],
|
||||
});
|
||||
});
|
||||
|
||||
it("surfaces single-owner shared and unused reports correctly", () => {
|
||||
const singleOwner = analyzeTopology({
|
||||
repoRoot,
|
||||
scope: buildFixtureScope(),
|
||||
report: "single-owner-shared",
|
||||
});
|
||||
const unused = analyzeTopology({
|
||||
repoRoot,
|
||||
scope: buildFixtureScope(),
|
||||
report: "unused-public-surface",
|
||||
});
|
||||
|
||||
expect(singleOwner.records.map((record) => record.exportNames[0])).toContain(
|
||||
"singleOwnerHelper",
|
||||
);
|
||||
expect(singleOwner.records.map((record) => record.exportNames[0])).not.toContain("sharedThing");
|
||||
expect(unused.records.map((record) => record.exportNames[0])).toEqual(["unusedThing"]);
|
||||
});
|
||||
|
||||
it("renders stable text summaries for the public-surface report", () => {
|
||||
const envelope = analyzeTopology({
|
||||
repoRoot,
|
||||
scope: buildFixtureScope(),
|
||||
report: "public-surface-usage",
|
||||
limit: 3,
|
||||
});
|
||||
|
||||
expect(renderTextReport(envelope, 3)).toMatchInlineSnapshot(`
|
||||
"Scope: custom
|
||||
Public exports analyzed: 6
|
||||
Production-used exports: 4
|
||||
Single-owner shared exports: 2
|
||||
Unused public exports: 1
|
||||
|
||||
Top 2 candidate-to-move exports:
|
||||
- fixture-sdk:aliasedThing -> src/lib/shared.ts:9 (prodRefs=1, owners=extension:alpha, sharedness=35, move=85)
|
||||
- fixture-sdk:singleOwnerHelper -> src/lib/shared.ts:5 (prodRefs=1, owners=extension:alpha, sharedness=35, move=85)
|
||||
|
||||
Top 1 duplicated public exports:
|
||||
- fixture-sdk:sharedThing via fixture-sdk, fixture-sdk/extra (src/lib/shared.ts:1)"
|
||||
`);
|
||||
});
|
||||
|
||||
it("emits stable JSON and filtered report output through the CLI", async () => {
|
||||
const captured = createCapturedIo();
|
||||
const jsonExit = await main(
|
||||
[
|
||||
"--scope=custom",
|
||||
"--entrypoint-root=src/public",
|
||||
"--import-prefix=fixture-sdk",
|
||||
"--repo-root=test/fixtures/ts-topology/basic",
|
||||
"--report=single-owner-shared",
|
||||
"--json",
|
||||
],
|
||||
captured.io,
|
||||
);
|
||||
|
||||
expect(jsonExit).toBe(0);
|
||||
const payload = JSON.parse(captured.readStdout());
|
||||
expect(payload.report).toBe("single-owner-shared");
|
||||
expect(
|
||||
payload.records.map((record: { exportNames: string[] }) => record.exportNames[0]),
|
||||
).toEqual(["aliasedThing", "singleOwnerHelper"]);
|
||||
|
||||
const textCaptured = createCapturedIo();
|
||||
const textExit = await main(
|
||||
[
|
||||
"--scope=custom",
|
||||
"--entrypoint-root=src/public",
|
||||
"--import-prefix=fixture-sdk",
|
||||
"--repo-root=test/fixtures/ts-topology/basic",
|
||||
"--report=consumer-topology",
|
||||
"--limit=2",
|
||||
],
|
||||
textCaptured.io,
|
||||
);
|
||||
expect(textExit).toBe(0);
|
||||
expect(textCaptured.readStdout()).toMatchInlineSnapshot(`
|
||||
"Scope: custom
|
||||
Records with consumers: 5
|
||||
|
||||
Top 2 consumer-topology records:
|
||||
- fixture-sdk:sharedThing prod=3 test=0 internal=0
|
||||
- fixture-sdk:SharedType prod=2 test=0 internal=0
|
||||
"
|
||||
`);
|
||||
});
|
||||
});
|
||||
105
openclaw/test/scripts/tsdown-build.test.ts
Normal file
105
openclaw/test/scripts/tsdown-build.test.ts
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
import fs from "node:fs";
|
||||
import fsPromises from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
pruneSourceCheckoutBundledPluginNodeModules,
|
||||
pruneStaleRootChunkFiles,
|
||||
resolveTsdownBuildInvocation,
|
||||
} from "../../scripts/tsdown-build.mjs";
|
||||
import { createScriptTestHarness } from "./test-helpers.js";
|
||||
|
||||
const { createTempDir } = createScriptTestHarness();
|
||||
|
||||
describe("resolveTsdownBuildInvocation", () => {
|
||||
it("routes Windows tsdown builds through the pnpm runner instead of shell=true", () => {
|
||||
const result = resolveTsdownBuildInvocation({
|
||||
platform: "win32",
|
||||
nodeExecPath: "C:\\Program Files\\nodejs\\node.exe",
|
||||
npmExecPath: "C:/Users/test/AppData/Local/pnpm/10.32.1/bin/pnpm.cjs",
|
||||
env: {},
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
command: "C:\\Program Files\\nodejs\\node.exe",
|
||||
args: [
|
||||
"C:/Users/test/AppData/Local/pnpm/10.32.1/bin/pnpm.cjs",
|
||||
"exec",
|
||||
"tsdown",
|
||||
"--config-loader",
|
||||
"unrun",
|
||||
"--logLevel",
|
||||
"warn",
|
||||
],
|
||||
options: {
|
||||
encoding: "utf8",
|
||||
stdio: "pipe",
|
||||
shell: false,
|
||||
windowsVerbatimArguments: undefined,
|
||||
env: {},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps source-checkout prune best-effort", () => {
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const rmSync = vi.spyOn(fs, "rmSync");
|
||||
|
||||
rmSync.mockImplementation(() => {
|
||||
throw new Error("locked");
|
||||
});
|
||||
|
||||
expect(() =>
|
||||
pruneSourceCheckoutBundledPluginNodeModules({
|
||||
cwd: process.cwd(),
|
||||
}),
|
||||
).not.toThrow();
|
||||
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
"tsdown: could not prune bundled plugin source node_modules: Error: locked",
|
||||
);
|
||||
|
||||
warn.mockRestore();
|
||||
rmSync.mockRestore();
|
||||
});
|
||||
|
||||
it("prunes stale hashed root chunk files but keeps stable aliases and nested assets", async () => {
|
||||
const rootDir = createTempDir("openclaw-tsdown-build-");
|
||||
const distDir = path.join(rootDir, "dist");
|
||||
const distRuntimeDir = path.join(rootDir, "dist-runtime");
|
||||
await fsPromises.mkdir(path.join(distDir, "control-ui"), { recursive: true });
|
||||
await fsPromises.mkdir(distRuntimeDir, { recursive: true });
|
||||
await fsPromises.writeFile(path.join(distDir, "delegate-BPjCe4gC.js"), "old delegate\n");
|
||||
await fsPromises.writeFile(path.join(distDir, "compact.runtime-2DiEmVcA.js"), "old runtime\n");
|
||||
await fsPromises.writeFile(path.join(distDir, "compact.runtime.js"), "stable alias\n");
|
||||
await fsPromises.writeFile(path.join(distDir, "entry.js"), "entry\n");
|
||||
await fsPromises.writeFile(path.join(distDir, "control-ui", "index.html"), "asset\n");
|
||||
await fsPromises.writeFile(
|
||||
path.join(distRuntimeDir, "heartbeat-runner.runtime-fspOEj_1.js"),
|
||||
"old runtime\n",
|
||||
);
|
||||
await fsPromises.writeFile(path.join(distRuntimeDir, "heartbeat-runner.runtime.js"), "alias\n");
|
||||
|
||||
pruneStaleRootChunkFiles({ cwd: rootDir });
|
||||
|
||||
await expect(
|
||||
fsPromises.readFile(path.join(distDir, "compact.runtime.js"), "utf8"),
|
||||
).resolves.toBe("stable alias\n");
|
||||
await expect(fsPromises.readFile(path.join(distDir, "entry.js"), "utf8")).resolves.toBe(
|
||||
"entry\n",
|
||||
);
|
||||
await expect(
|
||||
fsPromises.readFile(path.join(distDir, "control-ui", "index.html"), "utf8"),
|
||||
).resolves.toBe("asset\n");
|
||||
await expect(
|
||||
fsPromises.readFile(path.join(distRuntimeDir, "heartbeat-runner.runtime.js"), "utf8"),
|
||||
).resolves.toBe("alias\n");
|
||||
await expect(fsPromises.stat(path.join(distDir, "delegate-BPjCe4gC.js"))).rejects.toThrow();
|
||||
await expect(
|
||||
fsPromises.stat(path.join(distDir, "compact.runtime-2DiEmVcA.js")),
|
||||
).rejects.toThrow();
|
||||
await expect(
|
||||
fsPromises.stat(path.join(distRuntimeDir, "heartbeat-runner.runtime-fspOEj_1.js")),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
35
openclaw/test/scripts/ui.test.ts
Normal file
35
openclaw/test/scripts/ui.test.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { assertSafeWindowsShellArgs, shouldUseShellForCommand } from "../../scripts/ui.js";
|
||||
|
||||
describe("scripts/ui windows spawn behavior", () => {
|
||||
it("enables shell for Windows command launchers that require cmd.exe", () => {
|
||||
expect(
|
||||
shouldUseShellForCommand("C:\\Users\\dev\\AppData\\Local\\pnpm\\pnpm.CMD", "win32"),
|
||||
).toBe(true);
|
||||
expect(shouldUseShellForCommand("C:\\tools\\pnpm.bat", "win32")).toBe(true);
|
||||
});
|
||||
|
||||
it("does not enable shell for non-shell launchers", () => {
|
||||
expect(shouldUseShellForCommand("C:\\Program Files\\nodejs\\node.exe", "win32")).toBe(false);
|
||||
expect(shouldUseShellForCommand("/usr/local/bin/pnpm", "linux")).toBe(false);
|
||||
});
|
||||
|
||||
it("allows safe forwarded args when shell mode is required on Windows", () => {
|
||||
expect(() =>
|
||||
assertSafeWindowsShellArgs(["run", "build", "--filter", "@openclaw/ui"], "win32"),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("rejects dangerous forwarded args when shell mode is required on Windows", () => {
|
||||
expect(() => assertSafeWindowsShellArgs(["run", "build", "evil&calc"], "win32")).toThrow(
|
||||
/unsafe windows shell argument/i,
|
||||
);
|
||||
expect(() => assertSafeWindowsShellArgs(["run", "build", "%PATH%"], "win32")).toThrow(
|
||||
/unsafe windows shell argument/i,
|
||||
);
|
||||
});
|
||||
|
||||
it("does not reject args on non-windows platforms", () => {
|
||||
expect(() => assertSafeWindowsShellArgs(["contains&metacharacters"], "linux")).not.toThrow();
|
||||
});
|
||||
});
|
||||
56
openclaw/test/scripts/vitest-local-scheduling.test.ts
Normal file
56
openclaw/test/scripts/vitest-local-scheduling.test.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
resolveLocalFullSuiteProfile,
|
||||
resolveLocalVitestScheduling,
|
||||
shouldUseLargeLocalFullSuiteProfile,
|
||||
} from "../../scripts/lib/vitest-local-scheduling.mjs";
|
||||
|
||||
describe("vitest local full-suite profile", () => {
|
||||
it("selects the large local profile on roomy hosts that are not throttled", () => {
|
||||
const env = {};
|
||||
const hostInfo = {
|
||||
cpuCount: 14,
|
||||
loadAverage1m: 0,
|
||||
totalMemoryBytes: 48 * 1024 ** 3,
|
||||
};
|
||||
|
||||
expect(resolveLocalVitestScheduling(env, hostInfo, "threads")).toEqual({
|
||||
maxWorkers: 6,
|
||||
fileParallelism: true,
|
||||
throttledBySystem: false,
|
||||
});
|
||||
expect(shouldUseLargeLocalFullSuiteProfile(env, hostInfo)).toBe(true);
|
||||
expect(resolveLocalFullSuiteProfile(env, hostInfo)).toEqual({
|
||||
shardParallelism: 10,
|
||||
vitestMaxWorkers: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the smaller local profile when the host is already throttled", () => {
|
||||
const hostInfo = {
|
||||
cpuCount: 14,
|
||||
loadAverage1m: 14,
|
||||
totalMemoryBytes: 48 * 1024 ** 3,
|
||||
};
|
||||
|
||||
expect(shouldUseLargeLocalFullSuiteProfile({}, hostInfo)).toBe(false);
|
||||
expect(resolveLocalFullSuiteProfile({}, hostInfo)).toEqual({
|
||||
shardParallelism: 4,
|
||||
vitestMaxWorkers: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("never selects the large local profile in CI", () => {
|
||||
const hostInfo = {
|
||||
cpuCount: 14,
|
||||
loadAverage1m: 0,
|
||||
totalMemoryBytes: 48 * 1024 ** 3,
|
||||
};
|
||||
|
||||
expect(shouldUseLargeLocalFullSuiteProfile({ CI: "true" }, hostInfo)).toBe(false);
|
||||
expect(resolveLocalFullSuiteProfile({ CI: "true" }, hostInfo)).toEqual({
|
||||
shardParallelism: 4,
|
||||
vitestMaxWorkers: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
99
openclaw/test/scripts/vitest-process-group.test.ts
Normal file
99
openclaw/test/scripts/vitest-process-group.test.ts
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
forwardSignalToVitestProcessGroup,
|
||||
installVitestProcessGroupCleanup,
|
||||
resolveVitestProcessGroupSignalTarget,
|
||||
shouldUseDetachedVitestProcessGroup,
|
||||
} from "../../scripts/vitest-process-group.mjs";
|
||||
|
||||
describe("vitest process group helpers", () => {
|
||||
it("uses detached process groups on non-Windows hosts", () => {
|
||||
expect(shouldUseDetachedVitestProcessGroup("darwin")).toBe(true);
|
||||
expect(shouldUseDetachedVitestProcessGroup("linux")).toBe(true);
|
||||
expect(shouldUseDetachedVitestProcessGroup("win32")).toBe(false);
|
||||
});
|
||||
|
||||
it("targets the process group on Unix and the direct pid on Windows", () => {
|
||||
expect(resolveVitestProcessGroupSignalTarget({ childPid: 4200, platform: "darwin" })).toBe(
|
||||
-4200,
|
||||
);
|
||||
expect(resolveVitestProcessGroupSignalTarget({ childPid: 4200, platform: "win32" })).toBe(4200);
|
||||
expect(resolveVitestProcessGroupSignalTarget({ childPid: undefined, platform: "darwin" })).toBe(
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
it("forwards signals to the computed target and ignores cleanup races", () => {
|
||||
const kill = vi.fn();
|
||||
expect(
|
||||
forwardSignalToVitestProcessGroup({
|
||||
child: { pid: 4200 },
|
||||
signal: "SIGTERM",
|
||||
platform: "darwin",
|
||||
kill,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(kill).toHaveBeenCalledWith(-4200, "SIGTERM");
|
||||
|
||||
kill.mockImplementationOnce(() => {
|
||||
const error = new Error("gone") as NodeJS.ErrnoException;
|
||||
error.code = "ESRCH";
|
||||
throw error;
|
||||
});
|
||||
expect(
|
||||
forwardSignalToVitestProcessGroup({
|
||||
child: { pid: 4200 },
|
||||
signal: "SIGTERM",
|
||||
platform: "darwin",
|
||||
kill,
|
||||
}),
|
||||
).toBe(false);
|
||||
|
||||
kill.mockImplementationOnce(() => {
|
||||
const error = new Error("permission race") as NodeJS.ErrnoException;
|
||||
error.code = "EPERM";
|
||||
throw error;
|
||||
});
|
||||
expect(
|
||||
forwardSignalToVitestProcessGroup({
|
||||
child: { pid: 4200 },
|
||||
signal: "SIGTERM",
|
||||
platform: "darwin",
|
||||
kill,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("installs and removes process cleanup listeners", () => {
|
||||
const listeners = new Map<string, Set<() => void>>();
|
||||
const fakeProcess = {
|
||||
on(event: string, handler: () => void) {
|
||||
const set = listeners.get(event) ?? new Set();
|
||||
set.add(handler);
|
||||
listeners.set(event, set);
|
||||
},
|
||||
off(event: string, handler: () => void) {
|
||||
listeners.get(event)?.delete(handler);
|
||||
},
|
||||
};
|
||||
const kill = vi.fn();
|
||||
const teardown = installVitestProcessGroupCleanup({
|
||||
child: { pid: 4200 },
|
||||
processObject: fakeProcess as unknown as NodeJS.Process,
|
||||
platform: "darwin",
|
||||
kill,
|
||||
});
|
||||
|
||||
expect(listeners.get("SIGINT")?.size).toBe(1);
|
||||
expect(listeners.get("SIGTERM")?.size).toBe(1);
|
||||
expect(listeners.get("exit")?.size).toBe(1);
|
||||
|
||||
listeners.get("SIGTERM")?.values().next().value?.();
|
||||
expect(kill).toHaveBeenCalledWith(-4200, "SIGTERM");
|
||||
|
||||
teardown();
|
||||
expect(listeners.get("SIGINT")?.size ?? 0).toBe(0);
|
||||
expect(listeners.get("SIGTERM")?.size ?? 0).toBe(0);
|
||||
expect(listeners.get("exit")?.size ?? 0).toBe(0);
|
||||
});
|
||||
});
|
||||
42
openclaw/test/scripts/write-cli-startup-metadata.test.ts
Normal file
42
openclaw/test/scripts/write-cli-startup-metadata.test.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { writeCliStartupMetadata } from "../../scripts/write-cli-startup-metadata.ts";
|
||||
import { createScriptTestHarness } from "./test-helpers.js";
|
||||
|
||||
describe("write-cli-startup-metadata", () => {
|
||||
const { createTempDir } = createScriptTestHarness();
|
||||
|
||||
it("writes startup metadata with populated root help text when dist falls back to source rendering", async () => {
|
||||
const tempRoot = createTempDir("openclaw-startup-metadata-");
|
||||
const distDir = path.join(tempRoot, "dist");
|
||||
const extensionsDir = path.join(tempRoot, "extensions");
|
||||
const outputPath = path.join(distDir, "cli-startup-metadata.json");
|
||||
|
||||
mkdirSync(distDir, { recursive: true });
|
||||
mkdirSync(path.join(extensionsDir, "matrix"), { recursive: true });
|
||||
writeFileSync(
|
||||
path.join(extensionsDir, "matrix", "package.json"),
|
||||
JSON.stringify({
|
||||
openclaw: {
|
||||
channel: {
|
||||
id: "matrix",
|
||||
order: 120,
|
||||
label: "Matrix",
|
||||
},
|
||||
},
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
await writeCliStartupMetadata({ distDir, outputPath, extensionsDir });
|
||||
|
||||
const written = JSON.parse(readFileSync(outputPath, "utf8")) as {
|
||||
channelOptions: string[];
|
||||
rootHelpText: string;
|
||||
};
|
||||
expect(written.channelOptions).toContain("matrix");
|
||||
expect(written.rootHelpText).toContain("Usage:");
|
||||
expect(written.rootHelpText).toContain("openclaw");
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue