aps-agent/poc/pi-aps/e2e/e2e.mjs

453 lines
16 KiB
JavaScript

/**
* e2e.mjs - real Pi CLI end-to-end validation for the pi-aps extension.
*
* Chain under test:
* real APS HTTP server -> real Pi CLI -> pi-aps extension -> mock LLM (scripted)
*
* The mock LLM never generates APS data. If the APS server exposes
* /api/agent/intents with order.pool, the script asks the model to call
* aps_order_pool. Otherwise it falls back to aps_health so the real Pi-to-APS
* HTTP chain is still exercised.
*
* Pi headless always exits 0; verdict is based on JSONL events.
*/
import { spawn } from "node:child_process";
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
import net from "node:net";
import path from "node:path";
import { fileURLToPath } from "node:url";
const E2E_DIR = path.dirname(fileURLToPath(import.meta.url));
const PACKAGE_ROOT = path.resolve(E2E_DIR, "..");
const REPO_ROOT = path.resolve(E2E_DIR, "..", "..", "..");
const PI_CLI = path.join(
REPO_ROOT,
"poc",
"pi-fallback",
"runtime",
"node_modules",
"@mariozechner",
"pi-coding-agent",
"dist",
"cli.js",
);
const EXTENSION_FILE = path.join(PACKAGE_ROOT, "extensions", "aps.ts");
const MOCK_LLM_FILE = path.join(E2E_DIR, "mock-llm.mjs");
const PI_HOME = path.join(E2E_DIR, "pi-home");
const EVENTS_FILE = path.join(E2E_DIR, "last-events.jsonl");
const SUMMARY_FILE = path.join(E2E_DIR, "last-summary.json");
const MOCK_LOG_FILE = path.join(E2E_DIR, "mock-requests-last.jsonl");
const DEFAULT_APS_URL = "http://127.0.0.1:8000";
const MODEL_PROVIDER = "aps-e2e";
const MODEL_ID = "mock-local";
function env(key, fallback = "") {
return process.env[key] || fallback;
}
function parseArgs() {
const out = {
apsUrl: env("APS_BASE_URL", DEFAULT_APS_URL).trim().replace(/\/+$/, ""),
token: env("APS_AGENT_TOKEN", "").trim() || undefined,
configFile: env("APS_CONFIG_FILE", "").trim() || undefined,
configOnly: ["1", "true", "yes"].includes(
env("APS_E2E_CONFIG_FILE_ONLY", "").trim().toLowerCase(),
),
forceTool: env("APS_E2E_TOOL", "").trim() || undefined,
timeoutMs: Number(env("APS_E2E_TIMEOUT_MS", "120000")),
prompt: env(
"APS_E2E_PROMPT",
"请用 APS 只读工具查询订单池,拿到真实返回后给出一句中文总结。",
),
};
const argv = process.argv.slice(2);
for (let i = 0; i < argv.length; i += 1) {
if (argv[i] === "--aps-url") out.apsUrl = String(argv[i + 1] || out.apsUrl).replace(/\/+$/, "");
if (argv[i] === "--tool") out.forceTool = argv[i + 1] || out.forceTool;
if (argv[i] === "--prompt") out.prompt = argv[i + 1] || out.prompt;
if (argv[i] === "--timeout-ms") out.timeoutMs = Number(argv[i + 1] || out.timeoutMs);
}
return out;
}
function resolveConfigOnlyPath(configFile) {
const resolved = path.resolve(String(configFile || "").trim());
const home = path.resolve(PI_HOME);
if (!resolved.startsWith(home + path.sep)) {
throw new Error(
`[e2e] APS_E2E_CONFIG_FILE_ONLY=1 requires APS_CONFIG_FILE under ${home}`,
);
}
return resolved;
}
function normalizeToolName(intentName) {
const normalized = String(intentName || "")
.trim()
.toLowerCase()
.replace(/\./g, "_")
.replace(/[^a-z0-9_]+/g, "_")
.replace(/_+/g, "_")
.replace(/^_+|_+$/g, "");
if (!normalized) return undefined;
const toolName = `aps_${normalized}`;
return /^[a-z][a-z0-9_]*$/.test(toolName) ? toolName : undefined;
}
async function httpJson(url, options = {}) {
const headers = { Accept: "application/json", "Content-Type": "application/json" };
if (options.token) headers.Authorization = `Bearer ${options.token}`;
const response = await fetch(url, { headers, signal: AbortSignal.timeout(options.timeoutMs || 8000) });
const text = await response.text();
let body = {};
if (text) {
try {
body = JSON.parse(text);
} catch {
body = { raw: text.slice(0, 300) };
}
}
return { status: response.status, body };
}
async function waitForHealth(apsUrl, timeoutMs = 25000) {
const started = Date.now();
let lastError = "";
while (Date.now() - started < timeoutMs) {
try {
const { status, body } = await httpJson(`${apsUrl}/api/health`, { timeoutMs: 4000 });
if (status === 200 && body?.ok !== false) return { status, body };
lastError = `status=${status}`;
} catch (err) {
lastError = err instanceof Error ? err.message : String(err);
}
await new Promise((resolve) => setTimeout(resolve, 750));
}
throw new Error(`APS server not healthy within ${timeoutMs}ms at ${apsUrl}: ${lastError}`);
}
async function detectCatalog(apsUrl, token) {
const { status, body } = await httpJson(`${apsUrl}/api/agent/intents`, { token, timeoutMs: 6000 });
if (status !== 200) return { available: false, status };
const entries = Array.isArray(body?.intents) ? body.intents : [];
if (!entries.length) return { available: true, status, intents: [] };
const explicit = env("APS_E2E_INTENT", "").trim();
let entry = entries.find((item) => item && String(item.name || "").trim() === explicit);
if (!entry) entry = entries.find((item) => item && String(item.name || "") === "order.pool");
if (!entry && !explicit) entry = entries[0];
return { available: true, status, intents: entries, selected: entry || null };
}
function freePort() {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.once("error", reject);
server.listen(0, "127.0.0.1", () => {
const port = server.address().port;
server.close(() => resolve(port));
});
});
}
async function waitForPortFile(file, expectedPort, timeoutMs = 10000) {
const started = Date.now();
while (Date.now() - started < timeoutMs) {
try {
const text = await readFile(file, "utf8");
const match = String(text).match(/MOCK_LLM_PORT=(\d+)/);
if (match && Number(match[1]) === expectedPort) return text;
} catch {
await new Promise((resolve) => setTimeout(resolve, 200));
}
}
throw new Error(`mock LLM did not publish port file: ${file}`);
}
function collectProcess(name, child) {
return new Promise((resolve, reject) => {
let stdout = "";
let stderr = "";
child.stdout.on("data", (c) => {
stdout += c.toString("utf8");
if (stdout.length > 20_000_000) stdout = stdout.slice(-10_000_000);
});
child.stderr.on("data", (c) => {
stderr += c.toString("utf8");
if (stderr.length > 2_000_000) stderr = stderr.slice(-1_000_000);
});
child.on("error", reject);
child.on("exit", (code, signal) => resolve({ name, code, signal, stdout, stderr }));
});
}
function promptForTool(toolName) {
if (toolName === "aps_health") {
return "请用 aps_health 工具检查 APS 健康状态,拿到真实返回后给出一句中文总结。";
}
if (toolName === "aps_summary") {
return "请用 aps_summary 工具读取 APS 世界摘要,拿到真实返回后给出一句中文总结。";
}
return `请用 ${toolName} 工具查询 APS 只读业务数据,拿到真实返回后给出一句中文总结。`;
}
function redactOptions(options) {
const rest = {
apsUrl: options.apsUrl,
configOnly: Boolean(options.configOnly),
forceTool: options.forceTool,
timeoutMs: options.timeoutMs,
prompt: options.prompt,
configFile: options.configFile ? "configured" : undefined,
};
if (!options.token) return { ...rest, token: "not configured" };
return { ...rest, token: "configured (ends " + options.token.slice(-4) + ")" };
}
async function main() {
const options = parseArgs();
let generatedConfigFile;
if (options.configOnly) {
if (!options.configFile) {
throw new Error(
"[e2e] APS_E2E_CONFIG_FILE_ONLY=1 requires APS_CONFIG_FILE",
);
}
options.configFile = resolveConfigOnlyPath(options.configFile);
generatedConfigFile = options.configFile;
}
const checks = {};
const summary = { options: redactOptions(options), checks, ranAt: new Date().toISOString() };
const mockProcesses = [];
try {
checks.serverHealth = false;
const health = await waitForHealth(options.apsUrl);
checks.serverHealth = true;
console.log(`[e2e] APS health OK: ${JSON.stringify(health.body)}`);
const catalog = await detectCatalog(options.apsUrl, options.token);
let targetTool;
let catalogIntent;
if (catalog.available && catalog.selected) {
catalogIntent = String(catalog.selected.name || "").trim();
targetTool = normalizeToolName(catalogIntent) || options.forceTool || "aps_health";
console.log(`[e2e] APS agent catalog available: intent=${catalogIntent} tool=${targetTool}`);
} else {
targetTool = options.forceTool || "aps_health";
console.log(
`[e2e] APS agent catalog unavailable (status=${catalog.status}); ` +
`falling back to ${targetTool}`,
);
}
summary.catalog = {
available: Boolean(catalog.available),
status: catalog.status,
selectedIntent: catalogIntent || null,
targetTool,
};
const mockPort = await freePort();
const portFile = path.join(E2E_DIR, "mock-port.txt");
await rm(portFile, { force: true });
await rm(MOCK_LOG_FILE, { force: true });
const mockEnv = {
...process.env,
APS_E2E_TOOL: targetTool,
APS_MOCK_REQUEST_LOG: MOCK_LOG_FILE,
NO_PROXY: "127.0.0.1,localhost",
no_proxy: "127.0.0.1,localhost",
};
for (const key of [
"APS_BASE_URL",
"APS_AGENT_TOKEN",
"APS_REQUEST_TIMEOUT_MS",
"APS_CATALOG_TIMEOUT_MS",
"APS_CONFIG_FILE",
]) {
delete mockEnv[key];
}
const mockChild = spawn(
process.execPath,
[MOCK_LLM_FILE, "--port", String(mockPort), "--port-file", portFile],
{ env: mockEnv, stdio: ["ignore", "pipe", "pipe"], windowsHide: true },
);
mockProcesses.push(mockChild);
const mockCollect = collectProcess("mock-llm", mockChild);
const portText = await waitForPortFile(portFile, mockPort);
const actualPort = Number(String(portText).match(/MOCK_LLM_PORT=(\d+)/)?.[1] || mockPort);
summary.mockPort = actualPort;
console.log(`[e2e] mock LLM on ${actualPort}`);
await mkdir(PI_HOME, { recursive: true });
const models = {
providers: {
[MODEL_PROVIDER]: {
baseUrl: `http://127.0.0.1:${actualPort}/v1`,
api: "openai-completions",
apiKey: "aps-e2e-mock-no-secret",
models: [
{
id: MODEL_ID,
name: "APS E2E Scripted Mock",
reasoning: false,
input: ["text"],
contextWindow: 32768,
maxTokens: 2048,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
},
],
},
},
};
await writeFile(path.join(PI_HOME, "models.json"), `${JSON.stringify(models, null, 2)}\n`, "utf8");
await writeFile(path.join(PI_HOME, "auth.json"), "{}\n", "utf8");
if (options.configOnly) {
await mkdir(path.dirname(options.configFile), { recursive: true });
const configPayload = {
aps: {
baseUrl: options.apsUrl,
...(options.token ? { token: options.token } : {}),
},
};
await writeFile(
options.configFile,
`${JSON.stringify(configPayload, null, 2)}\n`,
"utf8",
);
console.log("[e2e] APS config file mode: token/base URL passed through aps.json");
}
const prompt = promptForTool(targetTool);
const piArgs = [
PI_CLI,
"--mode", "json",
"--no-session",
"--model", `${MODEL_PROVIDER}/${MODEL_ID}`,
"--extension", EXTENSION_FILE,
"--no-extensions",
"--no-builtin-tools",
"--no-skills",
"--no-context-files",
"--verbose",
"--print",
prompt,
];
const piEnv = {
...process.env,
PI_CODING_AGENT_DIR: PI_HOME,
APS_E2E_TOOL: targetTool,
NO_PROXY: "127.0.0.1,localhost",
no_proxy: "127.0.0.1,localhost",
};
if (options.configOnly) {
delete piEnv.APS_BASE_URL;
delete piEnv.APS_AGENT_TOKEN;
delete piEnv.APS_REQUEST_TIMEOUT_MS;
delete piEnv.APS_CATALOG_TIMEOUT_MS;
piEnv.APS_CONFIG_FILE = options.configFile;
} else {
piEnv.APS_BASE_URL = options.apsUrl;
if (options.token) piEnv.APS_AGENT_TOKEN = options.token;
}
console.log(`[e2e] starting real Pi CLI (timeout ${options.timeoutMs}ms)`);
const piChild = spawn(process.execPath, piArgs, {
cwd: PACKAGE_ROOT,
env: piEnv,
stdio: ["ignore", "pipe", "pipe"],
windowsHide: true,
});
const piCollect = collectProcess("pi-cli", piChild);
const timer = setTimeout(() => {
console.error("[e2e] Pi CLI timeout, killing process");
piChild.kill("SIGTERM");
}, options.timeoutMs);
const piResult = await piCollect;
clearTimeout(timer);
const mockResult = await Promise.race([mockCollect, new Promise((r) => setTimeout(() => r(null), 3000))]);
await writeFile(EVENTS_FILE, piResult.stdout, "utf8");
const events = piResult.stdout
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
.map((line) => {
try {
return JSON.parse(line);
} catch {
return { type: "non_json", raw: line.slice(0, 1000) };
}
});
const starts = events.filter((e) => e.type === "tool_execution_start" && e.toolName === targetTool);
const ends = events.filter((e) => e.type === "tool_execution_end" && e.toolName === targetTool);
const finalMessages = events
.filter((e) => e.type === "agent_end")
.flatMap((e) => Array.isArray(e.messages) ? e.messages : [])
.filter((m) => m?.role === "assistant");
const finalAssistant = [...finalMessages].reverse().find((m) => m?.stopReason === "stop");
const failedRetry = events.some((e) => e.type === "auto_retry_end" && e.success === false);
const extensionError = events.find((e) => e.type === "extension_error");
const toolEndOk = ends.some((e) => e.isError === false);
const toolStartOk = starts.length > 0;
const finalStopOk = Boolean(finalAssistant) && finalAssistant.stopReason === "stop";
checks.toolExecutionStart = toolStartOk;
checks.toolExecutionSuccess = toolEndOk;
checks.finalAssistantStop = finalStopOk;
checks.noFatalProviderRetry = !failedRetry;
checks.noExtensionLoadError = !extensionError;
summary.events = {
rawLines: piResult.stdout.split(/\r?\n/).filter(Boolean).length,
parsedEvents: events.filter((e) => e.type !== "non_json").length,
toolStartCount: starts.length,
toolEndCount: ends.length,
toolEndOk,
finalStopReason: finalAssistant?.stopReason || "missing",
finalText: (finalAssistant?.content || [])
.filter((c) => c?.type === "text")
.map((c) => c.text)
.join("")
.slice(0, 1200),
extensionError: extensionError || null,
};
summary.pi = {
exitCode: piResult.code,
signal: piResult.signal,
stderrTail: piResult.stderr.slice(-1200),
mockExitCode: mockResult?.code ?? null,
};
const allOk = [toolStartOk, toolEndOk, finalStopOk, !failedRetry, !extensionError].every(Boolean);
summary.pass = allOk;
await writeFile(SUMMARY_FILE, `${JSON.stringify(summary, null, 2)}\n`, "utf8");
console.log(`[e2e] verdict: ${allOk ? "PASS" : "FAIL"}`);
console.log(`[e2e] checks: ${JSON.stringify(checks)}`);
return allOk ? 0 : 1;
} catch (err) {
summary.pass = false;
summary.fatalError = err instanceof Error ? err.message : String(err);
try {
await writeFile(SUMMARY_FILE, `${JSON.stringify(summary, null, 2)}\n`, "utf8");
} catch {
// best effort
}
console.error(`[e2e] fatal: ${summary.fatalError}`);
return 2;
} finally {
if (generatedConfigFile) {
try {
await rm(generatedConfigFile, { force: true });
} catch {
// Best-effort cleanup of a token-bearing temp config.
}
}
for (const child of mockProcesses) {
if (child.exitCode === null && child.signalCode === null) child.kill("SIGTERM");
}
}
}
process.exitCode = await main();