mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-12 04:46:04 +00:00
* test: speed up isolated test suite * test: finish isolated latency cleanup * test: eliminate remaining isolated latency spikes * test: remove final isolated timing outliers * test: bound full-suite tooling processes * test: bound native test process lifetime * test: warm isolated runtime suites * test: eliminate final isolated timing outliers * test: fix isolated timing fixture types * test: make timeout cleanup timing deterministic * test: pin media manifests to source checkout * test: isolate provider manifest contracts * test: eliminate residual isolated timing spikes * test: restore final isolated timing fixes * test: eliminate remaining isolated timing spikes * test: warm Zalo lifecycle imports * test: keep isolated suites below one second * test: use readable browser response fixtures
205 lines
6.9 KiB
TypeScript
205 lines
6.9 KiB
TypeScript
// Qa Lab tests cover model catalog plugin behavior.
|
|
import fs from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { afterEach, describe, expect, it } from "vitest";
|
|
import {
|
|
loadQaRunnerModelOptions,
|
|
parseQaRunnerModelOptionsOutput,
|
|
selectQaRunnerModelOptions,
|
|
} from "./model-catalog.runtime.js";
|
|
import { createTempDirHarness } from "./temp-dir.test-helper.js";
|
|
|
|
const { cleanup, makeTempDir } = createTempDirHarness();
|
|
|
|
afterEach(cleanup);
|
|
|
|
async function waitForFile(filePath: string, timeoutMs: number): Promise<void> {
|
|
const deadlineAt = Date.now() + timeoutMs;
|
|
while (Date.now() < deadlineAt) {
|
|
try {
|
|
await fs.access(filePath);
|
|
return;
|
|
} catch {
|
|
await new Promise((resolvePoll) => {
|
|
setTimeout(resolvePoll, 25);
|
|
});
|
|
}
|
|
}
|
|
throw new Error(`timed out waiting for ${filePath}`);
|
|
}
|
|
|
|
function isProcessAlive(pid: number): boolean {
|
|
try {
|
|
process.kill(pid, 0);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function waitForDead(pid: number, timeoutMs: number): Promise<void> {
|
|
const deadlineAt = Date.now() + timeoutMs;
|
|
while (Date.now() < deadlineAt) {
|
|
if (!isProcessAlive(pid)) {
|
|
return;
|
|
}
|
|
await new Promise((resolvePoll) => {
|
|
setTimeout(resolvePoll, 25);
|
|
});
|
|
}
|
|
throw new Error(`timed out waiting for pid ${pid} to exit`);
|
|
}
|
|
|
|
describe("qa runner model catalog", () => {
|
|
it("filters to available rows and prefers gpt-5.5 first", () => {
|
|
expect(
|
|
selectQaRunnerModelOptions([
|
|
{
|
|
key: "anthropic/claude-sonnet-4-6",
|
|
name: "Claude Sonnet 4.6",
|
|
input: "text",
|
|
available: true,
|
|
missing: false,
|
|
},
|
|
{
|
|
key: "openai/gpt-5.5",
|
|
name: "gpt-5.5",
|
|
input: "text,image",
|
|
available: true,
|
|
missing: false,
|
|
},
|
|
{
|
|
key: "openrouter/auto",
|
|
name: "OpenRouter Auto",
|
|
input: "text",
|
|
available: false,
|
|
missing: false,
|
|
},
|
|
]).map((entry) => entry.key),
|
|
).toEqual(["openai/gpt-5.5", "anthropic/claude-sonnet-4-6"]);
|
|
});
|
|
|
|
it("reports malformed catalog JSON with an owned error", () => {
|
|
expect(() => parseQaRunnerModelOptionsOutput("{not json")).toThrow(
|
|
"qa model catalog returned malformed JSON",
|
|
);
|
|
});
|
|
|
|
it("ignores invalid catalog rows without failing the model picker", () => {
|
|
expect(
|
|
parseQaRunnerModelOptionsOutput(
|
|
JSON.stringify({
|
|
models: [
|
|
null,
|
|
{
|
|
key: "openai/gpt-5.5",
|
|
name: "gpt-5.5",
|
|
input: "text,image",
|
|
available: true,
|
|
missing: false,
|
|
},
|
|
],
|
|
}),
|
|
).map((entry) => entry.key),
|
|
).toEqual(["openai/gpt-5.5"]);
|
|
});
|
|
|
|
it.runIf(process.platform !== "win32")(
|
|
"kills aborted catalog process groups when the catalog child exits first",
|
|
async () => {
|
|
const repoRoot = await makeTempDir("openclaw-qa-model-catalog-");
|
|
const pidPath = path.join(repoRoot, "descendant.pid");
|
|
let descendantPid: number | undefined;
|
|
const controller = new AbortController();
|
|
const childScript = "process.on('SIGTERM', () => {}); setInterval(() => {}, 1000);";
|
|
const catalogScript = [
|
|
"const { spawn } = require('node:child_process');",
|
|
"const fs = require('node:fs');",
|
|
`const child = spawn(process.execPath, ['-e', ${JSON.stringify(childScript)}], { stdio: 'ignore' });`,
|
|
`fs.writeFileSync(${JSON.stringify(pidPath)}, String(child.pid));`,
|
|
"process.on('SIGTERM', () => process.exit(0));",
|
|
"setInterval(() => {}, 1000);",
|
|
].join("\n");
|
|
|
|
try {
|
|
await fs.mkdir(path.join(repoRoot, "dist"), { recursive: true });
|
|
await fs.writeFile(path.join(repoRoot, "dist", "index.js"), catalogScript, "utf8");
|
|
const runPromise = loadQaRunnerModelOptions({
|
|
repoRoot,
|
|
signal: controller.signal,
|
|
abortKillGraceMs: 100,
|
|
});
|
|
|
|
await waitForFile(pidPath, 2_000);
|
|
descendantPid = Number.parseInt(await fs.readFile(pidPath, "utf8"), 10);
|
|
expect(Number.isInteger(descendantPid)).toBe(true);
|
|
expect(isProcessAlive(descendantPid)).toBe(true);
|
|
controller.abort();
|
|
|
|
await expect(runPromise).rejects.toThrow("qa model catalog aborted");
|
|
await waitForDead(descendantPid, 2_000);
|
|
} finally {
|
|
if (descendantPid !== undefined && isProcessAlive(descendantPid)) {
|
|
process.kill(descendantPid, "SIGKILL");
|
|
}
|
|
await fs.rm(repoRoot, { force: true, recursive: true });
|
|
}
|
|
},
|
|
);
|
|
|
|
it.runIf(process.platform !== "win32")(
|
|
"preserves abort grace when catalog descendants exit cleanly",
|
|
async () => {
|
|
const repoRoot = await makeTempDir("openclaw-qa-model-catalog-clean-");
|
|
const readyPath = path.join(repoRoot, "descendant.ready");
|
|
const cleanupPath = path.join(repoRoot, "descendant.cleanup");
|
|
const pidPath = path.join(repoRoot, "descendant.pid");
|
|
let descendantPid: number | undefined;
|
|
const controller = new AbortController();
|
|
const childScript = [
|
|
"const fs = require('node:fs');",
|
|
`fs.writeFileSync(${JSON.stringify(pidPath)}, String(process.pid));`,
|
|
"process.on('SIGTERM', () => {",
|
|
" setTimeout(() => {",
|
|
` fs.writeFileSync(${JSON.stringify(cleanupPath)}, 'clean');`,
|
|
" process.exit(0);",
|
|
" }, 75);",
|
|
"});",
|
|
`fs.writeFileSync(${JSON.stringify(readyPath)}, 'ready');`,
|
|
"setInterval(() => {}, 1000);",
|
|
].join("\n");
|
|
const catalogScript = [
|
|
"const { spawn } = require('node:child_process');",
|
|
`spawn(process.execPath, ['-e', ${JSON.stringify(childScript)}], { stdio: 'ignore' });`,
|
|
"process.on('SIGTERM', () => process.exit(0));",
|
|
"setInterval(() => {}, 1000);",
|
|
].join("\n");
|
|
|
|
try {
|
|
await fs.mkdir(path.join(repoRoot, "dist"), { recursive: true });
|
|
await fs.writeFile(path.join(repoRoot, "dist", "index.js"), catalogScript, "utf8");
|
|
const runPromise = loadQaRunnerModelOptions({
|
|
repoRoot,
|
|
signal: controller.signal,
|
|
});
|
|
|
|
await waitForFile(readyPath, 2_000);
|
|
descendantPid = Number.parseInt(await fs.readFile(pidPath, "utf8"), 10);
|
|
expect(Number.isInteger(descendantPid)).toBe(true);
|
|
const abortStartedAt = Date.now();
|
|
controller.abort();
|
|
|
|
await expect(runPromise).rejects.toThrow("qa model catalog aborted");
|
|
expect(await fs.readFile(cleanupPath, "utf8")).toBe("clean");
|
|
expect(Date.now() - abortStartedAt).toBeLessThan(1_700);
|
|
await waitForDead(descendantPid, 2_000);
|
|
} finally {
|
|
if (descendantPid !== undefined && isProcessAlive(descendantPid)) {
|
|
process.kill(descendantPid, "SIGKILL");
|
|
}
|
|
await fs.rm(repoRoot, { force: true, recursive: true });
|
|
}
|
|
},
|
|
);
|
|
});
|