mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-23 07:38:10 +00:00
360 lines
13 KiB
TypeScript
360 lines
13 KiB
TypeScript
// Profile Extension Memory tests cover profile extension memory script behavior.
|
|
import { spawn, spawnSync } from "node:child_process";
|
|
import { EventEmitter } from "node:events";
|
|
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import path from "node:path";
|
|
import { pathToFileURL } from "node:url";
|
|
import { describe, expect, it } from "vitest";
|
|
import { parseArgs, runCase } from "../../scripts/profile-extension-memory.mjs";
|
|
|
|
const SCRIPT_PATH = path.resolve("scripts/profile-extension-memory.mjs");
|
|
|
|
async function waitForCondition(predicate: () => boolean, timeoutMs = 5_000): Promise<void> {
|
|
const started = Date.now();
|
|
while (Date.now() - started < timeoutMs) {
|
|
if (predicate()) {
|
|
return;
|
|
}
|
|
await new Promise((resolve) => {
|
|
setTimeout(resolve, 50);
|
|
});
|
|
}
|
|
throw new Error("timed out waiting for condition");
|
|
}
|
|
|
|
function isProcessAlive(pid: number): boolean {
|
|
try {
|
|
process.kill(pid, 0);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function runProfileExtensionMemory(args: string[], cwd = process.cwd()) {
|
|
return spawnSync(process.execPath, [SCRIPT_PATH, ...args], {
|
|
cwd,
|
|
encoding: "utf8",
|
|
});
|
|
}
|
|
|
|
async function waitForChildExit(
|
|
child: ReturnType<typeof spawn>,
|
|
timeoutMs = 8_000,
|
|
): Promise<{ status: number | null; signal: NodeJS.Signals | null }> {
|
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
try {
|
|
return await Promise.race([
|
|
new Promise<{ status: number | null; signal: NodeJS.Signals | null }>((resolve, reject) => {
|
|
child.once("error", reject);
|
|
child.once("exit", (status, signal) => resolve({ status, signal }));
|
|
}),
|
|
new Promise<never>((_, reject) => {
|
|
timer = setTimeout(() => reject(new Error("timed out waiting for child exit")), timeoutMs);
|
|
timer.unref?.();
|
|
}),
|
|
]);
|
|
} finally {
|
|
if (timer) {
|
|
clearTimeout(timer);
|
|
}
|
|
}
|
|
}
|
|
|
|
describe("scripts/profile-extension-memory", () => {
|
|
it("prints help without requiring built plugin artifacts", () => {
|
|
const result = runProfileExtensionMemory(["--help"]);
|
|
|
|
expect(result.status).toBe(0);
|
|
expect(result.stderr).toBe("");
|
|
expect(result.stdout).toContain("Usage: node scripts/profile-extension-memory.mjs");
|
|
});
|
|
|
|
it("stops parsing options after the argument terminator", () => {
|
|
expect(parseArgs(["--extension", "discord", "--", "--extension", "telegram"])).toMatchObject({
|
|
extensions: ["discord"],
|
|
});
|
|
});
|
|
|
|
it("accepts package-manager argument separators before script options", () => {
|
|
expect(parseArgs(["--", "--extension", "discord", "--skip-combined"])).toMatchObject({
|
|
extensions: ["discord"],
|
|
skipCombined: true,
|
|
});
|
|
});
|
|
|
|
it("rejects loose numeric flags before scanning built plugin artifacts", () => {
|
|
const cases = [
|
|
["--concurrency", "2abc"],
|
|
["--timeout-ms", "1e3"],
|
|
["--combined-timeout-ms", "90000ms"],
|
|
["--top", "0x10"],
|
|
];
|
|
|
|
for (const [flag, value] of cases) {
|
|
const result = runProfileExtensionMemory([flag, value]);
|
|
|
|
expect(result.status).toBe(1);
|
|
expect(result.stdout).toBe("");
|
|
expect(result.stderr).toContain(`[extension-memory] ${flag} must be a positive integer`);
|
|
expect(result.stderr).not.toContain("dist/extensions");
|
|
expect(result.stderr).not.toContain("at ");
|
|
}
|
|
});
|
|
|
|
it("rejects option-looking string flag values before scanning built plugin artifacts", () => {
|
|
for (const args of [
|
|
["--extension", "-h"],
|
|
["--json", "-h"],
|
|
]) {
|
|
const result = runProfileExtensionMemory(args);
|
|
|
|
expect(result.status).toBe(1);
|
|
expect(result.stdout).toBe("");
|
|
expect(result.stderr).toContain(`[extension-memory] ${args[0]} requires a value`);
|
|
expect(result.stderr).not.toContain("dist/extensions");
|
|
expect(result.stderr).not.toContain("at ");
|
|
}
|
|
});
|
|
|
|
it("bounds noisy child output without losing RSS samples", () => {
|
|
const root = mkdtempSync(path.join(tmpdir(), "openclaw-extension-memory-test-"));
|
|
try {
|
|
const extensionDir = path.join(root, "dist", "extensions", "noisy");
|
|
const reportPath = path.join(root, "report.json");
|
|
mkdirSync(extensionDir, { recursive: true });
|
|
writeFileSync(
|
|
path.join(extensionDir, "index.js"),
|
|
[
|
|
`const fs = require("node:fs");`,
|
|
`fs.writeSync(2, "old stderr " + "x".repeat(160000) + "\\n");`,
|
|
`fs.writeSync(1, "old stdout " + "y".repeat(160000) + "\\n");`,
|
|
`process.on("exit", () => fs.writeSync(2, "exit tail\\n"));`,
|
|
].join("\n"),
|
|
"utf8",
|
|
);
|
|
|
|
const result = runProfileExtensionMemory(
|
|
["--extension", "noisy", "--skip-combined", "--concurrency", "1", "--json", reportPath],
|
|
root,
|
|
);
|
|
|
|
expect(result.status, result.stderr).toBe(0);
|
|
const report = JSON.parse(readFileSync(reportPath, "utf8"));
|
|
expect(report.results).toHaveLength(1);
|
|
expect(report.results[0].status).toBe("ok");
|
|
expect(report.results[0].maxRssMb).toEqual(expect.any(Number));
|
|
expect(report.results[0].stderrPreview).toContain("[output truncated");
|
|
expect(report.results[0].stderrPreview).toContain("[stderr preview truncated");
|
|
expect(report.results[0].stderrPreview).toContain("exit tail");
|
|
expect(report.results[0].stderrPreview).not.toContain("old stderr");
|
|
expect(report.results[0].stderrPreview.length).toBeLessThan(9_000);
|
|
} finally {
|
|
rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("creates parent directories for nested JSON report paths", () => {
|
|
const root = mkdtempSync(path.join(tmpdir(), "openclaw-extension-memory-test-"));
|
|
try {
|
|
const extensionDir = path.join(root, "dist", "extensions", "simple");
|
|
const reportPath = path.join(root, ".artifacts", "memory", "report.json");
|
|
mkdirSync(extensionDir, { recursive: true });
|
|
writeFileSync(path.join(extensionDir, "index.js"), `export default {};\n`, "utf8");
|
|
|
|
const result = runProfileExtensionMemory(
|
|
["--extension", "simple", "--skip-combined", "--concurrency", "1", "--json", reportPath],
|
|
root,
|
|
);
|
|
|
|
expect(result.status, result.stderr).toBe(0);
|
|
const report = JSON.parse(readFileSync(reportPath, "utf8"));
|
|
expect(report.counts).toMatchObject({ totalEntries: 1, ok: 1, fail: 0, timeout: 0 });
|
|
} finally {
|
|
rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("fails when a profiled plugin import fails", () => {
|
|
const root = mkdtempSync(path.join(tmpdir(), "openclaw-extension-memory-test-"));
|
|
try {
|
|
const extensionDir = path.join(root, "dist", "extensions", "broken");
|
|
const reportPath = path.join(root, "report.json");
|
|
mkdirSync(extensionDir, { recursive: true });
|
|
writeFileSync(
|
|
path.join(extensionDir, "index.js"),
|
|
`throw new Error("broken plugin import");\n`,
|
|
"utf8",
|
|
);
|
|
|
|
const result = runProfileExtensionMemory(
|
|
["--extension", "broken", "--skip-combined", "--concurrency", "1", "--json", reportPath],
|
|
root,
|
|
);
|
|
|
|
expect(result.status).toBe(1);
|
|
expect(result.stderr).toContain("[extension-memory] broken import fail");
|
|
const report = JSON.parse(readFileSync(reportPath, "utf8"));
|
|
expect(report.counts).toMatchObject({ fail: 1, ok: 0, timeout: 0 });
|
|
expect(report.results[0]).toMatchObject({ dir: "broken", status: "fail" });
|
|
} finally {
|
|
rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("resolves spawn errors without waiting for the timeout", async () => {
|
|
const startedAt = Date.now();
|
|
const result = await runCase({
|
|
repoRoot: process.cwd(),
|
|
env: process.env,
|
|
hookPath: "missing-hook.mjs",
|
|
name: "spawn-error",
|
|
body: "",
|
|
timeoutMs: 30_000,
|
|
spawnImpl: () => {
|
|
const child = new EventEmitter() as EventEmitter & {
|
|
kill: () => boolean;
|
|
stderr: EventEmitter;
|
|
stdout: EventEmitter;
|
|
};
|
|
child.stderr = new EventEmitter();
|
|
child.stdout = new EventEmitter();
|
|
child.kill = () => true;
|
|
queueMicrotask(() => child.emit("error", new Error("spawn denied")));
|
|
return child;
|
|
},
|
|
});
|
|
|
|
expect(Date.now() - startedAt).toBeLessThan(1000);
|
|
expect(result).toMatchObject({
|
|
code: null,
|
|
error: "spawn denied",
|
|
name: "spawn-error",
|
|
signal: null,
|
|
timedOut: false,
|
|
});
|
|
});
|
|
|
|
it.runIf(process.platform !== "win32")(
|
|
"cleans timeout descendants before resolving the case",
|
|
async () => {
|
|
const root = mkdtempSync(path.join(tmpdir(), "openclaw-extension-memory-timeout-"));
|
|
const hookPath = path.join(root, "rss-hook.mjs");
|
|
const descendantPidPath = path.join(root, "descendant.pid");
|
|
let descendantPid = 0;
|
|
try {
|
|
writeFileSync(hookPath, "", "utf8");
|
|
const descendantScript = [
|
|
"process.on('SIGTERM', () => {});",
|
|
"setInterval(() => {}, 1000);",
|
|
].join("");
|
|
const body = [
|
|
"const childProcess = await import('node:child_process');",
|
|
"const fs = await import('node:fs');",
|
|
"const descendant = childProcess.spawn(process.execPath, [",
|
|
" '--input-type=module',",
|
|
` '--eval', ${JSON.stringify(descendantScript)},`,
|
|
"], { stdio: 'ignore' });",
|
|
`fs.writeFileSync(${JSON.stringify(descendantPidPath)}, String(descendant.pid));`,
|
|
"setInterval(() => {}, 1000);",
|
|
].join("\n");
|
|
const resultPromise = runCase({
|
|
body,
|
|
env: process.env,
|
|
hookPath,
|
|
name: "timeout-descendant",
|
|
repoRoot: root,
|
|
timeoutMs: 1_000,
|
|
});
|
|
|
|
await waitForCondition(() => existsSync(descendantPidPath));
|
|
descendantPid = Number.parseInt(readFileSync(descendantPidPath, "utf8"), 10);
|
|
expect(Number.isInteger(descendantPid)).toBe(true);
|
|
expect(isProcessAlive(descendantPid)).toBe(true);
|
|
|
|
await expect(resultPromise).resolves.toMatchObject({
|
|
name: "timeout-descendant",
|
|
signal: "SIGKILL",
|
|
timedOut: true,
|
|
});
|
|
await waitForCondition(() => !isProcessAlive(descendantPid));
|
|
} finally {
|
|
if (descendantPid && isProcessAlive(descendantPid)) {
|
|
process.kill(descendantPid, "SIGKILL");
|
|
}
|
|
rmSync(root, { recursive: true, force: true });
|
|
}
|
|
},
|
|
);
|
|
|
|
it.runIf(process.platform !== "win32")(
|
|
"cleans active case descendants on parent signal",
|
|
async () => {
|
|
const root = mkdtempSync(path.join(tmpdir(), "openclaw-extension-memory-parent-signal-"));
|
|
const hookPath = path.join(root, "rss-hook.mjs");
|
|
const runnerPath = path.join(root, "parent-signal-runner.mjs");
|
|
const descendantPidPath = path.join(root, "descendant.pid");
|
|
let descendantPid = 0;
|
|
try {
|
|
writeFileSync(hookPath, "", "utf8");
|
|
const descendantScript = [
|
|
"process.on('SIGTERM', () => {});",
|
|
"setInterval(() => {}, 1000);",
|
|
].join("");
|
|
const body = [
|
|
"const childProcess = await import('node:child_process');",
|
|
"const fs = await import('node:fs');",
|
|
"const descendant = childProcess.spawn(process.execPath, [",
|
|
" '--input-type=module',",
|
|
` '--eval', ${JSON.stringify(descendantScript)},`,
|
|
"], { stdio: 'ignore' });",
|
|
`fs.writeFileSync(${JSON.stringify(descendantPidPath)}, String(descendant.pid));`,
|
|
"setInterval(() => {}, 1000);",
|
|
].join("\n");
|
|
writeFileSync(
|
|
runnerPath,
|
|
[
|
|
`const { runCase } = await import(${JSON.stringify(
|
|
pathToFileURL(path.resolve("scripts/profile-extension-memory.mjs")).href,
|
|
)});`,
|
|
"void runCase({",
|
|
` body: ${JSON.stringify(body)},`,
|
|
" env: process.env,",
|
|
` hookPath: ${JSON.stringify(hookPath)},`,
|
|
" name: 'parent-signal-descendant',",
|
|
` repoRoot: ${JSON.stringify(root)},`,
|
|
" timeoutMs: 30000,",
|
|
"});",
|
|
].join("\n"),
|
|
"utf8",
|
|
);
|
|
const runner = spawn(process.execPath, [runnerPath], {
|
|
stdio: "ignore",
|
|
});
|
|
|
|
try {
|
|
await waitForCondition(() => existsSync(descendantPidPath));
|
|
descendantPid = Number.parseInt(readFileSync(descendantPidPath, "utf8"), 10);
|
|
expect(Number.isInteger(descendantPid)).toBe(true);
|
|
expect(isProcessAlive(descendantPid)).toBe(true);
|
|
|
|
const runnerExit = waitForChildExit(runner);
|
|
process.kill(runner.pid!, "SIGTERM");
|
|
await expect(runnerExit).resolves.toEqual({ status: 143, signal: null });
|
|
await waitForCondition(() => !isProcessAlive(descendantPid));
|
|
} finally {
|
|
if (runner.pid && isProcessAlive(runner.pid)) {
|
|
process.kill(runner.pid, "SIGKILL");
|
|
}
|
|
}
|
|
} finally {
|
|
if (descendantPid && isProcessAlive(descendantPid)) {
|
|
process.kill(descendantPid, "SIGKILL");
|
|
}
|
|
rmSync(root, { recursive: true, force: true });
|
|
}
|
|
},
|
|
);
|
|
});
|