Files
openclaw/test/scripts/profile-extension-memory.test.ts
Peter Steinberger fe261b0f59 chore(tooling): typecheck root test/** with a dedicated tsgo lane (#104475)
* chore(types): add declaration files for scripts/lib and scripts/e2e modules

* chore(types): add declaration files for top-level script modules (a-m)

* chore(types): add declaration files for top-level script modules (n-z)

* test: use a non-secret-shaped gateway token fixture

* test: type ci workflow guard helpers for the root test lane

* chore(tooling): typecheck root test/** with a dedicated tsgo lane

- test/tsconfig/tsconfig.test.root.json: root-test program (strict unused checks,
  fixtures excluded; two Docker E2E clients that import built dist/** stay out,
  same rationale as the scripts/e2e exclusion in tsconfig.scripts.json)
- tsgo:test:root wired into tsgo:test, check:test-types, scripts/check.mjs, and
  the ci.yml test-types shard, mirroring the tsgo:scripts lane (#104348)
- changed-lane routing: test/**/*.ts (excluding fixtures) and the lane tsconfig
  now trigger 'typecheck test root' in check:changed; previously test/ paths ran
  lint only, so harness type errors surfaced first in CI (#104287 envDir case)
- burn down all 1071 latent type errors in the program: precise param/local
  types across test/scripts, test/vitest, test/e2e, and transitive scripts/e2e
  program members; 205 sibling .d.mts declaration files for imported .mjs
  modules (committed separately); zero any, zero ts-expect-error
- resolve the pre-existing testing star-export ambiguity in
  scripts/e2e/parallels/common.ts with an explicit re-export

Closes #104388

* chore(types): correct declaration fidelity per structured review

- re-derive 51 .d.mts files from implementation data flow instead of
  initializers: fix a wrong never return (runTestProjectsDelegation returns
  the child), add encoding-sensitive exec/spawn overloads (plain-gh), restore
  the full release profile union, make parsed paths string | null, add missing
  parseArgs fields via help/non-help unions, add a missing sibling declaration
  (budget-number-args), drop 15 unused lint directives
- precise install-record/tuple typing removes the type-aware oxlint
  regressions the first declarations caused in scripts/e2e implementations
- route .mts declaration edits under test/ to the testRoot lane and reference
  the test-root project from tsconfig.projects.json so tsgo:all covers it
  (closes both review findings against the lane wiring)

* chore(scripts): keep telegram runner dist typing structural for the boundary guard

* chore(types): declare runtime pack and gateway readiness exports added on main

* test: pin the importTargetPlan form of the plugin-contract plan import

The guard expectation still referenced the raw await import( form that
7ae5996bb3 (#103975) replaced with the importTargetPlan fallback helper;
the assertion fails on current main.
2026-07-11 06:15:41 -07:00

409 lines
15 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",
});
}
function extractReportPath(stdout: string) {
const match = stdout.match(/^\[extension-memory\] report: (.+)$/mu);
const reportPath = match?.[1];
if (!reportPath) {
throw new Error(`missing report path in stdout:\n${stdout}`);
}
return reportPath;
}
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("uses distinct default JSON report paths for separate runs", () => {
const root = mkdtempSync(path.join(tmpdir(), "openclaw-extension-memory-test-"));
const reportPaths: string[] = [];
try {
const extensionDir = path.join(root, "dist", "extensions", "simple");
mkdirSync(extensionDir, { recursive: true });
writeFileSync(path.join(extensionDir, "index.js"), `export default {};\n`, "utf8");
for (let index = 0; index < 2; index += 1) {
const result = runProfileExtensionMemory(
["--extension", "simple", "--skip-combined", "--concurrency", "1"],
root,
);
expect(result.status, result.stderr).toBe(0);
const reportPath = extractReportPath(result.stdout);
reportPaths.push(reportPath);
expect(path.dirname(reportPath)).toBe(tmpdir());
expect(path.basename(reportPath)).toMatch(
/^openclaw-extension-memory-\d+-\d+-[0-9a-f-]+\.json$/u,
);
expect(JSON.parse(readFileSync(reportPath, "utf8")).counts).toMatchObject({
totalEntries: 1,
ok: 1,
fail: 0,
timeout: 0,
});
}
expect(reportPaths[0]).not.toBe(reportPaths[1]);
} finally {
for (const reportPath of reportPaths) {
rmSync(reportPath, { force: true });
}
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;
}) as unknown as typeof spawn,
});
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,
shutdownGraceMs: 100,
timeoutMs: 250,
});
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)},`,
" shutdownGraceMs: 100,",
" 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 });
}
},
);
});