Vendor OpenClaw source as Adolf fork baseline
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11), free to diverge. Tree copied sans upstream .git; upstream remote added for future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19. Preserves docs/ARCHITECTURE.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
This commit is contained in:
229
scripts/e2e/lib/agent-turn-output.mjs
Normal file
229
scripts/e2e/lib/agent-turn-output.mjs
Normal file
@@ -0,0 +1,229 @@
|
||||
// Helpers for extracting agent turn output from E2E protocol events.
|
||||
import fs from "node:fs";
|
||||
import { readTextFileTail, tailText } from "./text-file-utils.mjs";
|
||||
|
||||
const ERROR_DETAIL_TAIL_BYTES = 64 * 1024;
|
||||
const OUTPUT_SCAN_TAIL_BYTES = 2 * 1024 * 1024;
|
||||
const REPLY_TEXT_PREVIEW_BYTES = 8 * 1024;
|
||||
const REPLY_TEXT_PREVIEW_COUNT = 5;
|
||||
const REQUEST_LOG_SCAN_CHUNK_BYTES = 64 * 1024;
|
||||
const REQUEST_LOG_SCAN_CARRY_CHARS = 256;
|
||||
const OPENAI_REQUEST_PATH_PATTERN = /\/v1\/(responses|chat\/completions)/u;
|
||||
|
||||
function textByteLength(text) {
|
||||
return Buffer.byteLength(text, "utf8");
|
||||
}
|
||||
|
||||
function summarizeReplyTexts(replyTexts) {
|
||||
const previewStart = Math.max(0, replyTexts.length - REPLY_TEXT_PREVIEW_COUNT);
|
||||
const recent = replyTexts.slice(previewStart).map((text, index) => ({
|
||||
index: previewStart + index,
|
||||
bytes: textByteLength(text),
|
||||
tail: tailText(text, REPLY_TEXT_PREVIEW_BYTES),
|
||||
}));
|
||||
return JSON.stringify({ count: replyTexts.length, recent });
|
||||
}
|
||||
|
||||
function fileContainsPattern(file, pattern) {
|
||||
let stat;
|
||||
try {
|
||||
stat = fs.statSync(file);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (!stat.isFile() || stat.size <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const fd = fs.openSync(file, "r");
|
||||
try {
|
||||
const buffer = Buffer.alloc(Math.min(REQUEST_LOG_SCAN_CHUNK_BYTES, stat.size));
|
||||
let carry = "";
|
||||
let offset = 0;
|
||||
while (offset < stat.size) {
|
||||
const bytesToRead = Math.min(buffer.length, stat.size - offset);
|
||||
const bytesRead = fs.readSync(fd, buffer, 0, bytesToRead, offset);
|
||||
if (bytesRead <= 0) {
|
||||
break;
|
||||
}
|
||||
offset += bytesRead;
|
||||
const text = carry + buffer.subarray(0, bytesRead).toString("utf8");
|
||||
if (pattern.test(text)) {
|
||||
return true;
|
||||
}
|
||||
carry = text.slice(-REQUEST_LOG_SCAN_CARRY_CHARS);
|
||||
}
|
||||
return false;
|
||||
} finally {
|
||||
fs.closeSync(fd);
|
||||
}
|
||||
}
|
||||
|
||||
function parseJson(text) {
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function isJsonObjectRecordStart(text, index) {
|
||||
for (let cursor = index - 1; cursor >= 0; cursor -= 1) {
|
||||
const char = text[cursor];
|
||||
if (char === "\n" || char === "\r") {
|
||||
return true;
|
||||
}
|
||||
if (char !== " " && char !== "\t") {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function parseJsonObjectsFromText(text) {
|
||||
const payloads = [];
|
||||
let start = -1;
|
||||
let depth = 0;
|
||||
let inString = false;
|
||||
let escaped = false;
|
||||
|
||||
for (let index = 0; index < text.length; index += 1) {
|
||||
const char = text[index];
|
||||
if (start === -1) {
|
||||
if (char === "{" && isJsonObjectRecordStart(text, index)) {
|
||||
start = index;
|
||||
depth = 1;
|
||||
inString = false;
|
||||
escaped = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (inString) {
|
||||
if (escaped) {
|
||||
escaped = false;
|
||||
} else if (char === "\\") {
|
||||
escaped = true;
|
||||
} else if (char === '"') {
|
||||
inString = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === '"') {
|
||||
inString = true;
|
||||
continue;
|
||||
}
|
||||
if (char === "{") {
|
||||
depth += 1;
|
||||
continue;
|
||||
}
|
||||
if (char !== "}") {
|
||||
continue;
|
||||
}
|
||||
|
||||
depth -= 1;
|
||||
if (depth === 0) {
|
||||
const parsed = parseJson(text.slice(start, index + 1));
|
||||
if (parsed !== undefined) {
|
||||
payloads.push(parsed);
|
||||
}
|
||||
start = -1;
|
||||
}
|
||||
}
|
||||
return payloads;
|
||||
}
|
||||
|
||||
function parseJsonPayloads(text) {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) {
|
||||
return [];
|
||||
}
|
||||
const parsed = parseJson(trimmed);
|
||||
if (parsed !== undefined) {
|
||||
return [parsed];
|
||||
}
|
||||
return parseJsonObjectsFromText(trimmed);
|
||||
}
|
||||
|
||||
function textValues(values) {
|
||||
return values.filter((value) => typeof value === "string" && value.length > 0);
|
||||
}
|
||||
|
||||
function isRecord(value) {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isFailureStatus(value) {
|
||||
return (
|
||||
typeof value === "string" &&
|
||||
["blocked", "canceled", "cancelled", "error", "failed", "failure"].includes(value.toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
function hasFailureSignal(value) {
|
||||
if (!isRecord(value)) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
value.isError === true ||
|
||||
value.ok === false ||
|
||||
isFailureStatus(value.status) ||
|
||||
isFailureStatus(value.livenessState) ||
|
||||
(Object.hasOwn(value, "error") && value.error !== null && value.error !== undefined)
|
||||
);
|
||||
}
|
||||
|
||||
export function extractAgentReplyTexts(text) {
|
||||
return parseJsonPayloads(text).flatMap((payload) => {
|
||||
const envelopeFailed =
|
||||
hasFailureSignal(payload) ||
|
||||
hasFailureSignal(payload?.meta) ||
|
||||
hasFailureSignal(payload?.result) ||
|
||||
hasFailureSignal(payload?.result?.meta);
|
||||
if (envelopeFailed) {
|
||||
return [];
|
||||
}
|
||||
const payloadEntries = Array.isArray(payload?.payloads)
|
||||
? payload.payloads
|
||||
: Array.isArray(payload?.result?.payloads)
|
||||
? payload.result.payloads
|
||||
: [];
|
||||
const directTexts = textValues([
|
||||
payload?.finalAssistantVisibleText,
|
||||
payload?.finalAssistantRawText,
|
||||
payload?.meta?.finalAssistantVisibleText,
|
||||
payload?.meta?.finalAssistantRawText,
|
||||
payload?.result?.finalAssistantVisibleText,
|
||||
payload?.result?.finalAssistantRawText,
|
||||
payload?.result?.meta?.finalAssistantVisibleText,
|
||||
payload?.result?.meta?.finalAssistantRawText,
|
||||
]);
|
||||
const payloadTexts = payloadEntries.flatMap((entry) =>
|
||||
entry?.isError !== true && typeof entry?.text === "string" && entry.text.length > 0
|
||||
? [entry.text]
|
||||
: [],
|
||||
);
|
||||
return directTexts.concat(payloadTexts);
|
||||
});
|
||||
}
|
||||
|
||||
export function assertAgentReplyContainsMarker(marker, outputPath) {
|
||||
const output = readTextFileTail(outputPath, OUTPUT_SCAN_TAIL_BYTES);
|
||||
const replyTexts = extractAgentReplyTexts(output);
|
||||
if (replyTexts.some((text) => text.includes(marker))) {
|
||||
return;
|
||||
}
|
||||
const outputTail = tailText(output, ERROR_DETAIL_TAIL_BYTES);
|
||||
throw new Error(
|
||||
`agent reply payload did not contain marker ${marker}. Reply payload summary: ${summarizeReplyTexts(replyTexts)}. Output tail: ${outputTail}`,
|
||||
);
|
||||
}
|
||||
|
||||
export function assertOpenAiRequestLogUsed(requestLogPath, label = "mock OpenAI server") {
|
||||
if (fileContainsPattern(requestLogPath, OPENAI_REQUEST_PATH_PATTERN)) {
|
||||
return;
|
||||
}
|
||||
const requestLogTail = readTextFileTail(requestLogPath, ERROR_DETAIL_TAIL_BYTES);
|
||||
throw new Error(`${label} was not used. Request log tail: ${requestLogTail}`);
|
||||
}
|
||||
62
scripts/e2e/lib/auth-profile-store-assertions.mjs
Normal file
62
scripts/e2e/lib/auth-profile-store-assertions.mjs
Normal file
@@ -0,0 +1,62 @@
|
||||
// Shared auth profile store assertions for install/onboard E2E proof.
|
||||
function isRecord(value) {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function hasExpectedOpenAiEnvRef(profile) {
|
||||
if (!isRecord(profile)) {
|
||||
return false;
|
||||
}
|
||||
const keyRef = profile.keyRef;
|
||||
return (
|
||||
profile.type === "api_key" &&
|
||||
profile.provider === "openai" &&
|
||||
!Object.hasOwn(profile, "key") &&
|
||||
isRecord(keyRef) &&
|
||||
keyRef.source === "env" &&
|
||||
keyRef.provider === "default" &&
|
||||
keyRef.id === "OPENAI_API_KEY"
|
||||
);
|
||||
}
|
||||
|
||||
function hasInlineOpenAiKey(profile) {
|
||||
return (
|
||||
isRecord(profile) &&
|
||||
profile.type === "api_key" &&
|
||||
profile.provider === "openai" &&
|
||||
Object.hasOwn(profile, "key")
|
||||
);
|
||||
}
|
||||
|
||||
export function assertOpenAiEnvAuthProfileStore(storeJson, options = {}) {
|
||||
const missingMessage = options.missingMessage ?? "auth profile store was not persisted";
|
||||
const envRefMessage =
|
||||
options.envRefMessage ?? "auth profile did not persist OPENAI_API_KEY env ref";
|
||||
const rawKeyMessage = options.rawKeyMessage ?? "auth profile persisted an inline OpenAI key";
|
||||
const rawKeyNeedle = options.rawKeyNeedle;
|
||||
|
||||
if (!storeJson) {
|
||||
throw new Error(missingMessage);
|
||||
}
|
||||
if (rawKeyNeedle && storeJson.includes(rawKeyNeedle)) {
|
||||
throw new Error(rawKeyMessage);
|
||||
}
|
||||
|
||||
let store;
|
||||
try {
|
||||
store = JSON.parse(storeJson);
|
||||
} catch {
|
||||
throw new Error(envRefMessage);
|
||||
}
|
||||
const profiles = isRecord(store) && isRecord(store.profiles) ? store.profiles : null;
|
||||
if (!profiles) {
|
||||
throw new Error(envRefMessage);
|
||||
}
|
||||
const profileValues = Object.values(profiles);
|
||||
if (profileValues.some(hasInlineOpenAiKey)) {
|
||||
throw new Error(rawKeyMessage);
|
||||
}
|
||||
if (!profileValues.some(hasExpectedOpenAiEnvRef)) {
|
||||
throw new Error(envRefMessage);
|
||||
}
|
||||
}
|
||||
68
scripts/e2e/lib/bounded-response-text.mjs
Normal file
68
scripts/e2e/lib/bounded-response-text.mjs
Normal file
@@ -0,0 +1,68 @@
|
||||
// Bounded response body reader used by E2E HTTP fixture clients.
|
||||
function bodyTooLargeError(label, byteLimit) {
|
||||
return Object.assign(new Error(`${label} response body exceeded ${byteLimit} bytes`), {
|
||||
code: "ETOOBIG",
|
||||
});
|
||||
}
|
||||
|
||||
function cancelReaderSoon(reader) {
|
||||
void Promise.resolve()
|
||||
.then(() => reader.cancel())
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
function parseContentLengthHeader(headers) {
|
||||
const raw = headers.get("content-length");
|
||||
if (!raw || !/^\d+$/u.test(raw)) {
|
||||
return undefined;
|
||||
}
|
||||
const parsed = Number(raw);
|
||||
return Number.isSafeInteger(parsed) ? parsed : Number.POSITIVE_INFINITY;
|
||||
}
|
||||
|
||||
export async function readBoundedResponseText(response, label, byteLimit, timeoutPromise) {
|
||||
const contentLength = parseContentLengthHeader(response.headers);
|
||||
if (contentLength !== undefined && contentLength > byteLimit) {
|
||||
await response.body?.cancel().catch(() => {});
|
||||
throw bodyTooLargeError(label, byteLimit);
|
||||
}
|
||||
if (!response.body) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let byteCount = 0;
|
||||
let text = "";
|
||||
let canceled = false;
|
||||
try {
|
||||
while (true) {
|
||||
const read = reader.read();
|
||||
const readWithTimeout = timeoutPromise
|
||||
? Promise.race([
|
||||
read,
|
||||
timeoutPromise.catch((error) => {
|
||||
canceled = true;
|
||||
cancelReaderSoon(reader);
|
||||
throw error;
|
||||
}),
|
||||
])
|
||||
: read;
|
||||
const { done, value } = await readWithTimeout;
|
||||
if (done) {
|
||||
return text + decoder.decode();
|
||||
}
|
||||
byteCount += value.byteLength;
|
||||
if (byteCount > byteLimit) {
|
||||
canceled = true;
|
||||
await reader.cancel().catch(() => {});
|
||||
throw bodyTooLargeError(label, byteLimit);
|
||||
}
|
||||
text += decoder.decode(value, { stream: true });
|
||||
}
|
||||
} finally {
|
||||
if (!canceled) {
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
}
|
||||
71
scripts/e2e/lib/browser-cdp-snapshot/assert-snapshot.mjs
Normal file
71
scripts/e2e/lib/browser-cdp-snapshot/assert-snapshot.mjs
Normal file
@@ -0,0 +1,71 @@
|
||||
// Assertions for browser CDP snapshot E2E fixtures.
|
||||
import fs from "node:fs";
|
||||
|
||||
const DEFAULT_SNAPSHOT_MAX_BYTES = 512 * 1024;
|
||||
const SNAPSHOT_DIAGNOSTIC_MAX_BYTES = 32 * 1024;
|
||||
const snapshotPath = process.argv[2] ?? "/tmp/browser-cdp-snapshot.txt";
|
||||
|
||||
function readPositiveIntEnv(name, fallback) {
|
||||
const raw = process.env[name];
|
||||
if (raw === undefined || raw === "") {
|
||||
return fallback;
|
||||
}
|
||||
const text = raw.trim();
|
||||
if (!/^\d+$/u.test(text)) {
|
||||
throw new Error(`${name} must be a positive integer; got: ${raw}`);
|
||||
}
|
||||
const parsed = Number(text);
|
||||
if (!Number.isSafeInteger(parsed) || parsed <= 0) {
|
||||
throw new Error(`${name} must be a positive integer; got: ${raw}`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function readBoundedSnapshot(file, maxBytes) {
|
||||
const stats = fs.statSync(file);
|
||||
if (!stats.isFile()) {
|
||||
throw new Error(`${file} is not a file`);
|
||||
}
|
||||
if (stats.size > maxBytes) {
|
||||
throw new Error(`browser CDP snapshot exceeded ${maxBytes} bytes: ${stats.size} bytes`);
|
||||
}
|
||||
const snapshot = fs.readFileSync(file, "utf8");
|
||||
const bytes = Buffer.byteLength(snapshot, "utf8");
|
||||
if (bytes > maxBytes) {
|
||||
throw new Error(`browser CDP snapshot exceeded ${maxBytes} bytes: ${bytes} bytes`);
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
function snapshotDiagnostic(snapshot) {
|
||||
const buffer = Buffer.from(snapshot, "utf8");
|
||||
if (buffer.byteLength <= SNAPSHOT_DIAGNOSTIC_MAX_BYTES) {
|
||||
return snapshot;
|
||||
}
|
||||
return `[truncated snapshot diagnostic to ${SNAPSHOT_DIAGNOSTIC_MAX_BYTES} bytes]\n${buffer
|
||||
.subarray(buffer.byteLength - SNAPSHOT_DIAGNOSTIC_MAX_BYTES)
|
||||
.toString("utf8")}`;
|
||||
}
|
||||
|
||||
const snapshotMaxBytes = readPositiveIntEnv(
|
||||
"OPENCLAW_BROWSER_CDP_SNAPSHOT_MAX_BYTES",
|
||||
DEFAULT_SNAPSHOT_MAX_BYTES,
|
||||
);
|
||||
const snapshot = readBoundedSnapshot(snapshotPath, snapshotMaxBytes);
|
||||
|
||||
for (const needle of [
|
||||
'button "Save"',
|
||||
'link "Docs"',
|
||||
"https://docs.openclaw.ai/browser-cdp-live",
|
||||
'generic "Clickable Card"',
|
||||
"cursor:pointer",
|
||||
'Iframe "Child"',
|
||||
'button "Inside"',
|
||||
]) {
|
||||
if (!snapshot.includes(needle)) {
|
||||
console.error(snapshotDiagnostic(snapshot));
|
||||
throw new Error(`missing snapshot needle: ${needle}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("ok");
|
||||
24
scripts/e2e/lib/browser-cdp-snapshot/fixture-server.mjs
Normal file
24
scripts/e2e/lib/browser-cdp-snapshot/fixture-server.mjs
Normal file
@@ -0,0 +1,24 @@
|
||||
// Fixture HTTP server for browser CDP snapshot E2E scenarios.
|
||||
import http from "node:http";
|
||||
import { readTcpPortEnv } from "../env-limits.mjs";
|
||||
|
||||
const port = readTcpPortEnv("FIXTURE_PORT");
|
||||
|
||||
const html = `<!doctype html>
|
||||
<html>
|
||||
<body>
|
||||
<main>
|
||||
<button>Save</button>
|
||||
<a href="https://docs.openclaw.ai/browser-cdp-live">Docs</a>
|
||||
<div id="card" onclick="window.__clicked = true" style="cursor: pointer">Clickable Card</div>
|
||||
<iframe title="Child" srcdoc='<button>Inside</button>'></iframe>
|
||||
</main>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
http
|
||||
.createServer((_req, res) => {
|
||||
res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
||||
res.end(html);
|
||||
})
|
||||
.listen(port, "127.0.0.1");
|
||||
207
scripts/e2e/lib/bun-global-install/assertions.mjs
Normal file
207
scripts/e2e/lib/bun-global-install/assertions.mjs
Normal file
@@ -0,0 +1,207 @@
|
||||
// Assertions for Bun global install E2E validation.
|
||||
import { spawn } from "node:child_process";
|
||||
|
||||
const DEFAULT_TIMEOUT_KILL_GRACE_MS = 30_000;
|
||||
const PARENT_TERMINATION_SIGNALS = ["SIGINT", "SIGTERM", "SIGHUP"];
|
||||
|
||||
const usage = () => {
|
||||
console.error("Usage: assertions.mjs <run-with-timeout|assert-image-providers> [...]");
|
||||
process.exit(2);
|
||||
};
|
||||
|
||||
const [mode, ...args] = process.argv.slice(2);
|
||||
|
||||
const parsePositiveNumber = (value, label) => {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
throw new Error(`${label} must be a positive number`);
|
||||
}
|
||||
return parsed;
|
||||
};
|
||||
|
||||
const signalChild = (child, signal) => {
|
||||
if (!child.pid) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (process.platform === "win32") {
|
||||
child.kill(signal);
|
||||
return;
|
||||
}
|
||||
process.kill(-child.pid, signal);
|
||||
} catch (error) {
|
||||
if (error?.code !== "ESRCH") {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const processGroupAlive = (child) => {
|
||||
if (process.platform === "win32" || !child.pid) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
process.kill(-child.pid, 0);
|
||||
return true;
|
||||
} catch (error) {
|
||||
return error?.code === "EPERM";
|
||||
}
|
||||
};
|
||||
|
||||
const waitForProcessGroupExit = async (child, timeout) => {
|
||||
const deadlineAt = Date.now() + timeout;
|
||||
while (Date.now() < deadlineAt) {
|
||||
if (!processGroupAlive(child)) {
|
||||
return true;
|
||||
}
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 25);
|
||||
});
|
||||
}
|
||||
return !processGroupAlive(child);
|
||||
};
|
||||
|
||||
const resolveSignalExitCode = (signal) => {
|
||||
switch (signal) {
|
||||
case "SIGINT":
|
||||
return 130;
|
||||
case "SIGHUP":
|
||||
return 129;
|
||||
default:
|
||||
return 143;
|
||||
}
|
||||
};
|
||||
|
||||
const runWithTimeout = async (timeout, command, commandArgs) => {
|
||||
const killGrace = parsePositiveNumber(
|
||||
process.env.OPENCLAW_BUN_GLOBAL_SMOKE_TIMEOUT_KILL_GRACE_MS ??
|
||||
String(DEFAULT_TIMEOUT_KILL_GRACE_MS),
|
||||
"OPENCLAW_BUN_GLOBAL_SMOKE_TIMEOUT_KILL_GRACE_MS",
|
||||
);
|
||||
const child = spawn(command, commandArgs, {
|
||||
detached: process.platform !== "win32",
|
||||
env: process.env,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
let timedOut = false;
|
||||
let parentSignal = null;
|
||||
let killTimer;
|
||||
let killDeadlineAt = 0;
|
||||
const scheduleForceKill = () => {
|
||||
killDeadlineAt = Date.now() + killGrace;
|
||||
killTimer ??= setTimeout(() => signalChild(child, "SIGKILL"), killGrace);
|
||||
killTimer.unref();
|
||||
};
|
||||
|
||||
child.stdout.setEncoding("utf8");
|
||||
child.stderr.setEncoding("utf8");
|
||||
child.stdout.on("data", (chunk) => process.stdout.write(chunk));
|
||||
child.stderr.on("data", (chunk) => process.stderr.write(chunk));
|
||||
|
||||
const timeoutTimer = setTimeout(() => {
|
||||
timedOut = true;
|
||||
signalChild(child, "SIGTERM");
|
||||
scheduleForceKill();
|
||||
}, timeout);
|
||||
timeoutTimer.unref();
|
||||
|
||||
const parentSignalHandlers = new Map(
|
||||
PARENT_TERMINATION_SIGNALS.map((signal) => [
|
||||
signal,
|
||||
() => {
|
||||
parentSignal ??= signal;
|
||||
signalChild(child, signal);
|
||||
scheduleForceKill();
|
||||
},
|
||||
]),
|
||||
);
|
||||
for (const [signal, handler] of parentSignalHandlers) {
|
||||
process.on(signal, handler);
|
||||
}
|
||||
const cleanupParentSignalHandlers = () => {
|
||||
for (const [signal, handler] of parentSignalHandlers) {
|
||||
process.off(signal, handler);
|
||||
}
|
||||
};
|
||||
|
||||
let spawnError;
|
||||
child.on("error", (error) => {
|
||||
spawnError = error;
|
||||
});
|
||||
const result = await new Promise((resolve) => {
|
||||
child.on("close", (status, signal) => resolve({ error: spawnError, signal, status }));
|
||||
});
|
||||
|
||||
clearTimeout(timeoutTimer);
|
||||
cleanupParentSignalHandlers();
|
||||
if (timedOut || parentSignal) {
|
||||
const remainingGraceMs = Math.max(0, killDeadlineAt - Date.now());
|
||||
if (remainingGraceMs > 0) {
|
||||
await waitForProcessGroupExit(child, remainingGraceMs);
|
||||
}
|
||||
if (processGroupAlive(child)) {
|
||||
signalChild(child, "SIGKILL");
|
||||
await waitForProcessGroupExit(child, 100);
|
||||
}
|
||||
clearTimeout(killTimer);
|
||||
}
|
||||
if (parentSignal) {
|
||||
process.exit(resolveSignalExitCode(parentSignal));
|
||||
}
|
||||
if (timedOut) {
|
||||
console.error(`command timed out after ${timeout}ms: ${command}`);
|
||||
process.exit(1);
|
||||
}
|
||||
clearTimeout(killTimer);
|
||||
if (result.error) {
|
||||
console.error(`command failed: ${command}: ${result.error.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
if (result.signal) {
|
||||
console.error(`command terminated: ${command}: ${result.signal}`);
|
||||
process.exit(1);
|
||||
}
|
||||
process.exit(result.status ?? 0);
|
||||
};
|
||||
|
||||
if (mode === "run-with-timeout") {
|
||||
const [timeoutMs, command, ...commandArgs] = args;
|
||||
if (!command) {
|
||||
usage();
|
||||
}
|
||||
let timeout;
|
||||
try {
|
||||
timeout = parsePositiveNumber(timeoutMs, "timeoutMs");
|
||||
} catch {
|
||||
usage();
|
||||
}
|
||||
await runWithTimeout(timeout, command, commandArgs);
|
||||
}
|
||||
|
||||
if (mode === "assert-image-providers") {
|
||||
const raw = process.env.OPENCLAW_IMAGE_PROVIDERS_JSON ?? "";
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch (error) {
|
||||
console.error(raw);
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(`image providers output is not JSON: ${message}`, { cause: error });
|
||||
}
|
||||
if (!Array.isArray(parsed)) {
|
||||
throw new Error("image providers output must be a JSON array");
|
||||
}
|
||||
if (parsed.length === 0) {
|
||||
throw new Error("image providers output is empty");
|
||||
}
|
||||
const ids = new Set(parsed.map((entry) => (typeof entry?.id === "string" ? entry.id : "")));
|
||||
for (const expected of ["google", "openai", "xai"]) {
|
||||
if (!ids.has(expected)) {
|
||||
throw new Error(`image providers output is missing bundled provider '${expected}'`);
|
||||
}
|
||||
}
|
||||
console.log(`bun-global-install-smoke: image providers OK (${parsed.length} providers)`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
usage();
|
||||
307
scripts/e2e/lib/bundled-plugin-install-uninstall/probe.mjs
Normal file
307
scripts/e2e/lib/bundled-plugin-install-uninstall/probe.mjs
Normal file
@@ -0,0 +1,307 @@
|
||||
// Probe script for bundled plugin install/uninstall E2E scenarios.
|
||||
import { spawnSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { readPluginInstallRecords } from "../plugin-index-sqlite.mjs";
|
||||
|
||||
const readJson = (file) => JSON.parse(fs.readFileSync(file, "utf8"));
|
||||
const normalizePathForProbe = (value) => String(value ?? "").replace(/\\/g, "/");
|
||||
const bundledRuntimeFragments = (pluginDir) => [
|
||||
`/dist/extensions/${pluginDir}`,
|
||||
`/dist-runtime/extensions/${pluginDir}`,
|
||||
];
|
||||
const bundledRuntimeRootFragments = ["/dist/extensions/", "/dist-runtime/extensions/"];
|
||||
const DEFAULT_PLUGIN_LIST_TIMEOUT_MS = 30_000;
|
||||
const DEFAULT_PLUGIN_LIST_MAX_BUFFER_BYTES = 4 * 1024 * 1024;
|
||||
|
||||
function readIntegerEnv(name, fallback, minimum) {
|
||||
const raw = process.env[name];
|
||||
if (raw == null || raw === "") {
|
||||
return fallback;
|
||||
}
|
||||
const text = raw.trim();
|
||||
if (!/^\d+$/u.test(text)) {
|
||||
throw new Error(`invalid ${name}: ${text}`);
|
||||
}
|
||||
const value = Number(text);
|
||||
if (!Number.isSafeInteger(value) || value < minimum) {
|
||||
throw new Error(`invalid ${name}: ${text}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function readPositiveIntEnv(name, fallback) {
|
||||
return readIntegerEnv(name, fallback, 1);
|
||||
}
|
||||
|
||||
function readNonNegativeIntEnv(name, fallback) {
|
||||
return readIntegerEnv(name, fallback, 0);
|
||||
}
|
||||
|
||||
function resolveStateDir() {
|
||||
if (process.env.OPENCLAW_STATE_DIR) {
|
||||
return process.env.OPENCLAW_STATE_DIR;
|
||||
}
|
||||
return path.join(process.env.HOME || os.homedir(), ".openclaw");
|
||||
}
|
||||
|
||||
function pathReferencesBundledRuntime(value, pluginDir) {
|
||||
const normalized = normalizePathForProbe(value);
|
||||
return bundledRuntimeFragments(pluginDir).some((fragment) => normalized.includes(fragment));
|
||||
}
|
||||
|
||||
function pathReferencesPackagedBundledRoot(value) {
|
||||
const normalized = normalizePathForProbe(value);
|
||||
return bundledRuntimeRootFragments.some((fragment) => normalized.includes(fragment));
|
||||
}
|
||||
|
||||
function pathsEqualForProbe(actual, expected) {
|
||||
return normalizePathForProbe(actual) === normalizePathForProbe(expected);
|
||||
}
|
||||
|
||||
function resolveOpenClawEntry() {
|
||||
if (process.env.OPENCLAW_ENTRY) {
|
||||
return process.env.OPENCLAW_ENTRY;
|
||||
}
|
||||
for (const entry of ["dist/index.mjs", "dist/index.js"]) {
|
||||
if (fs.existsSync(entry)) {
|
||||
return entry;
|
||||
}
|
||||
}
|
||||
throw new Error("Missing OPENCLAW_ENTRY and dist/index.(m)js");
|
||||
}
|
||||
|
||||
function readPluginsList() {
|
||||
const entry = resolveOpenClawEntry();
|
||||
const timeoutMs = readPositiveIntEnv(
|
||||
"OPENCLAW_BUNDLED_PLUGIN_LIST_TIMEOUT_MS",
|
||||
DEFAULT_PLUGIN_LIST_TIMEOUT_MS,
|
||||
);
|
||||
const result = spawnSync(process.execPath, [entry, "plugins", "list", "--json"], {
|
||||
cwd: process.cwd(),
|
||||
encoding: "utf8",
|
||||
env: process.env,
|
||||
maxBuffer: readPositiveIntEnv(
|
||||
"OPENCLAW_BUNDLED_PLUGIN_LIST_MAX_BUFFER_BYTES",
|
||||
DEFAULT_PLUGIN_LIST_MAX_BUFFER_BYTES,
|
||||
),
|
||||
killSignal: "SIGKILL",
|
||||
timeout: timeoutMs,
|
||||
});
|
||||
if (result.error) {
|
||||
const timedOut = result.error.code === "ETIMEDOUT";
|
||||
throw new Error(
|
||||
timedOut
|
||||
? `Timed out listing packaged bundled plugins after ${timeoutMs}ms`
|
||||
: `Unable to list packaged bundled plugins: ${result.error.message}`,
|
||||
);
|
||||
}
|
||||
if (result.status !== 0) {
|
||||
throw new Error(
|
||||
`Unable to list packaged bundled plugins: ${result.stderr || result.stdout || `exit ${result.status}`}`,
|
||||
);
|
||||
}
|
||||
const payload = parsePluginListOutput(result.stdout);
|
||||
return Array.isArray(payload.plugins) ? payload.plugins : [];
|
||||
}
|
||||
|
||||
function parsePluginListOutput(stdout) {
|
||||
const trimmed = stdout.trim();
|
||||
const parsed = parseJsonValue(trimmed);
|
||||
if (parsed.ok) {
|
||||
return parsed.value;
|
||||
}
|
||||
let lastParsed;
|
||||
for (const line of trimmed.split(/\r?\n/u).toReversed()) {
|
||||
if (!line.trimStart().startsWith("{")) {
|
||||
continue;
|
||||
}
|
||||
const candidate = parseJsonValue(line);
|
||||
if (!candidate.ok) {
|
||||
continue;
|
||||
}
|
||||
lastParsed ??= candidate.value;
|
||||
if (Array.isArray(candidate.value?.plugins)) {
|
||||
return candidate.value;
|
||||
}
|
||||
}
|
||||
if (lastParsed !== undefined) {
|
||||
return lastParsed;
|
||||
}
|
||||
throw new Error(`Unable to parse packaged bundled plugin list JSON: ${trimmed}`);
|
||||
}
|
||||
|
||||
function parseJsonValue(text) {
|
||||
try {
|
||||
return { ok: true, value: JSON.parse(text) };
|
||||
} catch {
|
||||
return { ok: false };
|
||||
}
|
||||
}
|
||||
|
||||
function pluginRequiresConfig(pluginDir) {
|
||||
const manifestPath = path.join(pluginDir, "openclaw.plugin.json");
|
||||
if (!fs.existsSync(manifestPath)) {
|
||||
throw new Error(`missing bundled plugin manifest: ${manifestPath}`);
|
||||
}
|
||||
const manifest = readJson(manifestPath);
|
||||
const required = manifest.configSchema?.required;
|
||||
return Array.isArray(required) && required.some((value) => typeof value === "string");
|
||||
}
|
||||
|
||||
async function loadPackagedBundledEntries() {
|
||||
return readPluginsList()
|
||||
.filter((plugin) => plugin?.origin === "bundled")
|
||||
.map((plugin) => {
|
||||
const id = typeof plugin.id === "string" ? plugin.id.trim() : "";
|
||||
const rootDir = typeof plugin.rootDir === "string" ? plugin.rootDir.trim() : "";
|
||||
const source = typeof plugin.source === "string" ? plugin.source.trim() : "";
|
||||
const pluginDir = rootDir || (source ? path.dirname(source) : "");
|
||||
if (!id || !pluginDir || !pathReferencesPackagedBundledRoot(pluginDir)) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
id,
|
||||
dir: path.basename(pluginDir),
|
||||
rootDir: pluginDir,
|
||||
requiresConfig: pluginRequiresConfig(pluginDir),
|
||||
};
|
||||
})
|
||||
.filter(Boolean)
|
||||
.toSorted((a, b) => a.id.localeCompare(b.id));
|
||||
}
|
||||
|
||||
async function loadManifestEntries() {
|
||||
const explicit = (process.env.OPENCLAW_BUNDLED_PLUGIN_SWEEP_IDS || "")
|
||||
.split(/[,\s]+/u)
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean);
|
||||
const manifestEntries = await loadPackagedBundledEntries();
|
||||
|
||||
if (explicit.length === 0) {
|
||||
return manifestEntries;
|
||||
}
|
||||
const available = manifestEntries.map((entry) => entry.id).join(", ");
|
||||
return explicit.map((lookup) => {
|
||||
const found = manifestEntries.find((entry) => entry.id === lookup || entry.dir === lookup);
|
||||
if (!found) {
|
||||
throw new Error(
|
||||
`OPENCLAW_BUNDLED_PLUGIN_SWEEP_IDS entry is not an installable bundled plugin in this package: ${lookup}. Available: ${available}`,
|
||||
);
|
||||
}
|
||||
return found;
|
||||
});
|
||||
}
|
||||
|
||||
async function selectedManifestEntries() {
|
||||
const allEntries = await loadManifestEntries();
|
||||
const total = readPositiveIntEnv("OPENCLAW_BUNDLED_PLUGIN_SWEEP_TOTAL", 1);
|
||||
const index = readNonNegativeIntEnv("OPENCLAW_BUNDLED_PLUGIN_SWEEP_INDEX", 0);
|
||||
if (index >= total) {
|
||||
throw new Error(
|
||||
`OPENCLAW_BUNDLED_PLUGIN_SWEEP_INDEX must be in [0, ${total - 1}], got ${process.env.OPENCLAW_BUNDLED_PLUGIN_SWEEP_INDEX}`,
|
||||
);
|
||||
}
|
||||
|
||||
const selected = allEntries.filter((_, candidateIndex) => candidateIndex % total === index);
|
||||
if (selected.length === 0) {
|
||||
throw new Error(`No bundled plugin ids selected for shard ${index}/${total}`);
|
||||
}
|
||||
return selected;
|
||||
}
|
||||
|
||||
function assertInstalled(pluginId, pluginDir, requiresConfig, selectedPluginRoot = "") {
|
||||
const stateDir = resolveStateDir();
|
||||
const configPath = path.join(stateDir, "openclaw.json");
|
||||
const config = readJson(configPath);
|
||||
const records = readPluginInstallRecords({ stateDir, configPath });
|
||||
const record = records[pluginId];
|
||||
if (!record) {
|
||||
throw new Error(`missing install record for ${pluginId}`);
|
||||
}
|
||||
if (record.source !== "path") {
|
||||
throw new Error(
|
||||
`expected bundled install record source=path for ${pluginId}, got ${record.source}`,
|
||||
);
|
||||
}
|
||||
const sourcePath = typeof record.sourcePath === "string" ? record.sourcePath : "";
|
||||
if (!sourcePath) {
|
||||
throw new Error(`unexpected bundled source path for ${pluginId}: ${record.sourcePath}`);
|
||||
}
|
||||
if (selectedPluginRoot && !pathsEqualForProbe(sourcePath, selectedPluginRoot)) {
|
||||
throw new Error(
|
||||
`bundled source path for ${pluginId} did not match selected root: expected ${selectedPluginRoot}, got ${record.sourcePath}`,
|
||||
);
|
||||
}
|
||||
if (!selectedPluginRoot && !pathReferencesBundledRuntime(sourcePath, pluginDir)) {
|
||||
throw new Error(`unexpected bundled source path for ${pluginId}: ${record.sourcePath}`);
|
||||
}
|
||||
if (selectedPluginRoot && !fs.existsSync(sourcePath)) {
|
||||
throw new Error(`bundled source path for ${pluginId} does not exist: ${record.sourcePath}`);
|
||||
}
|
||||
if (!pathsEqualForProbe(record.installPath, record.sourcePath)) {
|
||||
throw new Error(`bundled install path should equal source path for ${pluginId}`);
|
||||
}
|
||||
const paths = config.plugins?.load?.paths || [];
|
||||
if (paths.some((entry) => pathReferencesBundledRuntime(entry, pluginDir))) {
|
||||
throw new Error(`config load paths should not include bundled install path for ${pluginId}`);
|
||||
}
|
||||
if (requiresConfig && config.plugins?.entries?.[pluginId]?.enabled === true) {
|
||||
throw new Error(
|
||||
`plugin requiring config should not be enabled immediately after install for ${pluginId}`,
|
||||
);
|
||||
}
|
||||
if (!requiresConfig && config.plugins?.entries?.[pluginId]?.enabled !== true) {
|
||||
throw new Error(`config entry is not enabled after install for ${pluginId}`);
|
||||
}
|
||||
const allow = config.plugins?.allow || [];
|
||||
if (Array.isArray(allow) && allow.length > 0 && !allow.includes(pluginId)) {
|
||||
throw new Error(`existing allowlist does not include ${pluginId} after install`);
|
||||
}
|
||||
if ((config.plugins?.deny || []).includes(pluginId)) {
|
||||
throw new Error(`denylist contains ${pluginId} after install`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertUninstalled(pluginId, pluginDir) {
|
||||
const stateDir = resolveStateDir();
|
||||
const configPath = path.join(stateDir, "openclaw.json");
|
||||
const config = fs.existsSync(configPath) ? readJson(configPath) : {};
|
||||
const records = readPluginInstallRecords({ stateDir, configPath });
|
||||
if (records[pluginId]) {
|
||||
throw new Error(`install record still present after uninstall for ${pluginId}`);
|
||||
}
|
||||
const paths = config.plugins?.load?.paths || [];
|
||||
if (paths.some((entry) => pathReferencesBundledRuntime(entry, pluginDir))) {
|
||||
throw new Error(`load path still present after uninstall for ${pluginId}`);
|
||||
}
|
||||
if (config.plugins?.entries?.[pluginId]) {
|
||||
throw new Error(`config entry still present after uninstall for ${pluginId}`);
|
||||
}
|
||||
if ((config.plugins?.allow || []).includes(pluginId)) {
|
||||
throw new Error(`allowlist still contains ${pluginId} after uninstall`);
|
||||
}
|
||||
if ((config.plugins?.deny || []).includes(pluginId)) {
|
||||
throw new Error(`denylist still contains ${pluginId} after uninstall`);
|
||||
}
|
||||
const managedPath = path.join(stateDir, "extensions", pluginId);
|
||||
if (fs.existsSync(managedPath)) {
|
||||
throw new Error(
|
||||
`managed install directory unexpectedly exists for bundled plugin ${pluginId}: ${managedPath}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const [command, pluginId, pluginDir, requiresConfig, selectedPluginRoot] = process.argv.slice(2);
|
||||
if (command === "select") {
|
||||
for (const entry of await selectedManifestEntries()) {
|
||||
console.log(`${entry.id}\t${entry.dir}\t${entry.requiresConfig ? "1" : "0"}\t${entry.rootDir}`);
|
||||
}
|
||||
} else if (command === "assert-installed") {
|
||||
assertInstalled(pluginId, pluginDir, requiresConfig === "1", selectedPluginRoot);
|
||||
} else if (command === "assert-uninstalled") {
|
||||
assertUninstalled(pluginId, pluginDir);
|
||||
} else {
|
||||
throw new Error(`Unknown bundled plugin probe command: ${command || "(missing)"}`);
|
||||
}
|
||||
1559
scripts/e2e/lib/bundled-plugin-install-uninstall/runtime-smoke.mjs
Normal file
1559
scripts/e2e/lib/bundled-plugin-install-uninstall/runtime-smoke.mjs
Normal file
File diff suppressed because it is too large
Load Diff
107
scripts/e2e/lib/bundled-plugin-install-uninstall/sweep.sh
Normal file
107
scripts/e2e/lib/bundled-plugin-install-uninstall/sweep.sh
Normal file
@@ -0,0 +1,107 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
source scripts/lib/openclaw-e2e-instance.sh
|
||||
source scripts/lib/docker-e2e-logs.sh
|
||||
|
||||
if [ -f dist/index.mjs ]; then
|
||||
OPENCLAW_ENTRY="dist/index.mjs"
|
||||
elif [ -f dist/index.js ]; then
|
||||
OPENCLAW_ENTRY="dist/index.js"
|
||||
else
|
||||
echo "Missing dist/index.(m)js (build output):"
|
||||
ls -la dist || true
|
||||
exit 1
|
||||
fi
|
||||
export OPENCLAW_ENTRY
|
||||
|
||||
openclaw_e2e_eval_test_state_from_b64 "${OPENCLAW_TEST_STATE_SCRIPT_B64:?missing OPENCLAW_TEST_STATE_SCRIPT_B64}"
|
||||
|
||||
probe="scripts/e2e/lib/bundled-plugin-install-uninstall/probe.mjs"
|
||||
runtime_smoke="scripts/e2e/lib/bundled-plugin-install-uninstall/runtime-smoke.mjs"
|
||||
node "$probe" select > /tmp/bundled-plugin-sweep-ids
|
||||
sweep_command_timeout="${OPENCLAW_BUNDLED_PLUGIN_SWEEP_COMMAND_TIMEOUT:-300s}"
|
||||
|
||||
now_ms() {
|
||||
node -e 'process.stdout.write(String(Date.now()))'
|
||||
}
|
||||
|
||||
run_logged_sweep_command() {
|
||||
local label="$1"
|
||||
local log_file="$2"
|
||||
shift 2
|
||||
if openclaw_e2e_maybe_timeout "$sweep_command_timeout" "$@" >"$log_file" 2>&1; then
|
||||
return 0
|
||||
else
|
||||
local status=$?
|
||||
docker_e2e_print_log "$log_file"
|
||||
if [ "$status" -eq 124 ]; then
|
||||
echo "Bundled plugin sweep command timed out after $sweep_command_timeout: $label" >&2
|
||||
else
|
||||
echo "Bundled plugin sweep command failed with status $status: $label" >&2
|
||||
fi
|
||||
return "$status"
|
||||
fi
|
||||
}
|
||||
|
||||
lifecycle_trace_enabled() {
|
||||
case "${OPENCLAW_PLUGIN_LIFECYCLE_TRACE:-}" in
|
||||
1 | true | TRUE | yes | YES)
|
||||
return 0
|
||||
;;
|
||||
*)
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
plugin_entries=()
|
||||
while IFS= read -r plugin_entry; do
|
||||
plugin_entries+=("$plugin_entry")
|
||||
done < /tmp/bundled-plugin-sweep-ids
|
||||
selected_labels=()
|
||||
for plugin_entry in "${plugin_entries[@]}"; do
|
||||
IFS=$'\t' read -r plugin_id plugin_dir _requires_config _plugin_root <<<"$plugin_entry"
|
||||
selected_labels+=("${plugin_id}@${plugin_dir}")
|
||||
done
|
||||
echo "Selected ${#plugin_entries[@]} bundled plugins for shard ${OPENCLAW_BUNDLED_PLUGIN_SWEEP_INDEX:-0}/${OPENCLAW_BUNDLED_PLUGIN_SWEEP_TOTAL:-1}: ${selected_labels[*]}"
|
||||
|
||||
plugin_index=0
|
||||
for plugin_entry in "${plugin_entries[@]}"; do
|
||||
IFS=$'\t' read -r plugin_id plugin_dir requires_config plugin_root <<<"$plugin_entry"
|
||||
install_log="/tmp/openclaw-install-${plugin_index}.log"
|
||||
uninstall_log="/tmp/openclaw-uninstall-${plugin_index}.log"
|
||||
plugin_started_at="$(now_ms)"
|
||||
echo "Installing bundled plugin: $plugin_id ($plugin_dir)"
|
||||
run_logged_sweep_command "install $plugin_id" "$install_log" \
|
||||
node "$OPENCLAW_ENTRY" plugins install "$plugin_id"
|
||||
if lifecycle_trace_enabled; then
|
||||
docker_e2e_print_log "$install_log"
|
||||
fi
|
||||
install_finished_at="$(now_ms)"
|
||||
node "$probe" assert-installed "$plugin_id" "$plugin_dir" "$requires_config" "$plugin_root"
|
||||
installed_asserted_at="$(now_ms)"
|
||||
if [[ "${OPENCLAW_BUNDLED_PLUGIN_RUNTIME_SMOKE:-1}" != "0" ]]; then
|
||||
echo "Running bundled plugin runtime smoke: $plugin_id ($plugin_dir)"
|
||||
node "$runtime_smoke" plugin "$plugin_id" "$plugin_dir" "$requires_config" "$plugin_index" "$plugin_root"
|
||||
node "$runtime_smoke" tts-global-disable "$plugin_id" "$plugin_dir" "$requires_config" "$plugin_index" "$plugin_root" ""
|
||||
if [[ "$plugin_id" == "${OPENCLAW_BUNDLED_PLUGIN_TTS_LIVE_PROVIDER:-openai}" ]]; then
|
||||
node "$runtime_smoke" tts-openai-live "$plugin_id" "$plugin_dir" "$requires_config" "$plugin_index"
|
||||
fi
|
||||
fi
|
||||
runtime_finished_at="$(now_ms)"
|
||||
|
||||
echo "Uninstalling bundled plugin: $plugin_id ($plugin_dir)"
|
||||
run_logged_sweep_command "uninstall $plugin_id" "$uninstall_log" \
|
||||
node "$OPENCLAW_ENTRY" plugins uninstall "$plugin_id" --force
|
||||
if lifecycle_trace_enabled; then
|
||||
docker_e2e_print_log "$uninstall_log"
|
||||
fi
|
||||
uninstall_finished_at="$(now_ms)"
|
||||
node "$probe" assert-uninstalled "$plugin_id" "$plugin_dir"
|
||||
uninstalled_asserted_at="$(now_ms)"
|
||||
echo "Bundled plugin lifecycle timing: $plugin_id install_ms=$((install_finished_at - plugin_started_at)) install_assert_ms=$((installed_asserted_at - install_finished_at)) runtime_ms=$((runtime_finished_at - installed_asserted_at)) uninstall_ms=$((uninstall_finished_at - runtime_finished_at)) uninstall_assert_ms=$((uninstalled_asserted_at - uninstall_finished_at)) total_ms=$((uninstalled_asserted_at - plugin_started_at))"
|
||||
plugin_index=$((plugin_index + 1))
|
||||
done
|
||||
|
||||
echo "bundled plugin install/uninstall sweep passed (${#plugin_entries[@]} plugin(s))"
|
||||
497
scripts/e2e/lib/clawhub-fixture-server.cjs
Normal file
497
scripts/e2e/lib/clawhub-fixture-server.cjs
Normal file
@@ -0,0 +1,497 @@
|
||||
// CommonJS fixture server for ClawHub package/install E2E scenarios.
|
||||
const crypto = require("node:crypto");
|
||||
const fs = require("node:fs");
|
||||
const http = require("node:http");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { createRequire } = require("node:module");
|
||||
|
||||
const profile = process.argv[2];
|
||||
const portFile = process.argv[3];
|
||||
const requireFromApp = createRequire(path.join(process.cwd(), "package.json"));
|
||||
const JSZip = requireFromApp("jszip");
|
||||
const tar = requireFromApp("tar");
|
||||
const packageName = "@openclaw/kitchen-sink";
|
||||
const pluginId = "openclaw-kitchen-sink-fixture";
|
||||
|
||||
const buildArtifactSummary = ({
|
||||
clawpackSha256,
|
||||
clawpackSize,
|
||||
npmIntegrity,
|
||||
npmShasum,
|
||||
npmTarballName,
|
||||
}) => ({
|
||||
kind: "npm-pack",
|
||||
format: "tgz",
|
||||
sha256: clawpackSha256,
|
||||
size: clawpackSize,
|
||||
npmIntegrity,
|
||||
npmShasum,
|
||||
npmTarballName,
|
||||
});
|
||||
|
||||
const buildClawPackSummary = ({
|
||||
clawpackSha256,
|
||||
clawpackSize,
|
||||
npmIntegrity,
|
||||
npmShasum,
|
||||
npmTarballName,
|
||||
}) => ({
|
||||
available: true,
|
||||
format: "tgz",
|
||||
sha256: clawpackSha256,
|
||||
size: clawpackSize,
|
||||
npmIntegrity,
|
||||
npmShasum,
|
||||
npmTarballName,
|
||||
});
|
||||
|
||||
async function buildNpmPackArtifact(fixture) {
|
||||
const packRoot = await fs.promises.mkdtemp(path.join(os.tmpdir(), "openclaw-clawhub-fixture-"));
|
||||
try {
|
||||
const packageDir = path.join(packRoot, "package");
|
||||
await fs.promises.mkdir(packageDir, { recursive: true });
|
||||
await fs.promises.writeFile(
|
||||
path.join(packageDir, "package.json"),
|
||||
`${JSON.stringify(fixture.packageJson, null, 2)}\n`,
|
||||
);
|
||||
await fs.promises.writeFile(path.join(packageDir, "index.js"), fixture.indexJs);
|
||||
await fs.promises.writeFile(
|
||||
path.join(packageDir, "openclaw.plugin.json"),
|
||||
`${JSON.stringify(fixture.manifest, null, 2)}\n`,
|
||||
);
|
||||
const npmTarballName = `${packageName.replace(/^@/, "").replace("/", "-")}-${fixture.version}.tgz`;
|
||||
const archivePath = path.join(packRoot, npmTarballName);
|
||||
await tar.c(
|
||||
{
|
||||
cwd: packRoot,
|
||||
file: archivePath,
|
||||
gzip: true,
|
||||
portable: true,
|
||||
noMtime: true,
|
||||
},
|
||||
["package"],
|
||||
);
|
||||
const archive = await fs.promises.readFile(archivePath);
|
||||
return {
|
||||
archive,
|
||||
clawpackSha256: crypto.createHash("sha256").update(archive).digest("hex"),
|
||||
clawpackSize: archive.length,
|
||||
npmIntegrity: `sha512-${crypto.createHash("sha512").update(archive).digest("base64")}`,
|
||||
npmShasum: crypto.createHash("sha1").update(archive).digest("hex"),
|
||||
npmTarballName,
|
||||
};
|
||||
} finally {
|
||||
await fs.promises.rm(packRoot, { recursive: true, force: true }).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
const profiles = {
|
||||
"kitchen-sink-plugin": {
|
||||
version: "0.2.5",
|
||||
packageJson: {
|
||||
name: packageName,
|
||||
version: "0.2.5",
|
||||
type: "module",
|
||||
dependencies: {
|
||||
"is-number": "7.0.0",
|
||||
},
|
||||
peerDependencies: {
|
||||
openclaw: ">=2026.4.11",
|
||||
},
|
||||
peerDependenciesMeta: {
|
||||
openclaw: {
|
||||
optional: true,
|
||||
},
|
||||
},
|
||||
openclaw: { extensions: ["./index.js"] },
|
||||
},
|
||||
indexJs: `import isNumber from "is-number";
|
||||
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
|
||||
|
||||
const dependencyUrl = import.meta.resolve("is-number");
|
||||
const expectedDependencyBaseUrl = new URL("./node_modules/is-number/", import.meta.url).href;
|
||||
if (!dependencyUrl.startsWith(expectedDependencyBaseUrl)) {
|
||||
throw new Error(\`kitchen-sink dependency resolved outside plugin root: \${dependencyUrl}\`);
|
||||
}
|
||||
|
||||
export default definePluginEntry({
|
||||
id: "${pluginId}",
|
||||
name: "OpenClaw Kitchen Sink",
|
||||
register(api) {
|
||||
if (!isNumber(42)) {
|
||||
throw new Error("kitchen-sink dependency sentinel did not load");
|
||||
}
|
||||
api.registerProvider({
|
||||
id: "kitchen-sink-provider",
|
||||
label: "Kitchen Sink Provider",
|
||||
docsPath: "/providers/kitchen-sink",
|
||||
auth: [],
|
||||
});
|
||||
api.registerContextEngine("${pluginId}", () => ({
|
||||
info: {
|
||||
id: "${pluginId}",
|
||||
name: "Kitchen Sink Context Engine",
|
||||
},
|
||||
async ingest() {
|
||||
return { ingested: false };
|
||||
},
|
||||
async assemble(params) {
|
||||
return {
|
||||
messages: params.messages,
|
||||
estimatedTokens: 0,
|
||||
};
|
||||
},
|
||||
async compact() {
|
||||
return {
|
||||
ok: true,
|
||||
compacted: false,
|
||||
reason: "kitchen-sink fixture does not compact",
|
||||
};
|
||||
},
|
||||
}));
|
||||
api.registerChannel({
|
||||
plugin: {
|
||||
id: "kitchen-sink-channel",
|
||||
meta: {
|
||||
id: "kitchen-sink-channel",
|
||||
label: "Kitchen Sink Channel",
|
||||
selectionLabel: "Kitchen Sink",
|
||||
docsPath: "/channels/kitchen-sink",
|
||||
blurb: "Kitchen sink ClawHub fixture channel",
|
||||
},
|
||||
capabilities: { chatTypes: ["direct"] },
|
||||
config: {
|
||||
listAccountIds: () => ["default"],
|
||||
resolveAccount: () => ({ accountId: "default" }),
|
||||
},
|
||||
outbound: { deliveryMode: "direct" },
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
`,
|
||||
manifest: {
|
||||
id: pluginId,
|
||||
name: "OpenClaw Kitchen Sink",
|
||||
kind: "context-engine",
|
||||
channels: ["kitchen-sink-channel"],
|
||||
channelConfigs: {
|
||||
"kitchen-sink-channel": {
|
||||
schema: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
enabled: { type: "boolean", default: true },
|
||||
token: { type: "string" },
|
||||
},
|
||||
},
|
||||
uiHints: {
|
||||
token: {
|
||||
sensitive: true,
|
||||
},
|
||||
},
|
||||
label: "Kitchen Sink",
|
||||
description:
|
||||
"Credential-free channel fixture for deterministic Kitchen Sink install tests.",
|
||||
commands: {
|
||||
nativeCommandsAutoEnabled: true,
|
||||
nativeSkillsAutoEnabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
providers: ["kitchen-sink-provider"],
|
||||
contracts: {
|
||||
tools: ["kitchen-sink-tool"],
|
||||
},
|
||||
configSchema: {
|
||||
type: "object",
|
||||
properties: {},
|
||||
},
|
||||
},
|
||||
packageDetail(artifact) {
|
||||
const clawpack = buildClawPackSummary(artifact);
|
||||
const packageArtifact = buildArtifactSummary(artifact);
|
||||
const packageDetail = {
|
||||
package: {
|
||||
name: packageName,
|
||||
displayName: "OpenClaw Kitchen Sink",
|
||||
family: "code-plugin",
|
||||
runtimeId: pluginId,
|
||||
channel: "official",
|
||||
isOfficial: true,
|
||||
summary: "Kitchen sink plugin fixture for prerelease CI.",
|
||||
ownerHandle: "openclaw",
|
||||
createdAt: 0,
|
||||
updatedAt: 0,
|
||||
latestVersion: this.version,
|
||||
tags: { latest: this.version },
|
||||
capabilityTags: ["test-fixture"],
|
||||
executesCode: true,
|
||||
compatibility: {
|
||||
pluginApiRange: ">=2026.4.11",
|
||||
minGatewayVersion: "2026.4.11",
|
||||
},
|
||||
capabilities: {
|
||||
executesCode: true,
|
||||
runtimeId: pluginId,
|
||||
capabilityTags: ["test-fixture"],
|
||||
channels: ["kitchen-sink-channel"],
|
||||
providers: ["kitchen-sink-provider"],
|
||||
},
|
||||
verification: {
|
||||
tier: "source-linked",
|
||||
sourceRepo: "https://github.com/openclaw/kitchen-sink",
|
||||
hasProvenance: false,
|
||||
scanStatus: "passed",
|
||||
},
|
||||
artifact: packageArtifact,
|
||||
clawpack,
|
||||
},
|
||||
};
|
||||
return {
|
||||
packageDetail,
|
||||
versionDetail: {
|
||||
package: {
|
||||
name: packageName,
|
||||
displayName: "OpenClaw Kitchen Sink",
|
||||
family: "code-plugin",
|
||||
},
|
||||
version: {
|
||||
version: this.version,
|
||||
createdAt: 0,
|
||||
changelog: "Fixture package for kitchen-sink plugin prerelease CI.",
|
||||
distTags: ["latest"],
|
||||
sha256hash: artifact.sha256hash,
|
||||
compatibility: packageDetail.package.compatibility,
|
||||
capabilities: packageDetail.package.capabilities,
|
||||
verification: packageDetail.package.verification,
|
||||
artifact: packageArtifact,
|
||||
clawpack,
|
||||
},
|
||||
},
|
||||
betaStatus: 404,
|
||||
};
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
version: "0.1.0",
|
||||
packageJson: {
|
||||
name: packageName,
|
||||
version: "0.1.0",
|
||||
dependencies: {
|
||||
"is-number": "7.0.0",
|
||||
},
|
||||
peerDependencies: {
|
||||
openclaw: ">=2026.4.11",
|
||||
},
|
||||
peerDependenciesMeta: {
|
||||
openclaw: {
|
||||
optional: true,
|
||||
},
|
||||
},
|
||||
openclaw: { extensions: ["./index.js"] },
|
||||
},
|
||||
indexJs: `module.exports = {
|
||||
id: "${pluginId}",
|
||||
name: "OpenClaw Kitchen Sink",
|
||||
description: "Docker E2E kitchen-sink plugin fixture",
|
||||
register(api) {
|
||||
api.on("before_agent_start", async (event, context) => ({
|
||||
kitchenSink: true,
|
||||
observedEventKeys: Object.keys(event || {}),
|
||||
observedContextKeys: Object.keys(context || {}),
|
||||
}));
|
||||
api.registerTool(() => null, { name: "kitchen_sink_tool" });
|
||||
api.registerGatewayMethod("kitchen-sink.ping", async () => ({ ok: true }));
|
||||
api.registerCli(() => {}, { commands: ["kitchen-sink"] });
|
||||
api.registerService({ id: "kitchen-sink-service", start: () => {} });
|
||||
},
|
||||
};
|
||||
`,
|
||||
manifest: {
|
||||
id: pluginId,
|
||||
contracts: {
|
||||
tools: ["kitchen-sink-tool", "kitchen_sink_tool"],
|
||||
},
|
||||
configSchema: {
|
||||
type: "object",
|
||||
properties: {},
|
||||
},
|
||||
},
|
||||
packageDetail(artifact) {
|
||||
const compatibility = {
|
||||
pluginApiRange: ">=2026.4.26",
|
||||
minGatewayVersion: "2026.4.26",
|
||||
};
|
||||
const clawpack = buildClawPackSummary(artifact);
|
||||
const packageArtifact = buildArtifactSummary(artifact);
|
||||
return {
|
||||
packageDetail: {
|
||||
package: {
|
||||
name: packageName,
|
||||
displayName: "OpenClaw Kitchen Sink",
|
||||
family: "code-plugin",
|
||||
channel: "official",
|
||||
isOfficial: true,
|
||||
runtimeId: pluginId,
|
||||
latestVersion: this.version,
|
||||
createdAt: 0,
|
||||
updatedAt: 0,
|
||||
compatibility,
|
||||
artifact: packageArtifact,
|
||||
clawpack,
|
||||
},
|
||||
},
|
||||
versionDetail: {
|
||||
version: {
|
||||
version: this.version,
|
||||
createdAt: 0,
|
||||
changelog: "Kitchen-sink fixture package for Docker plugin E2E.",
|
||||
sha256hash: artifact.sha256hash,
|
||||
compatibility,
|
||||
artifact: packageArtifact,
|
||||
clawpack,
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const fixture = profiles[profile];
|
||||
if (!fixture || !portFile) {
|
||||
console.error("usage: clawhub-fixture-server.cjs <kitchen-sink-plugin|plugins> <port-file>");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const zip = new JSZip();
|
||||
zip.file("package/package.json", `${JSON.stringify(fixture.packageJson, null, 2)}\n`, {
|
||||
date: new Date(0),
|
||||
});
|
||||
zip.file("package/index.js", fixture.indexJs, { date: new Date(0) });
|
||||
const manifestJson = `${JSON.stringify(fixture.manifest, null, 2)}\n`;
|
||||
zip.file("package/openclaw.plugin.json", manifestJson, { date: new Date(0) });
|
||||
|
||||
const archive = await zip.generateAsync({ type: "nodebuffer", compression: "DEFLATE" });
|
||||
const sha256hash = crypto.createHash("sha256").update(archive).digest("hex");
|
||||
const clawpack = await buildNpmPackArtifact(fixture);
|
||||
const { packageDetail, versionDetail, betaStatus } = fixture.packageDetail({
|
||||
sha256hash,
|
||||
...clawpack,
|
||||
});
|
||||
|
||||
const json = (response, value, status = 200) => {
|
||||
response.writeHead(status, { "content-type": "application/json" });
|
||||
response.end(`${JSON.stringify(value)}\n`);
|
||||
};
|
||||
const artifactResolverDetail = {
|
||||
package: versionDetail.package ?? {
|
||||
name: packageName,
|
||||
displayName: packageDetail.package?.displayName ?? "OpenClaw Kitchen Sink",
|
||||
family: packageDetail.package?.family ?? "code-plugin",
|
||||
},
|
||||
version: versionDetail.version,
|
||||
artifact: {
|
||||
source: "clawhub",
|
||||
artifactKind: "npm-pack",
|
||||
packageName,
|
||||
version: fixture.version,
|
||||
artifactSha256: clawpack.clawpackSha256,
|
||||
npmIntegrity: clawpack.npmIntegrity,
|
||||
npmShasum: clawpack.npmShasum,
|
||||
},
|
||||
};
|
||||
const securityDetail = {
|
||||
package: artifactResolverDetail.package,
|
||||
release: {
|
||||
version: fixture.version,
|
||||
},
|
||||
trust: {
|
||||
scanStatus: "clean",
|
||||
moderationState: null,
|
||||
blockedFromDownload: false,
|
||||
reasons: [],
|
||||
pending: false,
|
||||
stale: false,
|
||||
},
|
||||
};
|
||||
|
||||
const server = http.createServer((request, response) => {
|
||||
const url = new URL(request.url, "http://127.0.0.1");
|
||||
if (request.method !== "GET") {
|
||||
response.writeHead(405);
|
||||
response.end("method not allowed");
|
||||
return;
|
||||
}
|
||||
if (url.pathname === `/api/v1/packages/${encodeURIComponent(packageName)}`) {
|
||||
json(response, packageDetail);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
url.pathname ===
|
||||
`/api/v1/packages/${encodeURIComponent(packageName)}/versions/${fixture.version}`
|
||||
) {
|
||||
json(response, versionDetail);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
url.pathname ===
|
||||
`/api/v1/packages/${encodeURIComponent(packageName)}/versions/${fixture.version}/artifact`
|
||||
) {
|
||||
json(response, artifactResolverDetail);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
url.pathname ===
|
||||
`/api/v1/packages/${encodeURIComponent(packageName)}/versions/${fixture.version}/security`
|
||||
) {
|
||||
json(response, securityDetail);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
betaStatus !== undefined &&
|
||||
url.pathname === `/api/v1/packages/${encodeURIComponent(packageName)}/versions/beta`
|
||||
) {
|
||||
json(response, { error: "version not found" }, betaStatus ?? 404);
|
||||
return;
|
||||
}
|
||||
if (url.pathname === `/api/v1/packages/${encodeURIComponent(packageName)}/download`) {
|
||||
response.writeHead(200, {
|
||||
"content-type": "application/zip",
|
||||
"content-length": String(archive.length),
|
||||
});
|
||||
response.end(archive);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
url.pathname ===
|
||||
`/api/v1/packages/${encodeURIComponent(packageName)}/versions/${fixture.version}/artifact/download`
|
||||
) {
|
||||
response.writeHead(200, {
|
||||
"content-type": "application/octet-stream",
|
||||
"content-length": String(clawpack.archive.length),
|
||||
"X-ClawHub-Artifact-Type": "npm-pack-tarball",
|
||||
"X-ClawHub-Artifact-Sha256": clawpack.clawpackSha256,
|
||||
"X-ClawHub-Npm-Integrity": clawpack.npmIntegrity,
|
||||
"X-ClawHub-Npm-Shasum": clawpack.npmShasum,
|
||||
});
|
||||
response.end(clawpack.archive);
|
||||
return;
|
||||
}
|
||||
response.writeHead(404, { "content-type": "text/plain" });
|
||||
response.end(`not found: ${url.pathname}`);
|
||||
});
|
||||
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
fs.writeFileSync(portFile, String(server.address().port));
|
||||
});
|
||||
}
|
||||
|
||||
main().catch(
|
||||
/** @param {unknown} error */ (error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
},
|
||||
);
|
||||
57
scripts/e2e/lib/codex-install-utils.mjs
Normal file
57
scripts/e2e/lib/codex-install-utils.mjs
Normal file
@@ -0,0 +1,57 @@
|
||||
// Shared Codex plugin install helpers for E2E scenarios.
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { readJson } from "./fixtures/common.mjs";
|
||||
import { readPluginInstallRecords } from "./plugin-index-sqlite.mjs";
|
||||
|
||||
export { readJson };
|
||||
|
||||
export function stateDir() {
|
||||
return process.env.OPENCLAW_STATE_DIR || path.join(process.env.HOME, ".openclaw");
|
||||
}
|
||||
|
||||
export function configPath() {
|
||||
return process.env.OPENCLAW_CONFIG_PATH || path.join(stateDir(), "openclaw.json");
|
||||
}
|
||||
|
||||
export function managedNpmRoot() {
|
||||
return path.join(stateDir(), "npm");
|
||||
}
|
||||
|
||||
export function realPathMaybe(filePath) {
|
||||
try {
|
||||
return fs.realpathSync(filePath);
|
||||
} catch {
|
||||
return path.resolve(filePath);
|
||||
}
|
||||
}
|
||||
|
||||
export function assertPathInside(parentPath, childPath, label) {
|
||||
const parent = realPathMaybe(parentPath);
|
||||
const child = realPathMaybe(childPath);
|
||||
const relative = path.relative(parent, child);
|
||||
if (relative.startsWith("..") || path.isAbsolute(relative)) {
|
||||
throw new Error(`${label} resolved outside ${parentPath}: ${child}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function readInstallRecords(fallbackRecords = {}) {
|
||||
return readPluginInstallRecords({ fallbackRecords });
|
||||
}
|
||||
|
||||
export function npmProjectRootForInstalledPackage(installPath, packageName) {
|
||||
const packageRoot = packageName
|
||||
.split("/")
|
||||
.reduce((current) => path.dirname(current), installPath);
|
||||
return path.basename(packageRoot) === "node_modules"
|
||||
? path.dirname(packageRoot)
|
||||
: managedNpmRoot();
|
||||
}
|
||||
|
||||
export function findPackageJson(packageName, roots) {
|
||||
const packagePath = packageName.startsWith("@")
|
||||
? path.join(...packageName.split("/"), "package.json")
|
||||
: path.join(packageName, "package.json");
|
||||
const candidates = roots.map((root) => path.join(root, "node_modules", packagePath));
|
||||
return candidates.find((candidate) => fs.existsSync(candidate));
|
||||
}
|
||||
178
scripts/e2e/lib/codex-media-path/client.mjs
Normal file
178
scripts/e2e/lib/codex-media-path/client.mjs
Normal file
@@ -0,0 +1,178 @@
|
||||
// Client helpers for Codex media-path E2E fixtures.
|
||||
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
||||
import { setTimeout as delay } from "node:timers/promises";
|
||||
import { PROTOCOL_VERSION } from "../../../../dist/gateway/protocol/index.js";
|
||||
import { renderBitmapTextPngBase64 } from "../../../../test/helpers/live-image-probe.ts";
|
||||
import { createGatewayWsClient } from "../../../lib/gateway-ws-client.ts";
|
||||
import { resolveGatewaySuccessPayload } from "../gateway-frame-payload.mjs";
|
||||
import { createJsonlRequestTailer } from "./jsonl-request-tail.mjs";
|
||||
import { readPositiveIntEnv, readTcpPortEnv } from "./limits.mjs";
|
||||
|
||||
const portText = process.env.PORT;
|
||||
const token = process.env.OPENCLAW_GATEWAY_TOKEN;
|
||||
const appServerLog =
|
||||
process.env.OPENCLAW_CODEX_MEDIA_PATH_APP_SERVER_LOG ??
|
||||
"/tmp/openclaw-codex-media-path-app-server.jsonl";
|
||||
const timeoutSeconds = readPositiveIntEnv("OPENCLAW_CODEX_MEDIA_PATH_TIMEOUT_SECONDS", 180);
|
||||
const logTailMaxBytes = readPositiveIntEnv(
|
||||
"OPENCLAW_CODEX_MEDIA_PATH_LOG_TAIL_MAX_BYTES",
|
||||
2 * 1024 * 1024,
|
||||
);
|
||||
|
||||
if (!portText || !token) {
|
||||
throw new Error("missing PORT/OPENCLAW_GATEWAY_TOKEN");
|
||||
}
|
||||
const port = readTcpPortEnv("PORT", portText);
|
||||
|
||||
function assert(condition, message) {
|
||||
if (!condition) {
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
function sha256Base64(data) {
|
||||
return createHash("sha256").update(Buffer.from(data, "base64")).digest("hex");
|
||||
}
|
||||
|
||||
const loggedRequests = createJsonlRequestTailer(appServerLog, {
|
||||
maxReadBytes: logTailMaxBytes,
|
||||
});
|
||||
|
||||
async function waitFor(label, predicate, timeoutMs) {
|
||||
const started = Date.now();
|
||||
while (Date.now() - started < timeoutMs) {
|
||||
const value = await predicate();
|
||||
if (value !== undefined) {
|
||||
return value;
|
||||
}
|
||||
await delay(50);
|
||||
}
|
||||
throw new Error(`timeout waiting for ${label}`);
|
||||
}
|
||||
|
||||
async function connectGateway() {
|
||||
const gatewayClient = createGatewayWsClient({
|
||||
handshakeTimeoutMs: 45_000,
|
||||
openTimeoutMs: 45_000,
|
||||
openTimeoutMessage: "gateway ws open timeout",
|
||||
url: `ws://127.0.0.1:${port}`,
|
||||
});
|
||||
await gatewayClient.waitOpen();
|
||||
|
||||
async function request(method, params, opts = {}) {
|
||||
const timeoutMs = opts.timeoutMs ?? 60_000;
|
||||
const response = await gatewayClient.request(method, params ?? {}, timeoutMs);
|
||||
if (response.ok) {
|
||||
return resolveGatewaySuccessPayload(response);
|
||||
}
|
||||
throw new Error(
|
||||
response.error && typeof response.error === "object" && "message" in response.error
|
||||
? String(response.error.message)
|
||||
: "gateway request failed",
|
||||
);
|
||||
}
|
||||
|
||||
await request(
|
||||
"connect",
|
||||
{
|
||||
minProtocol: PROTOCOL_VERSION,
|
||||
maxProtocol: PROTOCOL_VERSION,
|
||||
client: {
|
||||
id: "gateway-client",
|
||||
displayName: "docker-codex-media-path",
|
||||
version: "1.0.0",
|
||||
platform: process.platform,
|
||||
mode: "backend",
|
||||
},
|
||||
role: "operator",
|
||||
scopes: ["operator.read", "operator.write", "operator.admin"],
|
||||
caps: [],
|
||||
auth: { token },
|
||||
},
|
||||
{ timeoutMs: 60_000 },
|
||||
);
|
||||
await request("sessions.subscribe", {}, { timeoutMs: 60_000 });
|
||||
|
||||
return {
|
||||
request,
|
||||
async close() {
|
||||
gatewayClient.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const gateway = await connectGateway();
|
||||
|
||||
function randomBitmapTextToken(length = 6) {
|
||||
const alphabet = "24567ACEF";
|
||||
return [...randomBytes(length)].map((byte) => alphabet[byte % alphabet.length]).join("");
|
||||
}
|
||||
|
||||
try {
|
||||
const expectedToken = randomBitmapTextToken();
|
||||
const imageBase64 = renderBitmapTextPngBase64(expectedToken);
|
||||
const expectedHash = sha256Base64(imageBase64);
|
||||
const runId = `codex-media-path-${randomUUID()}`;
|
||||
const started = Date.now();
|
||||
|
||||
const response = await gateway.request(
|
||||
"chat.send",
|
||||
{
|
||||
sessionKey: "agent:main:codex-media-path-e2e",
|
||||
idempotencyKey: runId,
|
||||
message: "Read the code printed in the attached image. Reply only the code.",
|
||||
attachments: [
|
||||
{
|
||||
mimeType: "image/png",
|
||||
fileName: "codex-media-path-probe.png",
|
||||
content: imageBase64,
|
||||
},
|
||||
],
|
||||
originatingChannel: "codex-media-path-e2e",
|
||||
originatingTo: "codex-media-path-e2e",
|
||||
originatingAccountId: "codex-media-path-e2e",
|
||||
},
|
||||
{ timeoutMs: timeoutSeconds * 1000 },
|
||||
);
|
||||
assert(response?.status === "started", `chat.send did not start: ${JSON.stringify(response)}`);
|
||||
|
||||
const turnRequest = await waitFor(
|
||||
"Codex turn/start image input",
|
||||
() =>
|
||||
loggedRequests.read().find((request) => {
|
||||
if (request.method !== "turn/start") {
|
||||
return undefined;
|
||||
}
|
||||
const imageInput = request.params?.input?.find?.(
|
||||
(entry) => entry?.type === "image" && typeof entry.url === "string",
|
||||
);
|
||||
return imageInput ? request : undefined;
|
||||
}),
|
||||
timeoutSeconds * 1000,
|
||||
);
|
||||
|
||||
const imageInput = turnRequest.params.input.find((entry) => entry?.type === "image");
|
||||
const imageUrl = imageInput.url;
|
||||
assert(
|
||||
imageUrl.startsWith("data:image/png;base64,"),
|
||||
`turn/start image input is not an inline PNG: ${JSON.stringify(imageInput)}`,
|
||||
);
|
||||
const actualBase64 = imageUrl.slice("data:image/png;base64,".length);
|
||||
const actualHash = sha256Base64(actualBase64);
|
||||
assert(
|
||||
actualHash === expectedHash,
|
||||
`forwarded PNG hash mismatch: expected ${expectedHash}, got ${actualHash}`,
|
||||
);
|
||||
|
||||
await delay(50);
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
ok: true,
|
||||
elapsedMs: Date.now() - started,
|
||||
expectedToken,
|
||||
imageSha256: actualHash,
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
await gateway.close();
|
||||
}
|
||||
104
scripts/e2e/lib/codex-media-path/fake-codex-app-server.mjs
Normal file
104
scripts/e2e/lib/codex-media-path/fake-codex-app-server.mjs
Normal file
@@ -0,0 +1,104 @@
|
||||
// Fake Codex app server used by media-path E2E scenarios.
|
||||
import fs from "node:fs";
|
||||
import readline from "node:readline";
|
||||
|
||||
const requestLog =
|
||||
process.env.OPENCLAW_CODEX_MEDIA_PATH_APP_SERVER_LOG ??
|
||||
"/tmp/openclaw-codex-media-path-app-server.jsonl";
|
||||
let turnCount = 0;
|
||||
|
||||
function appendRequest(request) {
|
||||
try {
|
||||
fs.appendFileSync(requestLog, `${JSON.stringify(request)}\n`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
process.stderr.write(`fake Codex app-server request log write failed: ${message}\n`);
|
||||
if (request?.id != null) {
|
||||
sendError(request.id, `fake Codex app-server request log write failed: ${message}`);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function send(id, result) {
|
||||
process.stdout.write(`${JSON.stringify({ id, result })}\n`);
|
||||
}
|
||||
|
||||
function sendError(id, message) {
|
||||
process.stdout.write(`${JSON.stringify({ error: { message }, id })}\n`);
|
||||
}
|
||||
|
||||
const rl = readline.createInterface({ input: process.stdin });
|
||||
rl.on("line", (line) => {
|
||||
if (!line.trim()) {
|
||||
return;
|
||||
}
|
||||
const request = JSON.parse(line);
|
||||
if (!appendRequest(request)) {
|
||||
return;
|
||||
}
|
||||
const { id, method, params } = request;
|
||||
if (method === "initialize") {
|
||||
send(id, {
|
||||
protocolVersion: "2",
|
||||
serverInfo: { name: "openclaw-codex-media-path-e2e", version: "0.125.0" },
|
||||
userAgent: "openclaw-codex-media-path-e2e/0.125.0 (Docker; test)",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (method === "thread/start") {
|
||||
const now = Date.now();
|
||||
send(id, {
|
||||
thread: {
|
||||
id: "thread-codex-media-path-e2e",
|
||||
sessionId: "session-codex-media-path-e2e",
|
||||
forkedFromId: null,
|
||||
preview: "",
|
||||
ephemeral: false,
|
||||
modelProvider: "openai",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
cwd: params?.cwd ?? process.cwd(),
|
||||
status: { type: "idle" },
|
||||
path: null,
|
||||
cliVersion: "0.125.0",
|
||||
source: "unknown",
|
||||
agentNickname: null,
|
||||
agentRole: null,
|
||||
gitInfo: null,
|
||||
name: null,
|
||||
turns: [],
|
||||
},
|
||||
model: params?.model ?? "gpt-5.5",
|
||||
modelProvider: "openai",
|
||||
serviceTier: null,
|
||||
cwd: params?.cwd ?? process.cwd(),
|
||||
instructionSources: [],
|
||||
approvalPolicy: params?.approvalPolicy ?? "never",
|
||||
approvalsReviewer: params?.approvalsReviewer ?? "user",
|
||||
sandbox: { type: "dangerFullAccess" },
|
||||
permissionProfile: null,
|
||||
reasoningEffort: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (method === "turn/start") {
|
||||
turnCount += 1;
|
||||
send(id, {
|
||||
turn: {
|
||||
id: `turn-codex-media-path-e2e-${turnCount}`,
|
||||
status: "completed",
|
||||
items: [
|
||||
{
|
||||
type: "agentMessage",
|
||||
id: `msg-codex-media-path-e2e-${turnCount}`,
|
||||
text: "CODEX_MEDIA_PATH_E2E_OK",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
send(id, {});
|
||||
});
|
||||
43
scripts/e2e/lib/codex-media-path/jsonl-request-tail.mjs
Normal file
43
scripts/e2e/lib/codex-media-path/jsonl-request-tail.mjs
Normal file
@@ -0,0 +1,43 @@
|
||||
// Tails JSONL request logs for Codex media-path E2E assertions.
|
||||
import {
|
||||
createIncrementalLineReader,
|
||||
resolvePositiveInteger,
|
||||
} from "../incremental-line-reader.mjs";
|
||||
|
||||
const DEFAULT_MAX_READ_BYTES = 2 * 1024 * 1024;
|
||||
const DEFAULT_HISTORY_LIMIT = 1024;
|
||||
|
||||
export function createJsonlRequestTailer(filePath, options = {}) {
|
||||
const maxReadBytes = resolvePositiveInteger(options.maxReadBytes, DEFAULT_MAX_READ_BYTES);
|
||||
const historyLimit = resolvePositiveInteger(options.historyLimit, DEFAULT_HISTORY_LIMIT);
|
||||
const reader = createIncrementalLineReader(filePath, { maxReadBytes });
|
||||
let requests = [];
|
||||
|
||||
function parseLine(line) {
|
||||
try {
|
||||
return JSON.parse(line);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(`invalid app-server JSONL at ${filePath}: ${message}`, { cause: error });
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
read() {
|
||||
const { lines, reset } = reader.readLines();
|
||||
if (reset) {
|
||||
requests = [];
|
||||
}
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) {
|
||||
continue;
|
||||
}
|
||||
requests.push(parseLine(line));
|
||||
}
|
||||
if (requests.length > historyLimit) {
|
||||
requests = requests.slice(-historyLimit);
|
||||
}
|
||||
return requests;
|
||||
},
|
||||
};
|
||||
}
|
||||
21
scripts/e2e/lib/codex-media-path/limits.mjs
Normal file
21
scripts/e2e/lib/codex-media-path/limits.mjs
Normal file
@@ -0,0 +1,21 @@
|
||||
// Limits shared by Codex media-path E2E fixtures.
|
||||
export function readPositiveIntEnv(name, fallback, env = process.env) {
|
||||
const text = String(env[name] ?? fallback).trim();
|
||||
if (!/^\d+$/u.test(text)) {
|
||||
throw new Error(`invalid ${name}: ${text}`);
|
||||
}
|
||||
const value = Number(text);
|
||||
if (!Number.isSafeInteger(value) || value <= 0) {
|
||||
throw new Error(`invalid ${name}: ${text}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function readTcpPortEnv(name, fallback, env = process.env) {
|
||||
const value = readPositiveIntEnv(name, fallback, env);
|
||||
if (value > 65_535) {
|
||||
const text = String(env[name] ?? fallback).trim();
|
||||
throw new Error(`invalid ${name}: ${text}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
56
scripts/e2e/lib/codex-media-path/scenario.sh
Normal file
56
scripts/e2e/lib/codex-media-path/scenario.sh
Normal file
@@ -0,0 +1,56 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
source scripts/lib/openclaw-e2e-instance.sh
|
||||
openclaw_e2e_eval_test_state_from_b64 "${OPENCLAW_TEST_STATE_SCRIPT_B64:?missing OPENCLAW_TEST_STATE_SCRIPT_B64}"
|
||||
export OPENCLAW_SKIP_CHANNELS=1
|
||||
export OPENCLAW_SKIP_GMAIL_WATCHER=1
|
||||
export OPENCLAW_SKIP_CRON=1
|
||||
export OPENCLAW_SKIP_CANVAS_HOST=1
|
||||
export OPENCLAW_SKIP_BROWSER_CONTROL_SERVER=1
|
||||
export OPENCLAW_SKIP_ACPX_RUNTIME=1
|
||||
export OPENCLAW_SKIP_ACPX_RUNTIME_PROBE=1
|
||||
export OPENCLAW_AGENT_HARNESS_FALLBACK=none
|
||||
export OPENCLAW_CODEX_MEDIA_PATH_APP_SERVER_LOG="/tmp/openclaw-codex-media-path-app-server.jsonl"
|
||||
|
||||
PORT="${PORT:?missing PORT}"
|
||||
TOKEN="${OPENCLAW_GATEWAY_TOKEN:?missing OPENCLAW_GATEWAY_TOKEN}"
|
||||
PLUGIN_SPEC="${OPENCLAW_CODEX_MEDIA_PATH_PLUGIN_SPEC:?missing OPENCLAW_CODEX_MEDIA_PATH_PLUGIN_SPEC}"
|
||||
GATEWAY_LOG="/tmp/openclaw-codex-media-path-gateway.log"
|
||||
CLIENT_LOG="/tmp/openclaw-codex-media-path-client.log"
|
||||
PLUGIN_INSTALL_LOG="/tmp/openclaw-codex-media-path-plugin-install.log"
|
||||
PLUGIN_INSPECT_LOG="/tmp/openclaw-codex-media-path-plugin-inspect.json"
|
||||
gateway_pid=""
|
||||
|
||||
cleanup() {
|
||||
openclaw_e2e_stop_process "$gateway_pid"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
dump_debug_logs() {
|
||||
local status="$1"
|
||||
echo "Codex media-path Docker E2E failed with exit code $status" >&2
|
||||
openclaw_e2e_dump_logs "$PLUGIN_INSTALL_LOG" "$PLUGIN_INSPECT_LOG" "$GATEWAY_LOG" "$CLIENT_LOG" "$OPENCLAW_CODEX_MEDIA_PATH_APP_SERVER_LOG"
|
||||
}
|
||||
trap 'status=$?; dump_debug_logs "$status"; exit "$status"' ERR
|
||||
|
||||
entry="$(openclaw_e2e_resolve_entrypoint)"
|
||||
mkdir -p "$OPENCLAW_STATE_DIR" "$OPENCLAW_TEST_WORKSPACE_DIR"
|
||||
rm -f "$OPENCLAW_CODEX_MEDIA_PATH_APP_SERVER_LOG"
|
||||
|
||||
openclaw_e2e_enable_openclaw_cli_timeout
|
||||
|
||||
echo "Installing Codex plugin: $PLUGIN_SPEC"
|
||||
openclaw plugins install "$PLUGIN_SPEC" --force >"$PLUGIN_INSTALL_LOG" 2>&1
|
||||
openclaw plugins inspect codex --runtime --json >"$PLUGIN_INSPECT_LOG"
|
||||
|
||||
node scripts/e2e/lib/codex-media-path/write-config.mjs
|
||||
|
||||
gateway_pid="$(openclaw_e2e_start_gateway "$entry" "$PORT" "$GATEWAY_LOG")"
|
||||
openclaw_e2e_wait_gateway_ready "$gateway_pid" "$GATEWAY_LOG" 480 "$PORT"
|
||||
|
||||
PORT="$PORT" OPENCLAW_GATEWAY_TOKEN="$TOKEN" \
|
||||
tsx scripts/e2e/lib/codex-media-path/client.mjs >"$CLIENT_LOG" 2>&1
|
||||
|
||||
openclaw_e2e_print_log "$CLIENT_LOG"
|
||||
echo "Codex media-path Docker E2E passed"
|
||||
79
scripts/e2e/lib/codex-media-path/write-config.mjs
Normal file
79
scripts/e2e/lib/codex-media-path/write-config.mjs
Normal file
@@ -0,0 +1,79 @@
|
||||
// Writes config fixtures for Codex media-path E2E scenarios.
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { readPositiveIntEnv, readTcpPortEnv } from "./limits.mjs";
|
||||
|
||||
function requireEnv(name) {
|
||||
const value = process.env[name];
|
||||
if (!value) {
|
||||
throw new Error(`missing ${name}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
const configPath = requireEnv("OPENCLAW_CONFIG_PATH");
|
||||
const stateDir = requireEnv("OPENCLAW_STATE_DIR");
|
||||
const workspaceDir = requireEnv("OPENCLAW_TEST_WORKSPACE_DIR");
|
||||
const token = requireEnv("OPENCLAW_GATEWAY_TOKEN");
|
||||
const timeoutSeconds = readPositiveIntEnv("OPENCLAW_CODEX_MEDIA_PATH_TIMEOUT_SECONDS", 180);
|
||||
const gatewayPort = readTcpPortEnv("PORT", 18790);
|
||||
|
||||
const config = {
|
||||
gateway: {
|
||||
port: gatewayPort,
|
||||
bind: "loopback",
|
||||
auth: { mode: "token", token },
|
||||
controlUi: { enabled: false },
|
||||
},
|
||||
plugins: {
|
||||
enabled: true,
|
||||
allow: ["codex"],
|
||||
entries: {
|
||||
codex: {
|
||||
enabled: true,
|
||||
config: {
|
||||
appServer: {
|
||||
mode: "yolo",
|
||||
command: "node",
|
||||
args: ["scripts/e2e/lib/codex-media-path/fake-codex-app-server.mjs"],
|
||||
requestTimeoutMs: timeoutSeconds * 1000,
|
||||
turnCompletionIdleTimeoutMs: timeoutSeconds * 1000,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "codex/gpt-5.5", fallbacks: [] },
|
||||
models: {
|
||||
"codex/gpt-5.5": {
|
||||
agentRuntime: { id: "codex" },
|
||||
},
|
||||
},
|
||||
workspace: workspaceDir,
|
||||
skipBootstrap: true,
|
||||
timeoutSeconds,
|
||||
sandbox: { mode: "off" },
|
||||
},
|
||||
list: [
|
||||
{
|
||||
id: "main",
|
||||
default: true,
|
||||
model: { primary: "codex/gpt-5.5", fallbacks: [] },
|
||||
models: {
|
||||
"codex/gpt-5.5": {
|
||||
agentRuntime: { id: "codex" },
|
||||
},
|
||||
},
|
||||
workspace: workspaceDir,
|
||||
},
|
||||
],
|
||||
},
|
||||
skills: { allowBundled: [] },
|
||||
};
|
||||
|
||||
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
||||
fs.mkdirSync(workspaceDir, { recursive: true });
|
||||
fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`);
|
||||
fs.mkdirSync(path.join(stateDir, "logs"), { recursive: true });
|
||||
518
scripts/e2e/lib/codex-npm-plugin-live/assertions.mjs
Normal file
518
scripts/e2e/lib/codex-npm-plugin-live/assertions.mjs
Normal file
@@ -0,0 +1,518 @@
|
||||
// Assertions for Codex npm plugin live E2E scenarios.
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { extractAgentReplyTexts } from "../agent-turn-output.mjs";
|
||||
import {
|
||||
assertPathInside,
|
||||
configPath,
|
||||
findPackageJson,
|
||||
managedNpmRoot,
|
||||
npmProjectRootForInstalledPackage,
|
||||
readInstallRecords,
|
||||
readJson,
|
||||
realPathMaybe,
|
||||
stateDir,
|
||||
} from "../codex-install-utils.mjs";
|
||||
|
||||
const command = process.argv[2];
|
||||
const allowBetaCompatDiagnostics =
|
||||
process.env.OPENCLAW_CODEX_NPM_PLUGIN_ALLOW_BETA_COMPAT_DIAGNOSTICS === "1";
|
||||
const MAX_TEXT_FILE_BYTES = readPositiveIntEnv(
|
||||
"OPENCLAW_CODEX_NPM_PLUGIN_ASSERT_MAX_TEXT_FILE_BYTES",
|
||||
1024 * 1024,
|
||||
);
|
||||
const MAX_ERROR_TAIL_BYTES = readPositiveIntEnv(
|
||||
"OPENCLAW_CODEX_NPM_PLUGIN_ASSERT_MAX_ERROR_TAIL_BYTES",
|
||||
64 * 1024,
|
||||
);
|
||||
const MAX_TRANSCRIPT_FILES = readPositiveIntEnv(
|
||||
"OPENCLAW_CODEX_NPM_PLUGIN_ASSERT_MAX_TRANSCRIPT_FILES",
|
||||
64,
|
||||
);
|
||||
const MAX_TRANSCRIPT_WALK_ENTRIES = readPositiveIntEnv(
|
||||
"OPENCLAW_CODEX_NPM_PLUGIN_ASSERT_MAX_TRANSCRIPT_WALK_ENTRIES",
|
||||
4096,
|
||||
);
|
||||
const MAX_TRANSCRIPT_SCAN_BYTES = readPositiveIntEnv(
|
||||
"OPENCLAW_CODEX_NPM_PLUGIN_ASSERT_MAX_TRANSCRIPT_SCAN_BYTES",
|
||||
2 * 1024 * 1024,
|
||||
);
|
||||
const AGENT_TURN_TIMEOUT_SECONDS = readPositiveIntEnv(
|
||||
"OPENCLAW_CODEX_NPM_PLUGIN_AGENT_TIMEOUT_SECONDS",
|
||||
420,
|
||||
);
|
||||
|
||||
function readPositiveIntEnv(name, fallback) {
|
||||
const text = String(process.env[name] ?? fallback).trim();
|
||||
if (!/^\d+$/u.test(text)) {
|
||||
throw new Error(`${name} must be a positive integer; got: ${text}`);
|
||||
}
|
||||
const value = Number(text);
|
||||
if (!Number.isSafeInteger(value) || value <= 0) {
|
||||
throw new Error(`${name} must be a positive integer; got: ${text}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function readTextFileBounded(filePath, label, maxBytes = MAX_TEXT_FILE_BYTES) {
|
||||
const stat = fs.statSync(filePath);
|
||||
if (stat.size > maxBytes) {
|
||||
throw new Error(`${label} exceeded ${maxBytes} bytes: ${filePath}`);
|
||||
}
|
||||
return fs.readFileSync(filePath, "utf8");
|
||||
}
|
||||
|
||||
function readTextFileTail(filePath, label, maxBytes = MAX_ERROR_TAIL_BYTES) {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return "";
|
||||
}
|
||||
const stat = fs.statSync(filePath);
|
||||
if (stat.size <= maxBytes) {
|
||||
return fs.readFileSync(filePath, "utf8");
|
||||
}
|
||||
const fd = fs.openSync(filePath, "r");
|
||||
try {
|
||||
const buffer = Buffer.alloc(maxBytes);
|
||||
fs.readSync(fd, buffer, 0, maxBytes, stat.size - maxBytes);
|
||||
return `[${label} truncated to last ${maxBytes} bytes]\n${buffer.toString("utf8")}`;
|
||||
} finally {
|
||||
fs.closeSync(fd);
|
||||
}
|
||||
}
|
||||
|
||||
function configure() {
|
||||
const modelRef = process.argv[3] || "codex/gpt-5.4";
|
||||
const state = stateDir();
|
||||
const cfgPath = configPath();
|
||||
const cfg = fs.existsSync(cfgPath) ? readJson(cfgPath) : {};
|
||||
cfg.plugins = {
|
||||
...cfg.plugins,
|
||||
enabled: true,
|
||||
allow: Array.from(new Set([...(cfg.plugins?.allow || []), "codex"])).toSorted((left, right) =>
|
||||
left.localeCompare(right),
|
||||
),
|
||||
entries: {
|
||||
...cfg.plugins?.entries,
|
||||
codex: {
|
||||
...cfg.plugins?.entries?.codex,
|
||||
enabled: true,
|
||||
config: {
|
||||
...cfg.plugins?.entries?.codex?.config,
|
||||
discovery: { enabled: false },
|
||||
appServer: {
|
||||
...cfg.plugins?.entries?.codex?.config?.appServer,
|
||||
mode: "yolo",
|
||||
approvalPolicy: "never",
|
||||
sandbox: "danger-full-access",
|
||||
requestTimeoutMs: AGENT_TURN_TIMEOUT_SECONDS * 1000,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
cfg.agents = {
|
||||
...cfg.agents,
|
||||
defaults: {
|
||||
...cfg.agents?.defaults,
|
||||
model: { primary: modelRef, fallbacks: [] },
|
||||
models: {
|
||||
...cfg.agents?.defaults?.models,
|
||||
[modelRef]: { agentRuntime: { id: "codex" } },
|
||||
},
|
||||
workspace: path.join(state, "workspace"),
|
||||
skipBootstrap: true,
|
||||
timeoutSeconds: AGENT_TURN_TIMEOUT_SECONDS,
|
||||
},
|
||||
};
|
||||
fs.mkdirSync(path.dirname(cfgPath), { recursive: true });
|
||||
fs.writeFileSync(cfgPath, `${JSON.stringify(cfg, null, 2)}\n`);
|
||||
}
|
||||
|
||||
function readInstallRecord() {
|
||||
const record = readInstallRecords().codex;
|
||||
if (!record) {
|
||||
throw new Error("missing codex install record");
|
||||
}
|
||||
return record;
|
||||
}
|
||||
|
||||
function normalizePluginSpec(spec) {
|
||||
if (spec.startsWith("npm:")) {
|
||||
return {
|
||||
expectedSpec: spec.slice("npm:".length),
|
||||
source: "npm",
|
||||
};
|
||||
}
|
||||
if (spec.startsWith("npm-pack:")) {
|
||||
return {
|
||||
artifactKind: "npm-pack",
|
||||
source: "npm",
|
||||
sourcePath: spec.slice("npm-pack:".length),
|
||||
};
|
||||
}
|
||||
if (spec.startsWith("git:")) {
|
||||
return {
|
||||
expectedSpec: spec,
|
||||
source: "git",
|
||||
};
|
||||
}
|
||||
return {
|
||||
expectedSpec: spec,
|
||||
source: "npm",
|
||||
};
|
||||
}
|
||||
|
||||
function assertPlugin() {
|
||||
const spec = process.argv[3] || "npm:@openclaw/codex";
|
||||
const list = readJson("/tmp/openclaw-codex-plugins-list.json");
|
||||
const inspect = readJson("/tmp/openclaw-codex-plugin-inspect.json");
|
||||
const plugin = (list.plugins || []).find((entry) => entry.id === "codex");
|
||||
if (!plugin) {
|
||||
throw new Error("codex plugin not found in plugins list --json output");
|
||||
}
|
||||
if (plugin.status !== "loaded" || plugin.enabled !== true) {
|
||||
throw new Error(
|
||||
`expected codex to be enabled+loaded, got enabled=${plugin.enabled} status=${plugin.status}`,
|
||||
);
|
||||
}
|
||||
if (inspect.plugin?.id !== "codex" || inspect.plugin?.status !== "loaded") {
|
||||
throw new Error(`unexpected inspect plugin state: ${JSON.stringify(inspect.plugin)}`);
|
||||
}
|
||||
if (
|
||||
!Array.isArray(inspect.plugin?.providerIds) ||
|
||||
!inspect.plugin.providerIds.includes("codex")
|
||||
) {
|
||||
throw new Error(`codex provider was not registered: ${JSON.stringify(inspect.plugin)}`);
|
||||
}
|
||||
const hasCodexHarness =
|
||||
(Array.isArray(inspect.plugin?.agentHarnessIds) &&
|
||||
inspect.plugin.agentHarnessIds.includes("codex")) ||
|
||||
(Array.isArray(inspect.capabilities) &&
|
||||
inspect.capabilities.some(
|
||||
(entry) => entry?.kind === "agent-harness" && entry.ids?.includes("codex"),
|
||||
));
|
||||
if (!hasCodexHarness) {
|
||||
throw new Error(`codex harness was not registered: ${JSON.stringify(inspect.plugin)}`);
|
||||
}
|
||||
const diagnostics = [...(list.diagnostics || []), ...(inspect.diagnostics || [])];
|
||||
const errors = diagnostics
|
||||
.filter((diag) => diag?.level === "error")
|
||||
.map((diag) => String(diag.message || ""));
|
||||
const unexpectedErrors = allowBetaCompatDiagnostics
|
||||
? errors.filter(
|
||||
(message) => message !== "only bundled plugins can claim reserved command ownership: codex",
|
||||
)
|
||||
: errors;
|
||||
if (unexpectedErrors.length > 0) {
|
||||
throw new Error(`unexpected plugin diagnostics errors: ${unexpectedErrors.join("; ")}`);
|
||||
}
|
||||
|
||||
const record = readInstallRecord();
|
||||
const expected = normalizePluginSpec(spec);
|
||||
if (record.source !== expected.source) {
|
||||
throw new Error(
|
||||
`expected codex ${expected.source} install record, got source=${record.source}`,
|
||||
);
|
||||
}
|
||||
if (expected.expectedSpec && record.spec !== expected.expectedSpec) {
|
||||
throw new Error(`expected codex install spec ${expected.expectedSpec}, got ${record.spec}`);
|
||||
}
|
||||
if (expected.artifactKind && record.artifactKind !== expected.artifactKind) {
|
||||
throw new Error(
|
||||
`expected codex artifact kind ${expected.artifactKind}, got ${record.artifactKind}`,
|
||||
);
|
||||
}
|
||||
if (
|
||||
expected.sourcePath &&
|
||||
realPathMaybe(record.sourcePath || "") !== realPathMaybe(expected.sourcePath)
|
||||
) {
|
||||
throw new Error(`expected codex source path ${expected.sourcePath}, got ${record.sourcePath}`);
|
||||
}
|
||||
if (record.source === "npm" && (!record.resolvedVersion || !record.resolvedSpec)) {
|
||||
throw new Error(`missing codex npm resolution metadata: ${JSON.stringify(record)}`);
|
||||
}
|
||||
if (record.source === "git" && !record.gitCommit) {
|
||||
throw new Error(`missing codex git resolution metadata: ${JSON.stringify(record)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function codexInstallPath() {
|
||||
const record = readInstallRecord();
|
||||
if (typeof record.installPath !== "string" || record.installPath.length === 0) {
|
||||
throw new Error(`missing codex installPath: ${JSON.stringify(record)}`);
|
||||
}
|
||||
return record.installPath.replace(/^~(?=$|\/)/u, process.env.HOME);
|
||||
}
|
||||
|
||||
function codexNpmProjectRoot() {
|
||||
return npmProjectRootForInstalledPackage(codexInstallPath(), "@openclaw/codex");
|
||||
}
|
||||
|
||||
function findCodexPackageJson(packageName) {
|
||||
const projectRoot = codexNpmProjectRoot();
|
||||
return findPackageJson(packageName, [projectRoot, codexInstallPath(), managedNpmRoot()]);
|
||||
}
|
||||
|
||||
function assertNpmDeps() {
|
||||
const npmRoot = managedNpmRoot();
|
||||
const installPath = codexInstallPath();
|
||||
const pluginPackageJson = path.join(installPath, "package.json");
|
||||
if (!fs.existsSync(pluginPackageJson)) {
|
||||
throw new Error(`missing npm-installed @openclaw/codex package.json: ${pluginPackageJson}`);
|
||||
}
|
||||
assertPathInside(npmRoot, installPath, "codex plugin install path");
|
||||
assertPathInside(npmRoot, pluginPackageJson, "codex plugin package");
|
||||
|
||||
const pluginPackage = readJson(pluginPackageJson);
|
||||
if (pluginPackage.name !== "@openclaw/codex") {
|
||||
throw new Error(`unexpected codex package name: ${pluginPackage.name}`);
|
||||
}
|
||||
|
||||
const openAiCodexPackageJson = findCodexPackageJson("@openai/codex");
|
||||
if (!openAiCodexPackageJson) {
|
||||
throw new Error("missing @openai/codex dependency under .openclaw/npm");
|
||||
}
|
||||
assertPathInside(npmRoot, openAiCodexPackageJson, "@openai/codex dependency");
|
||||
|
||||
const bin = resolveCodexBin();
|
||||
if (!fs.existsSync(bin)) {
|
||||
throw new Error(`missing managed Codex binary: ${bin}`);
|
||||
}
|
||||
assertPathInside(npmRoot, bin, "managed Codex binary");
|
||||
}
|
||||
|
||||
function resolveCodexBin() {
|
||||
const commandName = process.platform === "win32" ? "codex.cmd" : "codex";
|
||||
const candidates = [
|
||||
path.join(codexNpmProjectRoot(), "node_modules", ".bin", commandName),
|
||||
path.join(codexInstallPath(), "node_modules", ".bin", commandName),
|
||||
path.join(managedNpmRoot(), "node_modules", ".bin", commandName),
|
||||
];
|
||||
const candidate = candidates.find((entry) => fs.existsSync(entry));
|
||||
if (candidate) {
|
||||
return candidate;
|
||||
}
|
||||
const packageJson = findCodexPackageJson("@openai/codex");
|
||||
if (!packageJson) {
|
||||
throw new Error("cannot resolve Codex binary without @openai/codex package");
|
||||
}
|
||||
const packageRoot = path.dirname(packageJson);
|
||||
const pkg = readJson(packageJson);
|
||||
const binPath =
|
||||
typeof pkg.bin === "string"
|
||||
? pkg.bin
|
||||
: pkg.bin && typeof pkg.bin.codex === "string"
|
||||
? pkg.bin.codex
|
||||
: undefined;
|
||||
if (!binPath) {
|
||||
throw new Error(`@openai/codex package has no codex bin: ${packageJson}`);
|
||||
}
|
||||
return path.resolve(packageRoot, binPath);
|
||||
}
|
||||
|
||||
function printCodexBin() {
|
||||
assertNpmDeps();
|
||||
process.stdout.write(`${resolveCodexBin()}\n`);
|
||||
}
|
||||
|
||||
function assertPreflight() {
|
||||
const marker = process.argv[3];
|
||||
const output = readTextFileBounded("/tmp/openclaw-codex-preflight.log", "Codex preflight log");
|
||||
if (!output.includes(marker)) {
|
||||
throw new Error(`Codex CLI preflight did not contain ${marker}:\n${output}`);
|
||||
}
|
||||
}
|
||||
|
||||
function listFilesRecursive(root) {
|
||||
if (!fs.existsSync(root)) {
|
||||
return [];
|
||||
}
|
||||
const files = [];
|
||||
const stack = [root];
|
||||
let visited = 0;
|
||||
while (stack.length > 0) {
|
||||
const current = stack.pop();
|
||||
const entries = fs.readdirSync(current, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
visited += 1;
|
||||
if (visited > MAX_TRANSCRIPT_WALK_ENTRIES) {
|
||||
throw new Error(
|
||||
`native Codex session transcript walk exceeded ${MAX_TRANSCRIPT_WALK_ENTRIES} entries under ${root}`,
|
||||
);
|
||||
}
|
||||
const fullPath = path.join(current, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
stack.push(fullPath);
|
||||
} else if (entry.isFile()) {
|
||||
files.push(fullPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
function assertNativeCodexSessionEvidence(params) {
|
||||
const roots = params.roots.filter((root) => fs.existsSync(root));
|
||||
const files = roots
|
||||
.flatMap((root) => listFilesRecursive(root).filter((filePath) => filePath.endsWith(".jsonl")))
|
||||
.map((filePath) => ({ filePath, stat: fs.statSync(filePath) }))
|
||||
.toSorted((left, right) => right.stat.mtimeMs - left.stat.mtimeMs)
|
||||
.slice(0, MAX_TRANSCRIPT_FILES);
|
||||
if (files.length === 0) {
|
||||
throw new Error(
|
||||
`missing native Codex session transcript files; checked ${params.roots.join(", ")}`,
|
||||
);
|
||||
}
|
||||
let scannedBytes = 0;
|
||||
const matchingFile = files.find(({ filePath, stat }) => {
|
||||
const readableBytes = Math.min(stat.size, MAX_TEXT_FILE_BYTES);
|
||||
if (scannedBytes + readableBytes > MAX_TRANSCRIPT_SCAN_BYTES) {
|
||||
return false;
|
||||
}
|
||||
scannedBytes += readableBytes;
|
||||
const content = readTextFileTail(filePath, "native Codex session transcript", readableBytes);
|
||||
return content.includes(params.marker) || content.includes(params.threadId);
|
||||
})?.filePath;
|
||||
if (!matchingFile) {
|
||||
throw new Error(
|
||||
`native Codex session transcripts did not contain ${params.marker} or ${params.threadId}; scanned ${scannedBytes} bytes across ${files.length} newest files: ${files.map((entry) => entry.filePath).join(", ")}`,
|
||||
);
|
||||
}
|
||||
assertPathInside(params.codexHome, matchingFile, "native Codex session transcript");
|
||||
}
|
||||
|
||||
function assertAgentTurn() {
|
||||
const marker = process.argv[3];
|
||||
const sessionId = process.argv[4];
|
||||
const modelRef = process.argv[5];
|
||||
const stdout = readTextFileBounded("/tmp/openclaw-codex-agent.json", "OpenClaw agent JSON");
|
||||
const stderr = readTextFileTail("/tmp/openclaw-codex-agent.err", "OpenClaw agent stderr");
|
||||
const response = JSON.parse(stdout);
|
||||
const text = extractAgentReplyTexts(JSON.stringify(response)).join("\n");
|
||||
if (!text.includes(marker)) {
|
||||
throw new Error(
|
||||
`OpenClaw agent reply did not contain ${marker}:\nstdout=${stdout}\nstderr=${stderr}`,
|
||||
);
|
||||
}
|
||||
const expectedProvider = modelRef.split("/")[0] || "codex";
|
||||
const executionTrace = response.meta?.executionTrace;
|
||||
if (!executionTrace || executionTrace.winnerProvider !== expectedProvider) {
|
||||
throw new Error(
|
||||
`expected Codex plugin model provider ${expectedProvider} to win the agent turn, got ${JSON.stringify(executionTrace)}`,
|
||||
);
|
||||
}
|
||||
|
||||
const sessionsDir = path.join(stateDir(), "agents", "main", "sessions");
|
||||
const storePath = path.join(sessionsDir, "sessions.json");
|
||||
const store = readJson(storePath);
|
||||
const entry = Object.values(store).find((candidate) => candidate?.sessionId === sessionId);
|
||||
if (!entry) {
|
||||
throw new Error(`missing session store entry for ${sessionId}: ${JSON.stringify(store)}`);
|
||||
}
|
||||
if (entry.agentHarnessId !== "codex") {
|
||||
throw new Error(`expected codex harness in session entry, got ${entry.agentHarnessId}`);
|
||||
}
|
||||
if (entry.modelOverride && entry.modelOverride !== modelRef) {
|
||||
throw new Error(`unexpected session model override: ${entry.modelOverride}`);
|
||||
}
|
||||
if (typeof entry.sessionFile !== "string" || !fs.existsSync(entry.sessionFile)) {
|
||||
throw new Error(`missing OpenClaw session file: ${entry.sessionFile}`);
|
||||
}
|
||||
|
||||
const bindingPath = `${entry.sessionFile}.codex-app-server.json`;
|
||||
const binding = readJson(bindingPath);
|
||||
if (![1, 2].includes(binding.schemaVersion) || typeof binding.threadId !== "string") {
|
||||
throw new Error(`invalid Codex app-server binding: ${JSON.stringify(binding)}`);
|
||||
}
|
||||
if (binding.model !== modelRef.split("/").slice(1).join("/")) {
|
||||
throw new Error(`unexpected Codex binding model: ${binding.model}`);
|
||||
}
|
||||
if (binding.modelProvider && !["codex", "openai"].includes(binding.modelProvider)) {
|
||||
throw new Error(`unexpected Codex binding provider: ${binding.modelProvider}`);
|
||||
}
|
||||
|
||||
const agentDir = path.join(stateDir(), "agents", "main");
|
||||
const codexHomes = [
|
||||
path.join(agentDir, "codex-home"),
|
||||
path.join(agentDir, "agent", "codex-home"),
|
||||
path.join(path.dirname(agentDir), "codex-home"),
|
||||
].filter((entryValue, index, entries) => entries.indexOf(entryValue) === index);
|
||||
const codexHome = codexHomes.find((entryLocal) => fs.existsSync(entryLocal));
|
||||
if (!codexHome) {
|
||||
throw new Error(`missing isolated Codex home; checked ${codexHomes.join(", ")}`);
|
||||
}
|
||||
const codexSessionRoot = path.join(codexHome, "sessions");
|
||||
const nativeSessionRoot = path.join(codexHome, "home", ".codex", "sessions");
|
||||
assertNativeCodexSessionEvidence({
|
||||
codexHome,
|
||||
marker,
|
||||
roots: [codexSessionRoot, nativeSessionRoot],
|
||||
threadId: binding.threadId,
|
||||
});
|
||||
}
|
||||
|
||||
function assertUninstalled() {
|
||||
const records = readInstallRecords();
|
||||
if (records.codex) {
|
||||
throw new Error(
|
||||
`codex install record still exists after uninstall: ${JSON.stringify(records.codex)}`,
|
||||
);
|
||||
}
|
||||
const list = readJson("/tmp/openclaw-codex-plugins-list-after-uninstall.json");
|
||||
const plugin = (list.plugins || []).find((entry) => entry.id === "codex");
|
||||
if (plugin?.status === "loaded" || plugin?.enabled === true) {
|
||||
throw new Error(`codex plugin still loaded/enabled after uninstall: ${JSON.stringify(plugin)}`);
|
||||
}
|
||||
const diagnostics = list.diagnostics || [];
|
||||
const errors = diagnostics
|
||||
.filter((diag) => diag?.level === "error")
|
||||
.map((diag) => String(diag.message || ""));
|
||||
if (errors.length > 0) {
|
||||
throw new Error(`unexpected plugin diagnostics errors after uninstall: ${errors.join("; ")}`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertAgentError() {
|
||||
const status = Number(process.argv[3]);
|
||||
if (!Number.isInteger(status) || status === 0) {
|
||||
throw new Error(
|
||||
`expected OpenClaw agent to fail after Codex uninstall, got status ${process.argv[3]}`,
|
||||
);
|
||||
}
|
||||
const stdout = fs.existsSync("/tmp/openclaw-codex-agent-after-uninstall.json")
|
||||
? readTextFileTail(
|
||||
"/tmp/openclaw-codex-agent-after-uninstall.json",
|
||||
"post-uninstall agent stdout",
|
||||
)
|
||||
: "";
|
||||
const stderr = fs.existsSync("/tmp/openclaw-codex-agent-after-uninstall.err")
|
||||
? readTextFileTail(
|
||||
"/tmp/openclaw-codex-agent-after-uninstall.err",
|
||||
"post-uninstall agent stderr",
|
||||
)
|
||||
: "";
|
||||
const combined = `${stdout}\n${stderr}`;
|
||||
if (
|
||||
!combined.includes('Requested agent harness "codex" is not registered') &&
|
||||
!combined.includes("Unknown model: codex/")
|
||||
) {
|
||||
throw new Error(`unexpected post-uninstall agent error:\nstdout=${stdout}\nstderr=${stderr}`);
|
||||
}
|
||||
}
|
||||
|
||||
const commands = {
|
||||
configure,
|
||||
"assert-plugin": assertPlugin,
|
||||
"assert-npm-deps": assertNpmDeps,
|
||||
"print-codex-bin": printCodexBin,
|
||||
"assert-preflight": assertPreflight,
|
||||
"assert-agent-turn": assertAgentTurn,
|
||||
"assert-uninstalled": assertUninstalled,
|
||||
"assert-agent-error": assertAgentError,
|
||||
};
|
||||
|
||||
const fn = commands[command];
|
||||
if (!fn) {
|
||||
throw new Error(`unknown codex npm plugin live assertion command: ${command}`);
|
||||
}
|
||||
fn();
|
||||
127
scripts/e2e/lib/codex-on-demand/assertions.mjs
Normal file
127
scripts/e2e/lib/codex-on-demand/assertions.mjs
Normal file
@@ -0,0 +1,127 @@
|
||||
// Assertions for Codex on-demand plugin E2E scenarios.
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
import { assertOpenAiEnvAuthProfileStore } from "../auth-profile-store-assertions.mjs";
|
||||
import {
|
||||
assertPathInside,
|
||||
configPath,
|
||||
findPackageJson,
|
||||
managedNpmRoot,
|
||||
npmProjectRootForInstalledPackage,
|
||||
readInstallRecords,
|
||||
readJson,
|
||||
stateDir,
|
||||
} from "../codex-install-utils.mjs";
|
||||
|
||||
const cfg = readJson(configPath());
|
||||
const inspect = readJson("/tmp/openclaw-codex-inspect.json");
|
||||
const records = readInstallRecords(cfg.plugins?.installs);
|
||||
const codexRecord = records.codex || inspect.install;
|
||||
if (!codexRecord) {
|
||||
throw new Error(`missing codex install record: ${JSON.stringify(records)}`);
|
||||
}
|
||||
if (codexRecord.source !== "npm") {
|
||||
throw new Error(`expected npm codex install record, got ${codexRecord.source}`);
|
||||
}
|
||||
if (!String(codexRecord.spec || "").includes("@openclaw/codex")) {
|
||||
throw new Error(`expected @openclaw/codex install spec, got ${codexRecord.spec}`);
|
||||
}
|
||||
|
||||
const npmRoot = managedNpmRoot();
|
||||
const installPath = String(codexRecord.installPath || "").replace(/^~(?=$|\/)/u, process.env.HOME);
|
||||
if (!installPath) {
|
||||
throw new Error(`missing codex installPath: ${JSON.stringify(codexRecord)}`);
|
||||
}
|
||||
assertPathInside(npmRoot, installPath, "codex install path");
|
||||
|
||||
const codexPackageJson = path.join(installPath, "package.json");
|
||||
if (!fs.existsSync(codexPackageJson)) {
|
||||
throw new Error(`missing npm-installed @openclaw/codex package: ${codexPackageJson}`);
|
||||
}
|
||||
const codexPackage = readJson(codexPackageJson);
|
||||
if (codexPackage.name !== "@openclaw/codex") {
|
||||
throw new Error(`unexpected codex package name: ${codexPackage.name}`);
|
||||
}
|
||||
|
||||
const npmProjectRoot = npmProjectRootForInstalledPackage(installPath, "@openclaw/codex");
|
||||
const openAiCodexPackageJson = findPackageJson("@openai/codex", [
|
||||
installPath,
|
||||
npmProjectRoot,
|
||||
npmRoot,
|
||||
]);
|
||||
if (!openAiCodexPackageJson) {
|
||||
throw new Error("missing @openai/codex dependency under managed npm root");
|
||||
}
|
||||
assertPathInside(npmRoot, openAiCodexPackageJson, "@openai/codex dependency");
|
||||
const openAiCodexPackage = readJson(openAiCodexPackageJson);
|
||||
const codexBinPath =
|
||||
typeof openAiCodexPackage.bin === "string"
|
||||
? openAiCodexPackage.bin
|
||||
: openAiCodexPackage.bin && typeof openAiCodexPackage.bin.codex === "string"
|
||||
? openAiCodexPackage.bin.codex
|
||||
: undefined;
|
||||
if (!codexBinPath) {
|
||||
throw new Error(`@openai/codex package has no codex bin: ${openAiCodexPackageJson}`);
|
||||
}
|
||||
const codexBin = path.resolve(path.dirname(openAiCodexPackageJson), codexBinPath);
|
||||
if (!fs.existsSync(codexBin)) {
|
||||
throw new Error(`missing managed Codex binary: ${codexBin}`);
|
||||
}
|
||||
assertPathInside(npmRoot, codexBin, "managed Codex binary");
|
||||
|
||||
const list = readJson("/tmp/openclaw-plugins-list.json");
|
||||
const plugin = (list.plugins || []).find((entry) => entry.id === "codex");
|
||||
if (!plugin || plugin.enabled !== true || plugin.status !== "loaded") {
|
||||
throw new Error(`codex plugin was not enabled+loaded: ${JSON.stringify(plugin)}`);
|
||||
}
|
||||
|
||||
if (inspect.plugin?.id !== "codex" || inspect.plugin?.status !== "loaded") {
|
||||
throw new Error(`unexpected codex inspect state: ${JSON.stringify(inspect.plugin)}`);
|
||||
}
|
||||
const hasHarness =
|
||||
(Array.isArray(inspect.plugin?.agentHarnessIds) &&
|
||||
inspect.plugin.agentHarnessIds.includes("codex")) ||
|
||||
(Array.isArray(inspect.capabilities) &&
|
||||
inspect.capabilities.some(
|
||||
(entry) => entry?.kind === "agent-harness" && entry.ids?.includes("codex"),
|
||||
));
|
||||
if (!hasHarness) {
|
||||
throw new Error(`codex harness was not registered: ${JSON.stringify(inspect.plugin)}`);
|
||||
}
|
||||
|
||||
const primaryModel = cfg.agents?.defaults?.model?.primary;
|
||||
if (primaryModel !== "openai/gpt-5.5") {
|
||||
throw new Error(`expected OpenAI onboarding model openai/gpt-5.5, got ${primaryModel}`);
|
||||
}
|
||||
const providerRuntime = cfg.models?.providers?.openai?.agentRuntime?.id;
|
||||
if (providerRuntime && providerRuntime !== "codex") {
|
||||
throw new Error(`unexpected OpenAI provider runtime: ${providerRuntime}`);
|
||||
}
|
||||
|
||||
function readAuthProfileStoreText(agentDir) {
|
||||
const dbPath = path.join(agentDir, "openclaw-agent.sqlite");
|
||||
if (!fs.existsSync(dbPath)) {
|
||||
throw new Error("auth profile SQLite store was not persisted");
|
||||
}
|
||||
let db;
|
||||
try {
|
||||
db = new DatabaseSync(dbPath, { readOnly: true });
|
||||
const row = db
|
||||
.prepare("SELECT store_json FROM auth_profile_store WHERE store_key = ?")
|
||||
.get("primary");
|
||||
return typeof row?.store_json === "string" ? row.store_json : "";
|
||||
} finally {
|
||||
db?.close();
|
||||
}
|
||||
}
|
||||
|
||||
const authRaw = readAuthProfileStoreText(path.join(stateDir(), "agents", "main", "agent"));
|
||||
if (!authRaw) {
|
||||
throw new Error("auth profile SQLite store row was not persisted");
|
||||
}
|
||||
assertOpenAiEnvAuthProfileStore(authRaw, {
|
||||
envRefMessage: "auth profile did not persist OPENAI_API_KEY env ref",
|
||||
rawKeyMessage: "auth profile persisted the raw OpenAI test key",
|
||||
rawKeyNeedle: "sk-openclaw-codex-on-demand-e2e",
|
||||
});
|
||||
39
scripts/e2e/lib/config-reload/assert-log.mjs
Normal file
39
scripts/e2e/lib/config-reload/assert-log.mjs
Normal file
@@ -0,0 +1,39 @@
|
||||
// Log assertions for config reload E2E scenarios.
|
||||
import { sleep } from "../../../lib/sleep.mjs";
|
||||
import { readPositiveIntEnv } from "../env-limits.mjs";
|
||||
import { createConfigReloadLogScanner } from "./log-scanner.mjs";
|
||||
|
||||
const logPath = process.env.OPENCLAW_CONFIG_RELOAD_LOG_PATH ?? "/tmp/config-reload-e2e.log";
|
||||
const deadlineMs = Date.now() + readPositiveIntEnv("OPENCLAW_CONFIG_RELOAD_LOG_TIMEOUT_MS", 30_000);
|
||||
const maxReadBytes = readPositiveIntEnv("OPENCLAW_CONFIG_RELOAD_LOG_MAX_READ_BYTES", 256 * 1024);
|
||||
|
||||
const scanner = createConfigReloadLogScanner(logPath, {
|
||||
maxReadBytes,
|
||||
tailLineLimit: 160,
|
||||
});
|
||||
let result = { reloadLines: [], restartLines: [], tailLines: [] };
|
||||
|
||||
while (Date.now() < deadlineMs) {
|
||||
result = scanner.scan();
|
||||
if (result.restartLines.length > 0 || result.reloadLines.length > 0) {
|
||||
break;
|
||||
}
|
||||
await sleep(500);
|
||||
}
|
||||
|
||||
if (result.restartLines.length > 0) {
|
||||
console.error(result.tailLines.join("\n"));
|
||||
throw new Error("unexpected restart-required reload line found");
|
||||
}
|
||||
for (const line of result.reloadLines) {
|
||||
for (const needle of ["gateway.auth.token", "plugins.entries.firecrawl.config.webFetch"]) {
|
||||
if (line.includes(needle)) {
|
||||
console.error(result.tailLines.join("\n"));
|
||||
throw new Error(`runtime-only path appeared in reload diff: ${needle}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (result.reloadLines.length === 0) {
|
||||
console.error(result.tailLines.join("\n"));
|
||||
throw new Error("expected config reload detection log after metadata write");
|
||||
}
|
||||
52
scripts/e2e/lib/config-reload/log-scanner.mjs
Normal file
52
scripts/e2e/lib/config-reload/log-scanner.mjs
Normal file
@@ -0,0 +1,52 @@
|
||||
// Streaming log scanner for config reload E2E scenarios.
|
||||
import {
|
||||
createIncrementalLineReader,
|
||||
resolvePositiveInteger,
|
||||
} from "../incremental-line-reader.mjs";
|
||||
|
||||
const DEFAULT_MAX_READ_BYTES = 256 * 1024;
|
||||
const DEFAULT_TAIL_LINE_LIMIT = 160;
|
||||
const RELOAD_NEEDLE = "config change detected; evaluating reload";
|
||||
const RESTART_NEEDLE = "config change requires gateway restart";
|
||||
|
||||
export function inspectConfigReloadLogLine(line) {
|
||||
return {
|
||||
reload: line.includes(RELOAD_NEEDLE),
|
||||
restart: line.includes(RESTART_NEEDLE),
|
||||
};
|
||||
}
|
||||
|
||||
export function createConfigReloadLogScanner(logPath, options = {}) {
|
||||
const maxReadBytes = resolvePositiveInteger(options.maxReadBytes, DEFAULT_MAX_READ_BYTES);
|
||||
const tailLineLimit = resolvePositiveInteger(options.tailLineLimit, DEFAULT_TAIL_LINE_LIMIT);
|
||||
const reader = createIncrementalLineReader(logPath, { maxReadBytes });
|
||||
let tailLines = [];
|
||||
const reloadLines = [];
|
||||
const restartLines = [];
|
||||
|
||||
return {
|
||||
scan() {
|
||||
const { lines, reset } = reader.readLines();
|
||||
if (reset) {
|
||||
tailLines = [];
|
||||
reloadLines.length = 0;
|
||||
restartLines.length = 0;
|
||||
}
|
||||
for (const line of lines) {
|
||||
const trimmed = line.replace(/\r$/u, "");
|
||||
tailLines.push(trimmed);
|
||||
const match = inspectConfigReloadLogLine(trimmed);
|
||||
if (match.reload) {
|
||||
reloadLines.push(trimmed);
|
||||
}
|
||||
if (match.restart) {
|
||||
restartLines.push(trimmed);
|
||||
}
|
||||
}
|
||||
if (tailLines.length > tailLineLimit) {
|
||||
tailLines = tailLines.slice(-tailLineLimit);
|
||||
}
|
||||
return { reloadLines, restartLines, tailLines };
|
||||
},
|
||||
};
|
||||
}
|
||||
7
scripts/e2e/lib/config-reload/mutate-metadata.mjs
Normal file
7
scripts/e2e/lib/config-reload/mutate-metadata.mjs
Normal file
@@ -0,0 +1,7 @@
|
||||
// Mutates plugin metadata fixtures for config reload E2E scenarios.
|
||||
import fs from "node:fs";
|
||||
|
||||
const configPath = process.env.OPENCLAW_CONFIG_PATH;
|
||||
const config = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
||||
config.gateway.channelHealthCheckMinutes = 2;
|
||||
fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
|
||||
143
scripts/e2e/lib/docker-stats/assert-resource-ceiling.mjs
Normal file
143
scripts/e2e/lib/docker-stats/assert-resource-ceiling.mjs
Normal file
@@ -0,0 +1,143 @@
|
||||
// Resource ceiling assertions for Docker E2E stats output.
|
||||
import fs from "node:fs";
|
||||
import { createInterface } from "node:readline";
|
||||
|
||||
const [statsFile, maxMemoryRaw, maxCpuRaw, label = "docker"] = process.argv.slice(2);
|
||||
const NON_NEGATIVE_DECIMAL_PATTERN = /^(?:0|[1-9]\d*)(?:\.\d+)?$/u;
|
||||
|
||||
function parseFiniteLimit(raw, name) {
|
||||
const text = String(raw ?? "").trim();
|
||||
if (!NON_NEGATIVE_DECIMAL_PATTERN.test(text)) {
|
||||
throw new Error(
|
||||
`${name} must be a finite non-negative number in decimal notation. Got: ${JSON.stringify(raw)}`,
|
||||
);
|
||||
}
|
||||
const parsed = Number(text);
|
||||
if (!Number.isFinite(parsed)) {
|
||||
throw new Error(
|
||||
`${name} must be a finite non-negative number in decimal notation. Got: ${JSON.stringify(raw)}`,
|
||||
);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
const maxMemoryMiB = parseFiniteLimit(maxMemoryRaw, "max memory MiB");
|
||||
const maxCpuPercent = parseFiniteLimit(maxCpuRaw, "max CPU percent");
|
||||
|
||||
function parseMemoryMiB(raw) {
|
||||
const value =
|
||||
String(raw || "")
|
||||
.split("/")[0]
|
||||
?.trim() || "";
|
||||
const match = /^([0-9.]+)\s*([KMGT]?i?B)$/iu.exec(value);
|
||||
if (!match) {
|
||||
return undefined;
|
||||
}
|
||||
const amount = Number(match[1]);
|
||||
if (!Number.isFinite(amount)) {
|
||||
return undefined;
|
||||
}
|
||||
const unit = match[2].toLowerCase();
|
||||
if (unit === "b") {
|
||||
return amount / 1024 / 1024;
|
||||
}
|
||||
if (unit === "kb" || unit === "kib") {
|
||||
return amount / 1024;
|
||||
}
|
||||
if (unit === "mb" || unit === "mib") {
|
||||
return amount;
|
||||
}
|
||||
if (unit === "gb" || unit === "gib") {
|
||||
return amount * 1024;
|
||||
}
|
||||
if (unit === "tb" || unit === "tib") {
|
||||
return amount * 1024 * 1024;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function parseCpuPercent(raw) {
|
||||
const text = String(raw ?? "").trim();
|
||||
const valueText = text.endsWith("%") ? text.slice(0, -1).trim() : text;
|
||||
if (!NON_NEGATIVE_DECIMAL_PATTERN.test(valueText)) {
|
||||
return undefined;
|
||||
}
|
||||
const parsed = Number(valueText);
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
}
|
||||
|
||||
function isTerminalZeroMemorySample(raw) {
|
||||
const parts = String(raw || "").split("/");
|
||||
if (parts.length !== 2) {
|
||||
return false;
|
||||
}
|
||||
return parts.every((part) => parseMemoryMiB(part.trim()) === 0);
|
||||
}
|
||||
|
||||
function assertSampleValue(value, raw, name, labelLocal) {
|
||||
if (value === undefined) {
|
||||
throw new Error(
|
||||
`docker stats sample for ${labelLocal} had invalid ${name}: ${JSON.stringify(raw)}`,
|
||||
);
|
||||
}
|
||||
if (name === "MemUsage" && value <= 0) {
|
||||
throw new Error(
|
||||
`docker stats sample for ${labelLocal} had non-positive ${name}: ${JSON.stringify(raw)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function scanStatsFileLines(file, onLine) {
|
||||
if (!fs.existsSync(file)) {
|
||||
return;
|
||||
}
|
||||
const input = fs.createReadStream(file, { encoding: "utf8" });
|
||||
const lines = createInterface({ crlfDelay: Infinity, input });
|
||||
for await (const line of lines) {
|
||||
if (line) {
|
||||
onLine(line);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let maxObservedMemoryMiB = 0;
|
||||
let maxObservedCpuPercent = 0;
|
||||
let parsedSamples = 0;
|
||||
|
||||
await scanStatsFileLines(statsFile, (line) => {
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(line);
|
||||
} catch {
|
||||
throw new Error(`docker stats sample for ${label} was not valid JSON`);
|
||||
}
|
||||
const observedMemoryMiB = parseMemoryMiB(parsed.MemUsage);
|
||||
const observedCpuPercent = parseCpuPercent(parsed.CPUPerc);
|
||||
// Docker can emit 0B / 0B after the target container exits; it proves
|
||||
// lifecycle timing, not resource usage. Keep the real captured samples.
|
||||
if (isTerminalZeroMemorySample(parsed.MemUsage)) {
|
||||
return;
|
||||
}
|
||||
assertSampleValue(observedMemoryMiB, parsed.MemUsage, "MemUsage", label);
|
||||
assertSampleValue(observedCpuPercent, parsed.CPUPerc, "CPUPerc", label);
|
||||
parsedSamples += 1;
|
||||
maxObservedMemoryMiB = Math.max(maxObservedMemoryMiB, observedMemoryMiB);
|
||||
maxObservedCpuPercent = Math.max(maxObservedCpuPercent, observedCpuPercent);
|
||||
});
|
||||
|
||||
console.log(
|
||||
`${label} resource peak: memory=${maxObservedMemoryMiB.toFixed(1)}MiB cpu=${maxObservedCpuPercent.toFixed(1)}% samples=${parsedSamples}`,
|
||||
);
|
||||
if (parsedSamples === 0) {
|
||||
throw new Error(`no docker stats samples captured for ${label}`);
|
||||
}
|
||||
if (maxObservedMemoryMiB > maxMemoryMiB) {
|
||||
throw new Error(
|
||||
`${label} memory peak ${maxObservedMemoryMiB.toFixed(1)}MiB exceeded ${maxMemoryMiB}MiB`,
|
||||
);
|
||||
}
|
||||
if (maxObservedCpuPercent > maxCpuPercent) {
|
||||
throw new Error(
|
||||
`${label} CPU peak ${maxObservedCpuPercent.toFixed(1)}% exceeded ${maxCpuPercent}%`,
|
||||
);
|
||||
}
|
||||
293
scripts/e2e/lib/doctor-install-switch/scenario.sh
Normal file
293
scripts/e2e/lib/doctor-install-switch/scenario.sh
Normal file
@@ -0,0 +1,293 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
source scripts/lib/openclaw-e2e-instance.sh
|
||||
openclaw_e2e_eval_test_state_from_b64 "${OPENCLAW_TEST_STATE_FUNCTION_B64:?missing OPENCLAW_TEST_STATE_FUNCTION_B64}"
|
||||
|
||||
# Keep logs focused; the npm global install step can emit noisy deprecation warnings.
|
||||
export npm_config_loglevel=error
|
||||
export npm_config_fund=false
|
||||
export npm_config_audit=false
|
||||
export OPENCLAW_DISABLE_BUNDLED_PLUGINS=1
|
||||
|
||||
# Stub systemd/loginctl so doctor + daemon flows work in Docker.
|
||||
export PATH="/tmp/openclaw-bin:$PATH"
|
||||
mkdir -p /tmp/openclaw-bin
|
||||
cp scripts/e2e/lib/doctor-install-switch/shims/systemctl /tmp/openclaw-bin/systemctl
|
||||
cp scripts/e2e/lib/doctor-install-switch/shims/loginctl /tmp/openclaw-bin/loginctl
|
||||
chmod +x /tmp/openclaw-bin/systemctl /tmp/openclaw-bin/loginctl
|
||||
|
||||
package_tgz="${OPENCLAW_CURRENT_PACKAGE_TGZ:?missing OPENCLAW_CURRENT_PACKAGE_TGZ}"
|
||||
git_root="/tmp/openclaw-git"
|
||||
mkdir -p "$git_root"
|
||||
# The git-style install fixture is unpacked from the tarball so this lane does
|
||||
# not depend on checkout source files being present in the Docker image.
|
||||
tar -xzf "$package_tgz" -C "$git_root" --strip-components=1
|
||||
(
|
||||
cd "$git_root"
|
||||
openclaw_e2e_maybe_timeout "${OPENCLAW_E2E_NPM_INSTALL_TIMEOUT:-600s}" npm install --omit=optional --no-fund --no-audit >/tmp/openclaw-git-install.log 2>&1
|
||||
git init -q
|
||||
git config user.email "docker-e2e@openclaw.local"
|
||||
git config user.name "OpenClaw Docker E2E"
|
||||
git add -A --
|
||||
git commit -qm "test fixture"
|
||||
)
|
||||
npm_log="/tmp/openclaw-doctor-switch-npm-install.log"
|
||||
if ! openclaw_e2e_maybe_timeout "${OPENCLAW_E2E_NPM_INSTALL_TIMEOUT:-600s}" npm install -g --prefix /tmp/npm-prefix --omit=optional "$package_tgz" >"$npm_log" 2>&1; then
|
||||
openclaw_e2e_print_log "$npm_log"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
npm_bin="/tmp/npm-prefix/bin/openclaw"
|
||||
npm_root="/tmp/npm-prefix/lib/node_modules/openclaw"
|
||||
if [ -f "$npm_root/dist/index.mjs" ]; then
|
||||
npm_entry="$npm_root/dist/index.mjs"
|
||||
else
|
||||
npm_entry="$npm_root/dist/index.js"
|
||||
fi
|
||||
|
||||
if [ -f "$git_root/dist/index.mjs" ]; then
|
||||
git_entry="$git_root/dist/index.mjs"
|
||||
else
|
||||
git_entry="$git_root/dist/index.js"
|
||||
fi
|
||||
git_cli="$git_root/openclaw.mjs"
|
||||
|
||||
package_version="$(node -p "require(\"$npm_root/package.json\").version")"
|
||||
is_legacy_package_acceptance_compat() {
|
||||
[ "$(node scripts/e2e/lib/package-compat.mjs "$1")" = "1" ]
|
||||
}
|
||||
|
||||
assert_entrypoint() {
|
||||
local unit_path="$1"
|
||||
local expected="$2"
|
||||
local exec_line=""
|
||||
exec_line=$(grep -m1 "^ExecStart=" "$unit_path" || true)
|
||||
if [ -z "$exec_line" ]; then
|
||||
echo "Missing ExecStart in $unit_path"
|
||||
exit 1
|
||||
fi
|
||||
exec_line="${exec_line#ExecStart=}"
|
||||
entrypoint=$(echo "$exec_line" | awk "{print \$2}")
|
||||
entrypoint="${entrypoint%\"}"
|
||||
entrypoint="${entrypoint#\"}"
|
||||
if [ "$entrypoint" != "$expected" ]; then
|
||||
echo "Expected entrypoint $expected, got $entrypoint"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
assert_exec_arg() {
|
||||
local unit_path="$1"
|
||||
local index="$2"
|
||||
local expected="$3"
|
||||
local exec_line=""
|
||||
local actual=""
|
||||
exec_line=$(grep -m1 "^ExecStart=" "$unit_path" || true)
|
||||
if [ -z "$exec_line" ]; then
|
||||
echo "Missing ExecStart in $unit_path"
|
||||
exit 1
|
||||
fi
|
||||
exec_line="${exec_line#ExecStart=}"
|
||||
actual=$(echo "$exec_line" | awk -v field="$index" "{print \$field}")
|
||||
actual="${actual%\"}"
|
||||
actual="${actual#\"}"
|
||||
if [ "$actual" != "$expected" ]; then
|
||||
echo "Expected ExecStart arg $index to be $expected, got $actual"
|
||||
cat "$unit_path"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
assert_env_value() {
|
||||
local unit_path="$1"
|
||||
local key="$2"
|
||||
local expected="$3"
|
||||
if ! grep -Fxq "Environment=${key}=${expected}" "$unit_path"; then
|
||||
echo "Expected Environment=${key}=${expected} in $unit_path"
|
||||
cat "$unit_path"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
assert_no_env_key() {
|
||||
local unit_path="$1"
|
||||
local key="$2"
|
||||
if grep -q "^Environment=${key}=" "$unit_path"; then
|
||||
echo "Expected no Environment=${key}= line in $unit_path"
|
||||
cat "$unit_path"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Each flow: install service with one variant, run doctor from the other,
|
||||
# and verify ExecStart entrypoint switches accordingly.
|
||||
run_flow() {
|
||||
local name="$1"
|
||||
local install_cmd="$2"
|
||||
local install_expected="$3"
|
||||
local doctor_cmd="$4"
|
||||
local doctor_expected="$5"
|
||||
local install_log="/tmp/openclaw-doctor-switch-${name}-install.log"
|
||||
local doctor_log="/tmp/openclaw-doctor-switch-${name}-doctor.log"
|
||||
local command_timeout="${OPENCLAW_DOCKER_DOCTOR_SWITCH_COMMAND_TIMEOUT:-900s}"
|
||||
|
||||
echo "== Flow: $name =="
|
||||
openclaw_test_state_create "switch-${name}" empty
|
||||
export USER="testuser"
|
||||
|
||||
if ! openclaw_e2e_maybe_timeout "$command_timeout" bash -c "$install_cmd" >"$install_log" 2>&1; then
|
||||
openclaw_e2e_print_log "$install_log"
|
||||
exit 1
|
||||
fi
|
||||
rm -f "$HOME/.zshrc" "$HOME/.bashrc" "$HOME/.bash_profile"
|
||||
rm -rf "$HOME/.config/fish" "$HOME/.config/powershell"
|
||||
|
||||
unit_path="$HOME/.config/systemd/user/openclaw-gateway.service"
|
||||
if [ ! -f "$unit_path" ]; then
|
||||
echo "Missing unit file: $unit_path"
|
||||
exit 1
|
||||
fi
|
||||
assert_entrypoint "$unit_path" "$install_expected"
|
||||
|
||||
if ! openclaw_e2e_maybe_timeout "$command_timeout" bash -c "$doctor_cmd" >"$doctor_log" 2>&1; then
|
||||
openclaw_e2e_print_log "$doctor_log"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
assert_entrypoint "$unit_path" "$doctor_expected"
|
||||
}
|
||||
|
||||
run_flow \
|
||||
"npm-to-git" \
|
||||
"$npm_bin daemon install --force" \
|
||||
"$npm_entry" \
|
||||
"OPENCLAW_UPDATE_IN_PROGRESS=1 node $git_cli doctor --repair --force --yes --non-interactive" \
|
||||
"$git_entry"
|
||||
|
||||
run_flow \
|
||||
"git-to-npm" \
|
||||
"node $git_cli daemon install --force" \
|
||||
"$git_entry" \
|
||||
"OPENCLAW_UPDATE_IN_PROGRESS=1 $npm_bin doctor --repair --force --yes --non-interactive" \
|
||||
"$npm_entry"
|
||||
|
||||
run_proxy_env_flow() {
|
||||
local name="proxy-env-cleanup"
|
||||
local install_log="/tmp/openclaw-doctor-switch-${name}-install.log"
|
||||
local doctor_log="/tmp/openclaw-doctor-switch-${name}-doctor.log"
|
||||
local command_timeout="${OPENCLAW_DOCKER_DOCTOR_SWITCH_COMMAND_TIMEOUT:-900s}"
|
||||
|
||||
echo "== Flow: $name =="
|
||||
openclaw_test_state_create "switch-${name}" empty
|
||||
export USER="testuser"
|
||||
|
||||
unit_path="$HOME/.config/systemd/user/openclaw-gateway.service"
|
||||
if ! openclaw_e2e_maybe_timeout "$command_timeout" env \
|
||||
HTTP_PROXY="http://proxy.local:7890" \
|
||||
HTTPS_PROXY="https://proxy.local:7890" \
|
||||
NO_PROXY="localhost,127.0.0.1" \
|
||||
"$npm_bin" gateway install --force >"$install_log" 2>&1; then
|
||||
openclaw_e2e_print_log "$install_log"
|
||||
exit 1
|
||||
fi
|
||||
assert_no_env_key "$unit_path" "HTTP_PROXY"
|
||||
assert_no_env_key "$unit_path" "HTTPS_PROXY"
|
||||
assert_no_env_key "$unit_path" "NO_PROXY"
|
||||
|
||||
{
|
||||
printf "%s\n" "Environment=HTTP_PROXY=http://stale-proxy.local:7890"
|
||||
printf "%s\n" "Environment=HTTPS_PROXY=https://stale-proxy.local:7890"
|
||||
} >>"$unit_path"
|
||||
if ! openclaw_e2e_maybe_timeout "$command_timeout" env OPENCLAW_UPDATE_IN_PROGRESS=1 \
|
||||
node "$git_cli" doctor --repair --force --yes --non-interactive >"$doctor_log" 2>&1; then
|
||||
openclaw_e2e_print_log "$doctor_log"
|
||||
exit 1
|
||||
fi
|
||||
assert_no_env_key "$unit_path" "HTTP_PROXY"
|
||||
assert_no_env_key "$unit_path" "HTTPS_PROXY"
|
||||
}
|
||||
|
||||
run_proxy_env_flow
|
||||
|
||||
run_wrapper_flow() {
|
||||
local name="wrapper-persistence"
|
||||
local install_log="/tmp/openclaw-doctor-switch-${name}-install.log"
|
||||
local reinstall_log="/tmp/openclaw-doctor-switch-${name}-reinstall.log"
|
||||
local env_repair_log="/tmp/openclaw-doctor-switch-${name}-env-repair.log"
|
||||
local doctor_log="/tmp/openclaw-doctor-switch-${name}-doctor.log"
|
||||
local clear_log="/tmp/openclaw-doctor-switch-${name}-clear.log"
|
||||
local command_timeout="${OPENCLAW_DOCKER_DOCTOR_SWITCH_COMMAND_TIMEOUT:-900s}"
|
||||
|
||||
echo "== Flow: $name =="
|
||||
openclaw_test_state_create "switch-${name}" empty
|
||||
export USER="testuser"
|
||||
mkdir -p "$HOME/.local/bin"
|
||||
local wrapper="$HOME/.local/bin/openclaw-wrapper"
|
||||
node scripts/e2e/lib/doctor-install-switch/write-wrapper.mjs \
|
||||
"$wrapper" \
|
||||
"$npm_bin" \
|
||||
"$HOME/openclaw-wrapper-argv.log"
|
||||
|
||||
local unit_path="$HOME/.config/systemd/user/openclaw-gateway.service"
|
||||
|
||||
if ! openclaw_e2e_maybe_timeout "$command_timeout" "$npm_bin" gateway install --wrapper "$wrapper" --force >"$install_log" 2>&1; then
|
||||
openclaw_e2e_print_log "$install_log"
|
||||
exit 1
|
||||
fi
|
||||
assert_exec_arg "$unit_path" 1 "$wrapper"
|
||||
assert_exec_arg "$unit_path" 2 "gateway"
|
||||
assert_env_value "$unit_path" "OPENCLAW_WRAPPER" "$wrapper"
|
||||
|
||||
if ! openclaw_e2e_maybe_timeout "$command_timeout" "$npm_bin" gateway install --force >"$reinstall_log" 2>&1; then
|
||||
openclaw_e2e_print_log "$reinstall_log"
|
||||
exit 1
|
||||
fi
|
||||
assert_exec_arg "$unit_path" 1 "$wrapper"
|
||||
assert_exec_arg "$unit_path" 2 "gateway"
|
||||
assert_env_value "$unit_path" "OPENCLAW_WRAPPER" "$wrapper"
|
||||
|
||||
sed -i "/^Environment=OPENCLAW_WRAPPER=/d" "$unit_path"
|
||||
if ! openclaw_e2e_maybe_timeout "$command_timeout" "$npm_bin" gateway install --wrapper "$wrapper" >"$env_repair_log" 2>&1; then
|
||||
openclaw_e2e_print_log "$env_repair_log"
|
||||
exit 1
|
||||
fi
|
||||
assert_exec_arg "$unit_path" 1 "$wrapper"
|
||||
assert_env_value "$unit_path" "OPENCLAW_WRAPPER" "$wrapper"
|
||||
|
||||
sed -i "s#^Environment=OPENCLAW_WRAPPER=.*#Environment=OPENCLAW_WRAPPER=/tmp/stale-openclaw-wrapper#" "$unit_path"
|
||||
if ! openclaw_e2e_maybe_timeout "$command_timeout" "$npm_bin" gateway install --wrapper "$wrapper" >"$env_repair_log" 2>&1; then
|
||||
openclaw_e2e_print_log "$env_repair_log"
|
||||
exit 1
|
||||
fi
|
||||
assert_exec_arg "$unit_path" 1 "$wrapper"
|
||||
assert_env_value "$unit_path" "OPENCLAW_WRAPPER" "$wrapper"
|
||||
|
||||
if ! openclaw_e2e_maybe_timeout "$command_timeout" node "$git_cli" doctor --repair --force --yes >"$doctor_log" 2>&1; then
|
||||
openclaw_e2e_print_log "$doctor_log"
|
||||
exit 1
|
||||
fi
|
||||
if ! grep -Fq "Gateway service invokes OPENCLAW_WRAPPER:" "$doctor_log"; then
|
||||
echo "Expected doctor to report active wrapper"
|
||||
openclaw_e2e_print_log "$doctor_log"
|
||||
exit 1
|
||||
fi
|
||||
assert_exec_arg "$unit_path" 1 "$wrapper"
|
||||
assert_env_value "$unit_path" "OPENCLAW_WRAPPER" "$wrapper"
|
||||
|
||||
if ! openclaw_e2e_maybe_timeout "$command_timeout" env OPENCLAW_WRAPPER= "$npm_bin" gateway install --force >"$clear_log" 2>&1; then
|
||||
openclaw_e2e_print_log "$clear_log"
|
||||
exit 1
|
||||
fi
|
||||
assert_no_env_key "$unit_path" "OPENCLAW_WRAPPER"
|
||||
assert_entrypoint "$unit_path" "$npm_entry"
|
||||
}
|
||||
|
||||
if "$npm_bin" gateway install --help 2>&1 | grep -q -- "--wrapper"; then
|
||||
run_wrapper_flow
|
||||
elif is_legacy_package_acceptance_compat "$package_version"; then
|
||||
# Legacy compatibility: 2026.4.25 and older did not ship gateway install --wrapper.
|
||||
echo "Skipping wrapper persistence; package gateway install does not support --wrapper."
|
||||
else
|
||||
echo "Package $package_version must support gateway install --wrapper." >&2
|
||||
exit 1
|
||||
fi
|
||||
7
scripts/e2e/lib/doctor-install-switch/shims/loginctl
Executable file
7
scripts/e2e/lib/doctor-install-switch/shims/loginctl
Executable file
@@ -0,0 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
case "$*" in
|
||||
*show-user*) echo "Linger=yes" ;;
|
||||
*enable-linger*) ;;
|
||||
esac
|
||||
34
scripts/e2e/lib/doctor-install-switch/shims/systemctl
Executable file
34
scripts/e2e/lib/doctor-install-switch/shims/systemctl
Executable file
@@ -0,0 +1,34 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
args=("$@")
|
||||
if [[ "${args[0]:-}" == "--user" ]]; then
|
||||
args=("${args[@]:1}")
|
||||
fi
|
||||
cmd="${args[0]:-}"
|
||||
|
||||
case "$cmd" in
|
||||
status) ;;
|
||||
is-active)
|
||||
echo "inactive" >&2
|
||||
exit 3
|
||||
;;
|
||||
is-enabled)
|
||||
unit="${args[1]:-}"
|
||||
unit_path="$HOME/.config/systemd/user/${unit}"
|
||||
if [ -f "$unit_path" ]; then
|
||||
echo "enabled"
|
||||
exit 0
|
||||
fi
|
||||
echo "disabled" >&2
|
||||
exit 1
|
||||
;;
|
||||
show)
|
||||
printf "%s\n" \
|
||||
"ActiveState=inactive" \
|
||||
"SubState=dead" \
|
||||
"MainPID=0" \
|
||||
"ExecMainStatus=0" \
|
||||
"ExecMainCode=0"
|
||||
;;
|
||||
esac
|
||||
25
scripts/e2e/lib/doctor-install-switch/write-wrapper.mjs
Normal file
25
scripts/e2e/lib/doctor-install-switch/write-wrapper.mjs
Normal file
@@ -0,0 +1,25 @@
|
||||
#!/usr/bin/env node
|
||||
// Writes wrapper scripts for doctor install-switch E2E scenarios.
|
||||
import fs from "node:fs";
|
||||
|
||||
const [wrapperPath, npmBin, logPath = `${process.env.HOME}/openclaw-wrapper-argv.log`] =
|
||||
process.argv.slice(2);
|
||||
|
||||
if (!wrapperPath || !npmBin || !logPath || logPath.startsWith("undefined/")) {
|
||||
console.error("usage: write-wrapper.mjs <wrapper-path> <npm-bin> [log-path]");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function shellSingleQuote(value) {
|
||||
return `'${value.replaceAll("'", "'\\''")}'`;
|
||||
}
|
||||
|
||||
fs.writeFileSync(
|
||||
wrapperPath,
|
||||
`#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
printf "%s\\n" "$@" >> ${shellSingleQuote(logPath)}
|
||||
exec ${shellSingleQuote(npmBin)} "$@"
|
||||
`,
|
||||
{ mode: 0o755 },
|
||||
);
|
||||
23
scripts/e2e/lib/env-limits.mjs
Normal file
23
scripts/e2e/lib/env-limits.mjs
Normal file
@@ -0,0 +1,23 @@
|
||||
// Environment limit helpers for E2E subprocess scenarios.
|
||||
export function readPositiveIntEnv(name, fallback, env = process.env) {
|
||||
const raw = env[name] ?? fallback;
|
||||
const text = raw == null ? "unset" : String(raw).trim();
|
||||
if (!/^\d+$/u.test(text)) {
|
||||
throw new Error(`invalid ${name}: ${text}`);
|
||||
}
|
||||
const value = Number(text);
|
||||
if (!Number.isSafeInteger(value) || value <= 0) {
|
||||
throw new Error(`invalid ${name}: ${text}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function readTcpPortEnv(name, fallback, env = process.env) {
|
||||
const value = readPositiveIntEnv(name, fallback, env);
|
||||
if (value > 65_535) {
|
||||
const raw = env[name] ?? fallback;
|
||||
const text = raw == null ? "unset" : String(raw).trim();
|
||||
throw new Error(`invalid ${name}: ${text}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
17
scripts/e2e/lib/fixture.mjs
Normal file
17
scripts/e2e/lib/fixture.mjs
Normal file
@@ -0,0 +1,17 @@
|
||||
// Shared command fixture dispatcher for E2E scripts.
|
||||
import { configCommands } from "./fixtures/config.mjs";
|
||||
import { pluginCommands } from "./fixtures/plugins.mjs";
|
||||
import { workspaceCommands } from "./fixtures/workspace.mjs";
|
||||
|
||||
const [command, ...args] = process.argv.slice(2);
|
||||
|
||||
const handler = {
|
||||
...pluginCommands,
|
||||
...configCommands,
|
||||
...workspaceCommands,
|
||||
}[command];
|
||||
if (!handler) {
|
||||
throw new Error(`unknown fixture command: ${command}`);
|
||||
}
|
||||
|
||||
handler(args);
|
||||
25
scripts/e2e/lib/fixtures/common.mjs
Normal file
25
scripts/e2e/lib/fixtures/common.mjs
Normal file
@@ -0,0 +1,25 @@
|
||||
// Common file/assertion helpers for E2E fixture writers.
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
export const json = (value) => `${JSON.stringify(value, null, 2)}\n`;
|
||||
export const readJson = (file) => JSON.parse(fs.readFileSync(file, "utf8"));
|
||||
|
||||
export const write = (file, contents) => {
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
fs.writeFileSync(file, contents);
|
||||
};
|
||||
export const writeJson = (file, value) => write(file, json(value));
|
||||
|
||||
export const requireArg = (value, name) => {
|
||||
if (!value) {
|
||||
throw new Error(`${name} is required`);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
export const assert = (condition, message) => {
|
||||
if (!condition) {
|
||||
throw new Error(message);
|
||||
}
|
||||
};
|
||||
122
scripts/e2e/lib/fixtures/config.mjs
Normal file
122
scripts/e2e/lib/fixtures/config.mjs
Normal file
@@ -0,0 +1,122 @@
|
||||
// Config fixture writer commands for E2E scenarios.
|
||||
import path from "node:path";
|
||||
import { readPositiveIntEnv, readTcpPortEnv } from "../env-limits.mjs";
|
||||
import { requireArg, writeJson } from "./common.mjs";
|
||||
|
||||
function writeConfig(kind) {
|
||||
const configPath = requireArg(process.env.OPENCLAW_CONFIG_PATH, "OPENCLAW_CONFIG_PATH");
|
||||
const port = readTcpPortEnv("PORT", 18789);
|
||||
const config =
|
||||
kind === "config-reload"
|
||||
? {
|
||||
gateway: {
|
||||
port,
|
||||
auth: {
|
||||
mode: "token",
|
||||
token: { source: "env", provider: "default", id: "GATEWAY_AUTH_TOKEN_REF" },
|
||||
},
|
||||
channelHealthCheckMinutes: 1,
|
||||
controlUi: { enabled: false },
|
||||
reload: { mode: "hybrid", debounceMs: 0 },
|
||||
},
|
||||
}
|
||||
: kind === "browser-cdp"
|
||||
? {
|
||||
gateway: {
|
||||
port,
|
||||
auth: {
|
||||
mode: "token",
|
||||
token: requireArg(process.env.OPENCLAW_GATEWAY_TOKEN, "OPENCLAW_GATEWAY_TOKEN"),
|
||||
},
|
||||
controlUi: { enabled: false },
|
||||
},
|
||||
browser: {
|
||||
enabled: true,
|
||||
defaultProfile: "docker-cdp",
|
||||
ssrfPolicy: { allowedHostnames: ["127.0.0.1"] },
|
||||
profiles: {
|
||||
"docker-cdp": {
|
||||
cdpUrl: `http://127.0.0.1:${readTcpPortEnv("CDP_PORT", 19222)}`,
|
||||
color: "#FF4500",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
: null;
|
||||
writeJson(configPath, requireArg(config, "known config kind"));
|
||||
}
|
||||
|
||||
function writeOpenAiWebSearchMinimalConfig() {
|
||||
writeJson(path.join(process.env.OPENCLAW_STATE_DIR, "openclaw.json"), {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "openai/gpt-5" },
|
||||
models: {
|
||||
"openai/gpt-5": {
|
||||
params: { transport: "sse", openaiWsWarmup: false },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
models: {
|
||||
providers: {
|
||||
openai: {
|
||||
api: "openai-responses",
|
||||
baseUrl: "http://api.openai.com/v1",
|
||||
apiKey: { source: "env", provider: "default", id: "OPENAI_API_KEY" },
|
||||
request: { allowPrivateNetwork: true },
|
||||
models: [
|
||||
{
|
||||
id: "gpt-5",
|
||||
name: "gpt-5",
|
||||
api: "openai-responses",
|
||||
reasoning: true,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 128000,
|
||||
contextTokens: 96000,
|
||||
maxTokens: 4096,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
tools: { web: { search: { enabled: true, maxResults: 3 } } },
|
||||
plugins: { enabled: true, allow: ["openai"], entries: { openai: { enabled: true } } },
|
||||
gateway: { auth: { mode: "token", token: process.env.OPENCLAW_GATEWAY_TOKEN } },
|
||||
});
|
||||
}
|
||||
|
||||
function writeOpenWebUiConfig([openaiApiKey]) {
|
||||
const batchPath = requireArg(
|
||||
process.env.OPENCLAW_CONFIG_BATCH_PATH,
|
||||
"OPENCLAW_CONFIG_BATCH_PATH",
|
||||
);
|
||||
writeJson(batchPath, [
|
||||
{ path: "models.providers.openai.apiKey", value: requireArg(openaiApiKey, "OpenAI API key") },
|
||||
{
|
||||
path: "models.providers.openai.baseUrl",
|
||||
value: (process.env.OPENAI_BASE_URL || "https://api.openai.com/v1").trim(),
|
||||
},
|
||||
{ path: "models.providers.openai.models", value: [] },
|
||||
{
|
||||
path: "models.providers.openai.timeoutSeconds",
|
||||
value: readPositiveIntEnv("OPENCLAW_OPENWEBUI_PROVIDER_TIMEOUT_SECONDS", 900),
|
||||
},
|
||||
{ path: "models.providers.openai.agentRuntime", value: { id: "openclaw" } },
|
||||
{ path: "gateway.controlUi.enabled", value: false },
|
||||
{ path: "gateway.mode", value: "local" },
|
||||
{ path: "gateway.bind", value: "lan" },
|
||||
{ path: "gateway.auth.mode", value: "token" },
|
||||
{ path: "gateway.auth.token", value: process.env.OPENCLAW_GATEWAY_TOKEN },
|
||||
{ path: "gateway.http.endpoints.chatCompletions.enabled", value: true },
|
||||
{ path: "agents.defaults.model.primary", value: process.env.OPENCLAW_OPENWEBUI_MODEL },
|
||||
]);
|
||||
}
|
||||
|
||||
export const configCommands = {
|
||||
"config-reload": () => writeConfig("config-reload"),
|
||||
"browser-cdp": () => writeConfig("browser-cdp"),
|
||||
"openai-web-search-minimal-config": writeOpenAiWebSearchMinimalConfig,
|
||||
"openwebui-config": writeOpenWebUiConfig,
|
||||
};
|
||||
100
scripts/e2e/lib/fixtures/mock-openai-config.mjs
Normal file
100
scripts/e2e/lib/fixtures/mock-openai-config.mjs
Normal file
@@ -0,0 +1,100 @@
|
||||
// Mock OpenAI model config helpers for E2E fixture generation.
|
||||
function formatMockPortValue(value) {
|
||||
return value === undefined ? "<missing>" : JSON.stringify(String(value));
|
||||
}
|
||||
|
||||
export function parseMockOpenAiPort(value, label = "mock OpenAI port") {
|
||||
const text = String(value ?? "").trim();
|
||||
if (!/^[1-9]\d*$/u.test(text)) {
|
||||
throw new Error(
|
||||
`${label} must be a TCP port from 1 to 65535. Got: ${formatMockPortValue(value)}`,
|
||||
);
|
||||
}
|
||||
const port = Number(text);
|
||||
if (!Number.isSafeInteger(port) || port > 65535) {
|
||||
throw new Error(
|
||||
`${label} must be a TCP port from 1 to 65535. Got: ${formatMockPortValue(value)}`,
|
||||
);
|
||||
}
|
||||
return port;
|
||||
}
|
||||
|
||||
export function applyMockOpenAiModelConfig(cfg, params) {
|
||||
const mockPort = parseMockOpenAiPort(params.mockPort);
|
||||
const modelRef = params.modelRef ?? "openai/gpt-5.5";
|
||||
const modelId = modelRef.split("/").at(-1) ?? "gpt-5.5";
|
||||
const cost = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
|
||||
cfg.models = {
|
||||
...cfg.models,
|
||||
mode: "merge",
|
||||
providers: {
|
||||
...cfg.models?.providers,
|
||||
openai: {
|
||||
...cfg.models?.providers?.openai,
|
||||
baseUrl: `http://127.0.0.1:${mockPort}/v1`,
|
||||
apiKey: { source: "env", provider: "default", id: "OPENAI_API_KEY" },
|
||||
api: "openai-responses",
|
||||
agentRuntime: { id: "openclaw" },
|
||||
request: { ...cfg.models?.providers?.openai?.request, allowPrivateNetwork: true },
|
||||
models: [
|
||||
{
|
||||
id: modelId,
|
||||
name: modelId,
|
||||
api: "openai-responses",
|
||||
agentRuntime: { id: "openclaw" },
|
||||
reasoning: false,
|
||||
input: ["text", "image"],
|
||||
cost,
|
||||
contextWindow: 128000,
|
||||
contextTokens: 96000,
|
||||
maxTokens: 4096,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
cfg.agents = {
|
||||
...cfg.agents,
|
||||
defaults: {
|
||||
...cfg.agents?.defaults,
|
||||
model: { primary: modelRef },
|
||||
...(params.includeImageDefaults
|
||||
? {
|
||||
imageModel: { primary: modelRef, timeoutMs: 30_000 },
|
||||
imageGenerationModel: { primary: "openai/gpt-image-1", timeoutMs: 30_000 },
|
||||
}
|
||||
: {}),
|
||||
models: {
|
||||
...cfg.agents?.defaults?.models,
|
||||
[modelRef]: {
|
||||
agentRuntime: { id: "openclaw" },
|
||||
params: { transport: "sse", openaiWsWarmup: false },
|
||||
},
|
||||
},
|
||||
},
|
||||
...(Array.isArray(cfg.agents?.list)
|
||||
? {
|
||||
list: cfg.agents.list.map((agent) => ({
|
||||
...agent,
|
||||
model: { ...agent.model, primary: modelRef },
|
||||
models: {
|
||||
...agent.models,
|
||||
[modelRef]: {
|
||||
...agent.models?.[modelRef],
|
||||
agentRuntime: { id: "openclaw" },
|
||||
params: {
|
||||
...agent.models?.[modelRef]?.params,
|
||||
transport: "sse",
|
||||
openaiWsWarmup: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
})),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
cfg.plugins = {
|
||||
...cfg.plugins,
|
||||
enabled: true,
|
||||
};
|
||||
}
|
||||
173
scripts/e2e/lib/fixtures/plugins.mjs
Normal file
173
scripts/e2e/lib/fixtures/plugins.mjs
Normal file
@@ -0,0 +1,173 @@
|
||||
// Plugin fixture writer commands for E2E scenarios.
|
||||
import path from "node:path";
|
||||
import { requireArg, write, writeJson } from "./common.mjs";
|
||||
|
||||
function writePluginManifest(file, id, extra = {}) {
|
||||
writeJson(file, { id, ...extra, configSchema: { type: "object", properties: {} } });
|
||||
}
|
||||
|
||||
function writeFakeIsNumberPackage(dir) {
|
||||
writeJson(path.join(dir, "package.json"), {
|
||||
name: "is-number",
|
||||
version: "7.0.0",
|
||||
main: "index.js",
|
||||
});
|
||||
write(path.join(dir, "index.js"), "module.exports = (value) => typeof value === 'number';\n");
|
||||
}
|
||||
|
||||
function writePluginDemo([dir]) {
|
||||
write(
|
||||
path.join(requireArg(dir, "dir"), "index.js"),
|
||||
'module.exports = { id: "demo-plugin", name: "Demo Plugin", description: "Docker E2E demo plugin", register(api) { api.registerTool(() => null, { name: "demo_tool" }); api.registerGatewayMethod("demo.ping", async () => ({ ok: true })); api.registerCli(() => {}, { commands: ["demo"] }); api.registerService({ id: "demo-service", start: () => {} }); }, };\n',
|
||||
);
|
||||
writePluginManifest(path.join(dir, "openclaw.plugin.json"), "demo-plugin", {
|
||||
contracts: { tools: ["demo_tool"] },
|
||||
});
|
||||
}
|
||||
|
||||
function writePlugin([dir, id, version, method, name]) {
|
||||
for (const [value, label] of [
|
||||
[dir, "dir"],
|
||||
[id, "id"],
|
||||
[version, "version"],
|
||||
[method, "method"],
|
||||
[name, "name"],
|
||||
]) {
|
||||
requireArg(value, label);
|
||||
}
|
||||
writeJson(path.join(dir, "package.json"), {
|
||||
name: `@openclaw/${id}`,
|
||||
version,
|
||||
openclaw: { extensions: ["./index.js"] },
|
||||
});
|
||||
write(
|
||||
path.join(dir, "index.js"),
|
||||
`module.exports = { id: ${JSON.stringify(id)}, name: ${JSON.stringify(name)}, register(api) { api.registerGatewayMethod(${JSON.stringify(method)}, async () => ({ ok: true })); }, };\n`,
|
||||
);
|
||||
writePluginManifest(path.join(dir, "openclaw.plugin.json"), id);
|
||||
}
|
||||
|
||||
function writePluginWithVendoredDependency([dir, id, version, method, name]) {
|
||||
writePlugin([dir, id, version, method, name]);
|
||||
const packageJsonPath = path.join(dir, "package.json");
|
||||
writeJson(packageJsonPath, {
|
||||
name: `@openclaw/${id}`,
|
||||
version,
|
||||
dependencies: { "is-number": "7.0.0" },
|
||||
openclaw: { extensions: ["./index.js"] },
|
||||
});
|
||||
write(
|
||||
path.join(dir, "index.js"),
|
||||
`const isNumber = require("is-number");\nmodule.exports = { id: ${JSON.stringify(id)}, name: ${JSON.stringify(name)}, register(api) { api.registerGatewayMethod(${JSON.stringify(method)}, async () => ({ ok: isNumber(42) })); }, };\n`,
|
||||
);
|
||||
writeFakeIsNumberPackage(path.join(dir, "node_modules", "is-number"));
|
||||
}
|
||||
|
||||
function writePluginWithCli([dir, id, version, method, name, cliRoot, cliOutput]) {
|
||||
for (const [value, label] of [
|
||||
[dir, "dir"],
|
||||
[id, "id"],
|
||||
[version, "version"],
|
||||
[method, "method"],
|
||||
[name, "name"],
|
||||
[cliRoot, "cliRoot"],
|
||||
[cliOutput, "cliOutput"],
|
||||
]) {
|
||||
requireArg(value, label);
|
||||
}
|
||||
writeJson(path.join(dir, "package.json"), {
|
||||
name: `@openclaw/${id}`,
|
||||
version,
|
||||
dependencies: { "is-number": "file:./deps/is-number" },
|
||||
openclaw: { extensions: ["./index.js"] },
|
||||
});
|
||||
writeFakeIsNumberPackage(path.join(dir, "deps", "is-number"));
|
||||
write(
|
||||
path.join(dir, "index.js"),
|
||||
`const isNumber = require("is-number");\nmodule.exports = { id: ${JSON.stringify(id)}, name: ${JSON.stringify(name)}, register(api) { api.registerGatewayMethod(${JSON.stringify(method)}, async () => ({ ok: isNumber(42) })); api.registerCli(({ program }) => { const root = program.command(${JSON.stringify(cliRoot)}).description(${JSON.stringify(`${name} fixture command`)}); root.command("ping").description("Print fixture ping output").action(() => { console.log(${JSON.stringify(cliOutput)}); }); }, { descriptors: [{ name: ${JSON.stringify(cliRoot)}, description: ${JSON.stringify(`${name} fixture command`)}, hasSubcommands: true }] }); }, };\n`,
|
||||
);
|
||||
writePluginManifest(path.join(dir, "openclaw.plugin.json"), id);
|
||||
}
|
||||
|
||||
function writePluginWithCliRegistryDependency([
|
||||
dir,
|
||||
id,
|
||||
version,
|
||||
method,
|
||||
name,
|
||||
cliRoot,
|
||||
cliOutput,
|
||||
]) {
|
||||
for (const [value, label] of [
|
||||
[dir, "dir"],
|
||||
[id, "id"],
|
||||
[version, "version"],
|
||||
[method, "method"],
|
||||
[name, "name"],
|
||||
[cliRoot, "cliRoot"],
|
||||
[cliOutput, "cliOutput"],
|
||||
]) {
|
||||
requireArg(value, label);
|
||||
}
|
||||
writeJson(path.join(dir, "package.json"), {
|
||||
name: `@openclaw/${id}`,
|
||||
version,
|
||||
dependencies: { "is-number": "7.0.0" },
|
||||
openclaw: { extensions: ["./index.js"] },
|
||||
});
|
||||
write(
|
||||
path.join(dir, "index.js"),
|
||||
`const isNumber = require("is-number");\nmodule.exports = { id: ${JSON.stringify(id)}, name: ${JSON.stringify(name)}, register(api) { api.registerGatewayMethod(${JSON.stringify(method)}, async () => ({ ok: isNumber(42) })); api.registerCli(({ program }) => { const root = program.command(${JSON.stringify(cliRoot)}).description(${JSON.stringify(`${name} fixture command`)}); root.command("ping").description("Print fixture ping output").action(() => { console.log(${JSON.stringify(cliOutput)}); }); }, { descriptors: [{ name: ${JSON.stringify(cliRoot)}, description: ${JSON.stringify(`${name} fixture command`)}, hasSubcommands: true }] }); }, };\n`,
|
||||
);
|
||||
writePluginManifest(path.join(dir, "openclaw.plugin.json"), id);
|
||||
}
|
||||
|
||||
function writeClaudeBundle(args) {
|
||||
const root = requireArg(args[0], "root");
|
||||
writeJson(path.join(root, ".claude-plugin", "plugin.json"), { name: "claude-bundle-e2e" });
|
||||
write(
|
||||
path.join(root, "commands", "office-hours.md"),
|
||||
"---\ndescription: Help with architecture and rollout planning\n---\nAct as an engineering advisor.\n\nFocus on:\n$ARGUMENTS\n",
|
||||
);
|
||||
}
|
||||
|
||||
function writePluginMarketplace(args) {
|
||||
const root = requireArg(args[0], "root");
|
||||
writeJson(path.join(root, ".claude-plugin", "marketplace.json"), {
|
||||
name: "Fixture Marketplace",
|
||||
version: "1.0.0",
|
||||
plugins: [
|
||||
{
|
||||
name: "marketplace-shortcut",
|
||||
version: "0.0.1",
|
||||
description: "Shortcut install fixture",
|
||||
source: "./plugins/marketplace-shortcut",
|
||||
},
|
||||
{
|
||||
name: "marketplace-direct",
|
||||
version: "0.0.1",
|
||||
description: "Explicit marketplace fixture",
|
||||
source: { type: "path", path: "./plugins/marketplace-direct" },
|
||||
},
|
||||
],
|
||||
});
|
||||
writeJson(path.join(process.env.HOME, ".claude", "plugins", "known_marketplaces.json"), {
|
||||
"claude-fixtures": {
|
||||
installLocation: root,
|
||||
source: { type: "github", repo: "openclaw/fixture-marketplace" },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export const pluginCommands = {
|
||||
"plugin-demo": writePluginDemo,
|
||||
plugin: writePlugin,
|
||||
"plugin-vendored-dep": writePluginWithVendoredDependency,
|
||||
"plugin-cli": writePluginWithCli,
|
||||
"plugin-cli-registry-dep": writePluginWithCliRegistryDependency,
|
||||
"fake-is-number-package": ([dir]) => writeFakeIsNumberPackage(requireArg(dir, "dir")),
|
||||
"plugin-manifest": ([file, id]) =>
|
||||
writePluginManifest(requireArg(file, "file"), requireArg(id, "id")),
|
||||
"claude-bundle": writeClaudeBundle,
|
||||
marketplace: writePluginMarketplace,
|
||||
};
|
||||
103
scripts/e2e/lib/fixtures/workspace.mjs
Normal file
103
scripts/e2e/lib/fixtures/workspace.mjs
Normal file
@@ -0,0 +1,103 @@
|
||||
// Workspace fixture writer commands for E2E scenarios.
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { readTextFileTail } from "../text-file-utils.mjs";
|
||||
import { assert, readJson, requireArg, write, writeJson } from "./common.mjs";
|
||||
|
||||
const AGENTS_DELETE_OUTPUT_MAX_BYTES = readPositiveIntEnv(
|
||||
"OPENCLAW_FIXTURE_AGENTS_DELETE_OUTPUT_MAX_BYTES",
|
||||
1024 * 1024,
|
||||
);
|
||||
const ERROR_DETAIL_TAIL_BYTES = 16 * 1024;
|
||||
|
||||
function readPositiveIntEnv(name, fallback) {
|
||||
const text = String(process.env[name] ?? fallback).trim();
|
||||
if (!/^\d+$/u.test(text)) {
|
||||
throw new Error(`invalid ${name}: ${text}`);
|
||||
}
|
||||
const value = Number(text);
|
||||
if (!Number.isSafeInteger(value) || value <= 0) {
|
||||
throw new Error(`invalid ${name}: ${text}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function writeOpenWebUiWorkspace() {
|
||||
const workspace =
|
||||
process.env.OPENCLAW_WORKSPACE_DIR || path.join(process.env.HOME, ".openclaw", "workspace");
|
||||
write(
|
||||
path.join(workspace, "IDENTITY.md"),
|
||||
"# Identity\n\n- Name: OpenClaw\n- Purpose: Open WebUI Docker compatibility smoke test assistant.\n",
|
||||
);
|
||||
writeJson(path.join(workspace, ".openclaw", "workspace-state.json"), {
|
||||
version: 1,
|
||||
setupCompletedAt: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
fs.rmSync(path.join(workspace, "BOOTSTRAP.md"), { force: true });
|
||||
}
|
||||
|
||||
function writeAgentsDeleteConfig() {
|
||||
const stateDir = requireArg(process.env.OPENCLAW_STATE_DIR, "OPENCLAW_STATE_DIR");
|
||||
const sharedWorkspace = requireArg(process.env.SHARED_WORKSPACE, "SHARED_WORKSPACE");
|
||||
const gatewayToken = process.env.OPENCLAW_GATEWAY_TOKEN?.trim();
|
||||
fs.mkdirSync(sharedWorkspace, { recursive: true });
|
||||
writeJson(path.join(stateDir, "openclaw.json"), {
|
||||
agents: {
|
||||
list: [
|
||||
{ id: "main", workspace: sharedWorkspace },
|
||||
{ id: "ops", workspace: sharedWorkspace },
|
||||
],
|
||||
},
|
||||
...(gatewayToken ? { gateway: { auth: { mode: "token", token: gatewayToken } } } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
function assertAgentsDeleteResult([outputPath]) {
|
||||
const resolvedOutputPath = requireArg(outputPath, "outputPath");
|
||||
const outputStat = fs.statSync(resolvedOutputPath);
|
||||
if (outputStat.isFile() && outputStat.size > AGENTS_DELETE_OUTPUT_MAX_BYTES) {
|
||||
throw new Error(
|
||||
`agents delete --json output exceeded ${AGENTS_DELETE_OUTPUT_MAX_BYTES} bytes:\nstdout tail=${readTextFileTail(
|
||||
resolvedOutputPath,
|
||||
ERROR_DETAIL_TAIL_BYTES,
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
let parsed;
|
||||
try {
|
||||
parsed = readJson(resolvedOutputPath);
|
||||
} catch (error) {
|
||||
console.error("agents delete --json did not emit valid JSON:");
|
||||
console.error(readTextFileTail(resolvedOutputPath, ERROR_DETAIL_TAIL_BYTES).trim());
|
||||
const message = error instanceof Error ? error.message.split("\n").at(0) : String(error);
|
||||
throw new Error(`agents delete --json parse failed: ${message}`, { cause: error });
|
||||
}
|
||||
for (const [actual, expected, label] of [
|
||||
[parsed.agentId, "ops", "agentId"],
|
||||
[parsed.workspace, process.env.SHARED_WORKSPACE, "workspace"],
|
||||
[parsed.workspaceRetained, true, "workspaceRetained"],
|
||||
[parsed.workspaceRetainedReason, "shared", "workspaceRetainedReason"],
|
||||
]) {
|
||||
assert(actual === expected, `${label} mismatch: ${JSON.stringify(actual)}`);
|
||||
}
|
||||
assert(
|
||||
Array.isArray(parsed.workspaceSharedWith) && parsed.workspaceSharedWith.includes("main"),
|
||||
"missing shared-with main marker",
|
||||
);
|
||||
assert(fs.existsSync(process.env.SHARED_WORKSPACE), "shared workspace was removed");
|
||||
const remaining =
|
||||
readJson(path.join(process.env.OPENCLAW_STATE_DIR, "openclaw.json"))?.agents?.list ?? [];
|
||||
assert(Array.isArray(remaining), "agents list missing after delete");
|
||||
assert(!remaining.some((entry) => entry?.id === "ops"), "deleted agent remained in config");
|
||||
assert(
|
||||
remaining.some((entry) => entry?.id === "main"),
|
||||
"main agent missing after delete",
|
||||
);
|
||||
console.log("agents delete shared workspace smoke ok");
|
||||
}
|
||||
|
||||
export const workspaceCommands = {
|
||||
"openwebui-workspace": writeOpenWebUiWorkspace,
|
||||
"agents-delete-config": writeAgentsDeleteConfig,
|
||||
"agents-delete-assert": assertAgentsDeleteResult,
|
||||
};
|
||||
17
scripts/e2e/lib/gateway-frame-payload.mjs
Normal file
17
scripts/e2e/lib/gateway-frame-payload.mjs
Normal file
@@ -0,0 +1,17 @@
|
||||
// Gateway frame payload helpers for E2E WebSocket assertions.
|
||||
function hasOwnEnvelopeField(frame, field) {
|
||||
return (
|
||||
((typeof frame === "object" && frame !== null) || typeof frame === "function") &&
|
||||
Object.hasOwn(frame, field)
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveGatewaySuccessPayload(frame) {
|
||||
if (hasOwnEnvelopeField(frame, "payload")) {
|
||||
return frame.payload;
|
||||
}
|
||||
if (hasOwnEnvelopeField(frame, "result")) {
|
||||
return frame.result;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
152
scripts/e2e/lib/gateway-network/client.mjs
Normal file
152
scripts/e2e/lib/gateway-network/client.mjs
Normal file
@@ -0,0 +1,152 @@
|
||||
// WebSocket client helpers for gateway network E2E scenarios.
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { WebSocket } from "ws";
|
||||
import { sleep as delay } from "../../../lib/sleep.mjs";
|
||||
import { waitForWebSocketOpen } from "../websocket-open.mjs";
|
||||
import { readGatewayNetworkClientConnectTimeoutMs } from "./limits.mjs";
|
||||
import { onceFrame } from "./ws-frames.mjs";
|
||||
|
||||
function remainingDeadlineMs(deadline) {
|
||||
return Math.max(1, deadline - Date.now());
|
||||
}
|
||||
|
||||
async function openSocket(url, timeoutMs = 10_000) {
|
||||
const ws = new WebSocket(url);
|
||||
await waitForWebSocketOpen(ws, timeoutMs, "ws open timeout");
|
||||
return ws;
|
||||
}
|
||||
|
||||
function isRecord(value) {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export function hasGatewayHealthSummaryPayload(response) {
|
||||
if (!isRecord(response) || !isRecord(response.payload)) {
|
||||
return false;
|
||||
}
|
||||
const { payload } = response;
|
||||
return (
|
||||
payload.ok === true &&
|
||||
typeof payload.ts === "number" &&
|
||||
typeof payload.durationMs === "number" &&
|
||||
typeof payload.defaultAgentId === "string" &&
|
||||
payload.defaultAgentId.trim() !== "" &&
|
||||
Array.isArray(payload.agents) &&
|
||||
isRecord(payload.channels) &&
|
||||
Array.isArray(payload.channelOrder) &&
|
||||
isRecord(payload.sessions)
|
||||
);
|
||||
}
|
||||
|
||||
export function responseError(method, response) {
|
||||
const message = response.error?.message ?? "unknown";
|
||||
return new Error(`${method} failed: ${message}`);
|
||||
}
|
||||
|
||||
export function isRetryableStartupError(message) {
|
||||
return (
|
||||
message.includes("gateway starting") ||
|
||||
message.includes("closed before frame") ||
|
||||
message.includes("closed before open") ||
|
||||
message.includes("ws open timeout") ||
|
||||
message.includes("ECONNREFUSED") ||
|
||||
message.includes("ECONNRESET") ||
|
||||
message.includes("timeout")
|
||||
);
|
||||
}
|
||||
|
||||
async function readProtocolVersion() {
|
||||
const protocol = await import("../../../../dist/gateway/protocol/index.js");
|
||||
return protocol.PROTOCOL_VERSION;
|
||||
}
|
||||
|
||||
export async function runGatewayNetworkClient(
|
||||
{ token, url, timeoutMs = readGatewayNetworkClientConnectTimeoutMs() },
|
||||
deps = {},
|
||||
) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
const delayImpl = deps.delay ?? delay;
|
||||
const onceFrameImpl = deps.onceFrame ?? onceFrame;
|
||||
const openSocketImpl = deps.openSocket ?? openSocket;
|
||||
const protocolVersion = deps.protocolVersion ?? (await readProtocolVersion());
|
||||
const stdout = deps.stdout ?? console.log;
|
||||
|
||||
let lastError;
|
||||
while (Date.now() < deadline) {
|
||||
let ws;
|
||||
try {
|
||||
ws = await openSocketImpl(url, remainingDeadlineMs(deadline));
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "req",
|
||||
id: "c1",
|
||||
method: "connect",
|
||||
params: {
|
||||
minProtocol: protocolVersion,
|
||||
maxProtocol: protocolVersion,
|
||||
client: {
|
||||
id: "test",
|
||||
displayName: "docker-net-e2e",
|
||||
version: "dev",
|
||||
platform: process.platform,
|
||||
mode: "test",
|
||||
},
|
||||
caps: [],
|
||||
auth: { token },
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const connectRes = await onceFrameImpl(
|
||||
ws,
|
||||
(frame) => frame?.type === "res" && frame?.id === "c1",
|
||||
remainingDeadlineMs(deadline),
|
||||
);
|
||||
if (!connectRes.ok) {
|
||||
lastError = responseError("connect", connectRes);
|
||||
if (!isRetryableStartupError(lastError.message)) {
|
||||
throw lastError;
|
||||
}
|
||||
} else {
|
||||
ws.send(JSON.stringify({ type: "req", id: "h1", method: "health" }));
|
||||
const healthRes = await onceFrameImpl(
|
||||
ws,
|
||||
(frame) => frame?.type === "res" && frame?.id === "h1",
|
||||
remainingDeadlineMs(deadline),
|
||||
);
|
||||
if (healthRes.ok) {
|
||||
if (!hasGatewayHealthSummaryPayload(healthRes)) {
|
||||
throw new Error("health failed: missing health summary payload");
|
||||
}
|
||||
stdout("ok");
|
||||
return;
|
||||
}
|
||||
|
||||
throw responseError("health", healthRes);
|
||||
}
|
||||
} catch (error) {
|
||||
lastError = error instanceof Error ? error : new Error(String(error));
|
||||
if (!isRetryableStartupError(lastError.message)) {
|
||||
throw lastError;
|
||||
}
|
||||
} finally {
|
||||
ws?.close();
|
||||
}
|
||||
|
||||
const retryDelayMs = Math.min(500, deadline - Date.now());
|
||||
if (retryDelayMs > 0) {
|
||||
await delayImpl(retryDelayMs);
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError ?? new Error("connect failed: timeout");
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
const url = process.env.GW_URL;
|
||||
const token = process.env.GW_TOKEN;
|
||||
if (!url || !token) {
|
||||
throw new Error("missing GW_URL/GW_TOKEN");
|
||||
}
|
||||
await runGatewayNetworkClient({ token, url });
|
||||
}
|
||||
19
scripts/e2e/lib/gateway-network/limits.mjs
Normal file
19
scripts/e2e/lib/gateway-network/limits.mjs
Normal file
@@ -0,0 +1,19 @@
|
||||
// Limits shared by gateway network E2E fixtures.
|
||||
function readPositiveIntEnv(name, fallback, env) {
|
||||
const text = String(env[name] ?? fallback).trim();
|
||||
if (!/^\d+$/u.test(text)) {
|
||||
throw new Error(`invalid ${name}: ${text}`);
|
||||
}
|
||||
const value = Number(text);
|
||||
if (!Number.isSafeInteger(value) || value <= 0) {
|
||||
throw new Error(`invalid ${name}: ${text}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function readGatewayNetworkClientConnectTimeoutMs(env = process.env) {
|
||||
if (env.OPENCLAW_GATEWAY_NETWORK_CLIENT_CONNECT_TIMEOUT_MS != null) {
|
||||
return readPositiveIntEnv("OPENCLAW_GATEWAY_NETWORK_CLIENT_CONNECT_TIMEOUT_MS", 80000, env);
|
||||
}
|
||||
return readPositiveIntEnv("OPENCLAW_GATEWAY_NETWORK_CONNECT_READY_TIMEOUT_MS", 80000, env);
|
||||
}
|
||||
67
scripts/e2e/lib/gateway-network/ws-frames.mjs
Normal file
67
scripts/e2e/lib/gateway-network/ws-frames.mjs
Normal file
@@ -0,0 +1,67 @@
|
||||
// WebSocket frame helpers for gateway network E2E fixtures.
|
||||
function formatCloseValue(value) {
|
||||
if (value === undefined || value === null) {
|
||||
return "";
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") {
|
||||
return value.toString();
|
||||
}
|
||||
if (value instanceof Uint8Array) {
|
||||
return Buffer.from(value).toString();
|
||||
}
|
||||
return JSON.stringify(value) ?? "";
|
||||
}
|
||||
|
||||
export function onceFrame(ws, filter, timeoutMs = 10_000) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
|
||||
const cleanup = () => {
|
||||
clearTimeout(timer);
|
||||
ws.off?.("message", onMessage);
|
||||
ws.off?.("error", onError);
|
||||
ws.off?.("close", onClose);
|
||||
};
|
||||
const settle = (fn, value) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
cleanup();
|
||||
fn(value);
|
||||
};
|
||||
const onMessage = (data) => {
|
||||
let obj;
|
||||
try {
|
||||
obj = JSON.parse(String(data));
|
||||
if (!filter(obj)) {
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
settle(reject, error instanceof Error ? error : new Error(String(error)));
|
||||
return;
|
||||
}
|
||||
settle(resolve, obj);
|
||||
};
|
||||
const onError = (error) =>
|
||||
settle(reject, error instanceof Error ? error : new Error(String(error)));
|
||||
const onClose = (code, reason) => {
|
||||
const closeDetails = [formatCloseValue(code), formatCloseValue(reason)]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
const suffix = closeDetails ? `: ${closeDetails}` : "";
|
||||
settle(reject, new Error(`closed before frame${suffix}`));
|
||||
};
|
||||
const timer = setTimeout(() => {
|
||||
settle(reject, new Error("timeout"));
|
||||
}, timeoutMs);
|
||||
timer.unref?.();
|
||||
|
||||
ws.on("message", onMessage);
|
||||
ws.once("error", onError);
|
||||
ws.once("close", onClose);
|
||||
});
|
||||
}
|
||||
142
scripts/e2e/lib/incremental-line-reader.mjs
Normal file
142
scripts/e2e/lib/incremental-line-reader.mjs
Normal file
@@ -0,0 +1,142 @@
|
||||
// Incremental line reader for streaming E2E logs.
|
||||
import { createHash } from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
|
||||
function readSlice(filePath, start, length) {
|
||||
if (length <= 0) {
|
||||
return "";
|
||||
}
|
||||
const fd = fs.openSync(filePath, "r");
|
||||
try {
|
||||
const buffer = Buffer.alloc(length);
|
||||
const bytesRead = fs.readSync(fd, buffer, 0, length, start);
|
||||
return buffer.subarray(0, bytesRead).toString("utf8");
|
||||
} finally {
|
||||
fs.closeSync(fd);
|
||||
}
|
||||
}
|
||||
|
||||
function readBufferSlice(filePath, start, length) {
|
||||
if (length <= 0) {
|
||||
return Buffer.alloc(0);
|
||||
}
|
||||
const fd = fs.openSync(filePath, "r");
|
||||
try {
|
||||
const buffer = Buffer.alloc(length);
|
||||
const bytesRead = fs.readSync(fd, buffer, 0, length, start);
|
||||
return buffer.subarray(0, bytesRead);
|
||||
} finally {
|
||||
fs.closeSync(fd);
|
||||
}
|
||||
}
|
||||
|
||||
function resolveFileIdentity(stats) {
|
||||
if (Number.isSafeInteger(stats.dev) && Number.isSafeInteger(stats.ino) && stats.ino !== 0) {
|
||||
return `${stats.dev}:${stats.ino}`;
|
||||
}
|
||||
return Number.isFinite(stats.birthtimeMs) ? `birth:${stats.birthtimeMs}` : undefined;
|
||||
}
|
||||
|
||||
function readTailFingerprint(filePath, stats, maxReadBytes) {
|
||||
const length = Math.min(stats.size, maxReadBytes);
|
||||
const start = Math.max(0, stats.size - length);
|
||||
const buffer = readBufferSlice(filePath, start, length);
|
||||
const hash = createHash("sha256").update(buffer).digest("base64url");
|
||||
return `${start}:${buffer.byteLength}:${hash}`;
|
||||
}
|
||||
|
||||
export function resolvePositiveInteger(value, fallback) {
|
||||
return Number.isSafeInteger(value) && value > 0 ? value : fallback;
|
||||
}
|
||||
|
||||
export function createIncrementalLineReader(filePath, options = {}) {
|
||||
const maxReadBytes = resolvePositiveInteger(options.maxReadBytes, 256 * 1024);
|
||||
let fileIdentity;
|
||||
let contentFingerprint;
|
||||
let offset = 0;
|
||||
let pending = "";
|
||||
|
||||
return {
|
||||
readLines() {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return { lines: [], reset: false };
|
||||
}
|
||||
|
||||
const stats = fs.statSync(filePath);
|
||||
if (!stats.isFile()) {
|
||||
return { lines: [], reset: false };
|
||||
}
|
||||
|
||||
let reset = false;
|
||||
const nextFileIdentity = resolveFileIdentity(stats);
|
||||
if (
|
||||
fileIdentity !== undefined &&
|
||||
nextFileIdentity !== undefined &&
|
||||
fileIdentity !== nextFileIdentity
|
||||
) {
|
||||
offset = 0;
|
||||
pending = "";
|
||||
reset = true;
|
||||
}
|
||||
fileIdentity = nextFileIdentity;
|
||||
|
||||
if (!reset && stats.size === offset && contentFingerprint !== undefined) {
|
||||
const nextContentFingerprint = readTailFingerprint(filePath, stats, maxReadBytes);
|
||||
if (contentFingerprint !== nextContentFingerprint) {
|
||||
offset = 0;
|
||||
pending = "";
|
||||
reset = true;
|
||||
} else {
|
||||
contentFingerprint = nextContentFingerprint;
|
||||
return { lines: [], reset: false };
|
||||
}
|
||||
}
|
||||
|
||||
if (stats.size < offset) {
|
||||
offset = 0;
|
||||
pending = "";
|
||||
reset = true;
|
||||
}
|
||||
if (stats.size === offset) {
|
||||
return { lines: [], reset };
|
||||
}
|
||||
|
||||
let start = offset;
|
||||
let discardFirstLine = false;
|
||||
let clamped = false;
|
||||
if (start === 0 && stats.size > maxReadBytes) {
|
||||
start = stats.size - maxReadBytes;
|
||||
pending = "";
|
||||
clamped = true;
|
||||
} else if (stats.size - start > maxReadBytes) {
|
||||
start = stats.size - maxReadBytes;
|
||||
pending = "";
|
||||
clamped = true;
|
||||
}
|
||||
if (clamped && start > 0) {
|
||||
discardFirstLine = readSlice(filePath, start - 1, 1) !== "\n";
|
||||
}
|
||||
|
||||
const text = readSlice(filePath, start, stats.size - start);
|
||||
offset = stats.size;
|
||||
contentFingerprint = readTailFingerprint(filePath, stats, maxReadBytes);
|
||||
if (!text) {
|
||||
return { lines: [], reset };
|
||||
}
|
||||
|
||||
let chunk = pending + text;
|
||||
if (discardFirstLine) {
|
||||
const newlineIndex = chunk.indexOf("\n");
|
||||
if (newlineIndex === -1) {
|
||||
pending = "";
|
||||
return { lines: [], reset };
|
||||
}
|
||||
chunk = chunk.slice(newlineIndex + 1);
|
||||
}
|
||||
|
||||
const lines = chunk.split("\n");
|
||||
pending = lines.pop() ?? "";
|
||||
return { lines, reset };
|
||||
},
|
||||
};
|
||||
}
|
||||
698
scripts/e2e/lib/kitchen-sink-plugin/assertions.mjs
Normal file
698
scripts/e2e/lib/kitchen-sink-plugin/assertions.mjs
Normal file
@@ -0,0 +1,698 @@
|
||||
// Assertions for kitchen-sink plugin E2E scenarios.
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { readPluginInstallRecords } from "../plugin-index-sqlite.mjs";
|
||||
|
||||
const command = process.argv[2];
|
||||
const scratchRoot = process.env.KITCHEN_SINK_TMP_DIR || os.tmpdir();
|
||||
|
||||
const LOG_SCAN_CHUNK_BYTES = 64 * 1024;
|
||||
const LOG_SCAN_FINDING_CONTEXT_CHARS = 2048;
|
||||
const LOG_SCAN_MAX_ENTRIES = readPositiveIntEnv("KITCHEN_SINK_LOG_SCAN_MAX_ENTRIES", 20_000);
|
||||
const LOG_SCAN_MAX_FILES = 5000;
|
||||
const LOG_SCAN_MAX_FINDINGS = 100;
|
||||
const LOG_SCAN_MAX_LINE_CHARS = 16 * 1024;
|
||||
const LOG_SCAN_SEGMENT_OVERLAP_CHARS = 256;
|
||||
const EXPECT_FAILURE_OUTPUT_MAX_BYTES = readPositiveIntEnv(
|
||||
"KITCHEN_SINK_EXPECT_FAILURE_OUTPUT_MAX_BYTES",
|
||||
1024 * 1024,
|
||||
);
|
||||
|
||||
const readJson = (file) => JSON.parse(fs.readFileSync(file, "utf8"));
|
||||
const scratchFile = (name) => path.join(scratchRoot, name);
|
||||
const normalizedPath = (filePath) => filePath.replaceAll("\\", "/");
|
||||
|
||||
function readPositiveIntEnv(name, fallback) {
|
||||
const raw = process.env[name];
|
||||
if (raw === undefined || raw === "") {
|
||||
return fallback;
|
||||
}
|
||||
const text = raw.trim();
|
||||
if (!/^\d+$/u.test(text)) {
|
||||
throw new Error(`${name} must be a positive integer; got: ${raw}`);
|
||||
}
|
||||
const parsed = Number(text);
|
||||
if (!Number.isSafeInteger(parsed) || parsed <= 0) {
|
||||
throw new Error(`${name} must be a positive integer; got: ${raw}`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function resolveHomePath(value) {
|
||||
if (value === "~") {
|
||||
return process.env.HOME;
|
||||
}
|
||||
if (value?.startsWith("~/") || value?.startsWith("~\\")) {
|
||||
return path.join(process.env.HOME, value.slice(2));
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function readTextFileBounded(file, maxBytes, label) {
|
||||
const stats = fs.statSync(file);
|
||||
if (stats.size > maxBytes) {
|
||||
throw new Error(`${label} exceeded ${maxBytes} bytes: ${file} (${stats.size} bytes)`);
|
||||
}
|
||||
return fs.readFileSync(file, "utf8");
|
||||
}
|
||||
|
||||
function expectFailure() {
|
||||
const outputFile = process.argv[3];
|
||||
const output = readTextFileBounded(
|
||||
outputFile,
|
||||
EXPECT_FAILURE_OUTPUT_MAX_BYTES,
|
||||
"expected failure output",
|
||||
);
|
||||
const source = process.env.KITCHEN_SINK_SOURCE;
|
||||
const spec = process.env.KITCHEN_SINK_SPEC;
|
||||
const displayedSpec = source === "npm" ? spec.replace(/^npm:/u, "") : spec;
|
||||
const expected =
|
||||
source === "clawhub"
|
||||
? /Version not found on ClawHub|ClawHub .* failed \(404\)|version.*not found/iu
|
||||
: /No matching version|ETARGET|notarget|npm (?:error|ERR!)/iu;
|
||||
if (!output.includes(displayedSpec)) {
|
||||
throw new Error(`expected failure output to mention ${displayedSpec}`);
|
||||
}
|
||||
if (!expected.test(output)) {
|
||||
throw new Error(`unexpected ${source} beta failure output:\n${output}`);
|
||||
}
|
||||
}
|
||||
|
||||
function scanTextFileLines(file, onLine) {
|
||||
const fd = fs.openSync(file, "r");
|
||||
try {
|
||||
const buffer = Buffer.alloc(LOG_SCAN_CHUNK_BYTES);
|
||||
let currentLine = "";
|
||||
let lineNumber = 1;
|
||||
const emitLine = (line, info = {}) => onLine(line, lineNumber, info);
|
||||
const appendLineText = (text, complete) => {
|
||||
currentLine += text;
|
||||
while (currentLine.length > LOG_SCAN_MAX_LINE_CHARS) {
|
||||
const segment = currentLine.slice(0, LOG_SCAN_MAX_LINE_CHARS);
|
||||
currentLine = currentLine.slice(LOG_SCAN_MAX_LINE_CHARS - LOG_SCAN_SEGMENT_OVERLAP_CHARS);
|
||||
if (!emitLine(segment, { truncated: true })) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (complete) {
|
||||
if (!emitLine(currentLine)) {
|
||||
return false;
|
||||
}
|
||||
currentLine = "";
|
||||
lineNumber += 1;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
while (true) {
|
||||
const bytesRead = fs.readSync(fd, buffer, 0, buffer.length, null);
|
||||
if (bytesRead <= 0) {
|
||||
break;
|
||||
}
|
||||
const text = buffer.subarray(0, bytesRead).toString("utf8");
|
||||
const lines = text.split(/\r?\n/u);
|
||||
for (let index = 0; index < lines.length - 1; index += 1) {
|
||||
if (!appendLineText(lines[index], true)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!appendLineText(lines.at(-1) ?? "", false)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (currentLine.length > 0) {
|
||||
onLine(currentLine, lineNumber);
|
||||
}
|
||||
} finally {
|
||||
fs.closeSync(fd);
|
||||
}
|
||||
}
|
||||
|
||||
function formatFindingLine(line, pattern, info = {}) {
|
||||
const matchIndex = Math.max(0, line.search(pattern));
|
||||
const halfWindow = Math.floor(LOG_SCAN_FINDING_CONTEXT_CHARS / 2);
|
||||
const start = Math.max(0, matchIndex - halfWindow);
|
||||
const end = Math.min(line.length, start + LOG_SCAN_FINDING_CONTEXT_CHARS);
|
||||
const prefix = start > 0 ? "... " : "";
|
||||
const suffix = end < line.length || info.truncated ? " ..." : "";
|
||||
return `${prefix}${line.slice(start, end)}${suffix}`;
|
||||
}
|
||||
|
||||
function shouldScanLogFile(entry) {
|
||||
if (!(/\.(?:log|jsonl)$/u.test(entry) || /openclaw-kitchen-sink-/u.test(path.basename(entry)))) {
|
||||
return false;
|
||||
}
|
||||
return !normalizedPath(entry).includes("/.npm/_logs/");
|
||||
}
|
||||
|
||||
function scanLogFiles(roots, onFile) {
|
||||
let scannedFiles = 0;
|
||||
let visitedEntries = 0;
|
||||
for (const root of roots) {
|
||||
const pending = [{ entry: root, counted: false }];
|
||||
while (pending.length > 0) {
|
||||
const pendingEntry = pending.pop();
|
||||
const entry = pendingEntry?.entry;
|
||||
if (!entry || !fs.existsSync(entry)) {
|
||||
continue;
|
||||
}
|
||||
if (!pendingEntry.counted) {
|
||||
visitedEntries += 1;
|
||||
if (visitedEntries > LOG_SCAN_MAX_ENTRIES) {
|
||||
throw new Error(
|
||||
`kitchen-sink log scan exceeded ${LOG_SCAN_MAX_ENTRIES} filesystem entries`,
|
||||
);
|
||||
}
|
||||
}
|
||||
const entryType = pendingEntry.dirent ?? fs.lstatSync(entry);
|
||||
if (entryType.isSymbolicLink()) {
|
||||
continue;
|
||||
}
|
||||
if (entryType.isDirectory()) {
|
||||
const dir = fs.opendirSync(entry);
|
||||
try {
|
||||
let child;
|
||||
while ((child = dir.readSync()) !== null) {
|
||||
visitedEntries += 1;
|
||||
if (visitedEntries > LOG_SCAN_MAX_ENTRIES) {
|
||||
throw new Error(
|
||||
`kitchen-sink log scan exceeded ${LOG_SCAN_MAX_ENTRIES} filesystem entries`,
|
||||
);
|
||||
}
|
||||
pending.push({
|
||||
counted: true,
|
||||
dirent: child,
|
||||
entry: path.join(entry, child.name),
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
dir.closeSync();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!shouldScanLogFile(entry)) {
|
||||
continue;
|
||||
}
|
||||
scannedFiles += 1;
|
||||
if (scannedFiles > LOG_SCAN_MAX_FILES) {
|
||||
throw new Error(`kitchen-sink log scan exceeded ${LOG_SCAN_MAX_FILES} candidate files`);
|
||||
}
|
||||
if (!onFile(entry, scannedFiles)) {
|
||||
return scannedFiles;
|
||||
}
|
||||
}
|
||||
}
|
||||
return scannedFiles;
|
||||
}
|
||||
|
||||
function scanLogs() {
|
||||
if (!process.env.KITCHEN_SINK_TMP_DIR) {
|
||||
throw new Error("KITCHEN_SINK_TMP_DIR is required for kitchen-sink log scans");
|
||||
}
|
||||
const roots = [scratchRoot, path.join(process.env.HOME, ".openclaw")];
|
||||
const deny = [
|
||||
/\buncaught exception\b/iu,
|
||||
/\bunhandled rejection\b/iu,
|
||||
/\bfatal\b/iu,
|
||||
/\bpanic\b/iu,
|
||||
/\blevel["']?\s*:\s*["']error["']/iu,
|
||||
/\[(?:error|ERROR)\]/u,
|
||||
];
|
||||
const allow = [
|
||||
/^\s*0 errors?\s*$/iu,
|
||||
/^\s*expected no diagnostics errors?\s*$/iu,
|
||||
/^\s*diagnostics errors?:\s*$/iu,
|
||||
];
|
||||
const findings = [];
|
||||
let omittedFindings = false;
|
||||
const scannedFiles = scanLogFiles(roots, (file) => {
|
||||
scanTextFileLines(file, (line, lineNumber, info) => {
|
||||
if (allow.some((pattern) => pattern.test(line))) {
|
||||
return true;
|
||||
}
|
||||
const matchedPattern = deny.find((pattern) => pattern.test(line));
|
||||
if (matchedPattern) {
|
||||
if (findings.length >= LOG_SCAN_MAX_FINDINGS) {
|
||||
omittedFindings = true;
|
||||
return false;
|
||||
}
|
||||
findings.push(`${file}:${lineNumber}: ${formatFindingLine(line, matchedPattern, info)}`);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
if (omittedFindings) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
if (scannedFiles === 0) {
|
||||
throw new Error(
|
||||
"kitchen-sink log scan found no files under the isolated scratch root or OpenClaw home",
|
||||
);
|
||||
}
|
||||
if (findings.length > 0) {
|
||||
const suffix = omittedFindings ? "\n... additional findings omitted" : "";
|
||||
throw new Error(`unexpected error-like log lines:\n${findings.join("\n")}${suffix}`);
|
||||
}
|
||||
console.log(`log scan passed (${scannedFiles} file(s))`);
|
||||
}
|
||||
|
||||
function readConfig() {
|
||||
const configPath = path.join(process.env.HOME, ".openclaw", "openclaw.json");
|
||||
return {
|
||||
configPath,
|
||||
exists: fs.existsSync(configPath),
|
||||
config: fs.existsSync(configPath) ? readJson(configPath) : {},
|
||||
};
|
||||
}
|
||||
|
||||
function configureRuntime() {
|
||||
const pluginId = process.env.KITCHEN_SINK_ID;
|
||||
const { configPath, config } = readConfig();
|
||||
config.plugins = config.plugins || {};
|
||||
config.plugins.entries = config.plugins.entries || {};
|
||||
config.plugins.entries[pluginId] = {
|
||||
...config.plugins.entries[pluginId],
|
||||
hooks: {
|
||||
...config.plugins.entries[pluginId]?.hooks,
|
||||
allowConversationAccess: true,
|
||||
},
|
||||
};
|
||||
config.channels = {
|
||||
...config.channels,
|
||||
"kitchen-sink-channel": { enabled: true, token: "kitchen-sink-ci" },
|
||||
};
|
||||
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
||||
fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`);
|
||||
}
|
||||
|
||||
function removeChannelConfig() {
|
||||
const { configPath, exists, config } = readConfig();
|
||||
if (!exists) {
|
||||
return;
|
||||
}
|
||||
delete config.channels?.["kitchen-sink-channel"];
|
||||
fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`);
|
||||
}
|
||||
|
||||
const expectIncludes = (listValue, expected, field) => {
|
||||
if (!Array.isArray(listValue) || !listValue.includes(expected)) {
|
||||
throw new Error(`${field} missing ${expected}: ${JSON.stringify(listValue)}`);
|
||||
}
|
||||
};
|
||||
const expectIncludesAny = (listValue, expectedValues, field) => {
|
||||
if (
|
||||
!Array.isArray(listValue) ||
|
||||
!expectedValues.some((expected) => listValue.includes(expected))
|
||||
) {
|
||||
throw new Error(
|
||||
`${field} missing one of ${expectedValues.join(", ")}: ${JSON.stringify(listValue)}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
const expectMissing = (listValue, expected, field) => {
|
||||
if (Array.isArray(listValue) && listValue.includes(expected)) {
|
||||
throw new Error(`${field} unexpectedly included ${expected}: ${JSON.stringify(listValue)}`);
|
||||
}
|
||||
};
|
||||
|
||||
const INVALID_PROBE_DIAGNOSTIC_SURFACE_MODES = new Set(["full", "conformance", "adversarial"]);
|
||||
const requiredFullDiagnosticCanaries = new Set([
|
||||
"agent tool result middleware must be a function",
|
||||
"trusted tool policy registration requires id, description, and evaluate()",
|
||||
"plugin must declare contracts.tools for: kitchen-sink-tool",
|
||||
'channel "kitchen-sink-channel-probe" registration missing required config helpers',
|
||||
'agent harness "kitchen-sink-agent-harness" registration missing required runtime methods',
|
||||
"session scheduler job registration requires unique id, sessionKey, and kind",
|
||||
]);
|
||||
|
||||
function assertExpectedDiagnostics(surfaceMode, errorMessages) {
|
||||
const expectedErrorMessages = new Set([
|
||||
"cli registration missing explicit commands metadata",
|
||||
"only bundled plugins can register Codex app-server extension factories",
|
||||
"agent tool result middleware must be a function",
|
||||
'compaction provider "kitchen-sink-compaction-provider" registration missing summarize',
|
||||
"context engine registration missing id",
|
||||
"control UI descriptor registration requires id, surface, label, and valid optional fields",
|
||||
"hosted media resolver registration missing resolver",
|
||||
"http route registration missing or invalid auth: /kitchen-sink/http-route",
|
||||
"node invoke policy registration missing commands",
|
||||
"trusted tool policy registration requires id, description, and evaluate()",
|
||||
"plugin must declare contracts.embeddingProviders for adapter: kitchen-sink-embedding-provider",
|
||||
"plugin must own memory slot or declare contracts.memoryEmbeddingProviders for adapter: kitchen-sink-memory-embedding-provider",
|
||||
"plugin must declare contracts.tools for: kitchen-sink-tool",
|
||||
'channel "kitchen-sink-channel-probe" registration missing required config helpers',
|
||||
'agent harness "kitchen-sink-agent-harness" registration missing required runtime methods',
|
||||
"memory prompt supplement registration missing builder",
|
||||
"model catalog provider registration missing provider",
|
||||
"session extension registration requires namespace and description",
|
||||
"session scheduler job registration requires unique id, sessionKey, and kind",
|
||||
"tool metadata registration missing toolName",
|
||||
]);
|
||||
const optionalErrorMessages = new Set([
|
||||
"agent event subscription registration requires id and handle",
|
||||
]);
|
||||
const allowedErrorMessages = new Set([...expectedErrorMessages, ...optionalErrorMessages]);
|
||||
if (!INVALID_PROBE_DIAGNOSTIC_SURFACE_MODES.has(surfaceMode)) {
|
||||
if (errorMessages.size > 0) {
|
||||
throw new Error(
|
||||
`unexpected kitchen-sink diagnostic errors: ${[...errorMessages].join(", ")}`,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
for (const message of errorMessages) {
|
||||
if (!allowedErrorMessages.has(message)) {
|
||||
throw new Error(`unexpected kitchen-sink diagnostic error: ${message}`);
|
||||
}
|
||||
}
|
||||
if (surfaceMode === "full") {
|
||||
// Default Docker scenarios install the published package, which can lag this repo.
|
||||
// Exhaustive matching is reserved for synchronized/current package fixtures.
|
||||
const requiredMessages =
|
||||
process.env.KITCHEN_SINK_REQUIRE_ALL_DIAGNOSTICS === "1"
|
||||
? expectedErrorMessages
|
||||
: requiredFullDiagnosticCanaries;
|
||||
for (const message of requiredMessages) {
|
||||
if (!errorMessages.has(message)) {
|
||||
throw new Error(`missing expected kitchen-sink diagnostic error: ${message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function assertRealPathInside(parentPath, childPath, label) {
|
||||
const parentRealPath = fs.realpathSync(parentPath);
|
||||
const childRealPath = fs.realpathSync(childPath);
|
||||
if (
|
||||
childRealPath !== parentRealPath &&
|
||||
!childRealPath.startsWith(`${parentRealPath}${path.sep}`)
|
||||
) {
|
||||
throw new Error(`${label} resolved outside ${parentPath}: ${childRealPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertClawHubExternalInstallContract(installPath) {
|
||||
const openclawPeerPath = path.join(installPath, "node_modules", "openclaw");
|
||||
if (!fs.existsSync(openclawPeerPath)) {
|
||||
throw new Error(`missing kitchen-sink openclaw peer symlink: ${openclawPeerPath}`);
|
||||
}
|
||||
if (!fs.lstatSync(openclawPeerPath).isSymbolicLink()) {
|
||||
throw new Error(`kitchen-sink openclaw peer is not a symlink: ${openclawPeerPath}`);
|
||||
}
|
||||
const hostRoot = fs.realpathSync(process.cwd());
|
||||
const linkedHostRoot = fs.realpathSync(openclawPeerPath);
|
||||
if (linkedHostRoot !== hostRoot) {
|
||||
throw new Error(`expected kitchen-sink openclaw peer ${linkedHostRoot} to target ${hostRoot}`);
|
||||
}
|
||||
|
||||
const dependencyPackagePath = path.join(installPath, "node_modules", "is-number", "package.json");
|
||||
if (fs.existsSync(dependencyPackagePath)) {
|
||||
assertRealPathInside(installPath, dependencyPackagePath, "kitchen-sink isolated dependency");
|
||||
}
|
||||
}
|
||||
|
||||
function assertClawHubArtifactMetadata(record) {
|
||||
if (record.artifactKind === "legacy-zip") {
|
||||
if (record.artifactFormat !== "zip") {
|
||||
throw new Error(
|
||||
`missing kitchen-sink legacy ZIP artifact metadata: ${JSON.stringify(record)}`,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (record.artifactKind !== "npm-pack" || record.artifactFormat !== "tgz") {
|
||||
throw new Error(`missing kitchen-sink ClawHub artifact metadata: ${JSON.stringify(record)}`);
|
||||
}
|
||||
if (!record.clawpackSha256 || typeof record.clawpackSize !== "number") {
|
||||
throw new Error(`missing kitchen-sink ClawPack metadata: ${JSON.stringify(record)}`);
|
||||
}
|
||||
if (!record.npmIntegrity || !record.npmShasum || !record.npmTarballName) {
|
||||
throw new Error(`missing kitchen-sink npm artifact metadata: ${JSON.stringify(record)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function inferInstallSource(spec) {
|
||||
if (spec?.startsWith("npm:")) {
|
||||
return "npm";
|
||||
}
|
||||
if (spec?.startsWith("clawhub:")) {
|
||||
return "clawhub";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function assertCutoverPreinstalled() {
|
||||
const pluginId = process.env.KITCHEN_SINK_ID;
|
||||
const preinstallSpec = process.env.KITCHEN_SINK_PREINSTALL_SPEC;
|
||||
const source = inferInstallSource(preinstallSpec);
|
||||
if (!pluginId || !preinstallSpec || !source) {
|
||||
throw new Error(`invalid kitchen-sink cutover preinstall spec: ${preinstallSpec}`);
|
||||
}
|
||||
|
||||
const record = readPluginInstallRecords()[pluginId];
|
||||
if (!record) {
|
||||
throw new Error(`missing kitchen-sink cutover preinstall record for ${pluginId}`);
|
||||
}
|
||||
if (record.source !== source) {
|
||||
throw new Error(`expected kitchen-sink preinstall source=${source}, got ${record.source}`);
|
||||
}
|
||||
const expectedSpec = source === "npm" ? preinstallSpec.replace(/^npm:/u, "") : preinstallSpec;
|
||||
if (record.spec !== expectedSpec) {
|
||||
throw new Error(`expected kitchen-sink preinstall spec ${expectedSpec}, got ${record.spec}`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertInstalled() {
|
||||
const pluginId = process.env.KITCHEN_SINK_ID;
|
||||
const spec = process.env.KITCHEN_SINK_SPEC;
|
||||
const source = process.env.KITCHEN_SINK_SOURCE;
|
||||
const surfaceMode = process.env.KITCHEN_SINK_SURFACE_MODE;
|
||||
const label = process.env.KITCHEN_SINK_LABEL;
|
||||
const list = readJson(scratchFile(`kitchen-sink-${label}-plugins.json`));
|
||||
const inspect = readJson(scratchFile(`kitchen-sink-${label}-inspect.json`));
|
||||
const allInspect = readJson(scratchFile(`kitchen-sink-${label}-inspect-all.json`));
|
||||
if (!Array.isArray(allInspect)) {
|
||||
throw new Error("kitchen-sink inspect --all output was not an array");
|
||||
}
|
||||
const plugin = (list.plugins || []).find((entry) => entry.id === pluginId);
|
||||
if (!plugin) {
|
||||
throw new Error(`kitchen-sink plugin not found after install: ${pluginId}`);
|
||||
}
|
||||
const allInspectPlugin = allInspect.find((entry) => entry?.plugin?.id === pluginId);
|
||||
if (!allInspectPlugin) {
|
||||
throw new Error(`kitchen-sink plugin missing from inspect --all output: ${pluginId}`);
|
||||
}
|
||||
if (!allInspectPlugin.plugin?.enabled || allInspectPlugin.plugin?.status !== "loaded") {
|
||||
throw new Error(
|
||||
`expected enabled loaded kitchen-sink plugin in inspect --all, got enabled=${allInspectPlugin.plugin?.enabled} status=${allInspectPlugin.plugin?.status}`,
|
||||
);
|
||||
}
|
||||
if (plugin.status !== "loaded") {
|
||||
throw new Error(`unexpected kitchen-sink status after enable: ${plugin.status}`);
|
||||
}
|
||||
if (inspect.plugin?.id !== pluginId) {
|
||||
throw new Error(`unexpected inspected kitchen-sink plugin id: ${inspect.plugin?.id}`);
|
||||
}
|
||||
if (!inspect.plugin?.enabled || inspect.plugin?.status !== "loaded") {
|
||||
throw new Error(
|
||||
`expected enabled loaded kitchen-sink plugin, got enabled=${inspect.plugin?.enabled} status=${inspect.plugin?.status}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (surfaceMode !== "adversarial") {
|
||||
expectIncludes(inspect.plugin?.channelIds, "kitchen-sink-channel", "channels");
|
||||
expectIncludes(inspect.plugin?.providerIds, "kitchen-sink-provider", "providers");
|
||||
}
|
||||
if (source === "clawhub") {
|
||||
expectIncludes(inspect.plugin?.contextEngineIds, pluginId, "context engines");
|
||||
}
|
||||
|
||||
const diagnostics = [
|
||||
...(list.diagnostics || []),
|
||||
...(inspect.diagnostics || []),
|
||||
...(allInspectPlugin.diagnostics || []),
|
||||
];
|
||||
const errorMessages = new Set(
|
||||
diagnostics.filter((diag) => diag?.level === "error").map((diag) => String(diag.message || "")),
|
||||
);
|
||||
|
||||
if (surfaceMode === "full" || surfaceMode === "conformance") {
|
||||
const toolNames = Array.isArray(inspect.tools)
|
||||
? inspect.tools.flatMap((entry) => (Array.isArray(entry?.names) ? entry.names : []))
|
||||
: [];
|
||||
const pluginSurfaceIds = {
|
||||
speechProviderIds: [
|
||||
["kitchen-sink-speech", "kitchen-sink-speech-provider"],
|
||||
"speech providers",
|
||||
],
|
||||
realtimeTranscriptionProviderIds: [
|
||||
["kitchen-sink-realtime-transcription", "kitchen-sink-realtime-transcription-provider"],
|
||||
"realtime transcription providers",
|
||||
],
|
||||
realtimeVoiceProviderIds: [
|
||||
["kitchen-sink-realtime-voice", "kitchen-sink-realtime-voice-provider"],
|
||||
"realtime voice providers",
|
||||
],
|
||||
mediaUnderstandingProviderIds: [
|
||||
["kitchen-sink-media", "kitchen-sink-media-understanding-provider"],
|
||||
"media understanding providers",
|
||||
],
|
||||
imageGenerationProviderIds: [
|
||||
["kitchen-sink-image", "kitchen-sink-image-generation-provider"],
|
||||
"image generation providers",
|
||||
],
|
||||
videoGenerationProviderIds: [
|
||||
["kitchen-sink-video", "kitchen-sink-video-generation-provider"],
|
||||
"video generation providers",
|
||||
],
|
||||
musicGenerationProviderIds: [
|
||||
["kitchen-sink-music", "kitchen-sink-music-generation-provider"],
|
||||
"music generation providers",
|
||||
],
|
||||
webFetchProviderIds: [
|
||||
["kitchen-sink-fetch", "kitchen-sink-web-fetch-provider"],
|
||||
"web fetch providers",
|
||||
],
|
||||
webSearchProviderIds: [
|
||||
["kitchen-sink-search", "kitchen-sink-web-search-provider"],
|
||||
"web search providers",
|
||||
],
|
||||
migrationProviderIds: [
|
||||
["kitchen-sink-migration-providers", "kitchen-sink-migration-provider"],
|
||||
"migration providers",
|
||||
],
|
||||
};
|
||||
for (const [field, [ids, labelLocal]] of Object.entries(pluginSurfaceIds)) {
|
||||
expectIncludesAny(inspect.plugin?.[field], ids, labelLocal);
|
||||
}
|
||||
expectMissing(inspect.plugin?.agentHarnessIds, "kitchen-sink-agent-harness", "agent harnesses");
|
||||
expectIncludes(inspect.services, "kitchen-sink-service", "services");
|
||||
if (surfaceMode === "full") {
|
||||
expectIncludesAny(inspect.commands, ["kitchen", "kitchen-sink-command"], "commands");
|
||||
for (const toolName of [
|
||||
"kitchen_sink_text",
|
||||
"kitchen_sink_search",
|
||||
"kitchen_sink_image_job",
|
||||
]) {
|
||||
expectIncludes(toolNames, toolName, "tools");
|
||||
}
|
||||
} else {
|
||||
expectIncludes(inspect.commands, "kitchen", "commands");
|
||||
expectIncludes(toolNames, "kitchen_sink_text", "tools");
|
||||
}
|
||||
if (
|
||||
(inspect.plugin?.hookCount || 0) < 30 ||
|
||||
!Array.isArray(inspect.typedHooks) ||
|
||||
inspect.typedHooks.length < 30
|
||||
) {
|
||||
throw new Error(
|
||||
`expected kitchen-sink typed hooks to load, got hookCount=${inspect.plugin?.hookCount} typedHooks=${inspect.typedHooks?.length}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
assertExpectedDiagnostics(surfaceMode, errorMessages);
|
||||
|
||||
const record = readPluginInstallRecords()[pluginId];
|
||||
if (!record) {
|
||||
throw new Error(`missing kitchen-sink install record for ${pluginId}`);
|
||||
}
|
||||
if (record.source !== source) {
|
||||
throw new Error(`expected kitchen-sink install source=${source}, got ${record.source}`);
|
||||
}
|
||||
if (source === "npm") {
|
||||
const expectedSpec = spec.replace(/^npm:/u, "");
|
||||
if (record.spec !== expectedSpec) {
|
||||
throw new Error(`expected kitchen-sink npm spec ${expectedSpec}, got ${record.spec}`);
|
||||
}
|
||||
if (!record.resolvedVersion || !record.resolvedSpec) {
|
||||
throw new Error(`missing npm resolution metadata: ${JSON.stringify(record)}`);
|
||||
}
|
||||
} else if (source === "clawhub") {
|
||||
const value = spec.slice("clawhub:".length).trim();
|
||||
const slashIndex = value.lastIndexOf("/");
|
||||
const atIndex = value.lastIndexOf("@");
|
||||
const packageName = atIndex > 0 && atIndex > slashIndex ? value.slice(0, atIndex) : value;
|
||||
if (record.spec !== spec) {
|
||||
throw new Error(`expected kitchen-sink ClawHub spec ${spec}, got ${record.spec}`);
|
||||
}
|
||||
if (record.clawhubPackage !== packageName) {
|
||||
throw new Error(`expected ClawHub package ${packageName}, got ${record.clawhubPackage}`);
|
||||
}
|
||||
if (record.clawhubFamily !== "code-plugin" && record.clawhubFamily !== "bundle-plugin") {
|
||||
throw new Error(`unexpected ClawHub family: ${record.clawhubFamily}`);
|
||||
}
|
||||
if (!record.version || !record.integrity || !record.resolvedAt) {
|
||||
throw new Error(`missing ClawHub resolution metadata: ${JSON.stringify(record)}`);
|
||||
}
|
||||
assertClawHubArtifactMetadata(record);
|
||||
}
|
||||
if (typeof record.installPath !== "string" || record.installPath.length === 0) {
|
||||
throw new Error("missing kitchen-sink install path");
|
||||
}
|
||||
const installPath = resolveHomePath(record.installPath);
|
||||
if (!fs.existsSync(installPath)) {
|
||||
throw new Error(`kitchen-sink install path missing: ${record.installPath}`);
|
||||
}
|
||||
if (source === "clawhub") {
|
||||
const extensionsRoot = path.join(process.env.HOME, ".openclaw", "extensions");
|
||||
assertRealPathInside(extensionsRoot, installPath, "kitchen-sink ClawHub install path");
|
||||
}
|
||||
if (source === "clawhub" && record.artifactKind === "npm-pack") {
|
||||
assertClawHubExternalInstallContract(installPath);
|
||||
}
|
||||
fs.writeFileSync(scratchFile(`kitchen-sink-${label}-install-path.txt`), installPath, "utf8");
|
||||
}
|
||||
|
||||
function assertRemoved() {
|
||||
const pluginId = process.env.KITCHEN_SINK_ID;
|
||||
const label = process.env.KITCHEN_SINK_LABEL;
|
||||
const list = readJson(scratchFile(`kitchen-sink-${label}-uninstalled.json`));
|
||||
if ((list.plugins || []).some((entry) => entry.id === pluginId)) {
|
||||
throw new Error(`kitchen-sink plugin still listed after uninstall: ${pluginId}`);
|
||||
}
|
||||
|
||||
const records = readPluginInstallRecords();
|
||||
if (records[pluginId]) {
|
||||
throw new Error(`kitchen-sink install record still present after uninstall: ${pluginId}`);
|
||||
}
|
||||
|
||||
const { config } = readConfig();
|
||||
if (config.plugins?.entries?.[pluginId]) {
|
||||
throw new Error(`kitchen-sink config entry still present after uninstall: ${pluginId}`);
|
||||
}
|
||||
if ((config.plugins?.allow || []).includes(pluginId)) {
|
||||
throw new Error(`kitchen-sink allowlist still contains ${pluginId}`);
|
||||
}
|
||||
if ((config.plugins?.deny || []).includes(pluginId)) {
|
||||
throw new Error(`kitchen-sink denylist still contains ${pluginId}`);
|
||||
}
|
||||
if (config.channels?.["kitchen-sink-channel"]) {
|
||||
throw new Error("kitchen-sink channel config still present after uninstall");
|
||||
}
|
||||
const installPathFile = scratchFile(`kitchen-sink-${label}-install-path.txt`);
|
||||
if (fs.existsSync(installPathFile)) {
|
||||
const installPath = fs.readFileSync(installPathFile, "utf8").trim();
|
||||
if (installPath && fs.existsSync(installPath)) {
|
||||
throw new Error(`kitchen-sink managed install directory still exists: ${installPath}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const commands = {
|
||||
"expect-failure": expectFailure,
|
||||
"scan-logs": scanLogs,
|
||||
"configure-runtime": configureRuntime,
|
||||
"remove-channel-config": removeChannelConfig,
|
||||
"assert-cutover-preinstalled": assertCutoverPreinstalled,
|
||||
"assert-installed": assertInstalled,
|
||||
"assert-removed": assertRemoved,
|
||||
};
|
||||
|
||||
const fn = commands[command];
|
||||
if (!fn) {
|
||||
throw new Error(`unknown kitchen-sink assertion command: ${command}`);
|
||||
}
|
||||
fn();
|
||||
240
scripts/e2e/lib/kitchen-sink-plugin/sweep.sh
Normal file
240
scripts/e2e/lib/kitchen-sink-plugin/sweep.sh
Normal file
@@ -0,0 +1,240 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
source scripts/lib/openclaw-e2e-instance.sh
|
||||
|
||||
KITCHEN_SINK_SWEEP_SOURCE_ONLY="${KITCHEN_SINK_SWEEP_SOURCE_ONLY:-0}"
|
||||
if [[ -z "${OPENCLAW_ENTRY:-}" && "$KITCHEN_SINK_SWEEP_SOURCE_ONLY" != "1" ]]; then
|
||||
OPENCLAW_ENTRY="$(openclaw_e2e_resolve_entrypoint)"
|
||||
fi
|
||||
export OPENCLAW_ENTRY
|
||||
KITCHEN_SINK_CREATED_TMP_DIR=0
|
||||
if [[ -z "${KITCHEN_SINK_TMP_DIR:-}" ]]; then
|
||||
KITCHEN_SINK_TMP_DIR="$(mktemp -d "/tmp/openclaw-kitchen-sink.XXXXXX")"
|
||||
KITCHEN_SINK_CREATED_TMP_DIR=1
|
||||
else
|
||||
mkdir -p "$KITCHEN_SINK_TMP_DIR"
|
||||
fi
|
||||
export KITCHEN_SINK_TMP_DIR
|
||||
KITCHEN_SINK_CLI_TIMEOUT="${KITCHEN_SINK_CLI_TIMEOUT:-180s}"
|
||||
KITCHEN_SINK_CLAWHUB_FIXTURE_DIR=""
|
||||
KITCHEN_SINK_CLAWHUB_PID_FILE=""
|
||||
|
||||
cleanup_kitchen_sink_sweep() {
|
||||
if [[ -n "${KITCHEN_SINK_CLAWHUB_PID_FILE:-}" && -f "$KITCHEN_SINK_CLAWHUB_PID_FILE" ]]; then
|
||||
openclaw_e2e_stop_process "$(cat "$KITCHEN_SINK_CLAWHUB_PID_FILE" 2>/dev/null || true)"
|
||||
fi
|
||||
if [[ -n "${KITCHEN_SINK_CLAWHUB_FIXTURE_DIR:-}" ]]; then
|
||||
rm -rf "$KITCHEN_SINK_CLAWHUB_FIXTURE_DIR"
|
||||
fi
|
||||
if [[ "${KITCHEN_SINK_CREATED_TMP_DIR:-0}" = "1" ]]; then
|
||||
rm -rf "$KITCHEN_SINK_TMP_DIR"
|
||||
fi
|
||||
}
|
||||
|
||||
if [[ "$KITCHEN_SINK_SWEEP_SOURCE_ONLY" != "1" ]]; then
|
||||
trap cleanup_kitchen_sink_sweep EXIT
|
||||
openclaw_e2e_eval_test_state_from_b64 "${OPENCLAW_TEST_STATE_SCRIPT_B64:?missing OPENCLAW_TEST_STATE_SCRIPT_B64}"
|
||||
fi
|
||||
|
||||
print_kitchen_sink_log() {
|
||||
local log_file="$1"
|
||||
local max_bytes
|
||||
max_bytes="$(openclaw_e2e_read_positive_int_env OPENCLAW_DOCKER_E2E_LOG_PRINT_BYTES 65536)" || return $?
|
||||
if [ ! -f "$log_file" ]; then
|
||||
return 0
|
||||
fi
|
||||
local log_bytes
|
||||
log_bytes="$(wc -c <"$log_file" 2>/dev/null || echo 0)"
|
||||
log_bytes="${log_bytes//[[:space:]]/}"
|
||||
if ! [[ "$log_bytes" =~ ^[0-9]+$ ]]; then
|
||||
log_bytes="0"
|
||||
fi
|
||||
if [ "$log_bytes" -le "$max_bytes" ]; then
|
||||
cat "$log_file"
|
||||
return 0
|
||||
fi
|
||||
echo "--- ${log_file} truncated: showing last ${max_bytes} of ${log_bytes} bytes ---"
|
||||
tail -c "$max_bytes" "$log_file"
|
||||
}
|
||||
|
||||
openclaw_e2e_read_positive_int_env OPENCLAW_DOCKER_E2E_LOG_PRINT_BYTES 65536 >/dev/null
|
||||
|
||||
run_kitchen_sink_openclaw_logged() {
|
||||
local label="$1"
|
||||
shift
|
||||
local safe_label="${label//[^[:alnum:]._-]/_}"
|
||||
local log_file="${KITCHEN_SINK_TMP_DIR}/${safe_label}.log"
|
||||
if ! openclaw_e2e_maybe_timeout "$KITCHEN_SINK_CLI_TIMEOUT" node "$OPENCLAW_ENTRY" "$@" >"$log_file" 2>&1; then
|
||||
print_kitchen_sink_log "$log_file"
|
||||
return 1
|
||||
fi
|
||||
print_kitchen_sink_log "$log_file"
|
||||
}
|
||||
|
||||
run_kitchen_sink_openclaw_capture() {
|
||||
local output_file="$1"
|
||||
shift
|
||||
openclaw_e2e_maybe_timeout "$KITCHEN_SINK_CLI_TIMEOUT" node "$OPENCLAW_ENTRY" "$@" >"$output_file"
|
||||
}
|
||||
|
||||
run_expect_failure() {
|
||||
local label="$1"
|
||||
shift
|
||||
local safe_label="${label//[^[:alnum:]._-]/_}"
|
||||
local output_file="${KITCHEN_SINK_TMP_DIR}/kitchen-sink-expected-failure-${safe_label}.log"
|
||||
set +e
|
||||
"$@" >"$output_file" 2>&1
|
||||
local status="$?"
|
||||
set -e
|
||||
print_kitchen_sink_log "$output_file"
|
||||
if [ "$status" -eq 0 ]; then
|
||||
echo "Expected ${label} to fail, but it succeeded." >&2
|
||||
exit 1
|
||||
fi
|
||||
node scripts/e2e/lib/kitchen-sink-plugin/assertions.mjs expect-failure "$output_file"
|
||||
}
|
||||
|
||||
start_kitchen_sink_clawhub_fixture_server() {
|
||||
local fixture_dir="$1"
|
||||
local server_log="$fixture_dir/clawhub-fixture.log"
|
||||
local server_port_file="$fixture_dir/clawhub-fixture-port"
|
||||
local server_pid_file="$fixture_dir/clawhub-fixture-pid"
|
||||
|
||||
node scripts/e2e/lib/clawhub-fixture-server.cjs kitchen-sink-plugin "$server_port_file" >"$server_log" 2>&1 &
|
||||
local server_pid="$!"
|
||||
echo "$server_pid" >"$server_pid_file"
|
||||
KITCHEN_SINK_CLAWHUB_FIXTURE_DIR="$fixture_dir"
|
||||
KITCHEN_SINK_CLAWHUB_PID_FILE="$server_pid_file"
|
||||
|
||||
local wait_attempts
|
||||
wait_attempts="$(openclaw_e2e_read_positive_int_env OPENCLAW_CLAWHUB_FIXTURE_WAIT_ATTEMPTS 600)" || return $?
|
||||
for _ in $(seq 1 "$wait_attempts"); do
|
||||
if [[ -s "$server_port_file" ]]; then
|
||||
export OPENCLAW_CLAWHUB_URL="http://127.0.0.1:$(cat "$server_port_file")"
|
||||
return 0
|
||||
fi
|
||||
if ! kill -0 "$server_pid" 2>/dev/null; then
|
||||
print_kitchen_sink_log "$server_log"
|
||||
return 1
|
||||
fi
|
||||
sleep 0.1
|
||||
done
|
||||
|
||||
print_kitchen_sink_log "$server_log"
|
||||
ps -p "$server_pid" -o pid=,stat=,etime=,command= || true
|
||||
echo "Timed out waiting for kitchen-sink ClawHub fixture server." >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
scan_logs_for_unexpected_errors() {
|
||||
node scripts/e2e/lib/kitchen-sink-plugin/assertions.mjs scan-logs
|
||||
}
|
||||
|
||||
configure_kitchen_sink_runtime() {
|
||||
node scripts/e2e/lib/kitchen-sink-plugin/assertions.mjs configure-runtime
|
||||
}
|
||||
|
||||
remove_kitchen_sink_channel_config() {
|
||||
node scripts/e2e/lib/kitchen-sink-plugin/assertions.mjs remove-channel-config
|
||||
}
|
||||
|
||||
assert_kitchen_sink_installed() {
|
||||
node scripts/e2e/lib/kitchen-sink-plugin/assertions.mjs assert-installed
|
||||
}
|
||||
|
||||
assert_kitchen_sink_removed() {
|
||||
node scripts/e2e/lib/kitchen-sink-plugin/assertions.mjs assert-removed
|
||||
}
|
||||
|
||||
assert_kitchen_sink_cutover_preinstalled() {
|
||||
node scripts/e2e/lib/kitchen-sink-plugin/assertions.mjs assert-cutover-preinstalled
|
||||
}
|
||||
|
||||
run_success_scenario() {
|
||||
echo "Testing ${KITCHEN_SINK_LABEL} install from ${KITCHEN_SINK_SPEC}..."
|
||||
local install_args=("$KITCHEN_SINK_SPEC")
|
||||
if [ -n "${KITCHEN_SINK_PREINSTALL_SPEC:-}" ]; then
|
||||
run_kitchen_sink_openclaw_logged "kitchen-sink-preinstall-${KITCHEN_SINK_LABEL}" plugins install "$KITCHEN_SINK_PREINSTALL_SPEC"
|
||||
assert_kitchen_sink_cutover_preinstalled
|
||||
install_args+=("--force")
|
||||
fi
|
||||
run_kitchen_sink_openclaw_logged "kitchen-sink-install-${KITCHEN_SINK_LABEL}" plugins install "${install_args[@]}"
|
||||
configure_kitchen_sink_runtime
|
||||
run_kitchen_sink_openclaw_logged "kitchen-sink-enable-${KITCHEN_SINK_LABEL}" plugins enable "$KITCHEN_SINK_ID"
|
||||
run_kitchen_sink_openclaw_capture "${KITCHEN_SINK_TMP_DIR}/kitchen-sink-${KITCHEN_SINK_LABEL}-plugins.json" plugins list --json
|
||||
run_kitchen_sink_openclaw_capture "${KITCHEN_SINK_TMP_DIR}/kitchen-sink-${KITCHEN_SINK_LABEL}-inspect.json" plugins inspect "$KITCHEN_SINK_ID" --runtime --json
|
||||
run_kitchen_sink_openclaw_capture "${KITCHEN_SINK_TMP_DIR}/kitchen-sink-${KITCHEN_SINK_LABEL}-inspect-all.json" plugins inspect --all --runtime --json
|
||||
assert_kitchen_sink_installed
|
||||
if [ "$KITCHEN_SINK_SOURCE" = "clawhub" ]; then
|
||||
run_kitchen_sink_openclaw_logged "kitchen-sink-uninstall-${KITCHEN_SINK_LABEL}" plugins uninstall "$KITCHEN_SINK_SPEC" --force
|
||||
else
|
||||
run_kitchen_sink_openclaw_logged "kitchen-sink-uninstall-${KITCHEN_SINK_LABEL}" plugins uninstall "$KITCHEN_SINK_ID" --force
|
||||
fi
|
||||
remove_kitchen_sink_channel_config
|
||||
run_kitchen_sink_openclaw_capture "${KITCHEN_SINK_TMP_DIR}/kitchen-sink-${KITCHEN_SINK_LABEL}-uninstalled.json" plugins list --json
|
||||
assert_kitchen_sink_removed
|
||||
}
|
||||
|
||||
run_failure_scenario() {
|
||||
echo "Testing expected ${KITCHEN_SINK_LABEL} install failure from ${KITCHEN_SINK_SPEC}..."
|
||||
run_expect_failure "install-${KITCHEN_SINK_LABEL}" openclaw_e2e_maybe_timeout "$KITCHEN_SINK_CLI_TIMEOUT" node "$OPENCLAW_ENTRY" plugins install "$KITCHEN_SINK_SPEC"
|
||||
remove_kitchen_sink_channel_config
|
||||
run_kitchen_sink_openclaw_capture "${KITCHEN_SINK_TMP_DIR}/kitchen-sink-${KITCHEN_SINK_LABEL}-uninstalled.json" plugins list --json
|
||||
assert_kitchen_sink_removed
|
||||
}
|
||||
|
||||
run_kitchen_sink_sweep_main() {
|
||||
if [[ "$KITCHEN_SINK_SCENARIOS" == *"clawhub:"* ]]; then
|
||||
if [[ "${OPENCLAW_KITCHEN_SINK_LIVE_CLAWHUB:-0}" = "1" ]]; then
|
||||
export OPENCLAW_CLAWHUB_URL="${OPENCLAW_CLAWHUB_URL:-${CLAWHUB_URL:-https://clawhub.ai}}"
|
||||
else
|
||||
if [[ -n "${OPENCLAW_CLAWHUB_URL:-}" || -n "${CLAWHUB_URL:-}" ]]; then
|
||||
echo "Ignoring ambient ClawHub URL for fixture-mode kitchen-sink E2E; set OPENCLAW_KITCHEN_SINK_LIVE_CLAWHUB=1 for live ClawHub."
|
||||
fi
|
||||
unset OPENCLAW_CLAWHUB_URL CLAWHUB_URL
|
||||
clawhub_fixture_dir="$(mktemp -d "${KITCHEN_SINK_TMP_DIR}/clawhub.XXXXXX")"
|
||||
start_kitchen_sink_clawhub_fixture_server "$clawhub_fixture_dir"
|
||||
fi
|
||||
fi
|
||||
|
||||
scenario_count=0
|
||||
while IFS='|' read -r label spec plugin_id source expectation surface_mode personality preinstall_spec; do
|
||||
if [ -z "${label:-}" ] || [[ "$label" == \#* ]]; then
|
||||
continue
|
||||
fi
|
||||
scenario_count=$((scenario_count + 1))
|
||||
export KITCHEN_SINK_LABEL="$label"
|
||||
export KITCHEN_SINK_SPEC="$spec"
|
||||
export KITCHEN_SINK_ID="$plugin_id"
|
||||
export KITCHEN_SINK_SOURCE="$source"
|
||||
export KITCHEN_SINK_SURFACE_MODE="$surface_mode"
|
||||
export KITCHEN_SINK_PERSONALITY="${personality:-}"
|
||||
export OPENCLAW_KITCHEN_SINK_PERSONALITY="${personality:-}"
|
||||
export KITCHEN_SINK_PREINSTALL_SPEC="${preinstall_spec:-}"
|
||||
case "$expectation" in
|
||||
success)
|
||||
run_success_scenario
|
||||
;;
|
||||
failure)
|
||||
run_failure_scenario
|
||||
;;
|
||||
*)
|
||||
echo "Unknown kitchen-sink expectation for ${label}: ${expectation}" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done <<<"$KITCHEN_SINK_SCENARIOS"
|
||||
|
||||
if [ "$scenario_count" -eq 0 ]; then
|
||||
echo "No kitchen-sink plugin scenarios configured." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
scan_logs_for_unexpected_errors
|
||||
echo "kitchen-sink plugin Docker E2E passed (${scenario_count} scenario(s))"
|
||||
}
|
||||
|
||||
if [[ "$KITCHEN_SINK_SWEEP_SOURCE_ONLY" != "1" ]]; then
|
||||
run_kitchen_sink_sweep_main
|
||||
fi
|
||||
635
scripts/e2e/lib/live-plugin-tool/assertions.mjs
Normal file
635
scripts/e2e/lib/live-plugin-tool/assertions.mjs
Normal file
@@ -0,0 +1,635 @@
|
||||
// Assertions for live plugin tool E2E scenarios.
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { extractAgentReplyTexts } from "../agent-turn-output.mjs";
|
||||
import { readPluginInstallRecords } from "../plugin-index-sqlite.mjs";
|
||||
import { readTextFileTail, tailText } from "../text-file-utils.mjs";
|
||||
|
||||
const command = process.argv[2];
|
||||
const readJson = (file) => JSON.parse(fs.readFileSync(file, "utf8"));
|
||||
|
||||
function readPositiveIntEnv(name, fallback) {
|
||||
const text = String(process.env[name] ?? fallback).trim();
|
||||
if (!/^\d+$/u.test(text)) {
|
||||
throw new Error(`invalid ${name}: ${text}`);
|
||||
}
|
||||
const value = Number(text);
|
||||
if (!Number.isSafeInteger(value) || value <= 0) {
|
||||
throw new Error(`invalid ${name}: ${text}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
const agentTurnTimeoutSeconds = readPositiveIntEnv(
|
||||
"OPENCLAW_LIVE_PLUGIN_TOOL_TIMEOUT_SECONDS",
|
||||
300,
|
||||
);
|
||||
const SCAN_CHUNK_BYTES = 64 * 1024;
|
||||
const SCAN_CARRY_CHARS = 256;
|
||||
const SESSION_JSONL_LINE_MAX_BYTES = 1024 * 1024;
|
||||
const ERROR_DETAIL_TAIL_BYTES = 16 * 1024;
|
||||
const AGENT_OUTPUT_MAX_BYTES = readPositiveIntEnv(
|
||||
"OPENCLAW_LIVE_PLUGIN_TOOL_AGENT_OUTPUT_MAX_BYTES",
|
||||
1024 * 1024,
|
||||
);
|
||||
const SESSION_FILE_LIST_LIMIT = 20;
|
||||
const SESSION_SCAN_MAX_ENTRIES = readPositiveIntEnv(
|
||||
"OPENCLAW_LIVE_PLUGIN_TOOL_SESSION_SCAN_MAX_ENTRIES",
|
||||
50_000,
|
||||
);
|
||||
|
||||
function requireEnv(name) {
|
||||
const value = process.env[name];
|
||||
if (!value) {
|
||||
throw new Error(`missing ${name}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function stateDir() {
|
||||
return process.env.OPENCLAW_STATE_DIR || path.join(process.env.HOME, ".openclaw");
|
||||
}
|
||||
|
||||
function configPath() {
|
||||
return process.env.OPENCLAW_CONFIG_PATH || path.join(stateDir(), "openclaw.json");
|
||||
}
|
||||
|
||||
function agentOutputPath() {
|
||||
return process.env.OPENCLAW_LIVE_PLUGIN_TOOL_AGENT_OUTPUT_PATH || "/tmp/openclaw-agent.json";
|
||||
}
|
||||
|
||||
function agentErrorPath() {
|
||||
return process.env.OPENCLAW_LIVE_PLUGIN_TOOL_AGENT_ERROR_PATH || "/tmp/openclaw-agent.err";
|
||||
}
|
||||
|
||||
function isRecord(value) {
|
||||
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
||||
}
|
||||
|
||||
function readNonEmptyString(value) {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function normalizeToolCallId(value) {
|
||||
const id = readNonEmptyString(value);
|
||||
return id || undefined;
|
||||
}
|
||||
|
||||
function stringifyToolResult(value) {
|
||||
if (typeof value === "string") {
|
||||
return value;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
.map((entry) => stringifyToolResult(entry))
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
}
|
||||
if (!isRecord(value)) {
|
||||
return value == null ? "" : String(value);
|
||||
}
|
||||
const nested = value.text ?? value.content ?? value.result ?? value.output;
|
||||
return nested === undefined ? JSON.stringify(value) : stringifyToolResult(nested);
|
||||
}
|
||||
|
||||
function extractTranscriptText(value) {
|
||||
if (typeof value === "string") {
|
||||
return value;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
.map((entry) => extractTranscriptText(entry))
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
}
|
||||
if (!isRecord(value)) {
|
||||
return value == null ? "" : String(value);
|
||||
}
|
||||
return extractTranscriptText(value.text ?? value.content ?? value.result ?? value.output ?? "");
|
||||
}
|
||||
|
||||
function extractTranscriptToolCalls(message) {
|
||||
const calls = [];
|
||||
const content = message.content;
|
||||
if (Array.isArray(content)) {
|
||||
for (const block of content) {
|
||||
if (!isRecord(block)) {
|
||||
continue;
|
||||
}
|
||||
const type = readNonEmptyString(block.type)?.toLowerCase();
|
||||
if (type !== "tool_use" && type !== "toolcall" && type !== "tool_call") {
|
||||
continue;
|
||||
}
|
||||
const tool = readNonEmptyString(block.name);
|
||||
if (!tool) {
|
||||
continue;
|
||||
}
|
||||
calls.push({
|
||||
id:
|
||||
normalizeToolCallId(block.id) ??
|
||||
normalizeToolCallId(block.toolCallId) ??
|
||||
normalizeToolCallId(block.toolUseId),
|
||||
tool,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const rawToolCalls =
|
||||
message.tool_calls ?? message.toolCalls ?? message.function_call ?? message.functionCall;
|
||||
const toolCalls = Array.isArray(rawToolCalls) ? rawToolCalls : rawToolCalls ? [rawToolCalls] : [];
|
||||
for (const call of toolCalls) {
|
||||
if (!isRecord(call)) {
|
||||
continue;
|
||||
}
|
||||
const functionRecord = isRecord(call.function) ? call.function : undefined;
|
||||
const tool = readNonEmptyString(call.name) ?? readNonEmptyString(functionRecord?.name);
|
||||
if (!tool) {
|
||||
continue;
|
||||
}
|
||||
calls.push({
|
||||
id:
|
||||
normalizeToolCallId(call.id) ??
|
||||
normalizeToolCallId(call.toolCallId) ??
|
||||
normalizeToolCallId(call.toolUseId),
|
||||
tool,
|
||||
});
|
||||
}
|
||||
return calls;
|
||||
}
|
||||
|
||||
function isFailureLikeToolResult(params) {
|
||||
return (
|
||||
params.type === "tool_result_error" ||
|
||||
params.isError === true ||
|
||||
params.is_error === true ||
|
||||
/\b(?:denied|enoent|error|exception|fail(?:ed|ure)?|forbidden|invalid|missing|not found|permission)\b/iu.test(
|
||||
params.text,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function extractTranscriptToolResults(message) {
|
||||
const results = [];
|
||||
const tool =
|
||||
readNonEmptyString(message.toolName) ??
|
||||
readNonEmptyString(message.tool_name) ??
|
||||
readNonEmptyString(message.name) ??
|
||||
readNonEmptyString(message.tool);
|
||||
if ((message.role === "tool" || message.role === "toolResult") && message.content !== undefined) {
|
||||
const text = extractTranscriptText(message.content);
|
||||
results.push({
|
||||
id:
|
||||
normalizeToolCallId(message.tool_call_id) ??
|
||||
normalizeToolCallId(message.toolCallId) ??
|
||||
normalizeToolCallId(message.toolUseId) ??
|
||||
normalizeToolCallId(message.id),
|
||||
...(tool ? { tool } : {}),
|
||||
text,
|
||||
failure: isFailureLikeToolResult({
|
||||
text,
|
||||
isError: message.isError,
|
||||
is_error: message.is_error,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
const content = message.content;
|
||||
if (!Array.isArray(content)) {
|
||||
return results;
|
||||
}
|
||||
for (const block of content) {
|
||||
if (!isRecord(block)) {
|
||||
continue;
|
||||
}
|
||||
const type = readNonEmptyString(block.type)?.toLowerCase();
|
||||
if (type !== "tool_result" && type !== "toolresult" && type !== "tool_result_error") {
|
||||
continue;
|
||||
}
|
||||
const text = stringifyToolResult(
|
||||
block.content ?? block.text ?? block.result ?? block.output ?? block.error ?? block.message,
|
||||
);
|
||||
const blockTool =
|
||||
readNonEmptyString(block.toolName) ??
|
||||
readNonEmptyString(block.tool_name) ??
|
||||
readNonEmptyString(block.name) ??
|
||||
readNonEmptyString(block.tool);
|
||||
results.push({
|
||||
id:
|
||||
normalizeToolCallId(block.tool_use_id) ??
|
||||
normalizeToolCallId(block.toolUseId) ??
|
||||
normalizeToolCallId(block.tool_call_id) ??
|
||||
normalizeToolCallId(block.toolCallId) ??
|
||||
normalizeToolCallId(block.id),
|
||||
...(blockTool ? { tool: blockTool } : {}),
|
||||
text,
|
||||
failure: isFailureLikeToolResult({
|
||||
type,
|
||||
text,
|
||||
isError: block.isError,
|
||||
is_error: block.is_error,
|
||||
}),
|
||||
});
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
function resultLinksToolCall(call, result, targetCallCount) {
|
||||
if (call.id || result.id) {
|
||||
return Boolean(call.id && result.id && call.id === result.id);
|
||||
}
|
||||
if (result.tool) {
|
||||
return result.tool === call.tool;
|
||||
}
|
||||
return targetCallCount === 1;
|
||||
}
|
||||
|
||||
function createToolEvidenceTracker(toolName, expected) {
|
||||
const calls = [];
|
||||
return {
|
||||
recordMessage(message) {
|
||||
for (const call of extractTranscriptToolCalls(message)) {
|
||||
if (call.tool === toolName) {
|
||||
calls.push(call);
|
||||
}
|
||||
}
|
||||
for (const result of extractTranscriptToolResults(message)) {
|
||||
if (result.failure || !result.text.includes(expected)) {
|
||||
continue;
|
||||
}
|
||||
if (calls.some((call) => resultLinksToolCall(call, result, calls.length))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function transcriptMessageFromLine(line) {
|
||||
try {
|
||||
const parsed = JSON.parse(line);
|
||||
if (!isRecord(parsed)) {
|
||||
return undefined;
|
||||
}
|
||||
return isRecord(parsed.message) ? parsed.message : parsed;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function scanFileForToolEvidence(file, toolName, expected) {
|
||||
const tracker = createToolEvidenceTracker(toolName, expected);
|
||||
let stat;
|
||||
try {
|
||||
stat = fs.statSync(file);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (!stat.isFile() || stat.size <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const fd = fs.openSync(file, "r");
|
||||
try {
|
||||
const buffer = Buffer.alloc(Math.min(SCAN_CHUNK_BYTES, stat.size));
|
||||
let pendingLine = "";
|
||||
let offset = 0;
|
||||
while (offset < stat.size) {
|
||||
const bytesToRead = Math.min(buffer.length, stat.size - offset);
|
||||
const bytesRead = fs.readSync(fd, buffer, 0, bytesToRead, offset);
|
||||
if (bytesRead <= 0) {
|
||||
break;
|
||||
}
|
||||
offset += bytesRead;
|
||||
const lines = (pendingLine + buffer.subarray(0, bytesRead).toString("utf8")).split(/\r?\n/u);
|
||||
pendingLine = lines.pop() ?? "";
|
||||
if (Buffer.byteLength(pendingLine) > SESSION_JSONL_LINE_MAX_BYTES) {
|
||||
pendingLine = pendingLine.slice(-SCAN_CARRY_CHARS);
|
||||
}
|
||||
for (const line of lines) {
|
||||
const message = transcriptMessageFromLine(line.trim());
|
||||
if (message && tracker.recordMessage(message)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
const message = transcriptMessageFromLine(pendingLine.trim());
|
||||
if (message && tracker.recordMessage(message)) {
|
||||
return true;
|
||||
}
|
||||
} finally {
|
||||
fs.closeSync(fd);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function scanSessionTranscripts(sessionsDir, toolName, expected) {
|
||||
const checkedFiles = [];
|
||||
let filesChecked = 0;
|
||||
let stat;
|
||||
try {
|
||||
stat = fs.statSync(sessionsDir);
|
||||
} catch {
|
||||
return { checkedFiles, filesChecked, found: false, missingDir: true };
|
||||
}
|
||||
if (!stat.isDirectory()) {
|
||||
return { checkedFiles, filesChecked, found: false, missingDir: true };
|
||||
}
|
||||
|
||||
const pendingDirs = [sessionsDir];
|
||||
let scannedEntries = 0;
|
||||
while (pendingDirs.length > 0) {
|
||||
const dir = pendingDirs.pop();
|
||||
const handle = fs.opendirSync(dir);
|
||||
try {
|
||||
let entry;
|
||||
while ((entry = handle.readSync()) !== null) {
|
||||
scannedEntries += 1;
|
||||
if (scannedEntries > SESSION_SCAN_MAX_ENTRIES) {
|
||||
throw new Error(
|
||||
`session transcript scan exceeded ${SESSION_SCAN_MAX_ENTRIES} filesystem entries`,
|
||||
);
|
||||
}
|
||||
const entryPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
pendingDirs.push(entryPath);
|
||||
continue;
|
||||
}
|
||||
if (!entry.isFile() || !entry.name.endsWith(".jsonl")) {
|
||||
continue;
|
||||
}
|
||||
filesChecked += 1;
|
||||
if (checkedFiles.length < SESSION_FILE_LIST_LIMIT) {
|
||||
checkedFiles.push(path.relative(sessionsDir, entryPath));
|
||||
}
|
||||
if (scanFileForToolEvidence(entryPath, toolName, expected)) {
|
||||
return { checkedFiles, filesChecked, found: true, missingDir: false };
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
handle.closeSync();
|
||||
}
|
||||
}
|
||||
return { checkedFiles, filesChecked, found: false, missingDir: false };
|
||||
}
|
||||
|
||||
function realPathMaybe(filePath) {
|
||||
try {
|
||||
return fs.realpathSync(filePath);
|
||||
} catch {
|
||||
return path.resolve(filePath);
|
||||
}
|
||||
}
|
||||
|
||||
function assertPathInside(parentPath, childPath, label) {
|
||||
const parent = realPathMaybe(parentPath);
|
||||
const child = realPathMaybe(childPath);
|
||||
const relative = path.relative(parent, child);
|
||||
if (relative.startsWith("..") || path.isAbsolute(relative)) {
|
||||
throw new Error(`${label} resolved outside ${parentPath}: ${child}`);
|
||||
}
|
||||
}
|
||||
|
||||
function writeJson(file, value) {
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`);
|
||||
}
|
||||
|
||||
function installRecords() {
|
||||
const cfg = fs.existsSync(configPath()) ? readJson(configPath()) : {};
|
||||
return readPluginInstallRecords({
|
||||
stateDir: stateDir(),
|
||||
configPath: configPath(),
|
||||
fallbackRecords: cfg.plugins?.installs ?? {},
|
||||
});
|
||||
}
|
||||
|
||||
function pluginInstallPath() {
|
||||
const pluginId = requireEnv("PLUGIN_ID");
|
||||
const inspect = fs.existsSync("/tmp/openclaw-plugin-inspect.json")
|
||||
? readJson("/tmp/openclaw-plugin-inspect.json")
|
||||
: {};
|
||||
const record = installRecords()[pluginId] || inspect.install;
|
||||
if (!record) {
|
||||
throw new Error(`missing ${pluginId} install record`);
|
||||
}
|
||||
if (record.source !== "npm" || record.artifactKind !== "npm-pack") {
|
||||
throw new Error(`expected npm-pack install record: ${JSON.stringify(record)}`);
|
||||
}
|
||||
return String(record.installPath || "").replace(/^~(?=$|\/)/u, process.env.HOME);
|
||||
}
|
||||
|
||||
function writeFixture() {
|
||||
const dir = process.argv[3];
|
||||
if (!dir) {
|
||||
throw new Error("write-fixture requires output dir");
|
||||
}
|
||||
const pluginId = requireEnv("PLUGIN_ID");
|
||||
const pluginName = requireEnv("PLUGIN_NAME");
|
||||
const version = requireEnv("PLUGIN_VERSION");
|
||||
const toolName = requireEnv("TOOL_NAME");
|
||||
const seed = requireEnv("SEED");
|
||||
writeJson(path.join(dir, "package.json"), {
|
||||
name: pluginName,
|
||||
version,
|
||||
dependencies: { slugify: "^1.6.6" },
|
||||
openclaw: { extensions: ["./index.js"] },
|
||||
});
|
||||
writeJson(path.join(dir, "openclaw.plugin.json"), {
|
||||
id: pluginId,
|
||||
name: "E2E Slug Tool",
|
||||
description: "Docker E2E plugin tool fixture",
|
||||
activation: { onStartup: true },
|
||||
contracts: { tools: [toolName] },
|
||||
configSchema: { type: "object", additionalProperties: false },
|
||||
});
|
||||
fs.writeFileSync(
|
||||
path.join(dir, "index.js"),
|
||||
`const slugify = require("slugify");\n` +
|
||||
`const value = slugify(${JSON.stringify(seed)}, { lower: true, strict: true });\n` +
|
||||
`module.exports = {\n` +
|
||||
` id: ${JSON.stringify(pluginId)},\n` +
|
||||
` name: "E2E Slug Tool",\n` +
|
||||
` register(api) {\n` +
|
||||
` api.registerTool({\n` +
|
||||
` name: ${JSON.stringify(toolName)},\n` +
|
||||
` description: "Return the hidden Docker E2E slug generated by the plugin dependency.",\n` +
|
||||
` parameters: { type: "object", properties: {}, additionalProperties: false },\n` +
|
||||
` async execute() {\n` +
|
||||
` return { content: [{ type: "text", text: value }] };\n` +
|
||||
` },\n` +
|
||||
` });\n` +
|
||||
` },\n` +
|
||||
`};\n`,
|
||||
);
|
||||
}
|
||||
|
||||
function configure() {
|
||||
const modelRef = requireEnv("MODEL_REF");
|
||||
const pluginId = requireEnv("PLUGIN_ID");
|
||||
const toolName = requireEnv("TOOL_NAME");
|
||||
const cfgPath = configPath();
|
||||
const cfg = fs.existsSync(cfgPath) ? readJson(cfgPath) : {};
|
||||
const [providerId, modelId] = modelRef.split("/");
|
||||
if (providerId !== "openai" || !modelId) {
|
||||
throw new Error(`live plugin tool E2E expects an openai/* model, got ${modelRef}`);
|
||||
}
|
||||
cfg.plugins = {
|
||||
...cfg.plugins,
|
||||
enabled: true,
|
||||
allow: Array.from(new Set([...(cfg.plugins?.allow || []), "openai", pluginId])).toSorted(
|
||||
(left, right) => left.localeCompare(right),
|
||||
),
|
||||
entries: {
|
||||
...cfg.plugins?.entries,
|
||||
openai: { ...cfg.plugins?.entries?.openai, enabled: true },
|
||||
[pluginId]: { ...cfg.plugins?.entries?.[pluginId], enabled: true },
|
||||
},
|
||||
};
|
||||
cfg.tools = {
|
||||
...cfg.tools,
|
||||
allow: [toolName],
|
||||
};
|
||||
cfg.models = {
|
||||
...cfg.models,
|
||||
mode: "merge",
|
||||
providers: {
|
||||
...cfg.models?.providers,
|
||||
openai: {
|
||||
...cfg.models?.providers?.openai,
|
||||
api: "openai-responses",
|
||||
baseUrl: (process.env.OPENAI_BASE_URL || "https://api.openai.com/v1").trim(),
|
||||
apiKey: { source: "env", provider: "default", id: "OPENAI_API_KEY" },
|
||||
agentRuntime: { id: "openclaw" },
|
||||
timeoutSeconds: agentTurnTimeoutSeconds,
|
||||
models: [
|
||||
{
|
||||
id: modelId,
|
||||
name: modelId,
|
||||
api: "openai-responses",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 128000,
|
||||
contextTokens: 96000,
|
||||
maxTokens: 512,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
cfg.agents = {
|
||||
...cfg.agents,
|
||||
defaults: {
|
||||
...cfg.agents?.defaults,
|
||||
model: { primary: modelRef, fallbacks: [] },
|
||||
models: {
|
||||
...cfg.agents?.defaults?.models,
|
||||
[modelRef]: {
|
||||
...cfg.agents?.defaults?.models?.[modelRef],
|
||||
agentRuntime: { id: "openclaw" },
|
||||
params: { transport: "sse", openaiWsWarmup: false },
|
||||
},
|
||||
},
|
||||
workspace: path.join(stateDir(), "workspace"),
|
||||
skipBootstrap: true,
|
||||
timeoutSeconds: agentTurnTimeoutSeconds,
|
||||
},
|
||||
};
|
||||
writeJson(cfgPath, cfg);
|
||||
}
|
||||
|
||||
function findDependencyPackageJson(packageName) {
|
||||
const installPath = pluginInstallPath();
|
||||
const npmRoot = path.join(stateDir(), "npm");
|
||||
const pluginName = requireEnv("PLUGIN_NAME");
|
||||
const packageRoot = pluginName.split("/").reduce((current) => path.dirname(current), installPath);
|
||||
const projectRoot =
|
||||
path.basename(packageRoot) === "node_modules" ? path.dirname(packageRoot) : npmRoot;
|
||||
return [
|
||||
path.join(projectRoot, "node_modules", packageName, "package.json"),
|
||||
path.join(installPath, "node_modules", packageName, "package.json"),
|
||||
path.join(npmRoot, "node_modules", packageName, "package.json"),
|
||||
].find((candidate) => fs.existsSync(candidate));
|
||||
}
|
||||
|
||||
function assertInstalled() {
|
||||
const pluginId = requireEnv("PLUGIN_ID");
|
||||
const pluginName = requireEnv("PLUGIN_NAME");
|
||||
const toolName = requireEnv("TOOL_NAME");
|
||||
const npmRoot = path.join(stateDir(), "npm");
|
||||
const installPath = pluginInstallPath();
|
||||
assertPathInside(npmRoot, installPath, "fixture plugin install path");
|
||||
const packageJson = path.join(installPath, "package.json");
|
||||
if (!fs.existsSync(packageJson)) {
|
||||
throw new Error(`missing fixture plugin package.json: ${packageJson}`);
|
||||
}
|
||||
const pkg = readJson(packageJson);
|
||||
if (pkg.name !== pluginName) {
|
||||
throw new Error(`unexpected fixture package name: ${pkg.name}`);
|
||||
}
|
||||
const slugifyPackageJson = findDependencyPackageJson("slugify");
|
||||
if (!slugifyPackageJson) {
|
||||
throw new Error("missing slugify dependency installed by npm-pack plugin install");
|
||||
}
|
||||
assertPathInside(npmRoot, slugifyPackageJson, "slugify dependency");
|
||||
|
||||
const list = readJson("/tmp/openclaw-plugins-list.json");
|
||||
const plugin = (list.plugins || []).find((entry) => entry.id === pluginId);
|
||||
if (!plugin || plugin.enabled !== true || plugin.status !== "loaded") {
|
||||
throw new Error(`fixture plugin was not enabled+loaded: ${JSON.stringify(plugin)}`);
|
||||
}
|
||||
const inspect = readJson("/tmp/openclaw-plugin-inspect.json");
|
||||
const toolNames = Array.isArray(inspect.tools)
|
||||
? inspect.tools.flatMap((entry) => (Array.isArray(entry?.names) ? entry.names : []))
|
||||
: [];
|
||||
if (!toolNames.includes(toolName)) {
|
||||
throw new Error(`fixture tool was not registered: ${JSON.stringify(inspect.tools)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertAgentTurn() {
|
||||
const expected = requireEnv("EXPECTED_SLUG");
|
||||
const toolName = requireEnv("TOOL_NAME");
|
||||
const outputPath = agentOutputPath();
|
||||
const errorPath = agentErrorPath();
|
||||
const outputStat = fs.statSync(outputPath);
|
||||
if (outputStat.isFile() && outputStat.size > AGENT_OUTPUT_MAX_BYTES) {
|
||||
const stdoutTail = readTextFileTail(outputPath, ERROR_DETAIL_TAIL_BYTES);
|
||||
const stderrTail = readTextFileTail(errorPath, ERROR_DETAIL_TAIL_BYTES);
|
||||
throw new Error(
|
||||
`live agent output exceeded ${AGENT_OUTPUT_MAX_BYTES} bytes:\nstdout tail=${stdoutTail}\nstderr tail=${stderrTail}`,
|
||||
);
|
||||
}
|
||||
const stdout = fs.readFileSync(outputPath, "utf8");
|
||||
const response = JSON.parse(stdout);
|
||||
const text = extractAgentReplyTexts(JSON.stringify(response)).join("\n");
|
||||
if (!text.includes(expected)) {
|
||||
const stderrTail = readTextFileTail(errorPath, ERROR_DETAIL_TAIL_BYTES);
|
||||
throw new Error(
|
||||
`live agent reply did not contain tool slug ${expected}:\nstdout tail=${tailText(stdout, ERROR_DETAIL_TAIL_BYTES)}\nstderr tail=${stderrTail}`,
|
||||
);
|
||||
}
|
||||
const sessionsDir = path.join(stateDir(), "agents", "main", "sessions");
|
||||
const scan = scanSessionTranscripts(sessionsDir, toolName, expected);
|
||||
if (!scan.found) {
|
||||
const checkedFiles = scan.checkedFiles.length > 0 ? scan.checkedFiles.join(", ") : "<none>";
|
||||
const missingDir = scan.missingDir ? " sessions directory was missing." : "";
|
||||
throw new Error(
|
||||
`session transcript did not show ${toolName} returning ${expected}; missing causal tool-result evidence after checking ${scan.filesChecked} jsonl file(s): ${checkedFiles}.${missingDir}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const commands = {
|
||||
"write-fixture": writeFixture,
|
||||
configure,
|
||||
"assert-installed": assertInstalled,
|
||||
"assert-agent-turn": assertAgentTurn,
|
||||
};
|
||||
|
||||
const fn = commands[command];
|
||||
if (!fn) {
|
||||
throw new Error(`unknown live plugin tool assertion command: ${command}`);
|
||||
}
|
||||
fn();
|
||||
41
scripts/e2e/lib/mcp-code-mode-probe-server.ts
Normal file
41
scripts/e2e/lib/mcp-code-mode-probe-server.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
// MCP code-mode probe server fixture shared by local and Docker E2E scripts.
|
||||
import fs from "node:fs/promises";
|
||||
import { createRequire } from "node:module";
|
||||
import path from "node:path";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
export async function writeProbeMcpServer(serverPath: string) {
|
||||
const sdkMcpServerPath = require.resolve("@modelcontextprotocol/sdk/server/mcp.js");
|
||||
const sdkStdioServerPath = require.resolve("@modelcontextprotocol/sdk/server/stdio.js");
|
||||
const zodPath = require.resolve("zod");
|
||||
await fs.mkdir(path.dirname(serverPath), { recursive: true });
|
||||
await fs.writeFile(
|
||||
serverPath,
|
||||
`#!/usr/bin/env node
|
||||
import { McpServer } from ${JSON.stringify(sdkMcpServerPath)};
|
||||
import { StdioServerTransport } from ${JSON.stringify(sdkStdioServerPath)};
|
||||
import { z } from ${JSON.stringify(zodPath)};
|
||||
|
||||
const notes = new Map([
|
||||
["alpha", "fixture-note-alpha"],
|
||||
["beta", "fixture-note-beta"],
|
||||
]);
|
||||
const server = new McpServer({ name: "code-mode-fixture", version: "1.0.0" });
|
||||
|
||||
server.tool(
|
||||
"lookup_note",
|
||||
"Look up one read-only fixture note by id.",
|
||||
{
|
||||
id: z.string().describe("Fixture note id to look up."),
|
||||
},
|
||||
async ({ id }) => ({
|
||||
content: [{ type: "text", text: notes.get(id) ?? "missing-note" }],
|
||||
}),
|
||||
);
|
||||
|
||||
await server.connect(new StdioServerTransport());
|
||||
`,
|
||||
{ encoding: "utf8", mode: 0o755 },
|
||||
);
|
||||
}
|
||||
61
scripts/e2e/lib/mcp-code-mode-validation.ts
Normal file
61
scripts/e2e/lib/mcp-code-mode-validation.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
export type McpCodeModeMentions = Record<
|
||||
"apiCall" | "apiFileList" | "apiFileRead" | "mcpNamespace" | "mcpTool" | "toolSearchPollution",
|
||||
number
|
||||
>;
|
||||
|
||||
function assert(condition: unknown, message: string): asserts condition {
|
||||
if (!condition) {
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
export function outputText(response: unknown): string {
|
||||
const output = (response as { output?: Array<{ type?: unknown; content?: unknown }> }).output;
|
||||
if (!Array.isArray(output)) {
|
||||
return "";
|
||||
}
|
||||
return output
|
||||
.flatMap((item) => {
|
||||
if (item.type !== "message" || !Array.isArray(item.content)) {
|
||||
return [];
|
||||
}
|
||||
return item.content.flatMap((piece) => {
|
||||
if (!piece || typeof piece !== "object") {
|
||||
return [];
|
||||
}
|
||||
const record = piece as { text?: unknown };
|
||||
return typeof record.text === "string" ? [record.text] : [];
|
||||
});
|
||||
})
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
export function validateMcpCodeModeResult(
|
||||
response: unknown,
|
||||
mentions: McpCodeModeMentions,
|
||||
options: { plannedTools?: string[]; requireExec?: boolean } = {},
|
||||
): string {
|
||||
const finalText = outputText(response);
|
||||
assert(
|
||||
finalText.includes("MCP_CODE_MODE_FILE_OK"),
|
||||
`agent did not complete MCP API file check: ${finalText}`,
|
||||
);
|
||||
assert(
|
||||
finalText.includes("fixture-note-alpha"),
|
||||
`agent did not return fixture note from MCP call: ${finalText}`,
|
||||
);
|
||||
assert(
|
||||
!/MCP\s+(?:was\s+)?not\s+defined|failed|error/i.test(finalText),
|
||||
`agent reported MCP failure instead of a successful call: ${finalText}`,
|
||||
);
|
||||
if (options.requireExec) {
|
||||
assert(options.plannedTools?.includes("exec"), "agent did not call code-mode exec");
|
||||
}
|
||||
assert(mentions.apiFileList > 0, "session log lacks API.list usage");
|
||||
assert(mentions.apiFileRead > 0, "session log lacks API.read usage");
|
||||
assert(mentions.mcpNamespace > 0, "session log lacks MCP.fixture usage");
|
||||
assert(mentions.mcpTool > 0, "session log lacks fixture__lookup_note call");
|
||||
assert(mentions.apiCall === 0, "agent should not call MCP.$api when API files are available");
|
||||
assert(mentions.toolSearchPollution === 0, "agent should not use tools.search for MCP lookup");
|
||||
return finalText;
|
||||
}
|
||||
124
scripts/e2e/lib/mock-openai-http.mjs
Normal file
124
scripts/e2e/lib/mock-openai-http.mjs
Normal file
@@ -0,0 +1,124 @@
|
||||
// Mock OpenAI-compatible HTTP server helpers for E2E scenarios.
|
||||
import fs from "node:fs";
|
||||
import { readPositiveIntEnv } from "./env-limits.mjs";
|
||||
|
||||
const DEFAULT_REQUEST_MAX_BYTES = 4 * 1024 * 1024;
|
||||
const DEFAULT_REQUEST_LOG_BODY_MAX_BYTES = 256 * 1024;
|
||||
const REQUEST_LOG_PREVIEW_CHARS = 4096;
|
||||
|
||||
export function readMockOpenAiHttpLimits(env = process.env) {
|
||||
return {
|
||||
requestMaxBytes: readPositiveIntEnv(
|
||||
"OPENCLAW_MOCK_OPENAI_REQUEST_MAX_BYTES",
|
||||
DEFAULT_REQUEST_MAX_BYTES,
|
||||
env,
|
||||
),
|
||||
requestLogBodyMaxBytes: readPositiveIntEnv(
|
||||
"OPENCLAW_MOCK_OPENAI_REQUEST_LOG_BODY_MAX_BYTES",
|
||||
DEFAULT_REQUEST_LOG_BODY_MAX_BYTES,
|
||||
env,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function requestBodyTooLargeError(limit) {
|
||||
return Object.assign(new Error(`mock OpenAI request body exceeded ${limit} bytes`), {
|
||||
code: "ETOOBIG",
|
||||
});
|
||||
}
|
||||
|
||||
export function isRequestBodyTooLargeError(error) {
|
||||
return error instanceof Error && error.code === "ETOOBIG";
|
||||
}
|
||||
|
||||
export function readBody(req, limits = readMockOpenAiHttpLimits()) {
|
||||
const { requestMaxBytes } = limits;
|
||||
return new Promise((resolve, reject) => {
|
||||
let body = "";
|
||||
let bytes = 0;
|
||||
let settled = false;
|
||||
req.setEncoding("utf8");
|
||||
req.on("data", (chunk) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
bytes += Buffer.byteLength(chunk, "utf8");
|
||||
if (bytes > requestMaxBytes) {
|
||||
settled = true;
|
||||
body = "";
|
||||
req.resume();
|
||||
reject(requestBodyTooLargeError(requestMaxBytes));
|
||||
return;
|
||||
}
|
||||
body += chunk;
|
||||
});
|
||||
req.on("end", () => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
resolve(body);
|
||||
}
|
||||
});
|
||||
req.on("error", (error) => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
reject(error instanceof Error ? error : new Error(String(error)));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function boundedRequestLogBody(value, bodyText, limits = readMockOpenAiHttpLimits()) {
|
||||
const { requestLogBodyMaxBytes } = limits;
|
||||
const byteLength = Buffer.byteLength(bodyText, "utf8");
|
||||
if (byteLength <= requestLogBodyMaxBytes) {
|
||||
return value;
|
||||
}
|
||||
return {
|
||||
truncated: true,
|
||||
byteLength,
|
||||
preview: bodyText.slice(0, REQUEST_LOG_PREVIEW_CHARS),
|
||||
};
|
||||
}
|
||||
|
||||
export function writeRequestLogEntryOrFail(
|
||||
res,
|
||||
{ requestLog, entry, label = "mock-openai", required = false },
|
||||
) {
|
||||
if (!requestLog) {
|
||||
if (!required) {
|
||||
return false;
|
||||
}
|
||||
const message = "MOCK_REQUEST_LOG is not configured";
|
||||
console.error(`${label} request log write failed: ${message}`);
|
||||
writeJson(res, 500, { error: { message: `mock OpenAI request log write failed: ${message}` } });
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
fs.appendFileSync(requestLog, `${JSON.stringify(entry)}\n`);
|
||||
return false;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.error(`${label} request log write failed: ${message}`);
|
||||
writeJson(res, 500, { error: { message: `mock OpenAI request log write failed: ${message}` } });
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export function writeJson(res, status, body) {
|
||||
res.writeHead(status, { "content-type": "application/json" });
|
||||
res.end(JSON.stringify(body));
|
||||
}
|
||||
|
||||
export function writeSse(res, events) {
|
||||
res.writeHead(200, {
|
||||
"content-type": "text/event-stream",
|
||||
"cache-control": "no-store",
|
||||
connection: "keep-alive",
|
||||
});
|
||||
for (const event of events) {
|
||||
res.write(`data: ${JSON.stringify(event)}\n\n`);
|
||||
}
|
||||
res.write("data: [DONE]\n\n");
|
||||
res.end();
|
||||
}
|
||||
275
scripts/e2e/lib/npm-onboard-channel-agent/assertions.mjs
Normal file
275
scripts/e2e/lib/npm-onboard-channel-agent/assertions.mjs
Normal file
@@ -0,0 +1,275 @@
|
||||
// Assertions for npm onboard channel-agent E2E scenarios.
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
import {
|
||||
assertAgentReplyContainsMarker,
|
||||
assertOpenAiRequestLogUsed,
|
||||
} from "../agent-turn-output.mjs";
|
||||
import { assertOpenAiEnvAuthProfileStore } from "../auth-profile-store-assertions.mjs";
|
||||
import { readPositiveIntEnv } from "../env-limits.mjs";
|
||||
import {
|
||||
applyMockOpenAiModelConfig,
|
||||
parseMockOpenAiPort,
|
||||
} from "../fixtures/mock-openai-config.mjs";
|
||||
import { readTextFileBounded, readTextFileTail } from "../text-file-utils.mjs";
|
||||
|
||||
const command = process.argv[2];
|
||||
const ERROR_DETAIL_TAIL_BYTES = 16 * 1024;
|
||||
const JSON_ARTIFACT_MAX_BYTES = readPositiveIntEnv(
|
||||
"OPENCLAW_NPM_ONBOARD_JSON_ARTIFACT_MAX_BYTES",
|
||||
1024 * 1024,
|
||||
);
|
||||
const STATUS_TEXT_MAX_BYTES = readPositiveIntEnv(
|
||||
"OPENCLAW_NPM_ONBOARD_STATUS_TEXT_MAX_BYTES",
|
||||
1024 * 1024,
|
||||
);
|
||||
const ansiEscapePattern = new RegExp(String.raw`\u001b\[[0-?]*[ -/]*[@-~]`, "g");
|
||||
|
||||
function readJson(file) {
|
||||
return JSON.parse(
|
||||
readTextFileBounded(file, "JSON artifact", JSON_ARTIFACT_MAX_BYTES, {
|
||||
tailBytes: ERROR_DETAIL_TAIL_BYTES,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function stripAnsi(text) {
|
||||
return text.replace(ansiEscapePattern, "");
|
||||
}
|
||||
|
||||
const statusSectionTitles = new Set([
|
||||
"openclaw status",
|
||||
"overview",
|
||||
"plugin compatibility",
|
||||
"model selection",
|
||||
"security audit",
|
||||
"channels",
|
||||
"sessions",
|
||||
"system events",
|
||||
"health",
|
||||
"usage",
|
||||
]);
|
||||
|
||||
function normalizedStatusHeading(line) {
|
||||
return stripAnsi(line)
|
||||
.trim()
|
||||
.replace(/^#+\s*/, "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
function extractStatusSection(text, title) {
|
||||
const target = title.toLowerCase();
|
||||
const lines = text.split(/\r?\n/);
|
||||
const start = lines.findIndex((line) => normalizedStatusHeading(line) === target);
|
||||
if (start === -1) {
|
||||
return null;
|
||||
}
|
||||
const section = [];
|
||||
for (const line of lines.slice(start + 1)) {
|
||||
const normalized = normalizedStatusHeading(line);
|
||||
if (normalized && statusSectionTitles.has(normalized)) {
|
||||
break;
|
||||
}
|
||||
section.push(line);
|
||||
}
|
||||
return stripAnsi(section.join("\n"));
|
||||
}
|
||||
|
||||
function readAuthProfileStoreText(agentDir) {
|
||||
const dbPath = path.join(agentDir, "openclaw-agent.sqlite");
|
||||
if (!fs.existsSync(dbPath)) {
|
||||
return "";
|
||||
}
|
||||
let db;
|
||||
try {
|
||||
db = new DatabaseSync(dbPath, { readOnly: true });
|
||||
const row = db
|
||||
.prepare("SELECT store_json FROM auth_profile_store WHERE store_key = ?")
|
||||
.get("primary");
|
||||
return typeof row?.store_json === "string" ? row.store_json : "";
|
||||
} catch {
|
||||
return "";
|
||||
} finally {
|
||||
db?.close();
|
||||
}
|
||||
}
|
||||
|
||||
function assertOnboardState() {
|
||||
const home = process.argv[3];
|
||||
const stateDir = path.join(home, ".openclaw");
|
||||
const configPath = path.join(stateDir, "openclaw.json");
|
||||
const agentDir = path.join(stateDir, "agents", "main", "agent");
|
||||
|
||||
if (!fs.existsSync(configPath)) {
|
||||
throw new Error("onboard did not write openclaw.json");
|
||||
}
|
||||
if (!fs.existsSync(agentDir)) {
|
||||
throw new Error("onboard did not create main agent dir");
|
||||
}
|
||||
const authStoreText = readAuthProfileStoreText(agentDir);
|
||||
if (!authStoreText) {
|
||||
throw new Error("onboard did not persist auth profile store");
|
||||
}
|
||||
assertOpenAiEnvAuthProfileStore(authStoreText, {
|
||||
envRefMessage: "auth profile did not persist OPENAI_API_KEY env ref",
|
||||
rawKeyMessage: "auth profile persisted the raw OpenAI test key",
|
||||
rawKeyNeedle: "sk-openclaw-npm-onboard-e2e",
|
||||
});
|
||||
}
|
||||
|
||||
function configureMockModel() {
|
||||
const mockPort = parseMockOpenAiPort(process.argv[3]);
|
||||
const configPath = path.join(process.env.HOME, ".openclaw", "openclaw.json");
|
||||
const cfg = readJson(configPath);
|
||||
applyMockOpenAiModelConfig(cfg, { mockPort });
|
||||
fs.writeFileSync(configPath, `${JSON.stringify(cfg, null, 2)}\n`);
|
||||
}
|
||||
|
||||
function assertMockModelConfig() {
|
||||
const mockPort = parseMockOpenAiPort(process.argv[3]);
|
||||
const expectedModelRef = "openai/gpt-5.5";
|
||||
const expectedBaseUrl = `http://127.0.0.1:${mockPort}/v1`;
|
||||
const configPath = path.join(process.env.HOME, ".openclaw", "openclaw.json");
|
||||
const cfg = readJson(configPath);
|
||||
const provider = cfg.models?.providers?.openai;
|
||||
const defaultModel = cfg.agents?.defaults?.model?.primary;
|
||||
const defaultRuntime = cfg.agents?.defaults?.models?.[expectedModelRef]?.agentRuntime?.id;
|
||||
const agent = Array.isArray(cfg.agents?.list)
|
||||
? (cfg.agents.list.find((entry) => entry?.id === "main") ?? cfg.agents.list[0])
|
||||
: undefined;
|
||||
const agentModel = agent?.model?.primary;
|
||||
const agentRuntime = agent?.models?.[expectedModelRef]?.agentRuntime?.id;
|
||||
if (provider?.baseUrl !== expectedBaseUrl) {
|
||||
throw new Error(
|
||||
`mock OpenAI baseUrl was not preserved; expected ${expectedBaseUrl}, got ${provider?.baseUrl}`,
|
||||
);
|
||||
}
|
||||
if (provider?.api !== "openai-responses") {
|
||||
throw new Error(`mock OpenAI api was not preserved; got ${provider?.api}`);
|
||||
}
|
||||
if (provider?.agentRuntime?.id !== "openclaw") {
|
||||
throw new Error(`mock OpenAI runtime was not preserved; got ${provider?.agentRuntime?.id}`);
|
||||
}
|
||||
if (defaultModel !== expectedModelRef) {
|
||||
throw new Error(
|
||||
`mock default model was not preserved; expected ${expectedModelRef}, got ${defaultModel}`,
|
||||
);
|
||||
}
|
||||
if (defaultRuntime !== "openclaw") {
|
||||
throw new Error(`mock default runtime was not preserved; got ${defaultRuntime}`);
|
||||
}
|
||||
if (agent && agentModel !== expectedModelRef) {
|
||||
throw new Error(
|
||||
`mock agent model was not preserved; expected ${expectedModelRef}, got ${agentModel}`,
|
||||
);
|
||||
}
|
||||
if (agent && agentRuntime !== "openclaw") {
|
||||
throw new Error(`mock agent runtime was not preserved; got ${agentRuntime}`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertChannelConfig() {
|
||||
const channel = process.argv[3];
|
||||
const expectedTokens = process.argv.slice(4);
|
||||
const configPath = path.join(process.env.HOME, ".openclaw", "openclaw.json");
|
||||
const cfg = readJson(configPath);
|
||||
const entry = cfg.channels?.[channel];
|
||||
if (!entry || entry.enabled === false) {
|
||||
throw new Error(`${channel} was not enabled`);
|
||||
}
|
||||
const assertTokenField = (field, expected) => {
|
||||
if (entry[field] !== expected) {
|
||||
throw new Error(
|
||||
`${channel} config did not persist ${field}; expected ${expected}, got ${JSON.stringify(entry[field])}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
switch (channel) {
|
||||
case "telegram": {
|
||||
if (expectedTokens.length !== 1) {
|
||||
throw new Error("telegram channel config assertion requires one bot token");
|
||||
}
|
||||
assertTokenField("botToken", expectedTokens[0]);
|
||||
return;
|
||||
}
|
||||
case "discord": {
|
||||
if (expectedTokens.length !== 1) {
|
||||
throw new Error("discord channel config assertion requires one bot token");
|
||||
}
|
||||
assertTokenField("token", expectedTokens[0]);
|
||||
return;
|
||||
}
|
||||
case "slack": {
|
||||
if (expectedTokens.length !== 2) {
|
||||
throw new Error("slack channel config assertion requires bot and app tokens");
|
||||
}
|
||||
assertTokenField("botToken", expectedTokens[0]);
|
||||
assertTokenField("appToken", expectedTokens[1]);
|
||||
return;
|
||||
}
|
||||
default:
|
||||
throw new Error(`unsupported channel config assertion: ${channel}`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertStatusSurfaces() {
|
||||
const channel = process.argv[3];
|
||||
const channelsStatusPath = process.argv[4];
|
||||
const statusTextPath = process.argv[5];
|
||||
const channelsStatus = readJson(channelsStatusPath);
|
||||
const statusText = readTextFileBounded(
|
||||
statusTextPath,
|
||||
"plain status output",
|
||||
STATUS_TEXT_MAX_BYTES,
|
||||
{ tailBytes: ERROR_DETAIL_TAIL_BYTES },
|
||||
);
|
||||
const statusTail = readTextFileTail(statusTextPath, ERROR_DETAIL_TAIL_BYTES);
|
||||
const configuredChannels = Array.isArray(channelsStatus.configuredChannels)
|
||||
? channelsStatus.configuredChannels
|
||||
: [];
|
||||
if (!configuredChannels.includes(channel)) {
|
||||
throw new Error(
|
||||
`channels status did not list configured channel ${channel}. Payload: ${JSON.stringify(channelsStatus)}`,
|
||||
);
|
||||
}
|
||||
if (!/channels/i.test(statusText)) {
|
||||
throw new Error(
|
||||
`plain status output did not render a Channels section. Output tail: ${statusTail}`,
|
||||
);
|
||||
}
|
||||
const channelsSection = extractStatusSection(statusText, "channels");
|
||||
if (!channelsSection) {
|
||||
throw new Error(
|
||||
`plain status output did not render a Channels section. Output tail: ${statusTail}`,
|
||||
);
|
||||
}
|
||||
if (!channelsSection.toLowerCase().includes(channel.toLowerCase())) {
|
||||
throw new Error(
|
||||
`plain status output did not mention ${channel} in the Channels section. Output tail: ${statusTail}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function assertAgentTurn() {
|
||||
const marker = process.argv[3];
|
||||
const logPath = process.argv[4];
|
||||
assertAgentReplyContainsMarker(marker, "/tmp/openclaw-agent.combined");
|
||||
assertOpenAiRequestLogUsed(logPath);
|
||||
}
|
||||
|
||||
const commands = {
|
||||
"assert-onboard-state": assertOnboardState,
|
||||
"configure-mock-model": configureMockModel,
|
||||
"assert-mock-model-config": assertMockModelConfig,
|
||||
"assert-channel-config": assertChannelConfig,
|
||||
"assert-status-surfaces": assertStatusSurfaces,
|
||||
"assert-agent-turn": assertAgentTurn,
|
||||
};
|
||||
|
||||
const fn = commands[command];
|
||||
if (!fn) {
|
||||
throw new Error(`unknown npm onboard/channel/agent assertion command: ${command}`);
|
||||
}
|
||||
fn();
|
||||
14
scripts/e2e/lib/npm-telegram-live/prepare-package.mjs
Normal file
14
scripts/e2e/lib/npm-telegram-live/prepare-package.mjs
Normal file
@@ -0,0 +1,14 @@
|
||||
// Prepares package manifests for npm Telegram live E2E scenarios.
|
||||
import fs from "node:fs";
|
||||
|
||||
for (const packageJsonPath of process.argv.slice(2)) {
|
||||
const pkg = JSON.parse(fs.readFileSync(packageJsonPath, "utf8"));
|
||||
pkg.exports = pkg.exports && typeof pkg.exports === "object" ? pkg.exports : {};
|
||||
if (!pkg.exports["./plugin-sdk/gateway-runtime"]) {
|
||||
pkg.exports["./plugin-sdk/gateway-runtime"] = {
|
||||
types: "./dist/plugin-sdk/gateway-runtime.d.ts",
|
||||
default: "./dist/plugin-sdk/gateway-runtime.js",
|
||||
};
|
||||
}
|
||||
fs.writeFileSync(packageJsonPath, `${JSON.stringify(pkg, null, 2)}\n`);
|
||||
}
|
||||
87
scripts/e2e/lib/onboard/assert-config.mjs
Normal file
87
scripts/e2e/lib/onboard/assert-config.mjs
Normal file
@@ -0,0 +1,87 @@
|
||||
// Config assertions for onboard E2E scenarios.
|
||||
import fs from "node:fs";
|
||||
import JSON5 from "json5";
|
||||
|
||||
const [scenario, configPath, expectedWorkspace] = process.argv.slice(2);
|
||||
if (!scenario || !configPath) {
|
||||
throw new Error("usage: assert-config.mjs <scenario> <config-path> [expected-workspace]");
|
||||
}
|
||||
|
||||
const cfg = JSON5.parse(fs.readFileSync(configPath, "utf8"));
|
||||
const errors = [];
|
||||
const got = (value) => value ?? "unset";
|
||||
const expectEqual = (label, actual, expected) => {
|
||||
if (actual !== expected) {
|
||||
errors.push(`${label} mismatch (got ${got(actual)})`);
|
||||
}
|
||||
};
|
||||
|
||||
const assertLocalWizard = () => {
|
||||
expectEqual("gateway.mode", cfg?.gateway?.mode, "local");
|
||||
expectEqual("wizard.lastRunMode", cfg?.wizard?.lastRunMode, "local");
|
||||
};
|
||||
|
||||
const assertSectionScopedConfigure = () => {
|
||||
expectEqual("wizard.lastRunCommand", cfg?.wizard?.lastRunCommand, "configure");
|
||||
expectEqual("wizard.lastRunMode", cfg?.wizard?.lastRunMode, "local");
|
||||
if (cfg?.gateway?.mode) {
|
||||
errors.push(`gateway.mode should stay unset (got ${cfg.gateway.mode})`);
|
||||
}
|
||||
};
|
||||
|
||||
switch (scenario) {
|
||||
case "local-basic": {
|
||||
expectEqual("agents.defaults.workspace", cfg?.agents?.defaults?.workspace, expectedWorkspace);
|
||||
assertLocalWizard();
|
||||
expectEqual("gateway.bind", cfg?.gateway?.bind, "loopback");
|
||||
expectEqual("gateway.tailscale.mode", cfg?.gateway?.tailscale?.mode ?? "off", "off");
|
||||
if (!cfg?.wizard?.lastRunAt) {
|
||||
errors.push("wizard.lastRunAt missing");
|
||||
}
|
||||
if (!cfg?.wizard?.lastRunVersion) {
|
||||
errors.push("wizard.lastRunVersion missing");
|
||||
}
|
||||
expectEqual("wizard.lastRunCommand", cfg?.wizard?.lastRunCommand, "onboard");
|
||||
break;
|
||||
}
|
||||
case "remote-non-interactive":
|
||||
expectEqual("gateway.mode", cfg?.gateway?.mode, "remote");
|
||||
expectEqual("gateway.remote.url", cfg?.gateway?.remote?.url, "ws://gateway.local:18789");
|
||||
expectEqual("gateway.remote.token", cfg?.gateway?.remote?.token, "remote-token");
|
||||
expectEqual("wizard.lastRunMode", cfg?.wizard?.lastRunMode, "remote");
|
||||
break;
|
||||
case "reset":
|
||||
assertLocalWizard();
|
||||
if (cfg?.gateway?.remote?.url) {
|
||||
errors.push(`gateway.remote.url should be cleared (got ${cfg.gateway.remote.url})`);
|
||||
}
|
||||
break;
|
||||
case "channels":
|
||||
if (cfg?.telegram?.botToken) {
|
||||
errors.push(`telegram.botToken should be unset (got ${cfg.telegram.botToken})`);
|
||||
}
|
||||
if (cfg?.discord?.token) {
|
||||
errors.push(`discord.token should be unset (got ${cfg.discord.token})`);
|
||||
}
|
||||
if (cfg?.slack?.botToken || cfg?.slack?.appToken) {
|
||||
errors.push(
|
||||
`slack tokens should be unset (got bot=${got(cfg?.slack?.botToken)}, app=${got(cfg?.slack?.appToken)})`,
|
||||
);
|
||||
}
|
||||
assertSectionScopedConfigure();
|
||||
break;
|
||||
case "skills":
|
||||
expectEqual("skills.install.nodeManager", cfg?.skills?.install?.nodeManager, "bun");
|
||||
if (!Array.isArray(cfg?.skills?.allowBundled) || cfg.skills.allowBundled[0] !== "__none__") {
|
||||
errors.push("skills.allowBundled missing");
|
||||
}
|
||||
assertSectionScopedConfigure();
|
||||
break;
|
||||
default:
|
||||
throw new Error(`unknown onboard assertion scenario: ${scenario}`);
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
console.error(errors.join("\n"));
|
||||
process.exit(1);
|
||||
}
|
||||
58
scripts/e2e/lib/onboard/log-contains.mjs
Normal file
58
scripts/e2e/lib/onboard/log-contains.mjs
Normal file
@@ -0,0 +1,58 @@
|
||||
// Log substring assertion helper for onboard E2E scenarios.
|
||||
import fs from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
export const DEFAULT_MAX_LOG_BYTES = 120_000;
|
||||
|
||||
const normalizeScriptOutput = (value) => value.replace(/\r?\n/g, "").replace(/\r/g, "");
|
||||
const oscPattern = new RegExp(String.raw`\u001b\][^\u0007]*(?:\u0007|\u001b\\)`, "g");
|
||||
const csiPattern = new RegExp(String.raw`\u001b\[[0-?]*[ -/]*[@-~]`, "g");
|
||||
|
||||
const stripAnsi = (value) =>
|
||||
normalizeScriptOutput(value).replace(oscPattern, "").replace(csiPattern, "");
|
||||
|
||||
const compact = (value) =>
|
||||
stripAnsi(value)
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z]+/g, "");
|
||||
|
||||
export function readLogTail(file, maxBytes = DEFAULT_MAX_LOG_BYTES) {
|
||||
if (!Number.isSafeInteger(maxBytes) || maxBytes < 1) {
|
||||
throw new Error("maxBytes must be a positive integer");
|
||||
}
|
||||
const stats = fs.statSync(file);
|
||||
if (!stats.isFile()) {
|
||||
throw new Error(`${file} is not a file`);
|
||||
}
|
||||
const length = Math.min(stats.size, maxBytes);
|
||||
const start = Math.max(0, stats.size - length);
|
||||
const fd = fs.openSync(file, "r");
|
||||
try {
|
||||
const buffer = Buffer.alloc(length);
|
||||
const bytesRead = fs.readSync(fd, buffer, 0, length, start);
|
||||
return buffer.subarray(0, bytesRead).toString("utf8");
|
||||
} finally {
|
||||
fs.closeSync(fd);
|
||||
}
|
||||
}
|
||||
|
||||
export function logTailContains(file, needle, maxBytes = DEFAULT_MAX_LOG_BYTES) {
|
||||
const compactNeedle = compact(needle);
|
||||
if (!compactNeedle) {
|
||||
return false;
|
||||
}
|
||||
return compact(readLogTail(file, maxBytes)).includes(compactNeedle);
|
||||
}
|
||||
|
||||
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
|
||||
const [file, needle] = process.argv.slice(2);
|
||||
if (!file || !needle) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
process.exit(logTailContains(file, needle) ? 0 : 1);
|
||||
} catch {
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
299
scripts/e2e/lib/onboard/scenario.sh
Normal file
299
scripts/e2e/lib/onboard/scenario.sh
Normal file
@@ -0,0 +1,299 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
trap "" PIPE
|
||||
export TERM=xterm-256color
|
||||
source scripts/lib/openclaw-e2e-instance.sh
|
||||
OPENCLAW_ONBOARD_SCENARIO_SOURCE_ONLY="${OPENCLAW_ONBOARD_SCENARIO_SOURCE_ONLY:-0}"
|
||||
if [ "$OPENCLAW_ONBOARD_SCENARIO_SOURCE_ONLY" != "1" ]; then
|
||||
openclaw_e2e_eval_test_state_from_b64 "${OPENCLAW_TEST_STATE_FUNCTION_B64:?missing OPENCLAW_TEST_STATE_FUNCTION_B64}"
|
||||
fi
|
||||
ONBOARD_FLAGS="${ONBOARD_FLAGS:---flow quickstart --auth-choice skip --skip-channels --skip-skills --skip-daemon --skip-ui}"
|
||||
if [ -z "${OPENCLAW_ENTRY:-}" ] && [ "$OPENCLAW_ONBOARD_SCENARIO_SOURCE_ONLY" != "1" ]; then
|
||||
OPENCLAW_ENTRY="$(openclaw_e2e_resolve_entrypoint)"
|
||||
fi
|
||||
export OPENCLAW_ENTRY
|
||||
ONBOARD_TMP_ROOT="${OPENCLAW_ONBOARD_E2E_TMPDIR:-${TMPDIR:-/tmp}}"
|
||||
ONBOARD_TMP_ROOT="${ONBOARD_TMP_ROOT%/}"
|
||||
[ -n "$ONBOARD_TMP_ROOT" ] || ONBOARD_TMP_ROOT="/tmp"
|
||||
mkdir -p "$ONBOARD_TMP_ROOT"
|
||||
ONBOARD_TMP_DIR="$(mktemp -d "$ONBOARD_TMP_ROOT/openclaw-onboard.XXXXXX")"
|
||||
OPENCLAW_E2E_LOG_DIR="$ONBOARD_TMP_DIR/logs"
|
||||
GATEWAY_LOG_PATH="$ONBOARD_TMP_DIR/gateway-e2e.log"
|
||||
export OPENCLAW_E2E_LOG_DIR
|
||||
export GATEWAY_LOG_PATH
|
||||
mkdir -p "$OPENCLAW_E2E_LOG_DIR"
|
||||
cleanup_onboard_artifacts() {
|
||||
openclaw_e2e_stop_process "${GATEWAY_PID:-}"
|
||||
rm -rf "$ONBOARD_TMP_DIR"
|
||||
}
|
||||
if [ "$OPENCLAW_ONBOARD_SCENARIO_SOURCE_ONLY" != "1" ]; then
|
||||
trap cleanup_onboard_artifacts EXIT
|
||||
fi
|
||||
|
||||
# Provide a minimal trash shim to avoid noisy "missing trash" logs in containers.
|
||||
if [ "$OPENCLAW_ONBOARD_SCENARIO_SOURCE_ONLY" != "1" ]; then
|
||||
openclaw_e2e_install_trash_shim
|
||||
fi
|
||||
|
||||
send() {
|
||||
local payload="$1"
|
||||
local delay="${2:-0.4}"
|
||||
# Let prompts render before sending keystrokes.
|
||||
sleep "$delay"
|
||||
printf "%b" "$payload" >&3 2>/dev/null || true
|
||||
}
|
||||
|
||||
wait_for_log() {
|
||||
local needle="$1"
|
||||
local timeout_s="${2:-45}"
|
||||
local quiet_on_timeout="${3:-false}"
|
||||
local start_s
|
||||
start_s="$(date +%s)"
|
||||
while true; do
|
||||
if [ -n "${WIZARD_LOG_PATH:-}" ] && [ -f "$WIZARD_LOG_PATH" ]; then
|
||||
if grep -a -F -q "$needle" "$WIZARD_LOG_PATH"; then
|
||||
return 0
|
||||
fi
|
||||
if node scripts/e2e/lib/onboard/log-contains.mjs "$WIZARD_LOG_PATH" "$needle"; then
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
if [ $(($(date +%s) - start_s)) -ge "$timeout_s" ]; then
|
||||
if [ "$quiet_on_timeout" = "true" ]; then
|
||||
return 1
|
||||
fi
|
||||
echo "Timeout waiting for log: $needle"
|
||||
if [ -n "${WIZARD_LOG_PATH:-}" ] && [ -f "$WIZARD_LOG_PATH" ]; then
|
||||
tail -n 140 "$WIZARD_LOG_PATH" || true
|
||||
fi
|
||||
return 1
|
||||
fi
|
||||
sleep 0.2
|
||||
done
|
||||
}
|
||||
|
||||
start_gateway() {
|
||||
GATEWAY_PID="$(openclaw_e2e_start_gateway "$OPENCLAW_ENTRY" 18789 "$GATEWAY_LOG_PATH")"
|
||||
}
|
||||
|
||||
wait_for_gateway() {
|
||||
local wait_attempts
|
||||
wait_attempts="$(openclaw_e2e_read_positive_int_env OPENCLAW_ONBOARD_GATEWAY_WAIT_ATTEMPTS 20)" || return $?
|
||||
local wait_interval_s="${OPENCLAW_ONBOARD_GATEWAY_WAIT_INTERVAL_S:-1}"
|
||||
local saw_listening_log="false"
|
||||
for _ in $(seq 1 "$wait_attempts"); do
|
||||
if openclaw_e2e_probe_tcp 127.0.0.1 18789 500 >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
if [ -f "$GATEWAY_LOG_PATH" ] && grep -E -q "listening on ws://[^ ]+:18789" "$GATEWAY_LOG_PATH"; then
|
||||
saw_listening_log="true"
|
||||
fi
|
||||
sleep "$wait_interval_s"
|
||||
done
|
||||
echo "Gateway failed to start"
|
||||
if [ "$saw_listening_log" = "true" ]; then
|
||||
echo "Gateway log reported listening, but TCP probe never succeeded"
|
||||
fi
|
||||
cat "$GATEWAY_LOG_PATH" || true
|
||||
return 1
|
||||
}
|
||||
|
||||
stop_gateway() {
|
||||
openclaw_e2e_stop_process "$1"
|
||||
}
|
||||
|
||||
cleanup_wizard_case() {
|
||||
exec 3>&- 2>/dev/null || true
|
||||
openclaw_e2e_stop_process "${wizard_pid:-}"
|
||||
stop_gateway "${gw_pid:-}"
|
||||
rm -rf "${input_fifo_dir:-}"
|
||||
}
|
||||
|
||||
run_wizard_cmd() {
|
||||
local case_name="$1"
|
||||
local state_ref="$2"
|
||||
local command="$3"
|
||||
local send_fn="$4"
|
||||
local with_gateway="${5:-false}"
|
||||
local validate_fn="${6:-}"
|
||||
local input_fifo_dir=""
|
||||
local input_fifo=""
|
||||
local wizard_pid=""
|
||||
local gw_pid=""
|
||||
local wizard_status=0
|
||||
|
||||
echo "== Wizard case: $case_name =="
|
||||
set_isolated_openclaw_env "$state_ref"
|
||||
|
||||
input_fifo_dir="$(mktemp -d "$ONBOARD_TMP_DIR/${case_name}.fifo.XXXXXX")"
|
||||
input_fifo="$input_fifo_dir/stdin.fifo"
|
||||
if ! mkfifo "$input_fifo"; then
|
||||
rm -rf "$input_fifo_dir"
|
||||
return 1
|
||||
fi
|
||||
local log_path="$OPENCLAW_E2E_LOG_DIR/${case_name}.log"
|
||||
WIZARD_LOG_PATH="$log_path"
|
||||
export WIZARD_LOG_PATH
|
||||
# Run under script to keep an interactive TTY for clack prompts.
|
||||
openclaw_e2e_run_script_with_pty "$command" "$log_path" <"$input_fifo" >/dev/null 2>&1 &
|
||||
wizard_pid=$!
|
||||
if ! exec 3>"$input_fifo"; then
|
||||
cleanup_wizard_case
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [ "$with_gateway" = "true" ]; then
|
||||
start_gateway
|
||||
gw_pid="$GATEWAY_PID"
|
||||
if ! wait_for_gateway; then
|
||||
cleanup_wizard_case
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
"$send_fn" || wizard_status=$?
|
||||
if [ "$wizard_status" -ne 0 ]; then
|
||||
cleanup_wizard_case
|
||||
echo "Wizard input driver exited with status $wizard_status"
|
||||
if [ -f "$log_path" ]; then
|
||||
tail -n 160 "$log_path" || true
|
||||
fi
|
||||
exit "$wizard_status"
|
||||
fi
|
||||
|
||||
wait "$wizard_pid" || wizard_status=$?
|
||||
wizard_pid=""
|
||||
if [ "$wizard_status" -ne 0 ]; then
|
||||
cleanup_wizard_case
|
||||
echo "Wizard exited with status $wizard_status"
|
||||
if [ -f "$log_path" ]; then
|
||||
tail -n 160 "$log_path" || true
|
||||
fi
|
||||
exit "$wizard_status"
|
||||
fi
|
||||
cleanup_wizard_case
|
||||
if [ -n "$validate_fn" ]; then
|
||||
"$validate_fn" "$log_path"
|
||||
fi
|
||||
}
|
||||
|
||||
assert_onboard_config() {
|
||||
local scenario="$1"
|
||||
shift
|
||||
openclaw_e2e_assert_file "$OPENCLAW_CONFIG_PATH"
|
||||
node scripts/e2e/lib/onboard/assert-config.mjs "$scenario" "$OPENCLAW_CONFIG_PATH" "$@"
|
||||
}
|
||||
|
||||
set_isolated_openclaw_env() {
|
||||
local state_ref="$1"
|
||||
openclaw_test_state_create "$state_ref" empty
|
||||
}
|
||||
|
||||
send_channels_flow() {
|
||||
# Configure channels via configure wizard. Use the remove-config branch for
|
||||
# a stable no-op smoke path when the config starts empty.
|
||||
# Section-scoped configure flows skip gateway run-mode selection.
|
||||
wait_for_log "Channel setup" 120
|
||||
send $'\e[B\r' 0.8
|
||||
# Keep stdin open until wizard exits.
|
||||
send "" 2.0
|
||||
}
|
||||
|
||||
send_skills_flow() {
|
||||
# configure --section skills still runs the configure wizard, without the
|
||||
# gateway run-mode prompt used by the full wizard.
|
||||
wait_for_log "Configure skills now?" 120
|
||||
send $'n\r' 0.8
|
||||
send "" 2.0
|
||||
}
|
||||
|
||||
run_case_local_basic() {
|
||||
set_isolated_openclaw_env local-basic
|
||||
openclaw_e2e_run_logged local-basic node "$OPENCLAW_ENTRY" onboard \
|
||||
--non-interactive \
|
||||
--accept-risk \
|
||||
--flow quickstart \
|
||||
--mode local \
|
||||
--skip-channels \
|
||||
--skip-skills \
|
||||
--skip-daemon \
|
||||
--skip-ui \
|
||||
--skip-health
|
||||
|
||||
validate_local_basic_log "$OPENCLAW_E2E_LAST_LOG_PATH"
|
||||
|
||||
# Assert config + workspace scaffolding.
|
||||
workspace_dir="$OPENCLAW_STATE_DIR/workspace"
|
||||
sessions_dir="$OPENCLAW_STATE_DIR/agents/main/sessions"
|
||||
|
||||
openclaw_e2e_assert_dir "$sessions_dir"
|
||||
for file in AGENTS.md BOOTSTRAP.md IDENTITY.md SOUL.md TOOLS.md USER.md; do
|
||||
openclaw_e2e_assert_file "$workspace_dir/$file"
|
||||
done
|
||||
|
||||
assert_onboard_config local-basic "$workspace_dir"
|
||||
|
||||
}
|
||||
|
||||
run_case_remote_non_interactive() {
|
||||
set_isolated_openclaw_env remote-non-interactive
|
||||
# Smoke test non-interactive remote config write.
|
||||
openclaw_e2e_run_logged remote-non-interactive node "$OPENCLAW_ENTRY" onboard --non-interactive --accept-risk \
|
||||
--mode remote \
|
||||
--remote-url ws://gateway.local:18789 \
|
||||
--remote-token remote-token \
|
||||
--skip-skills \
|
||||
--skip-health
|
||||
|
||||
assert_onboard_config remote-non-interactive
|
||||
}
|
||||
|
||||
run_case_reset() {
|
||||
set_isolated_openclaw_env reset-config
|
||||
node scripts/e2e/lib/onboard/write-config.mjs reset "$OPENCLAW_CONFIG_PATH"
|
||||
|
||||
openclaw_e2e_run_logged reset-config node "$OPENCLAW_ENTRY" onboard \
|
||||
--non-interactive \
|
||||
--accept-risk \
|
||||
--flow quickstart \
|
||||
--mode local \
|
||||
--reset \
|
||||
--skip-channels \
|
||||
--skip-skills \
|
||||
--skip-daemon \
|
||||
--skip-ui \
|
||||
--skip-health
|
||||
|
||||
assert_onboard_config reset
|
||||
}
|
||||
|
||||
run_case_channels() {
|
||||
# Channels-only configure flow.
|
||||
run_wizard_cmd channels channels "node \"$OPENCLAW_ENTRY\" configure --section channels" send_channels_flow
|
||||
|
||||
assert_onboard_config channels
|
||||
}
|
||||
|
||||
run_case_skills() {
|
||||
local home_dir
|
||||
set_isolated_openclaw_env skills
|
||||
home_dir="$HOME"
|
||||
node scripts/e2e/lib/onboard/write-config.mjs skills "$OPENCLAW_CONFIG_PATH"
|
||||
|
||||
run_wizard_cmd skills "$home_dir" "node \"$OPENCLAW_ENTRY\" configure --section skills" send_skills_flow
|
||||
|
||||
assert_onboard_config skills
|
||||
}
|
||||
|
||||
validate_local_basic_log() {
|
||||
local log_path="$1"
|
||||
openclaw_e2e_assert_log_not_contains "$log_path" "systemctl --user unavailable"
|
||||
}
|
||||
|
||||
if [ "$OPENCLAW_ONBOARD_SCENARIO_SOURCE_ONLY" != "1" ]; then
|
||||
run_case_local_basic
|
||||
run_case_remote_non_interactive
|
||||
run_case_reset
|
||||
run_case_channels
|
||||
run_case_skills
|
||||
fi
|
||||
21
scripts/e2e/lib/onboard/write-config.mjs
Normal file
21
scripts/e2e/lib/onboard/write-config.mjs
Normal file
@@ -0,0 +1,21 @@
|
||||
// Config writer helper for onboard E2E scenarios.
|
||||
import fs from "node:fs";
|
||||
|
||||
const [scenario, configPath] = process.argv.slice(2);
|
||||
if (!scenario || !configPath) {
|
||||
throw new Error("usage: write-config.mjs <reset|skills> <config-path>");
|
||||
}
|
||||
|
||||
const config = {
|
||||
reset: {
|
||||
meta: {},
|
||||
agents: { defaults: { workspace: "/root/old" } },
|
||||
gateway: { mode: "remote", remote: { url: "ws://old.example:18789", token: "old-token" } },
|
||||
},
|
||||
skills: { meta: {}, skills: { allowBundled: ["__none__"], install: { nodeManager: "bun" } } },
|
||||
}[scenario];
|
||||
if (!config) {
|
||||
throw new Error(`unknown config scenario: ${scenario}`);
|
||||
}
|
||||
|
||||
fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`);
|
||||
205
scripts/e2e/lib/openai-chat-tools/client.mjs
Normal file
205
scripts/e2e/lib/openai-chat-tools/client.mjs
Normal file
@@ -0,0 +1,205 @@
|
||||
// Gateway client for OpenAI chat tools E2E scenarios.
|
||||
import { readPositiveIntEnv, readTcpPortEnv } from "../env-limits.mjs";
|
||||
|
||||
const portText = process.env.PORT;
|
||||
const token = process.env.OPENCLAW_GATEWAY_TOKEN;
|
||||
const backendModel = process.env.MODEL_REF || "openai/gpt-5.4-mini";
|
||||
|
||||
const timeoutSeconds = readPositiveIntEnv("OPENCLAW_OPENAI_CHAT_TOOLS_TIMEOUT_SECONDS", 180);
|
||||
const maxBodyBytes = readPositiveIntEnv("OPENCLAW_OPENAI_CHAT_TOOLS_MAX_BODY_BYTES", 1048576);
|
||||
|
||||
if (!portText || !token) {
|
||||
throw new Error("missing PORT/OPENCLAW_GATEWAY_TOKEN");
|
||||
}
|
||||
const port = readTcpPortEnv("PORT", portText);
|
||||
if (!Number.isFinite(timeoutSeconds) || timeoutSeconds <= 0) {
|
||||
throw new Error(`invalid OPENCLAW_OPENAI_CHAT_TOOLS_TIMEOUT_SECONDS: ${timeoutSeconds}`);
|
||||
}
|
||||
if (!Number.isFinite(maxBodyBytes) || maxBodyBytes <= 0) {
|
||||
throw new Error(`invalid OPENCLAW_OPENAI_CHAT_TOOLS_MAX_BODY_BYTES: ${maxBodyBytes}`);
|
||||
}
|
||||
|
||||
function cancelReaderSoon(reader) {
|
||||
void Promise.resolve()
|
||||
.then(() => reader.cancel())
|
||||
.catch(() => undefined);
|
||||
}
|
||||
|
||||
async function readResponseChunk(reader, timeoutPromise, markCanceled) {
|
||||
const readPromise = reader.read();
|
||||
if (!timeoutPromise) {
|
||||
return await readPromise;
|
||||
}
|
||||
|
||||
let waitingForRead = true;
|
||||
const timeoutReadPromise = timeoutPromise.catch((error) => {
|
||||
if (waitingForRead) {
|
||||
markCanceled();
|
||||
cancelReaderSoon(reader);
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
|
||||
try {
|
||||
return await Promise.race([readPromise, timeoutReadPromise]);
|
||||
} finally {
|
||||
waitingForRead = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function readBoundedResponseText(response, byteLimit, timeoutPromise) {
|
||||
const contentLength = response.headers?.get?.("content-length");
|
||||
if (contentLength && /^\d+$/u.test(contentLength)) {
|
||||
const parsedContentLength = Number(contentLength);
|
||||
if (!Number.isSafeInteger(parsedContentLength) || parsedContentLength > byteLimit) {
|
||||
await response.body?.cancel().catch(() => undefined);
|
||||
throw new Error(`chat completions response body exceeded ${byteLimit} bytes`);
|
||||
}
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) {
|
||||
return "";
|
||||
}
|
||||
const chunks = [];
|
||||
let totalBytes = 0;
|
||||
let canceled = false;
|
||||
try {
|
||||
for (;;) {
|
||||
const { done, value } = await readResponseChunk(reader, timeoutPromise, () => {
|
||||
canceled = true;
|
||||
});
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
totalBytes += value.byteLength;
|
||||
if (totalBytes > byteLimit) {
|
||||
canceled = true;
|
||||
await reader.cancel();
|
||||
throw new Error(`chat completions response body exceeded ${byteLimit} bytes`);
|
||||
}
|
||||
chunks.push(Buffer.from(value));
|
||||
}
|
||||
} finally {
|
||||
if (!canceled) {
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
return Buffer.concat(chunks, totalBytes).toString("utf8");
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutError = new Error(`chat completions request timed out after ${timeoutSeconds}s`);
|
||||
let timeout;
|
||||
const timeoutPromise = new Promise((_, reject) => {
|
||||
timeout = setTimeout(() => {
|
||||
controller.abort(timeoutError);
|
||||
reject(timeoutError);
|
||||
}, timeoutSeconds * 1000);
|
||||
timeout.unref?.();
|
||||
});
|
||||
const started = Date.now();
|
||||
let response;
|
||||
let text;
|
||||
try {
|
||||
response = await Promise.race([
|
||||
fetch(`http://127.0.0.1:${port}/v1/chat/completions`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
authorization: `Bearer ${token}`,
|
||||
"content-type": "application/json",
|
||||
"x-openclaw-model": backendModel,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: "openclaw",
|
||||
stream: false,
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content:
|
||||
"Use the get_weather tool exactly once for Paris, France. Return the tool call only.",
|
||||
},
|
||||
],
|
||||
tool_choice: "auto",
|
||||
tools: [
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "get_weather",
|
||||
description: "Return weather for a city.",
|
||||
strict: true,
|
||||
parameters: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
city: { type: "string", description: "City and country." },
|
||||
},
|
||||
required: ["city"],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
signal: controller.signal,
|
||||
}),
|
||||
timeoutPromise,
|
||||
]);
|
||||
text = await readBoundedResponseText(response, maxBodyBytes, timeoutPromise);
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
|
||||
let body;
|
||||
try {
|
||||
body = text ? JSON.parse(text) : {};
|
||||
} catch {
|
||||
throw new Error(`non-JSON response ${response.status}: ${text}`);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`chat completions request failed ${response.status}: ${JSON.stringify(body)}`);
|
||||
}
|
||||
|
||||
const choice = body.choices?.[0];
|
||||
const toolCalls = choice?.message?.tool_calls;
|
||||
if (choice?.finish_reason !== "tool_calls") {
|
||||
throw new Error(`expected finish_reason tool_calls: ${JSON.stringify(body)}`);
|
||||
}
|
||||
const messageContent = choice?.message?.content;
|
||||
const hasVisibleContent =
|
||||
(typeof messageContent === "string" && messageContent.trim().length > 0) ||
|
||||
(Array.isArray(messageContent) && messageContent.length > 0) ||
|
||||
(messageContent !== undefined &&
|
||||
messageContent !== null &&
|
||||
typeof messageContent !== "string" &&
|
||||
!Array.isArray(messageContent));
|
||||
if (hasVisibleContent) {
|
||||
throw new Error(`expected tool call only response: ${JSON.stringify(choice.message)}`);
|
||||
}
|
||||
if (!Array.isArray(toolCalls) || toolCalls.length !== 1) {
|
||||
throw new Error(`expected exactly one tool call: ${JSON.stringify(body)}`);
|
||||
}
|
||||
const [toolCall] = toolCalls;
|
||||
if (toolCall?.type !== "function" || toolCall?.function?.name !== "get_weather") {
|
||||
throw new Error(`unexpected tool call: ${JSON.stringify(toolCall)}`);
|
||||
}
|
||||
|
||||
let args;
|
||||
try {
|
||||
args = JSON.parse(toolCall.function.arguments || "{}");
|
||||
} catch {
|
||||
throw new Error(`tool arguments were not valid JSON: ${toolCall.function.arguments}`);
|
||||
}
|
||||
if (typeof args.city !== "string" || !/paris/i.test(args.city)) {
|
||||
throw new Error(`expected Paris city argument: ${JSON.stringify(args)}`);
|
||||
}
|
||||
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
ok: true,
|
||||
elapsedMs: Date.now() - started,
|
||||
finishReason: choice.finish_reason,
|
||||
toolName: toolCall.function.name,
|
||||
args,
|
||||
}),
|
||||
);
|
||||
86
scripts/e2e/lib/openai-chat-tools/scenario.sh
Normal file
86
scripts/e2e/lib/openai-chat-tools/scenario.sh
Normal file
@@ -0,0 +1,86 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
source scripts/lib/openclaw-e2e-instance.sh
|
||||
openclaw_e2e_eval_test_state_from_b64 "${OPENCLAW_TEST_STATE_SCRIPT_B64:?missing OPENCLAW_TEST_STATE_SCRIPT_B64}"
|
||||
export OPENCLAW_SKIP_CHANNELS=1
|
||||
export OPENCLAW_SKIP_GMAIL_WATCHER=1
|
||||
export OPENCLAW_SKIP_CRON=1
|
||||
export OPENCLAW_SKIP_CANVAS_HOST=1
|
||||
export OPENCLAW_SKIP_BROWSER_CONTROL_SERVER=1
|
||||
export OPENCLAW_SKIP_ACPX_RUNTIME=1
|
||||
export OPENCLAW_SKIP_ACPX_RUNTIME_PROBE=1
|
||||
export OPENCLAW_AGENT_HARNESS_FALLBACK=none
|
||||
|
||||
for profile_path in "$HOME/.profile" /home/appuser/.profile; do
|
||||
if [ -f "$profile_path" ] && [ -r "$profile_path" ]; then
|
||||
set +e +u
|
||||
# shellcheck disable=SC1090
|
||||
source "$profile_path"
|
||||
set -euo pipefail
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [ -z "${OPENAI_API_KEY:-}" ]; then
|
||||
echo "ERROR: OPENAI_API_KEY was not available after sourcing ~/.profile." >&2
|
||||
exit 1
|
||||
fi
|
||||
export OPENAI_API_KEY
|
||||
if [ -n "${OPENAI_BASE_URL:-}" ]; then
|
||||
export OPENAI_BASE_URL
|
||||
fi
|
||||
|
||||
PORT="${PORT:?missing PORT}"
|
||||
TOKEN="${OPENCLAW_GATEWAY_TOKEN:?missing OPENCLAW_GATEWAY_TOKEN}"
|
||||
MODEL_REF="${OPENCLAW_OPENAI_CHAT_TOOLS_MODEL:?missing OPENCLAW_OPENAI_CHAT_TOOLS_MODEL}"
|
||||
GATEWAY_LOG="/tmp/openclaw-openai-chat-tools-gateway.log"
|
||||
CLIENT_LOG="/tmp/openclaw-openai-chat-tools-client.log"
|
||||
gateway_pid=""
|
||||
|
||||
cleanup() {
|
||||
openclaw_e2e_stop_process "$gateway_pid"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
dump_debug_logs() {
|
||||
local status="$1"
|
||||
echo "OpenAI Chat Completions tools Docker E2E failed with exit code $status" >&2
|
||||
openclaw_e2e_dump_logs "$GATEWAY_LOG" "$CLIENT_LOG"
|
||||
if [ -f "$OPENCLAW_CONFIG_PATH" ]; then
|
||||
echo "--- $OPENCLAW_CONFIG_PATH keys ---" >&2
|
||||
node -e "const fs=require('fs'); const cfg=JSON.parse(fs.readFileSync(process.argv[1],'utf8')); console.error(JSON.stringify({model:cfg.agents?.defaults?.model, tools:cfg.tools, provider:cfg.models?.providers?.openai && {api:cfg.models.providers.openai.api, baseUrl:cfg.models.providers.openai.baseUrl, agentRuntime:cfg.models.providers.openai.agentRuntime}}, null, 2));" "$OPENCLAW_CONFIG_PATH" || true
|
||||
fi
|
||||
}
|
||||
trap 'status=$?; dump_debug_logs "$status"; exit "$status"' ERR
|
||||
|
||||
entry="$(openclaw_e2e_resolve_entrypoint)"
|
||||
mkdir -p "$OPENCLAW_STATE_DIR" "$OPENCLAW_TEST_WORKSPACE_DIR"
|
||||
|
||||
node scripts/e2e/lib/openai-chat-tools/write-config.mjs
|
||||
|
||||
gateway_pid="$(openclaw_e2e_start_gateway "$entry" "$PORT" "$GATEWAY_LOG")"
|
||||
for _ in $(seq 1 360); do
|
||||
if ! kill -0 "$gateway_pid" 2>/dev/null; then
|
||||
echo "gateway exited before listening" >&2
|
||||
exit 1
|
||||
fi
|
||||
if node "$entry" gateway health \
|
||||
--url "ws://127.0.0.1:$PORT" \
|
||||
--token "$TOKEN" \
|
||||
--timeout 120000 \
|
||||
--json >/dev/null 2>&1; then
|
||||
break
|
||||
fi
|
||||
sleep 0.25
|
||||
done
|
||||
node "$entry" gateway health \
|
||||
--url "ws://127.0.0.1:$PORT" \
|
||||
--token "$TOKEN" \
|
||||
--timeout 120000 \
|
||||
--json >/dev/null
|
||||
|
||||
PORT="$PORT" OPENCLAW_GATEWAY_TOKEN="$TOKEN" MODEL_REF="$MODEL_REF" \
|
||||
node scripts/e2e/lib/openai-chat-tools/client.mjs >"$CLIENT_LOG" 2>&1
|
||||
|
||||
openclaw_e2e_print_log "$CLIENT_LOG"
|
||||
echo "OpenAI Chat Completions tools Docker E2E passed"
|
||||
90
scripts/e2e/lib/openai-chat-tools/write-config.mjs
Normal file
90
scripts/e2e/lib/openai-chat-tools/write-config.mjs
Normal file
@@ -0,0 +1,90 @@
|
||||
// Config writer for OpenAI chat tools E2E scenarios.
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { readPositiveIntEnv, readTcpPortEnv } from "../env-limits.mjs";
|
||||
|
||||
function requireEnv(name) {
|
||||
const value = process.env[name];
|
||||
if (!value) {
|
||||
throw new Error(`missing ${name}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
const configPath = requireEnv("OPENCLAW_CONFIG_PATH");
|
||||
const stateDir = requireEnv("OPENCLAW_STATE_DIR");
|
||||
const workspaceDir = requireEnv("OPENCLAW_TEST_WORKSPACE_DIR");
|
||||
const modelRef = requireEnv("OPENCLAW_OPENAI_CHAT_TOOLS_MODEL");
|
||||
const token = requireEnv("OPENCLAW_GATEWAY_TOKEN");
|
||||
const timeoutSeconds = readPositiveIntEnv("OPENCLAW_OPENAI_CHAT_TOOLS_TIMEOUT_SECONDS", 180);
|
||||
const gatewayPort = readTcpPortEnv("PORT", 18789);
|
||||
const [providerId, modelId] = modelRef.split("/");
|
||||
if (providerId !== "openai" || !modelId) {
|
||||
throw new Error(`OPENCLAW_OPENAI_CHAT_TOOLS_MODEL must be openai/*, got ${modelRef}`);
|
||||
}
|
||||
|
||||
const config = {
|
||||
gateway: {
|
||||
port: gatewayPort,
|
||||
bind: "loopback",
|
||||
auth: { mode: "token", token },
|
||||
controlUi: { enabled: false },
|
||||
http: {
|
||||
endpoints: {
|
||||
chatCompletions: { enabled: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
models: {
|
||||
mode: "merge",
|
||||
providers: {
|
||||
openai: {
|
||||
api: "openai-responses",
|
||||
apiKey: { source: "env", provider: "default", id: "OPENAI_API_KEY" },
|
||||
baseUrl: (process.env.OPENAI_BASE_URL || "https://api.openai.com/v1").trim(),
|
||||
agentRuntime: { id: "openclaw" },
|
||||
timeoutSeconds,
|
||||
models: [
|
||||
{
|
||||
id: modelId,
|
||||
name: modelId,
|
||||
api: "openai-responses",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 128000,
|
||||
contextTokens: 64000,
|
||||
maxTokens: 512,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: modelRef, fallbacks: [] },
|
||||
models: {
|
||||
[modelRef]: {
|
||||
agentRuntime: { id: "openclaw" },
|
||||
params: { transport: "sse", openaiWsWarmup: false },
|
||||
},
|
||||
},
|
||||
workspace: workspaceDir,
|
||||
skipBootstrap: true,
|
||||
timeoutSeconds,
|
||||
contextTokens: 64000,
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
enabled: true,
|
||||
allow: ["openai"],
|
||||
entries: { openai: { enabled: true } },
|
||||
},
|
||||
skills: { allowBundled: [] },
|
||||
tools: { allow: ["get_weather"] },
|
||||
};
|
||||
|
||||
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
||||
fs.mkdirSync(workspaceDir, { recursive: true });
|
||||
fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`);
|
||||
fs.mkdirSync(path.join(stateDir, "logs"), { recursive: true });
|
||||
140
scripts/e2e/lib/openai-web-search-minimal/assertions.mjs
Normal file
140
scripts/e2e/lib/openai-web-search-minimal/assertions.mjs
Normal file
@@ -0,0 +1,140 @@
|
||||
// Assertions for minimal OpenAI web-search E2E scenarios.
|
||||
import fs from "node:fs";
|
||||
import { readTextFileTail, tailText } from "../text-file-utils.mjs";
|
||||
|
||||
const command = process.argv[2];
|
||||
|
||||
const ERROR_DETAIL_TAIL_BYTES = 64 * 1024;
|
||||
const REQUEST_LOG_SCAN_CHUNK_BYTES = 64 * 1024;
|
||||
const RESPONSE_PREVIEW_BYTES = 8 * 1024;
|
||||
const RESPONSE_PREVIEW_COUNT = 5;
|
||||
|
||||
function scanTextFileLines(file, onLine) {
|
||||
const fd = fs.openSync(file, "r");
|
||||
try {
|
||||
const buffer = Buffer.alloc(REQUEST_LOG_SCAN_CHUNK_BYTES);
|
||||
let carry = "";
|
||||
let lineNumber = 1;
|
||||
while (true) {
|
||||
const bytesRead = fs.readSync(fd, buffer, 0, buffer.length, null);
|
||||
if (bytesRead <= 0) {
|
||||
break;
|
||||
}
|
||||
const text = carry + buffer.subarray(0, bytesRead).toString("utf8");
|
||||
const lines = text.split(/\r?\n/u);
|
||||
carry = lines.pop() ?? "";
|
||||
for (const line of lines) {
|
||||
onLine(line, lineNumber);
|
||||
lineNumber += 1;
|
||||
}
|
||||
}
|
||||
if (carry.length > 0) {
|
||||
onLine(carry, lineNumber);
|
||||
}
|
||||
} finally {
|
||||
fs.closeSync(fd);
|
||||
}
|
||||
}
|
||||
|
||||
function scanSuccessRequest(logPath) {
|
||||
let responseCount = 0;
|
||||
let success;
|
||||
const recentResponses = [];
|
||||
scanTextFileLines(logPath, (line, lineNumber) => {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) {
|
||||
return;
|
||||
}
|
||||
const entry = JSON.parse(trimmed);
|
||||
if (entry.method !== "POST" || entry.path !== "/v1/responses") {
|
||||
return;
|
||||
}
|
||||
responseCount += 1;
|
||||
const bodyText = JSON.stringify(entry.body);
|
||||
if (recentResponses.length >= RESPONSE_PREVIEW_COUNT) {
|
||||
recentResponses.shift();
|
||||
}
|
||||
recentResponses.push({
|
||||
line: lineNumber,
|
||||
bodyTail: tailText(bodyText, RESPONSE_PREVIEW_BYTES),
|
||||
});
|
||||
if (!success && bodyText.includes("OPENCLAW_SCHEMA_E2E_OK")) {
|
||||
success = entry;
|
||||
}
|
||||
});
|
||||
return { responseCount, success, recentResponses };
|
||||
}
|
||||
|
||||
function assertPatchBehavior() {
|
||||
return import("../../../../dist/extensions/openai/native-web-search.js").then(
|
||||
({ patchOpenAINativeWebSearchPayload }) => {
|
||||
const injectedPayload = {
|
||||
reasoning: { effort: "minimal", summary: "auto" },
|
||||
};
|
||||
const injectedResult = patchOpenAINativeWebSearchPayload(injectedPayload);
|
||||
if (injectedResult !== "injected") {
|
||||
throw new Error(`expected native web_search injection, got ${injectedResult}`);
|
||||
}
|
||||
if (injectedPayload.reasoning.effort !== "low") {
|
||||
throw new Error(
|
||||
`expected injected native web_search to raise minimal reasoning to low, got ${JSON.stringify(injectedPayload.reasoning)}`,
|
||||
);
|
||||
}
|
||||
if (!injectedPayload.tools?.some((tool) => tool?.type === "web_search")) {
|
||||
throw new Error(`native web_search was not injected: ${JSON.stringify(injectedPayload)}`);
|
||||
}
|
||||
|
||||
const existingNativePayload = {
|
||||
tools: [{ type: "web_search" }],
|
||||
reasoning: { effort: "minimal" },
|
||||
};
|
||||
const existingResult = patchOpenAINativeWebSearchPayload(existingNativePayload);
|
||||
if (existingResult !== "native_tool_already_present") {
|
||||
throw new Error(`expected existing native web_search, got ${existingResult}`);
|
||||
}
|
||||
if (existingNativePayload.reasoning.effort !== "low") {
|
||||
throw new Error(
|
||||
`expected existing native web_search to raise minimal reasoning to low, got ${JSON.stringify(existingNativePayload.reasoning)}`,
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function assertSuccessRequest() {
|
||||
const logPath = process.argv[3];
|
||||
const { responseCount, success, recentResponses } = scanSuccessRequest(logPath);
|
||||
if (responseCount < 1) {
|
||||
throw new Error(
|
||||
`mock OpenAI /v1/responses was not used. Request log tail: ${readTextFileTail(logPath, ERROR_DETAIL_TAIL_BYTES)}`,
|
||||
);
|
||||
}
|
||||
if (!success) {
|
||||
throw new Error(
|
||||
`missing success request. Recent /v1/responses: ${JSON.stringify(recentResponses)}`,
|
||||
);
|
||||
}
|
||||
const tools = Array.isArray(success.body.tools) ? success.body.tools : [];
|
||||
const hasNativeWebSearch = tools.some((tool) => tool?.type === "web_search");
|
||||
if (!hasNativeWebSearch) {
|
||||
throw new Error(
|
||||
`success request did not include native web_search. Body: ${JSON.stringify(success.body)}`,
|
||||
);
|
||||
}
|
||||
if (success.body.reasoning?.effort === "minimal") {
|
||||
throw new Error(
|
||||
`expected web_search request to avoid minimal reasoning, got ${JSON.stringify(success.body.reasoning)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const commands = {
|
||||
"assert-patch-behavior": assertPatchBehavior,
|
||||
"assert-success-request": assertSuccessRequest,
|
||||
};
|
||||
|
||||
const fn = commands[command];
|
||||
if (!fn) {
|
||||
throw new Error(`unknown OpenAI web-search minimal assertion command: ${command}`);
|
||||
}
|
||||
await fn();
|
||||
201
scripts/e2e/lib/openai-web-search-minimal/client.mjs
Normal file
201
scripts/e2e/lib/openai-web-search-minimal/client.mjs
Normal file
@@ -0,0 +1,201 @@
|
||||
// Client script for minimal OpenAI web-search E2E scenarios.
|
||||
import { readdirSync } from "node:fs";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { readTcpPortEnv } from "../env-limits.mjs";
|
||||
|
||||
async function loadCallGateway() {
|
||||
const candidates = readdirSync("/app/dist")
|
||||
.filter((name) => /^call(?:\.runtime)?-[A-Za-z0-9_-]+\.js$/.test(name))
|
||||
.toSorted();
|
||||
for (const name of candidates) {
|
||||
const mod = await import(pathToFileURL(`/app/dist/${name}`).href);
|
||||
if (typeof mod.callGateway === "function") {
|
||||
return mod.callGateway;
|
||||
}
|
||||
}
|
||||
throw new Error(`unable to find callGateway export in /app/dist (${candidates.join(", ")})`);
|
||||
}
|
||||
|
||||
const DEFAULT_RAW_SCHEMA_ERROR =
|
||||
"400 The following tools cannot be used with reasoning.effort 'minimal': web_search.";
|
||||
const DEFAULT_GATEWAY_SCHEMA_ERROR = "provider rejected the request schema or tool payload";
|
||||
const SUCCESS_MARKER = "OPENCLAW_SCHEMA_E2E_OK";
|
||||
|
||||
function readExpectedRawSchemaError() {
|
||||
return process.env.RAW_SCHEMA_ERROR?.trim() || DEFAULT_RAW_SCHEMA_ERROR;
|
||||
}
|
||||
|
||||
function resolveGatewayPort(env = process.env) {
|
||||
const portText = env.PORT;
|
||||
if (!portText) {
|
||||
throw new Error("missing PORT");
|
||||
}
|
||||
return readTcpPortEnv("PORT", portText, env);
|
||||
}
|
||||
|
||||
async function gatewayAgent(params) {
|
||||
const token = process.env.OPENCLAW_GATEWAY_TOKEN;
|
||||
if (!token) {
|
||||
throw new Error("missing PORT/OPENCLAW_GATEWAY_TOKEN");
|
||||
}
|
||||
const port = resolveGatewayPort();
|
||||
|
||||
try {
|
||||
const callGateway = await loadCallGateway();
|
||||
return {
|
||||
ok: true,
|
||||
value: await callGateway({
|
||||
url: `ws://127.0.0.1:${port}`,
|
||||
token,
|
||||
method: "agent",
|
||||
params,
|
||||
expectFinal: true,
|
||||
timeoutMs: 240_000,
|
||||
clientName: "gateway-client",
|
||||
mode: "backend",
|
||||
scopes: ["operator.write"],
|
||||
deviceIdentity: null,
|
||||
}),
|
||||
};
|
||||
} catch (error) {
|
||||
const combined = String(error);
|
||||
return { ok: false, error: new Error(combined) };
|
||||
}
|
||||
}
|
||||
|
||||
function stringifyError(value) {
|
||||
return value instanceof Error ? value.message || String(value) : String(value);
|
||||
}
|
||||
|
||||
function validateRejectResult(result, expectedRawSchemaError = readExpectedRawSchemaError()) {
|
||||
if (result.ok) {
|
||||
throw new Error(`reject mode unexpectedly completed: ${JSON.stringify(result.value)}`);
|
||||
}
|
||||
const errorText = stringifyError(result.error);
|
||||
if (
|
||||
!errorText.includes(expectedRawSchemaError) &&
|
||||
!errorText.includes(DEFAULT_GATEWAY_SCHEMA_ERROR)
|
||||
) {
|
||||
throw new Error(
|
||||
`reject mode failed for an unexpected reason; expected ${JSON.stringify(
|
||||
expectedRawSchemaError,
|
||||
)} or ${JSON.stringify(DEFAULT_GATEWAY_SCHEMA_ERROR)} in ${JSON.stringify(errorText)}`,
|
||||
);
|
||||
}
|
||||
return errorText;
|
||||
}
|
||||
|
||||
function pushStringText(texts, value) {
|
||||
if (typeof value === "string" && value.trim().length > 0) {
|
||||
texts.push(value);
|
||||
}
|
||||
}
|
||||
|
||||
function pushContentText(texts, content) {
|
||||
if (typeof content === "string") {
|
||||
pushStringText(texts, content);
|
||||
return;
|
||||
}
|
||||
if (!Array.isArray(content)) {
|
||||
return;
|
||||
}
|
||||
for (const item of content) {
|
||||
if (typeof item === "string") {
|
||||
pushStringText(texts, item);
|
||||
} else if (item && typeof item === "object") {
|
||||
pushStringText(texts, item.text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function extractSuccessReplyTexts(value) {
|
||||
const texts = [];
|
||||
pushSuccessReplyTexts(texts, value);
|
||||
pushSuccessReplyTexts(texts, value?.result);
|
||||
return texts;
|
||||
}
|
||||
|
||||
function pushSuccessReplyTexts(texts, value) {
|
||||
pushStringText(texts, value?.finalAssistantVisibleText);
|
||||
pushStringText(texts, value?.meta?.finalAssistantVisibleText);
|
||||
pushContentText(texts, value?.message?.content);
|
||||
for (const payload of Array.isArray(value?.payloads) ? value.payloads : []) {
|
||||
if (payload?.isError === true) {
|
||||
continue;
|
||||
}
|
||||
pushStringText(texts, payload?.text);
|
||||
pushContentText(texts, payload?.content);
|
||||
}
|
||||
}
|
||||
|
||||
function validateSuccessResult(result, marker = SUCCESS_MARKER) {
|
||||
if (result.value?.status !== "ok") {
|
||||
throw new Error(`agent run did not complete successfully: ${JSON.stringify(result.value)}`);
|
||||
}
|
||||
const replyTexts = extractSuccessReplyTexts(result.value);
|
||||
if (!replyTexts.some((text) => text.includes(marker))) {
|
||||
throw new Error(
|
||||
`agent run completed without success marker ${JSON.stringify(marker)} in final reply: ${JSON.stringify(
|
||||
result.value,
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const mode = process.argv[2];
|
||||
const sessionKey = `agent:main:openai-web-search-minimal:${mode}`;
|
||||
const message = mode === "reject" ? "FORCE_SCHEMA_REJECT" : `Return exactly ${SUCCESS_MARKER}.`;
|
||||
const id = mode === "reject" ? "schema-reject" : "schema-success";
|
||||
|
||||
const result = await gatewayAgent({
|
||||
sessionKey,
|
||||
message,
|
||||
thinking: "minimal",
|
||||
deliver: false,
|
||||
timeout: 180,
|
||||
idempotencyKey: id,
|
||||
});
|
||||
|
||||
if (mode === "reject") {
|
||||
console.error(validateRejectResult(result));
|
||||
return;
|
||||
}
|
||||
if (!result.ok) {
|
||||
throw toLintErrorObject(result.error, "Non-Error thrown");
|
||||
}
|
||||
validateSuccessResult(result);
|
||||
}
|
||||
|
||||
function toLintErrorObject(value, fallbackMessage) {
|
||||
if (value instanceof Error) {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return new Error(value);
|
||||
}
|
||||
const error = new Error(fallbackMessage, { cause: value });
|
||||
if ((typeof value === "object" && value !== null) || typeof value === "function") {
|
||||
Object.assign(error, value);
|
||||
}
|
||||
return error;
|
||||
}
|
||||
|
||||
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
|
||||
try {
|
||||
await main();
|
||||
} catch (error) {
|
||||
console.error(error instanceof Error ? error.stack || error.message : String(error));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
export const testing = {
|
||||
DEFAULT_GATEWAY_SCHEMA_ERROR,
|
||||
DEFAULT_RAW_SCHEMA_ERROR,
|
||||
SUCCESS_MARKER,
|
||||
extractSuccessReplyTexts,
|
||||
resolveGatewayPort,
|
||||
validateSuccessResult,
|
||||
validateRejectResult,
|
||||
};
|
||||
166
scripts/e2e/lib/openai-web-search-minimal/mock-server.mjs
Normal file
166
scripts/e2e/lib/openai-web-search-minimal/mock-server.mjs
Normal file
@@ -0,0 +1,166 @@
|
||||
// Mock server for minimal OpenAI web-search E2E scenarios.
|
||||
import http from "node:http";
|
||||
import { readTcpPortEnv } from "../env-limits.mjs";
|
||||
import {
|
||||
boundedRequestLogBody,
|
||||
isRequestBodyTooLargeError,
|
||||
readBody,
|
||||
writeRequestLogEntryOrFail,
|
||||
writeJson,
|
||||
writeSse,
|
||||
} from "../mock-openai-http.mjs";
|
||||
|
||||
const port = readTcpPortEnv("MOCK_PORT");
|
||||
const requestLog = process.env.MOCK_REQUEST_LOG;
|
||||
const successMarker = process.env.SUCCESS_MARKER;
|
||||
const rawSchemaError = process.env.RAW_SCHEMA_ERROR;
|
||||
|
||||
function writeOpenAiReject(res) {
|
||||
writeJson(res, 400, {
|
||||
error: {
|
||||
message: rawSchemaError.replace(/^400\s+/, ""),
|
||||
type: "invalid_request_error",
|
||||
code: "invalid_request_error",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function hasWebSearchTool(tools) {
|
||||
return (
|
||||
Array.isArray(tools) &&
|
||||
tools.some((tool) => {
|
||||
if (!tool || typeof tool !== "object") {
|
||||
return false;
|
||||
}
|
||||
if (tool.type === "web_search") {
|
||||
return true;
|
||||
}
|
||||
if (tool.type === "function" && tool.name === "web_search") {
|
||||
return true;
|
||||
}
|
||||
if (tool.type === "function" && tool.function?.name === "web_search") {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function bodyContainsForceReject(body) {
|
||||
return JSON.stringify(body).includes("FORCE_SCHEMA_REJECT");
|
||||
}
|
||||
|
||||
function responseEvents(text) {
|
||||
return [
|
||||
{
|
||||
type: "response.output_item.added",
|
||||
item: {
|
||||
type: "message",
|
||||
id: "msg_schema_e2e_1",
|
||||
role: "assistant",
|
||||
content: [],
|
||||
status: "in_progress",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: {
|
||||
type: "message",
|
||||
id: "msg_schema_e2e_1",
|
||||
role: "assistant",
|
||||
status: "completed",
|
||||
content: [{ type: "output_text", text, annotations: [] }],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "response.completed",
|
||||
response: {
|
||||
id: "resp_schema_e2e_1",
|
||||
status: "completed",
|
||||
usage: {
|
||||
input_tokens: 11,
|
||||
output_tokens: 7,
|
||||
total_tokens: 18,
|
||||
input_tokens_details: { cached_tokens: 0 },
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
void (async () => {
|
||||
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
||||
if (req.method === "GET" && url.pathname === "/health") {
|
||||
writeJson(res, 200, { ok: true });
|
||||
return;
|
||||
}
|
||||
if (req.method === "GET" && url.pathname === "/v1/models") {
|
||||
writeJson(res, 200, {
|
||||
object: "list",
|
||||
data: [{ id: "gpt-5", object: "model", owned_by: "openclaw-e2e" }],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let bodyText;
|
||||
try {
|
||||
bodyText = await readBody(req);
|
||||
} catch (error) {
|
||||
if (isRequestBodyTooLargeError(error)) {
|
||||
writeJson(res, 413, { error: { message: error.message } });
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
let body;
|
||||
try {
|
||||
body = bodyText ? JSON.parse(bodyText) : {};
|
||||
} catch {
|
||||
body = {};
|
||||
}
|
||||
if (
|
||||
writeRequestLogEntryOrFail(res, {
|
||||
requestLog,
|
||||
required: true,
|
||||
label: "mock-openai-web-search",
|
||||
entry: {
|
||||
method: req.method,
|
||||
path: url.pathname,
|
||||
body: boundedRequestLogBody(body, bodyText),
|
||||
},
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === "POST" && url.pathname === "/v1/responses") {
|
||||
if (bodyContainsForceReject(body)) {
|
||||
writeOpenAiReject(res);
|
||||
return;
|
||||
}
|
||||
if (body?.reasoning?.effort === "minimal" && hasWebSearchTool(body.tools)) {
|
||||
writeOpenAiReject(res);
|
||||
return;
|
||||
}
|
||||
writeSse(res, responseEvents(successMarker));
|
||||
return;
|
||||
}
|
||||
|
||||
writeJson(res, 404, {
|
||||
error: { message: `unhandled mock route: ${req.method} ${url.pathname}` },
|
||||
});
|
||||
})().catch((/** @type {unknown} */ error) => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.error(`mock-openai-web-search request handler failed: ${message}`);
|
||||
if (!res.headersSent) {
|
||||
writeJson(res, 500, { error: { message: `mock OpenAI handler failed: ${message}` } });
|
||||
return;
|
||||
}
|
||||
res.destroy(error instanceof Error ? error : new Error(message));
|
||||
});
|
||||
});
|
||||
|
||||
server.listen(port, "127.0.0.1", () => {
|
||||
console.log(`mock-openai listening on ${port}`);
|
||||
});
|
||||
91
scripts/e2e/lib/openai-web-search-minimal/scenario.sh
Normal file
91
scripts/e2e/lib/openai-web-search-minimal/scenario.sh
Normal file
@@ -0,0 +1,91 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
source scripts/lib/openclaw-e2e-instance.sh
|
||||
openclaw_e2e_eval_test_state_from_b64 "${OPENCLAW_TEST_STATE_SCRIPT_B64:?missing OPENCLAW_TEST_STATE_SCRIPT_B64}"
|
||||
export OPENCLAW_SKIP_CHANNELS=1
|
||||
export OPENCLAW_SKIP_GMAIL_WATCHER=1
|
||||
export OPENCLAW_SKIP_CRON=1
|
||||
export OPENCLAW_SKIP_CANVAS_HOST=1
|
||||
export OPENCLAW_SKIP_BROWSER_CONTROL_SERVER=1
|
||||
export OPENCLAW_SKIP_ACPX_RUNTIME=1
|
||||
export OPENCLAW_SKIP_ACPX_RUNTIME_PROBE=1
|
||||
|
||||
PORT="${PORT:?missing PORT}"
|
||||
MOCK_PORT="${MOCK_PORT:?missing MOCK_PORT}"
|
||||
TOKEN="${OPENCLAW_GATEWAY_TOKEN:?missing OPENCLAW_GATEWAY_TOKEN}"
|
||||
SUCCESS_MARKER="OPENCLAW_SCHEMA_E2E_OK"
|
||||
RAW_SCHEMA_ERROR="400 The following tools cannot be used with reasoning.effort 'minimal': web_search."
|
||||
scenario_tmp="$(mktemp -d "${TMPDIR:-/tmp}/openclaw-openai-web-search-minimal.XXXXXX")"
|
||||
MOCK_REQUEST_LOG="$scenario_tmp/requests.jsonl"
|
||||
GATEWAY_LOG="$scenario_tmp/gateway.log"
|
||||
MOCK_LOG="$scenario_tmp/mock.log"
|
||||
CLIENT_SUCCESS_LOG="$scenario_tmp/client-success.log"
|
||||
CLIENT_REJECT_LOG="$scenario_tmp/client-reject.log"
|
||||
mock_pid=""
|
||||
gateway_pid=""
|
||||
|
||||
cleanup() {
|
||||
openclaw_e2e_terminate_gateways "${gateway_pid:-}"
|
||||
openclaw_e2e_stop_process "${mock_pid:-}"
|
||||
rm -rf "$scenario_tmp"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
dump_debug_logs() {
|
||||
local status="$1"
|
||||
echo "OpenAI web_search minimal Docker E2E failed with exit code $status" >&2
|
||||
for file in \
|
||||
"$GATEWAY_LOG" \
|
||||
"$MOCK_LOG" \
|
||||
"$CLIENT_SUCCESS_LOG" \
|
||||
"$CLIENT_REJECT_LOG" \
|
||||
"$MOCK_REQUEST_LOG" \
|
||||
"$OPENCLAW_STATE_DIR/openclaw.json"; do
|
||||
if [ -f "$file" ]; then
|
||||
echo "--- $file ---" >&2
|
||||
openclaw_e2e_print_log "$file" >&2
|
||||
fi
|
||||
done
|
||||
}
|
||||
trap 'status=$?; dump_debug_logs "$status"; exit "$status"' ERR
|
||||
|
||||
entry="$(openclaw_e2e_resolve_entrypoint)"
|
||||
mkdir -p "$OPENCLAW_STATE_DIR"
|
||||
|
||||
node scripts/e2e/lib/openai-web-search-minimal/assertions.mjs assert-patch-behavior
|
||||
|
||||
node scripts/e2e/lib/fixture.mjs openai-web-search-minimal-config
|
||||
|
||||
MOCK_PORT="$MOCK_PORT" \
|
||||
MOCK_REQUEST_LOG="$MOCK_REQUEST_LOG" \
|
||||
SUCCESS_MARKER="$SUCCESS_MARKER" \
|
||||
RAW_SCHEMA_ERROR="$RAW_SCHEMA_ERROR" \
|
||||
node scripts/e2e/lib/openai-web-search-minimal/mock-server.mjs >"$MOCK_LOG" 2>&1 &
|
||||
mock_pid="$!"
|
||||
|
||||
openclaw_e2e_wait_mock_openai "$MOCK_PORT"
|
||||
|
||||
gateway_pid="$(openclaw_e2e_start_gateway "$entry" "$PORT" "$GATEWAY_LOG")"
|
||||
openclaw_e2e_wait_gateway_ready "$gateway_pid" "$GATEWAY_LOG" 360 "$PORT"
|
||||
node "$entry" gateway health \
|
||||
--url "ws://127.0.0.1:$PORT" \
|
||||
--token "$TOKEN" \
|
||||
--timeout 120000 \
|
||||
--json >/dev/null
|
||||
|
||||
PORT="$PORT" OPENCLAW_GATEWAY_TOKEN="$TOKEN" node scripts/e2e/lib/openai-web-search-minimal/client.mjs success >"$CLIENT_SUCCESS_LOG" 2>&1
|
||||
|
||||
node scripts/e2e/lib/openai-web-search-minimal/assertions.mjs assert-success-request "$MOCK_REQUEST_LOG"
|
||||
|
||||
PORT="$PORT" OPENCLAW_GATEWAY_TOKEN="$TOKEN" node scripts/e2e/lib/openai-web-search-minimal/client.mjs reject >"$CLIENT_REJECT_LOG" 2>&1
|
||||
|
||||
for _ in $(seq 1 80); do
|
||||
if grep -Fq "$RAW_SCHEMA_ERROR" "$GATEWAY_LOG"; then
|
||||
break
|
||||
fi
|
||||
sleep 0.25
|
||||
done
|
||||
grep -F "$RAW_SCHEMA_ERROR" "$GATEWAY_LOG" >/dev/null
|
||||
|
||||
echo "OpenAI web_search minimal reasoning Docker E2E passed"
|
||||
63
scripts/e2e/lib/openwebui/http-probe.mjs
Normal file
63
scripts/e2e/lib/openwebui/http-probe.mjs
Normal file
@@ -0,0 +1,63 @@
|
||||
// HTTP probe for OpenWebUI E2E scenarios.
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { readPositiveIntEnv } from "../env-limits.mjs";
|
||||
|
||||
const MAX_TIMER_TIMEOUT_MS = 2_147_000_000;
|
||||
|
||||
function parseExpectedStatus(raw) {
|
||||
if (!/^[1-5]\d\d$/u.test(raw)) {
|
||||
throw new Error(`expected status must be lt500 or a decimal HTTP status. Got: ${raw}`);
|
||||
}
|
||||
return Number(raw);
|
||||
}
|
||||
|
||||
function resolveTimerTimeoutMs(valueMs, fallbackMs) {
|
||||
const value = Number.isFinite(valueMs) ? valueMs : fallbackMs;
|
||||
return Math.min(Math.max(Math.floor(value), 1), MAX_TIMER_TIMEOUT_MS);
|
||||
}
|
||||
|
||||
export async function probeHttpStatus({
|
||||
url,
|
||||
expectedRaw = "200",
|
||||
timeoutMs = 30_000,
|
||||
bearer = "",
|
||||
fetchImpl = fetch,
|
||||
}) {
|
||||
if (!url) {
|
||||
throw new Error("usage: http-probe.mjs <url> [status|lt500]");
|
||||
}
|
||||
const expectedStatus = expectedRaw === "lt500" ? undefined : parseExpectedStatus(expectedRaw);
|
||||
const resolvedTimeoutMs = resolveTimerTimeoutMs(timeoutMs, 30_000);
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), resolvedTimeoutMs);
|
||||
let res;
|
||||
const headers = {};
|
||||
if (bearer) {
|
||||
headers.authorization = `Bearer ${bearer}`;
|
||||
}
|
||||
|
||||
try {
|
||||
res = await fetchImpl(url, { headers, signal: controller.signal }).catch(() => null);
|
||||
return expectedRaw === "lt500"
|
||||
? Boolean(res && res.status < 500)
|
||||
: res?.status === expectedStatus;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
await res?.body?.cancel?.().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const [url, expectedRaw = "200"] = process.argv.slice(2);
|
||||
const ok = await probeHttpStatus({
|
||||
url,
|
||||
expectedRaw,
|
||||
timeoutMs: readPositiveIntEnv("OPENCLAW_HTTP_PROBE_TIMEOUT_MS", 30_000),
|
||||
bearer: process.env.OPENCLAW_HTTP_PROBE_BEARER,
|
||||
});
|
||||
process.exit(ok ? 0 : 1);
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
await main();
|
||||
}
|
||||
12
scripts/e2e/lib/package-compat.mjs
Normal file
12
scripts/e2e/lib/package-compat.mjs
Normal file
@@ -0,0 +1,12 @@
|
||||
// Package-version compatibility helpers for E2E acceptance scripts.
|
||||
export function legacyPackageAcceptanceCompat(version) {
|
||||
const match = /^(\d{4})\.(\d{1,2})\.(\d{1,2})(?:[-+].*)?/.exec(version || "");
|
||||
const [year, month, day] = match?.slice(1, 4).map(Number) ?? [];
|
||||
return (
|
||||
Boolean(match) && (year < 2026 || (year === 2026 && (month < 4 || (month === 4 && day <= 25))))
|
||||
);
|
||||
}
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
console.log(legacyPackageAcceptanceCompat(process.argv[2]) ? "1" : "0");
|
||||
}
|
||||
104
scripts/e2e/lib/parallels-macos-common.sh
Normal file
104
scripts/e2e/lib/parallels-macos-common.sh
Normal file
@@ -0,0 +1,104 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
parallels_macos_resolve_desktop_user() {
|
||||
local vm_name="$1"
|
||||
local user
|
||||
user="$(prlctl exec "$vm_name" /usr/bin/stat -f '%Su' /dev/console 2>/dev/null | tr -d '\r' | tail -n 1 || true)"
|
||||
if [[ "$user" =~ ^[A-Za-z0-9._-]+$ && "$user" != "root" && "$user" != "loginwindow" ]]; then
|
||||
printf '%s\n' "$user"
|
||||
return 0
|
||||
fi
|
||||
prlctl exec "$vm_name" /usr/bin/dscl . -list /Users NFSHomeDirectory 2>/dev/null \
|
||||
| tr -d '\r' \
|
||||
| awk '$2 ~ /^\/Users\// && $1 !~ /^_/ && $1 != "Shared" && $1 != ".localized" { print $1; exit }'
|
||||
}
|
||||
|
||||
parallels_macos_resolve_desktop_home() {
|
||||
local vm_name="$1"
|
||||
local user="$2"
|
||||
local home
|
||||
home="$(
|
||||
prlctl exec "$vm_name" /usr/bin/dscl . -read "/Users/$user" NFSHomeDirectory 2>/dev/null \
|
||||
| tr -d '\r' \
|
||||
| awk '/NFSHomeDirectory:/ { print $2; exit }'
|
||||
)"
|
||||
if [[ -n "$home" ]]; then
|
||||
printf '%s\n' "$home"
|
||||
else
|
||||
printf '/Users/%s\n' "$user"
|
||||
fi
|
||||
}
|
||||
|
||||
parallels_macos_current_user_available() {
|
||||
local vm_name="$1"
|
||||
prlctl exec "$vm_name" --current-user /usr/bin/whoami >/dev/null 2>&1
|
||||
}
|
||||
|
||||
parallels_macos_desktop_user_exec_with_secret_file() {
|
||||
local vm_name="$1"
|
||||
local user_flag="$2"
|
||||
local user_name="$3"
|
||||
local home="$4"
|
||||
local path_value="$5"
|
||||
local api_key_env="$6"
|
||||
local api_key_value="$7"
|
||||
shift 7
|
||||
|
||||
local secret_path
|
||||
secret_path="/tmp/openclaw-secret-${api_key_env:-env}-$RANDOM-$RANDOM"
|
||||
|
||||
if [[ -n "$api_key_env" && -n "$api_key_value" ]]; then
|
||||
if [[ "$user_flag" == "current-user" ]]; then
|
||||
printf '%s' "$api_key_value" | /usr/bin/base64 | prlctl exec "$vm_name" \
|
||||
--current-user /usr/bin/base64 -D -o "$secret_path"
|
||||
else
|
||||
printf '%s' "$api_key_value" | /usr/bin/base64 | prlctl exec "$vm_name" \
|
||||
/usr/bin/sudo -H -u "$user_name" /usr/bin/base64 -D -o "$secret_path"
|
||||
fi
|
||||
fi
|
||||
|
||||
local wrapper
|
||||
local wrapper_path
|
||||
wrapper_path="/tmp/openclaw-secret-env-wrapper-$RANDOM-$RANDOM.sh"
|
||||
wrapper='#!/bin/bash
|
||||
set -e
|
||||
cleanup() {
|
||||
rm -f "${OPENCLAW_WRAPPER_FILE:-}"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
if [ -n "${OPENCLAW_SECRET_ENV_NAME:-}" ] && [ -n "${OPENCLAW_SECRET_FILE:-}" ] && [ -f "$OPENCLAW_SECRET_FILE" ]; then
|
||||
secret_value="$(cat "$OPENCLAW_SECRET_FILE")"
|
||||
rm -f "$OPENCLAW_SECRET_FILE"
|
||||
export "${OPENCLAW_SECRET_ENV_NAME}=${secret_value}"
|
||||
fi
|
||||
"$@"
|
||||
'
|
||||
|
||||
if [[ "$user_flag" == "current-user" ]]; then
|
||||
printf '%s' "$wrapper" | /usr/bin/base64 | prlctl exec "$vm_name" \
|
||||
--current-user /usr/bin/base64 -D -o "$wrapper_path"
|
||||
else
|
||||
printf '%s' "$wrapper" | /usr/bin/base64 | prlctl exec "$vm_name" \
|
||||
/usr/bin/sudo -H -u "$user_name" /usr/bin/base64 -D -o "$wrapper_path"
|
||||
fi
|
||||
|
||||
if [[ "$user_flag" == "current-user" ]]; then
|
||||
prlctl exec "$vm_name" --current-user /usr/bin/env \
|
||||
"PATH=$path_value" \
|
||||
"OPENCLAW_SECRET_ENV_NAME=$api_key_env" \
|
||||
"OPENCLAW_SECRET_FILE=$secret_path" \
|
||||
"OPENCLAW_WRAPPER_FILE=$wrapper_path" \
|
||||
/bin/bash "$wrapper_path" "$@"
|
||||
return
|
||||
fi
|
||||
|
||||
prlctl exec "$vm_name" /usr/bin/sudo -H -u "$user_name" /usr/bin/env \
|
||||
"HOME=$home" \
|
||||
"USER=$user_name" \
|
||||
"LOGNAME=$user_name" \
|
||||
"PATH=$path_value" \
|
||||
"OPENCLAW_SECRET_ENV_NAME=$api_key_env" \
|
||||
"OPENCLAW_SECRET_FILE=$secret_path" \
|
||||
"OPENCLAW_WRAPPER_FILE=$wrapper_path" \
|
||||
/bin/bash "$wrapper_path" "$@"
|
||||
}
|
||||
25
scripts/e2e/lib/parallels-package-common.sh
Normal file
25
scripts/e2e/lib/parallels-package-common.sh
Normal file
@@ -0,0 +1,25 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
parallels_package_acquire_build_lock() {
|
||||
local lock_dir="$1"
|
||||
local owner_pid=""
|
||||
while ! mkdir "$lock_dir" 2>/dev/null; do
|
||||
if [[ -f "$lock_dir/pid" ]]; then
|
||||
owner_pid="$(cat "$lock_dir/pid" 2>/dev/null || true)"
|
||||
if [[ -n "$owner_pid" ]] && ! kill -0 "$owner_pid" >/dev/null 2>&1; then
|
||||
printf 'warn: Removing stale Parallels build lock\n' >&2
|
||||
rm -rf "$lock_dir"
|
||||
continue
|
||||
fi
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
printf '%s\n' "$$" >"$lock_dir/pid"
|
||||
}
|
||||
|
||||
parallels_package_release_build_lock() {
|
||||
local lock_dir="$1"
|
||||
if [[ -d "$lock_dir" ]]; then
|
||||
rm -rf "$lock_dir"
|
||||
fi
|
||||
}
|
||||
10
scripts/e2e/lib/parallels-package/build-info-commit.mjs
Normal file
10
scripts/e2e/lib/parallels-package/build-info-commit.mjs
Normal file
@@ -0,0 +1,10 @@
|
||||
// Validates build-info commit metadata for Parallels package E2E scenarios.
|
||||
import fs from "node:fs";
|
||||
|
||||
const path = "dist/build-info.json";
|
||||
if (!fs.existsSync(path)) {
|
||||
console.log("");
|
||||
} else {
|
||||
const buildInfo = JSON.parse(fs.readFileSync(path, "utf8"));
|
||||
console.log(buildInfo.commit ?? "");
|
||||
}
|
||||
22
scripts/e2e/lib/parallels-package/log-progress-extract.mjs
Normal file
22
scripts/e2e/lib/parallels-package/log-progress-extract.mjs
Normal file
@@ -0,0 +1,22 @@
|
||||
// Extracts progress markers from Parallels package E2E logs.
|
||||
import fs from "node:fs";
|
||||
import { readTextFileTail } from "../text-file-utils.mjs";
|
||||
|
||||
const LOG_PROGRESS_TAIL_BYTES = 256 * 1024;
|
||||
|
||||
const [logPath] = process.argv.slice(2);
|
||||
if (!logPath || !fs.existsSync(logPath)) {
|
||||
console.log("");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const text = readTextFileTail(logPath, LOG_PROGRESS_TAIL_BYTES);
|
||||
const lines = text
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
const reversed = lines.toReversed();
|
||||
|
||||
const progress = reversed.find((line) => line.startsWith("==> "));
|
||||
const warning = reversed.find((line) => line.startsWith("warn:") || line.startsWith("error:"));
|
||||
console.log(progress?.slice(4).trim() ?? warning ?? lines.at(-1)?.slice(0, 240) ?? "");
|
||||
227
scripts/e2e/lib/plugin-index-sqlite.mjs
Normal file
227
scripts/e2e/lib/plugin-index-sqlite.mjs
Normal file
@@ -0,0 +1,227 @@
|
||||
// SQLite readers for plugin install indexes produced during E2E scenarios.
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
import { readPositiveIntEnv } from "./env-limits.mjs";
|
||||
import { readTextFileBounded } from "./text-file-utils.mjs";
|
||||
|
||||
const INDEX_KEY = "installed-plugin-index";
|
||||
const ERROR_DETAIL_TAIL_BYTES = 16 * 1024;
|
||||
const JSON_ARTIFACT_MAX_BYTES = readPositiveIntEnv(
|
||||
"OPENCLAW_PLUGIN_INDEX_JSON_MAX_BYTES",
|
||||
1024 * 1024,
|
||||
);
|
||||
|
||||
export function stateDir() {
|
||||
return process.env.OPENCLAW_STATE_DIR || path.join(process.env.HOME, ".openclaw");
|
||||
}
|
||||
|
||||
export function configPath() {
|
||||
return process.env.OPENCLAW_CONFIG_PATH || path.join(stateDir(), "openclaw.json");
|
||||
}
|
||||
|
||||
function readJsonMaybe(file) {
|
||||
let text;
|
||||
try {
|
||||
text = readTextFileBounded(file, "plugin index JSON artifact", JSON_ARTIFACT_MAX_BYTES, {
|
||||
tailBytes: ERROR_DETAIL_TAIL_BYTES,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error?.code === "ETOOBIG") {
|
||||
throw error;
|
||||
}
|
||||
return {};
|
||||
}
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function textTooLargeError(message) {
|
||||
return Object.assign(new Error(message), { code: "ETOOBIG" });
|
||||
}
|
||||
|
||||
function parseIndexJsonText(text, label) {
|
||||
const bytes = Buffer.byteLength(text, "utf8");
|
||||
if (bytes > JSON_ARTIFACT_MAX_BYTES) {
|
||||
throw textTooLargeError(`${label} exceeded ${JSON_ARTIFACT_MAX_BYTES} bytes (${bytes} bytes)`);
|
||||
}
|
||||
return JSON.parse(text);
|
||||
}
|
||||
|
||||
function assertIndexJsonByteLength(bytesRaw, label) {
|
||||
const bytes = Number(bytesRaw);
|
||||
if (!Number.isFinite(bytes) || bytes < 0) {
|
||||
throw new Error(`${label} byte length was invalid: ${String(bytesRaw)}`);
|
||||
}
|
||||
if (bytes > JSON_ARTIFACT_MAX_BYTES) {
|
||||
throw textTooLargeError(`${label} exceeded ${JSON_ARTIFACT_MAX_BYTES} bytes (${bytes} bytes)`);
|
||||
}
|
||||
}
|
||||
|
||||
function sqlitePath(root = stateDir()) {
|
||||
return path.join(root, "state", "openclaw.sqlite");
|
||||
}
|
||||
|
||||
function legacyIndexPath(root = stateDir()) {
|
||||
return path.join(root, "plugins", "installs.json");
|
||||
}
|
||||
|
||||
function readSqlitePluginIndex(root = stateDir()) {
|
||||
const dbPath = sqlitePath(root);
|
||||
if (!fs.existsSync(dbPath)) {
|
||||
return {};
|
||||
}
|
||||
let db;
|
||||
try {
|
||||
db = new DatabaseSync(dbPath, { readOnly: true });
|
||||
const lengths = db
|
||||
.prepare(
|
||||
`
|
||||
SELECT octet_length(install_records_json) AS install_records_json_bytes,
|
||||
octet_length(plugins_json) AS plugins_json_bytes,
|
||||
octet_length(diagnostics_json) AS diagnostics_json_bytes
|
||||
FROM installed_plugin_index
|
||||
WHERE index_key = ?
|
||||
`,
|
||||
)
|
||||
.get(INDEX_KEY);
|
||||
if (!lengths) {
|
||||
return {};
|
||||
}
|
||||
assertIndexJsonByteLength(
|
||||
lengths.install_records_json_bytes,
|
||||
"plugin index install_records_json",
|
||||
);
|
||||
assertIndexJsonByteLength(lengths.plugins_json_bytes, "plugin index plugins_json");
|
||||
assertIndexJsonByteLength(lengths.diagnostics_json_bytes, "plugin index diagnostics_json");
|
||||
const row = db
|
||||
.prepare(
|
||||
`
|
||||
SELECT version, warning, host_contract_version, compat_registry_version,
|
||||
migration_version, policy_hash, generated_at_ms, refresh_reason,
|
||||
install_records_json, plugins_json, diagnostics_json
|
||||
FROM installed_plugin_index
|
||||
WHERE index_key = ?
|
||||
`,
|
||||
)
|
||||
.get(INDEX_KEY);
|
||||
if (!row) {
|
||||
return {};
|
||||
}
|
||||
return {
|
||||
version: Number(row.version),
|
||||
...(row.warning ? { warning: row.warning } : {}),
|
||||
hostContractVersion: row.host_contract_version,
|
||||
compatRegistryVersion: row.compat_registry_version,
|
||||
migrationVersion: Number(row.migration_version),
|
||||
policyHash: row.policy_hash,
|
||||
generatedAtMs: Number(row.generated_at_ms),
|
||||
...(row.refresh_reason ? { refreshReason: row.refresh_reason } : {}),
|
||||
installRecords: parseIndexJsonText(
|
||||
row.install_records_json,
|
||||
"plugin index install_records_json",
|
||||
),
|
||||
plugins: parseIndexJsonText(row.plugins_json, "plugin index plugins_json"),
|
||||
diagnostics: parseIndexJsonText(row.diagnostics_json, "plugin index diagnostics_json"),
|
||||
};
|
||||
} catch (error) {
|
||||
if (error?.code === "ETOOBIG") {
|
||||
throw error;
|
||||
}
|
||||
return {};
|
||||
} finally {
|
||||
db?.close();
|
||||
}
|
||||
}
|
||||
|
||||
export function readPluginInstallIndex(options = {}) {
|
||||
const root = options.stateDir ?? stateDir();
|
||||
const config = readJsonMaybe(options.configPath ?? configPath());
|
||||
const sqliteIndex = readSqlitePluginIndex(root);
|
||||
if (sqliteIndex.installRecords) {
|
||||
return sqliteIndex;
|
||||
}
|
||||
const legacyIndex = readJsonMaybe(legacyIndexPath(root));
|
||||
const installRecords =
|
||||
legacyIndex.installRecords ??
|
||||
legacyIndex.records ??
|
||||
options.fallbackRecords ??
|
||||
config.plugins?.installs ??
|
||||
{};
|
||||
return {
|
||||
...legacyIndex,
|
||||
installRecords,
|
||||
};
|
||||
}
|
||||
|
||||
export function readPluginInstallRecords(options = {}) {
|
||||
return readPluginInstallIndex(options).installRecords ?? {};
|
||||
}
|
||||
|
||||
export function writePluginInstallIndexForE2E(index, options = {}) {
|
||||
const root = options.stateDir ?? stateDir();
|
||||
const dbPath = sqlitePath(root);
|
||||
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
|
||||
const db = new DatabaseSync(dbPath);
|
||||
try {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS installed_plugin_index (
|
||||
index_key TEXT NOT NULL PRIMARY KEY,
|
||||
version INTEGER NOT NULL,
|
||||
host_contract_version TEXT NOT NULL,
|
||||
compat_registry_version TEXT NOT NULL,
|
||||
migration_version INTEGER NOT NULL,
|
||||
policy_hash TEXT NOT NULL,
|
||||
generated_at_ms INTEGER NOT NULL,
|
||||
refresh_reason TEXT,
|
||||
install_records_json TEXT NOT NULL,
|
||||
plugins_json TEXT NOT NULL,
|
||||
diagnostics_json TEXT NOT NULL,
|
||||
warning TEXT,
|
||||
updated_at_ms INTEGER NOT NULL
|
||||
);
|
||||
`);
|
||||
const now = Date.now();
|
||||
db.prepare(
|
||||
`
|
||||
INSERT INTO installed_plugin_index (
|
||||
index_key, version, host_contract_version, compat_registry_version,
|
||||
migration_version, policy_hash, generated_at_ms, refresh_reason,
|
||||
install_records_json, plugins_json, diagnostics_json, warning, updated_at_ms
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(index_key) DO UPDATE SET
|
||||
version = excluded.version,
|
||||
host_contract_version = excluded.host_contract_version,
|
||||
compat_registry_version = excluded.compat_registry_version,
|
||||
migration_version = excluded.migration_version,
|
||||
policy_hash = excluded.policy_hash,
|
||||
generated_at_ms = excluded.generated_at_ms,
|
||||
refresh_reason = excluded.refresh_reason,
|
||||
install_records_json = excluded.install_records_json,
|
||||
plugins_json = excluded.plugins_json,
|
||||
diagnostics_json = excluded.diagnostics_json,
|
||||
warning = excluded.warning,
|
||||
updated_at_ms = excluded.updated_at_ms
|
||||
`,
|
||||
).run(
|
||||
INDEX_KEY,
|
||||
index.version ?? 1,
|
||||
index.hostContractVersion ?? "docker-e2e",
|
||||
index.compatRegistryVersion ?? "docker-e2e",
|
||||
index.migrationVersion ?? 1,
|
||||
index.policyHash ?? "docker-e2e",
|
||||
index.generatedAtMs ?? now,
|
||||
index.refreshReason ?? null,
|
||||
JSON.stringify(index.installRecords ?? {}),
|
||||
JSON.stringify(index.plugins ?? []),
|
||||
JSON.stringify(index.diagnostics ?? []),
|
||||
index.warning ?? "DO NOT EDIT. This row is generated by OpenClaw plugin registry commands.",
|
||||
now,
|
||||
);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
388
scripts/e2e/lib/plugin-lifecycle-matrix/measure.mjs
Normal file
388
scripts/e2e/lib/plugin-lifecycle-matrix/measure.mjs
Normal file
@@ -0,0 +1,388 @@
|
||||
// Measures plugin lifecycle matrix E2E command timings.
|
||||
import { spawn, spawnSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const [summaryPath, phase, separator, command, ...args] = process.argv.slice(2);
|
||||
if (!summaryPath || !phase || separator !== "--" || !command) {
|
||||
console.error("usage: measure.mjs <summary.tsv> <phase> -- <command> [args...]");
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
function readPositiveIntEnv(name, fallback) {
|
||||
const text = String(process.env[name] ?? fallback).trim();
|
||||
if (!/^\d+$/u.test(text)) {
|
||||
throw new Error(`${name} must be a positive integer; got: ${text}`);
|
||||
}
|
||||
const value = Number(text);
|
||||
if (!Number.isSafeInteger(value) || value <= 0) {
|
||||
throw new Error(`${name} must be a positive integer; got: ${text}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function readPositiveIntEnvOrGetconf(name, variable) {
|
||||
if (process.env[name] !== undefined) {
|
||||
return readPositiveIntEnv(name, "");
|
||||
}
|
||||
const result = spawnSync("getconf", [variable], { encoding: "utf8" });
|
||||
if (result.error || result.status !== 0) {
|
||||
const details =
|
||||
result.error?.message || result.stderr.trim() || `exit ${String(result.status)}`;
|
||||
throw new Error(
|
||||
`failed to derive ${name} from getconf ${variable}: ${details}; set ${name} explicitly`,
|
||||
);
|
||||
}
|
||||
return readPositiveIntEnv(name, result.stdout);
|
||||
}
|
||||
|
||||
function readPositiveNumberEnv(name, fallback) {
|
||||
const text = String(process.env[name] ?? fallback).trim();
|
||||
if (!/^\d+(?:\.\d+)?$/u.test(text)) {
|
||||
throw new Error(`${name} must be a positive number; got: ${text}`);
|
||||
}
|
||||
const value = Number(text);
|
||||
if (!Number.isFinite(value) || value <= 0) {
|
||||
throw new Error(`${name} must be a positive number; got: ${text}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
const MAX_TIMER_TIMEOUT_MS = 2_147_000_000;
|
||||
|
||||
function clampTimerTimeoutMs(valueMs) {
|
||||
return Math.min(Math.max(Math.floor(valueMs), 1), MAX_TIMER_TIMEOUT_MS);
|
||||
}
|
||||
|
||||
const pollMs = clampTimerTimeoutMs(
|
||||
readPositiveIntEnv("OPENCLAW_PLUGIN_LIFECYCLE_METRIC_POLL_MS", 100),
|
||||
);
|
||||
const timeoutMs = clampTimerTimeoutMs(
|
||||
readPositiveIntEnv("OPENCLAW_PLUGIN_LIFECYCLE_PHASE_TIMEOUT_MS", 300000),
|
||||
);
|
||||
const timeoutKillGraceMs = clampTimerTimeoutMs(
|
||||
readPositiveIntEnv("OPENCLAW_PLUGIN_LIFECYCLE_TIMEOUT_KILL_GRACE_MS", 2000),
|
||||
);
|
||||
const maxRssKbThreshold = readPositiveIntEnv(
|
||||
"OPENCLAW_PLUGIN_LIFECYCLE_MAX_RSS_KB",
|
||||
4 * 1024 * 1024,
|
||||
);
|
||||
const maxWallMs = readPositiveIntEnv("OPENCLAW_PLUGIN_LIFECYCLE_MAX_WALL_MS", timeoutMs);
|
||||
const maxCpuCoreRatio = readPositiveNumberEnv("OPENCLAW_PLUGIN_LIFECYCLE_MAX_CPU_CORE_RATIO", 16);
|
||||
|
||||
if (!fs.existsSync("/proc")) {
|
||||
console.error("plugin lifecycle resource sampler requires Linux /proc");
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
// /proc RSS is in host pages and CPU times are in host clock ticks. Query the
|
||||
// live units so 64 KiB ARM kernels do not under-report resource use.
|
||||
const pageSize = readPositiveIntEnvOrGetconf("OPENCLAW_PROC_PAGE_SIZE", "PAGESIZE");
|
||||
const clockTicks = readPositiveIntEnvOrGetconf("OPENCLAW_PROC_CLK_TCK", "CLK_TCK");
|
||||
|
||||
function readProcSnapshot() {
|
||||
const stats = new Map();
|
||||
for (const entry of fs.readdirSync("/proc", { withFileTypes: true })) {
|
||||
if (!entry.isDirectory() || !/^\d+$/u.test(entry.name)) {
|
||||
continue;
|
||||
}
|
||||
const pid = Number.parseInt(entry.name, 10);
|
||||
const statPath = path.join("/proc", entry.name, "stat");
|
||||
try {
|
||||
const raw = fs.readFileSync(statPath, "utf8");
|
||||
const closeParen = raw.lastIndexOf(")");
|
||||
if (closeParen === -1) {
|
||||
continue;
|
||||
}
|
||||
const fields = raw
|
||||
.slice(closeParen + 2)
|
||||
.trim()
|
||||
.split(/\s+/u);
|
||||
const ppid = Number.parseInt(fields[1] ?? "", 10);
|
||||
const pgrp = Number.parseInt(fields[2] ?? "", 10);
|
||||
const userTicks = Number.parseInt(fields[11] ?? "", 10);
|
||||
const systemTicks = Number.parseInt(fields[12] ?? "", 10);
|
||||
const rssPages = Number.parseInt(fields[21] ?? "", 10);
|
||||
if (
|
||||
!Number.isFinite(ppid) ||
|
||||
!Number.isFinite(pgrp) ||
|
||||
!Number.isFinite(userTicks) ||
|
||||
!Number.isFinite(systemTicks) ||
|
||||
!Number.isFinite(rssPages)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
stats.set(pid, {
|
||||
ppid,
|
||||
pgrp,
|
||||
cpuTicks: userTicks + systemTicks,
|
||||
rssBytes: Math.max(0, rssPages) * pageSize,
|
||||
});
|
||||
} catch {
|
||||
// Processes can exit while /proc is being scanned.
|
||||
}
|
||||
}
|
||||
return stats;
|
||||
}
|
||||
|
||||
function descendantsOf(rootPid, stats) {
|
||||
const children = new Map();
|
||||
for (const [pid, stat] of stats.entries()) {
|
||||
const siblings = children.get(stat.ppid) ?? [];
|
||||
siblings.push(pid);
|
||||
children.set(stat.ppid, siblings);
|
||||
}
|
||||
const seen = new Set([rootPid]);
|
||||
const queue = [rootPid];
|
||||
for (const queuedPid of queue) {
|
||||
for (const child of children.get(queuedPid) ?? []) {
|
||||
if (!seen.has(child)) {
|
||||
seen.add(child);
|
||||
queue.push(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
return seen;
|
||||
}
|
||||
|
||||
function sample(rootPid) {
|
||||
const stats = readProcSnapshot();
|
||||
const groupPids = new Set(
|
||||
[...stats.entries()].filter(([, stat]) => stat.pgrp === rootPid).map(([pid]) => pid),
|
||||
);
|
||||
const pids = new Set([...descendantsOf(rootPid, stats), ...groupPids]);
|
||||
let rssBytes = 0;
|
||||
let cpuTicks = 0;
|
||||
for (const pid of pids) {
|
||||
const stat = stats.get(pid);
|
||||
if (!stat) {
|
||||
continue;
|
||||
}
|
||||
rssBytes += stat.rssBytes;
|
||||
cpuTicks += stat.cpuTicks;
|
||||
}
|
||||
return { rssBytes, cpuTicks };
|
||||
}
|
||||
|
||||
const started = performance.now();
|
||||
const child = spawn(command, args, {
|
||||
cwd: process.cwd(),
|
||||
env: process.env,
|
||||
detached: true,
|
||||
stdio: "inherit",
|
||||
});
|
||||
|
||||
let maxRssBytes = 0;
|
||||
let maxCpuTicks = 0;
|
||||
let timedOut = false;
|
||||
let finished = false;
|
||||
let parentSignalInFlight = false;
|
||||
let forwardedParentSignal = null;
|
||||
let killTimer;
|
||||
let parentSignalTimer;
|
||||
let parentSignalPollTimer;
|
||||
let childGroupDrainTimer;
|
||||
// The leader can exit before descendants in its detached process group.
|
||||
// Keep the wrapper alive so timeout cleanup still owns those descendants.
|
||||
let childClosedResult = null;
|
||||
const updateMetrics = () => {
|
||||
if (!child.pid) {
|
||||
return;
|
||||
}
|
||||
const current = sample(child.pid);
|
||||
maxRssBytes = Math.max(maxRssBytes, current.rssBytes);
|
||||
maxCpuTicks = Math.max(maxCpuTicks, current.cpuTicks);
|
||||
};
|
||||
|
||||
function finishChildClosedResultIfGroupDrained() {
|
||||
if (childClosedResult && !childGroupExists()) {
|
||||
finish(childClosedResult.code, childClosedResult.signal);
|
||||
}
|
||||
}
|
||||
|
||||
updateMetrics();
|
||||
const interval = setInterval(updateMetrics, pollMs);
|
||||
const timeoutTimer =
|
||||
Number.isFinite(timeoutMs) && timeoutMs > 0
|
||||
? setTimeout(() => {
|
||||
if (childClosedResult && !childGroupExists()) {
|
||||
finish(childClosedResult.code, childClosedResult.signal);
|
||||
return;
|
||||
}
|
||||
timedOut = true;
|
||||
terminateChildGroup("SIGTERM");
|
||||
killTimer = setTimeout(() => {
|
||||
terminateChildGroup("SIGKILL");
|
||||
finish(124);
|
||||
}, timeoutKillGraceMs);
|
||||
killTimer.unref?.();
|
||||
}, timeoutMs)
|
||||
: null;
|
||||
timeoutTimer?.unref?.();
|
||||
|
||||
function terminateChildGroup(signal) {
|
||||
if (!child.pid) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
process.kill(-child.pid, signal);
|
||||
return;
|
||||
} catch {}
|
||||
try {
|
||||
child.kill(signal);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function childGroupExists() {
|
||||
if (!child.pid) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
process.kill(-child.pid, 0);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error && error.code === "ESRCH") {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function clearRuntimeTimers() {
|
||||
clearInterval(interval);
|
||||
if (timeoutTimer) {
|
||||
clearTimeout(timeoutTimer);
|
||||
}
|
||||
if (killTimer) {
|
||||
clearTimeout(killTimer);
|
||||
}
|
||||
if (parentSignalTimer) {
|
||||
clearTimeout(parentSignalTimer);
|
||||
}
|
||||
if (parentSignalPollTimer) {
|
||||
clearInterval(parentSignalPollTimer);
|
||||
}
|
||||
if (childGroupDrainTimer) {
|
||||
clearInterval(childGroupDrainTimer);
|
||||
}
|
||||
}
|
||||
|
||||
function rethrowParentSignal(signal) {
|
||||
clearRuntimeTimers();
|
||||
process.removeAllListeners(signal);
|
||||
process.kill(process.pid, signal);
|
||||
process.exit(128);
|
||||
}
|
||||
|
||||
function handleParentSignal(signal) {
|
||||
if (parentSignalInFlight) {
|
||||
terminateChildGroup("SIGKILL");
|
||||
rethrowParentSignal(signal);
|
||||
return;
|
||||
}
|
||||
parentSignalInFlight = true;
|
||||
if (finished) {
|
||||
rethrowParentSignal(signal);
|
||||
return;
|
||||
}
|
||||
finished = true;
|
||||
forwardedParentSignal = signal;
|
||||
clearRuntimeTimers();
|
||||
terminateChildGroup(signal);
|
||||
parentSignalTimer = setTimeout(() => {
|
||||
terminateChildGroup("SIGKILL");
|
||||
rethrowParentSignal(signal);
|
||||
}, timeoutKillGraceMs);
|
||||
parentSignalPollTimer = setInterval(
|
||||
() => {
|
||||
if (!childGroupExists()) {
|
||||
rethrowParentSignal(signal);
|
||||
}
|
||||
},
|
||||
Math.min(50, timeoutKillGraceMs),
|
||||
);
|
||||
}
|
||||
|
||||
for (const signal of ["SIGHUP", "SIGINT", "SIGTERM"]) {
|
||||
process.once(signal, () => handleParentSignal(signal));
|
||||
}
|
||||
|
||||
process.once("exit", () => {
|
||||
if (!finished) {
|
||||
terminateChildGroup("SIGTERM");
|
||||
}
|
||||
});
|
||||
|
||||
function finish(code, signal) {
|
||||
if (finished) {
|
||||
return;
|
||||
}
|
||||
finished = true;
|
||||
updateMetrics();
|
||||
clearRuntimeTimers();
|
||||
const wallMs = performance.now() - started;
|
||||
const cpuSeconds = maxCpuTicks / clockTicks;
|
||||
const maxRssKb = Math.round(maxRssBytes / 1024);
|
||||
const cpuCoreRatio = wallMs > 0 ? cpuSeconds / (wallMs / 1000) : 0;
|
||||
const summarySignal = timedOut ? "timeout" : (signal ?? "");
|
||||
fs.appendFileSync(
|
||||
summaryPath,
|
||||
`${phase}\t${maxRssKb}\t${cpuSeconds.toFixed(3)}\t${wallMs.toFixed(0)}\t${cpuCoreRatio.toFixed(3)}\t${summarySignal}\n`,
|
||||
);
|
||||
console.log(
|
||||
`plugin lifecycle resource: phase=${phase} max_rss_kb=${maxRssKb} cpu_s=${cpuSeconds.toFixed(3)} wall_ms=${wallMs.toFixed(0)} cpu_core_ratio=${cpuCoreRatio.toFixed(3)} signal=${summarySignal}`,
|
||||
);
|
||||
const violations = [];
|
||||
if (maxRssKb > maxRssKbThreshold) {
|
||||
violations.push(`max_rss_kb=${maxRssKb} > ${maxRssKbThreshold}`);
|
||||
}
|
||||
if (wallMs > maxWallMs) {
|
||||
violations.push(`wall_ms=${wallMs.toFixed(0)} > ${maxWallMs}`);
|
||||
}
|
||||
if (cpuCoreRatio > maxCpuCoreRatio) {
|
||||
violations.push(`cpu_core_ratio=${cpuCoreRatio.toFixed(3)} > ${maxCpuCoreRatio}`);
|
||||
}
|
||||
if (violations.length > 0) {
|
||||
console.error(
|
||||
`plugin lifecycle resource ceiling exceeded: phase=${phase} ${violations.join("; ")}`,
|
||||
);
|
||||
if (!timedOut && !signal && (code ?? 0) === 0) {
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (timedOut) {
|
||||
process.exit(124);
|
||||
return;
|
||||
}
|
||||
if (signal) {
|
||||
process.kill(process.pid, signal);
|
||||
return;
|
||||
}
|
||||
process.exit(code ?? 0);
|
||||
}
|
||||
|
||||
child.on("error", (error) => {
|
||||
finished = true;
|
||||
clearRuntimeTimers();
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
child.on("exit", (code, signal) => {
|
||||
if (parentSignalInFlight && forwardedParentSignal) {
|
||||
if (!childGroupExists()) {
|
||||
rethrowParentSignal(forwardedParentSignal);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (timedOut && killTimer) {
|
||||
return;
|
||||
}
|
||||
if (childGroupExists()) {
|
||||
childClosedResult = { code, signal };
|
||||
childGroupDrainTimer = setInterval(finishChildClosedResultIfGroupDrained, Math.min(25, pollMs));
|
||||
return;
|
||||
}
|
||||
finish(code, signal);
|
||||
});
|
||||
110
scripts/e2e/lib/plugin-update/corrupt-update-scenario.sh
Normal file
110
scripts/e2e/lib/plugin-update/corrupt-update-scenario.sh
Normal file
@@ -0,0 +1,110 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
source scripts/lib/openclaw-e2e-instance.sh
|
||||
source scripts/e2e/lib/plugins/fixtures.sh
|
||||
|
||||
openclaw_e2e_eval_test_state_from_b64 "${OPENCLAW_TEST_STATE_SCRIPT_B64:?missing OPENCLAW_TEST_STATE_SCRIPT_B64}"
|
||||
|
||||
export npm_config_loglevel=error
|
||||
export npm_config_fund=false
|
||||
export npm_config_audit=false
|
||||
export npm_config_prefix=/tmp/npm-prefix
|
||||
export NPM_CONFIG_PREFIX=/tmp/npm-prefix
|
||||
export PATH="/tmp/npm-prefix/bin:$PATH"
|
||||
export CI=true
|
||||
export OPENCLAW_DISABLE_BUNDLED_PLUGINS=1
|
||||
export OPENCLAW_NO_ONBOARD=1
|
||||
export OPENCLAW_NO_PROMPT=1
|
||||
|
||||
baseline="${OPENCLAW_UPDATE_CORRUPT_PLUGIN_BASELINE:-openclaw@latest}"
|
||||
update_timeout_seconds="$(openclaw_e2e_read_positive_int_env OPENCLAW_UPDATE_CORRUPT_PLUGIN_TIMEOUT_SECONDS 900)"
|
||||
echo "Installing baseline OpenClaw package: $baseline"
|
||||
if ! openclaw_e2e_maybe_timeout "${OPENCLAW_E2E_NPM_INSTALL_TIMEOUT:-600s}" npm install -g --prefix /tmp/npm-prefix --omit=optional "$baseline" >/tmp/openclaw-update-corrupt-baseline-install.log 2>&1; then
|
||||
openclaw_e2e_print_log /tmp/openclaw-update-corrupt-baseline-install.log >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
package_root="$(openclaw_e2e_package_root /tmp/npm-prefix)"
|
||||
entry="$(openclaw_e2e_package_entrypoint "$package_root")"
|
||||
export OPENCLAW_ENTRY="$entry"
|
||||
|
||||
npm_pack_dir="$(mktemp -d "/tmp/openclaw-corrupt-plugin-pack.XXXXXX")"
|
||||
npm_registry_dir="$(mktemp -d "/tmp/openclaw-corrupt-plugin-registry.XXXXXX")"
|
||||
pack_fixture_plugin "$npm_pack_dir" /tmp/demo-corrupt-plugin.tgz demo-corrupt-plugin 0.0.1 demo.corrupt "Demo Corrupt Plugin"
|
||||
start_npm_fixture_registry "@openclaw/demo-corrupt-plugin" "0.0.1" /tmp/demo-corrupt-plugin.tgz "$npm_registry_dir"
|
||||
|
||||
echo "Installing managed external plugin..."
|
||||
node "$entry" plugins install "npm:@openclaw/demo-corrupt-plugin@0.0.1" >/tmp/openclaw-corrupt-plugin-install.log 2>&1
|
||||
node "$entry" plugins inspect demo-corrupt-plugin --runtime --json >/tmp/openclaw-corrupt-plugin-before.json
|
||||
unset NPM_CONFIG_REGISTRY npm_config_registry
|
||||
|
||||
plugin_dir="$(
|
||||
node -e '
|
||||
const fs = require("node:fs");
|
||||
const payload = JSON.parse(fs.readFileSync(process.argv[1], "utf8"));
|
||||
const installPath = payload.install?.installPath ?? payload.plugin?.rootDir;
|
||||
if (!installPath) {
|
||||
throw new Error("missing plugin install path in inspect output");
|
||||
}
|
||||
process.stdout.write(installPath);
|
||||
' /tmp/openclaw-corrupt-plugin-before.json
|
||||
)"
|
||||
rm -f "$plugin_dir/package.json"
|
||||
if [ -f "$plugin_dir/package.json" ]; then
|
||||
echo "Expected corrupt plugin package.json to be removed before update." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Updating OpenClaw with corrupt plugin present..."
|
||||
set +e
|
||||
openclaw_e2e_maybe_timeout "${update_timeout_seconds}s" \
|
||||
node "$entry" update \
|
||||
--channel beta \
|
||||
--tag "${OPENCLAW_CURRENT_PACKAGE_TGZ:?missing OPENCLAW_CURRENT_PACKAGE_TGZ}" \
|
||||
--yes \
|
||||
--no-restart \
|
||||
--json \
|
||||
>/tmp/openclaw-update-corrupt-plugin.json \
|
||||
2>/tmp/openclaw-update-corrupt-plugin.err
|
||||
update_status=$?
|
||||
set -e
|
||||
if [ "$update_status" -ne 0 ]; then
|
||||
if ! node scripts/e2e/lib/plugin-update/probe.mjs assert-legacy-post-update-plugin-failure /tmp/openclaw-update-corrupt-plugin.json; then
|
||||
echo "openclaw update failed or timed out after ${update_timeout_seconds}s with corrupt plugin present" >&2
|
||||
openclaw_e2e_print_log /tmp/openclaw-update-corrupt-plugin.err >&2
|
||||
openclaw_e2e_print_log /tmp/openclaw-update-corrupt-plugin.json >&2
|
||||
exit "$update_status"
|
||||
fi
|
||||
echo "Legacy updater reported post-update plugin failure after installing the new core; verifying updated entrypoint..."
|
||||
set +e
|
||||
OPENCLAW_UPDATE_POST_CORE=1 \
|
||||
OPENCLAW_UPDATE_POST_CORE_CHANNEL=beta \
|
||||
OPENCLAW_UPDATE_POST_CORE_RESULT_PATH=/tmp/openclaw-update-corrupt-plugin-post-core.json \
|
||||
openclaw_e2e_maybe_timeout "${update_timeout_seconds}s" \
|
||||
node "$entry" update \
|
||||
--yes \
|
||||
--no-restart \
|
||||
--json \
|
||||
>/tmp/openclaw-update-corrupt-plugin-post-core.stdout \
|
||||
2>/tmp/openclaw-update-corrupt-plugin-post-core.err
|
||||
post_core_status=$?
|
||||
set -e
|
||||
if [ "$post_core_status" -ne 0 ]; then
|
||||
echo "updated OpenClaw entry failed or timed out after ${update_timeout_seconds}s during post-core plugin verification" >&2
|
||||
openclaw_e2e_print_log /tmp/openclaw-update-corrupt-plugin-post-core.err >&2
|
||||
openclaw_e2e_print_log /tmp/openclaw-update-corrupt-plugin-post-core.stdout >&2
|
||||
openclaw_e2e_print_log /tmp/openclaw-update-corrupt-plugin-post-core.json >&2
|
||||
exit "$post_core_status"
|
||||
fi
|
||||
node scripts/e2e/lib/plugin-update/probe.mjs assert-corrupt-plugin-result /tmp/openclaw-update-corrupt-plugin-post-core.json demo-corrupt-plugin
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if ! node scripts/e2e/lib/plugin-update/probe.mjs assert-corrupt-update /tmp/openclaw-update-corrupt-plugin.json demo-corrupt-plugin; then
|
||||
echo "corrupt update JSON payload:" >&2
|
||||
openclaw_e2e_print_log /tmp/openclaw-update-corrupt-plugin.json >&2
|
||||
echo "corrupt update stderr:" >&2
|
||||
openclaw_e2e_print_log /tmp/openclaw-update-corrupt-plugin.err >&2
|
||||
exit 1
|
||||
fi
|
||||
307
scripts/e2e/lib/plugin-update/probe.mjs
Normal file
307
scripts/e2e/lib/plugin-update/probe.mjs
Normal file
@@ -0,0 +1,307 @@
|
||||
// Probe script for plugin update E2E scenarios.
|
||||
import fs from "node:fs";
|
||||
import http from "node:http";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { legacyPackageAcceptanceCompat } from "../package-compat.mjs";
|
||||
import {
|
||||
readPluginInstallRecords,
|
||||
writePluginInstallIndexForE2E,
|
||||
} from "../plugin-index-sqlite.mjs";
|
||||
|
||||
const home = os.homedir();
|
||||
const OUTPUT_TAIL_BYTES = 64 * 1024;
|
||||
const OUTPUT_TAIL_LINES = 120;
|
||||
const OUTPUT_SCAN_WINDOW_BYTES = 8 * 1024;
|
||||
|
||||
const readJson = (file) => {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(file, "utf8"));
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
const pluginRecordSnapshot = () => {
|
||||
const config = readJson(openclawPath("openclaw.json"));
|
||||
const records = readPluginInstallRecords({ fallbackRecords: config.plugins?.installs ?? {} });
|
||||
const record = records["lossless-claw"] ?? records["@example/lossless-claw"];
|
||||
if (!record) {
|
||||
throw new Error("missing plugin install record");
|
||||
}
|
||||
const { source, spec, resolvedName, resolvedVersion, resolvedSpec, integrity, shasum } = record;
|
||||
return { source, spec, resolvedName, resolvedVersion, resolvedSpec, integrity, shasum };
|
||||
};
|
||||
|
||||
function openclawPath(...parts) {
|
||||
return path.join(home, ".openclaw", ...parts);
|
||||
}
|
||||
|
||||
function writeJson(file, value) {
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`);
|
||||
}
|
||||
|
||||
function seedInstallState() {
|
||||
writeJson(openclawPath("extensions", "lossless-claw", "package.json"), {
|
||||
name: "@example/lossless-claw",
|
||||
version: "0.9.0",
|
||||
});
|
||||
writeJson(process.env.OPENCLAW_CONFIG_PATH, { plugins: {} });
|
||||
writePluginInstallIndexForE2E({
|
||||
version: 1,
|
||||
warning: "DO NOT EDIT. This file is generated by OpenClaw plugin registry commands.",
|
||||
hostContractVersion: "docker-e2e",
|
||||
compatRegistryVersion: "docker-e2e",
|
||||
migrationVersion: 1,
|
||||
policyHash: "docker-e2e",
|
||||
generatedAtMs: 1777118400000,
|
||||
installRecords: {
|
||||
"lossless-claw": {
|
||||
source: "npm",
|
||||
spec: "@example/lossless-claw@0.9.0",
|
||||
installPath: "~/.openclaw/extensions/lossless-claw",
|
||||
resolvedName: "@example/lossless-claw",
|
||||
resolvedVersion: "0.9.0",
|
||||
resolvedSpec: "@example/lossless-claw@0.9.0",
|
||||
integrity: "sha512-same",
|
||||
shasum: "same",
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
diagnostics: [],
|
||||
});
|
||||
}
|
||||
|
||||
async function waitRegistry() {
|
||||
for (let attempt = 0; attempt < 50; attempt += 1) {
|
||||
if (await registryHealthy()) {
|
||||
return;
|
||||
}
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 100);
|
||||
});
|
||||
}
|
||||
throw new Error("Local npm metadata registry failed to start");
|
||||
}
|
||||
|
||||
function registryHealthy() {
|
||||
return new Promise((resolve) => {
|
||||
const registry = process.env.NPM_CONFIG_REGISTRY ?? "http://127.0.0.1:4873";
|
||||
const req = http.get(`${registry.replace(/\/$/u, "")}/@example%2flossless-claw`, (res) => {
|
||||
resolve(res.statusCode === 200);
|
||||
res.resume();
|
||||
});
|
||||
req.on("error", () => resolve(false));
|
||||
req.setTimeout(200, () => {
|
||||
req.destroy();
|
||||
resolve(false);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function assertSnapshot(beforePath) {
|
||||
const before = readJson(beforePath);
|
||||
const after = pluginRecordSnapshot();
|
||||
if (JSON.stringify(before) !== JSON.stringify(after)) {
|
||||
throw new Error(
|
||||
`plugin install record changed unexpectedly: ${JSON.stringify({ before, after })}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function appendBufferTail(tail, chunk, maxBytes) {
|
||||
if (chunk.length >= maxBytes) {
|
||||
return chunk.subarray(chunk.length - maxBytes);
|
||||
}
|
||||
if (tail.length + chunk.length <= maxBytes) {
|
||||
return Buffer.concat([tail, chunk]);
|
||||
}
|
||||
return Buffer.concat([tail, chunk]).subarray(tail.length + chunk.length - maxBytes);
|
||||
}
|
||||
|
||||
async function readOutputEvidence(logPath) {
|
||||
let outputTail = Buffer.alloc(0);
|
||||
let scanWindow = "";
|
||||
let sawDownload = false;
|
||||
let sawUpToDate = false;
|
||||
for await (const chunk of fs.createReadStream(logPath)) {
|
||||
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
||||
const text = buffer.toString("utf8");
|
||||
const searchable = `${scanWindow}${text}`;
|
||||
outputTail = appendBufferTail(outputTail, buffer, OUTPUT_TAIL_BYTES);
|
||||
sawDownload ||= searchable.includes("Downloading @example/lossless-claw");
|
||||
sawUpToDate ||= searchable.includes("lossless-claw is up to date (0.9.0).");
|
||||
scanWindow = searchable.slice(-OUTPUT_SCAN_WINDOW_BYTES);
|
||||
}
|
||||
return {
|
||||
outputTail: outputTail
|
||||
.toString("utf8")
|
||||
.split(/\r?\n/u)
|
||||
.slice(-OUTPUT_TAIL_LINES)
|
||||
.join("\n")
|
||||
.trimEnd(),
|
||||
sawDownload,
|
||||
sawUpToDate,
|
||||
};
|
||||
}
|
||||
|
||||
async function assertOutput(logPath) {
|
||||
const evidence = await readOutputEvidence(logPath);
|
||||
const failure = evidence.sawDownload
|
||||
? "Unexpected npm download/reinstall path"
|
||||
: !evidence.sawUpToDate
|
||||
? "Expected up-to-date output missing"
|
||||
: "";
|
||||
if (failure) {
|
||||
throw new Error(`${failure}\nOutput tail:\n${evidence.outputTail}`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertCorruptUpdate(updateJsonPath, pluginId) {
|
||||
const payload = readJson(updateJsonPath);
|
||||
if (payload.status !== "ok") {
|
||||
throw new Error(`expected core update status ok, got ${JSON.stringify(payload.status)}`);
|
||||
}
|
||||
const plugins = payload.postUpdate?.plugins;
|
||||
if (!plugins) {
|
||||
throw new Error(`missing postUpdate.plugins in update output: ${JSON.stringify(payload)}`);
|
||||
}
|
||||
assertCorruptPluginTolerated(plugins, pluginId);
|
||||
}
|
||||
|
||||
function assertCorruptPluginResult(pluginJsonPath, pluginId) {
|
||||
const plugins = readJson(pluginJsonPath);
|
||||
assertCorruptPluginTolerated(plugins, pluginId);
|
||||
}
|
||||
|
||||
function assertCorruptPluginTolerated(plugins, pluginId) {
|
||||
const evidence = collectPluginEvidence(plugins, pluginId);
|
||||
if (plugins.status === "ok") {
|
||||
assertCorruptPluginCleanOrRepaired(evidence);
|
||||
return;
|
||||
}
|
||||
if (plugins.status !== "warning") {
|
||||
throw new Error(
|
||||
`expected post-update plugin status warning, got ${JSON.stringify(plugins.status)}`,
|
||||
);
|
||||
}
|
||||
assertCorruptPluginDetails(plugins, pluginId);
|
||||
}
|
||||
|
||||
function isCorruptPluginDisabledAfterUpdate(evidence, pluginId) {
|
||||
const outcome = evidence.outcome;
|
||||
const message = typeof outcome?.message === "string" ? outcome.message : "";
|
||||
return (
|
||||
outcome?.status === "skipped" &&
|
||||
message.includes(`Disabled "${pluginId}" after plugin update failure`) &&
|
||||
message.includes("OpenClaw will continue without it")
|
||||
);
|
||||
}
|
||||
|
||||
function assertCorruptPluginCleanOrRepaired(evidence) {
|
||||
if (evidence.outcome) {
|
||||
throw new Error(
|
||||
`expected clean or repaired corrupt plugin state, got ${JSON.stringify(evidence)}`,
|
||||
);
|
||||
}
|
||||
if (evidence.warning || evidence.integrityDrift || evidence.syncMessages.length > 0) {
|
||||
throw new Error(
|
||||
`expected warning post-update status for corrupt plugin evidence, got ok: ${JSON.stringify(
|
||||
evidence,
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function assertCorruptPluginDetails(plugins, pluginId) {
|
||||
const evidence = collectPluginEvidence(plugins, pluginId);
|
||||
const outcome = evidence.outcome;
|
||||
const disabledAfterFailure = isCorruptPluginDisabledAfterUpdate(evidence, pluginId);
|
||||
if (!outcome || (outcome.status !== "error" && !disabledAfterFailure)) {
|
||||
throw new Error(
|
||||
`expected error or disabled-after-failure outcome for ${pluginId}, got ${JSON.stringify({
|
||||
outcomes: plugins.npm?.outcomes ?? [],
|
||||
warnings: plugins.warnings ?? [],
|
||||
sync: plugins.sync,
|
||||
integrityDrifts: plugins.integrityDrifts ?? [],
|
||||
})}`,
|
||||
);
|
||||
}
|
||||
const warning = evidence.warning;
|
||||
if (!warning) {
|
||||
throw new Error(
|
||||
`expected warning for ${pluginId}, got ${JSON.stringify(plugins.warnings ?? [])}`,
|
||||
);
|
||||
}
|
||||
const text = [outcome.message, warning.reason, warning.message, ...(warning.guidance ?? [])]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
const expectedFragments = disabledAfterFailure
|
||||
? [
|
||||
`Disabled "${pluginId}" after plugin update failure`,
|
||||
"OpenClaw will continue without it",
|
||||
"Run openclaw update repair to retry post-update plugin repair.",
|
||||
`Run openclaw plugins inspect ${pluginId} --runtime --json for details.`,
|
||||
]
|
||||
: [
|
||||
"package.json is missing",
|
||||
"Run openclaw update repair to retry post-update plugin repair.",
|
||||
`Run openclaw plugins inspect ${pluginId} --runtime --json for details.`,
|
||||
];
|
||||
for (const expected of expectedFragments) {
|
||||
if (!text.includes(expected)) {
|
||||
throw new Error(`expected update output to include ${expected}: ${text}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function collectPluginEvidence(plugins, pluginId) {
|
||||
const outcomes = plugins.npm?.outcomes ?? [];
|
||||
const warnings = plugins.warnings ?? [];
|
||||
const integrityDrifts = plugins.integrityDrifts ?? [];
|
||||
const syncMessages = [...(plugins.sync?.warnings ?? []), ...(plugins.sync?.errors ?? [])].filter(
|
||||
(message) => String(message).includes(pluginId),
|
||||
);
|
||||
return {
|
||||
outcome: outcomes.find((entry) => entry?.pluginId === pluginId),
|
||||
warning: warnings.find((entry) => entry?.pluginId === pluginId),
|
||||
integrityDrift: integrityDrifts.find((entry) => entry?.pluginId === pluginId),
|
||||
syncMessages,
|
||||
};
|
||||
}
|
||||
|
||||
function assertLegacyPostUpdatePluginFailure(updateJsonPath) {
|
||||
const payload = readJson(updateJsonPath);
|
||||
if (payload.status !== "error" || payload.reason !== "post-update-plugins") {
|
||||
throw new Error(
|
||||
`expected legacy post-update plugin failure, got ${JSON.stringify({
|
||||
status: payload.status,
|
||||
reason: payload.reason,
|
||||
})}`,
|
||||
);
|
||||
}
|
||||
if (!payload.after?.version) {
|
||||
throw new Error(`expected core update to install a new version: ${JSON.stringify(payload)}`);
|
||||
}
|
||||
}
|
||||
|
||||
const [command, arg, arg2] = process.argv.slice(2);
|
||||
const commands = {
|
||||
"legacy-compat": () => console.log(legacyPackageAcceptanceCompat(arg || "") ? "1" : "0"),
|
||||
seed: seedInstallState,
|
||||
"wait-registry": waitRegistry,
|
||||
snapshot: () => process.stdout.write(JSON.stringify(pluginRecordSnapshot(), null, 2)),
|
||||
"assert-snapshot": () => assertSnapshot(arg),
|
||||
"assert-output": () => assertOutput(arg),
|
||||
"assert-corrupt-update": () => assertCorruptUpdate(arg, arg2),
|
||||
"assert-corrupt-plugin-result": () => assertCorruptPluginResult(arg, arg2),
|
||||
"assert-legacy-post-update-plugin-failure": () => assertLegacyPostUpdatePluginFailure(arg),
|
||||
};
|
||||
const run = commands[command];
|
||||
await (
|
||||
run ??
|
||||
(() => {
|
||||
throw new Error(`Unknown plugin update probe command: ${command || "(missing)"}`);
|
||||
})
|
||||
)();
|
||||
52
scripts/e2e/lib/plugin-update/registry-server.mjs
Normal file
52
scripts/e2e/lib/plugin-update/registry-server.mjs
Normal file
@@ -0,0 +1,52 @@
|
||||
// Fixture npm registry server for plugin update E2E scenarios.
|
||||
import fs from "node:fs";
|
||||
import http from "node:http";
|
||||
import { readTcpPortEnv } from "../env-limits.mjs";
|
||||
|
||||
const portFile = process.argv[2];
|
||||
if (!portFile) {
|
||||
console.error("usage: registry-server.mjs <port-file>");
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
function buildMetadata(req) {
|
||||
const host = req.headers.host ?? "127.0.0.1";
|
||||
return {
|
||||
name: "@example/lossless-claw",
|
||||
"dist-tags": { latest: "0.9.0" },
|
||||
versions: {
|
||||
"0.9.0": {
|
||||
name: "@example/lossless-claw",
|
||||
version: "0.9.0",
|
||||
dist: {
|
||||
integrity: "sha512-same",
|
||||
shasum: "same",
|
||||
tarball: `http://${host}/@example/lossless-claw/-/lossless-claw-0.9.0.tgz`,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
if (req.url === "/@example%2flossless-claw" || req.url === "/@example%2Flossless-claw") {
|
||||
res.writeHead(200, { "content-type": "application/json" });
|
||||
res.end(JSON.stringify(buildMetadata(req)));
|
||||
return;
|
||||
}
|
||||
res.writeHead(404, { "content-type": "text/plain" });
|
||||
res.end(`not found: ${req.url}`);
|
||||
});
|
||||
|
||||
const requestedPort =
|
||||
process.env.OPENCLAW_PLUGIN_UPDATE_REGISTRY_PORT === undefined
|
||||
? 0
|
||||
: readTcpPortEnv("OPENCLAW_PLUGIN_UPDATE_REGISTRY_PORT");
|
||||
|
||||
server.listen(requestedPort, "127.0.0.1", () => {
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
throw new Error("plugin update registry did not expose a TCP port");
|
||||
}
|
||||
fs.writeFileSync(portFile, `${address.port}\n`);
|
||||
});
|
||||
76
scripts/e2e/lib/plugin-update/unchanged-scenario.sh
Normal file
76
scripts/e2e/lib/plugin-update/unchanged-scenario.sh
Normal file
@@ -0,0 +1,76 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
source scripts/lib/openclaw-e2e-instance.sh
|
||||
|
||||
openclaw_e2e_eval_test_state_from_b64 "${OPENCLAW_TEST_STATE_SCRIPT_B64:?missing OPENCLAW_TEST_STATE_SCRIPT_B64}"
|
||||
openclaw_e2e_install_package /tmp/openclaw-install.log "mounted OpenClaw package" /tmp/npm-prefix
|
||||
|
||||
package_root="$(openclaw_e2e_package_root /tmp/npm-prefix)"
|
||||
entry="$(openclaw_e2e_package_entrypoint "$package_root")"
|
||||
probe="scripts/e2e/lib/plugin-update/probe.mjs"
|
||||
package_version="$(node -p "require('$package_root/package.json').version")"
|
||||
OPENCLAW_PACKAGE_ACCEPTANCE_LEGACY_COMPAT="$(node "$probe" legacy-compat "$package_version")"
|
||||
export OPENCLAW_PACKAGE_ACCEPTANCE_LEGACY_COMPAT
|
||||
export PATH="/tmp/npm-prefix/bin:$PATH"
|
||||
|
||||
node "$probe" seed
|
||||
|
||||
registry_port_file=/tmp/openclaw-e2e-registry.port
|
||||
rm -f "$registry_port_file"
|
||||
node scripts/e2e/lib/plugin-update/registry-server.mjs "$registry_port_file" >/tmp/openclaw-e2e-registry.log 2>&1 &
|
||||
registry_pid=$!
|
||||
trap 'openclaw_e2e_stop_process "${registry_pid:-}"' EXIT
|
||||
for _ in $(seq 1 50); do
|
||||
if [ -s "$registry_port_file" ]; then
|
||||
break
|
||||
fi
|
||||
sleep 0.1
|
||||
done
|
||||
if [ ! -s "$registry_port_file" ]; then
|
||||
echo "Local npm metadata registry did not expose a port"
|
||||
openclaw_e2e_print_log /tmp/openclaw-e2e-registry.log
|
||||
exit 1
|
||||
fi
|
||||
export NPM_CONFIG_REGISTRY="http://127.0.0.1:$(cat "$registry_port_file")"
|
||||
export npm_config_registry="$NPM_CONFIG_REGISTRY"
|
||||
|
||||
if ! node "$probe" wait-registry; then
|
||||
echo "Local npm metadata registry failed to start"
|
||||
openclaw_e2e_print_log /tmp/openclaw-e2e-registry.log
|
||||
exit 1
|
||||
fi
|
||||
|
||||
before_config_hash=""
|
||||
if [ "$OPENCLAW_PACKAGE_ACCEPTANCE_LEGACY_COMPAT" != "1" ]; then
|
||||
before_config_hash="$(sha256sum "$OPENCLAW_CONFIG_PATH" | awk '{print $1}')"
|
||||
fi
|
||||
plugin_update_timeout_seconds="$(openclaw_e2e_read_positive_int_env OPENCLAW_PLUGIN_UPDATE_TIMEOUT_SECONDS 180)"
|
||||
|
||||
node "$probe" snapshot > /tmp/plugin-update-before.json
|
||||
|
||||
set +e
|
||||
openclaw_e2e_maybe_timeout "${plugin_update_timeout_seconds}s" node "$entry" plugins update @example/lossless-claw > /tmp/plugin-update-output.log 2>&1
|
||||
plugin_update_status=$?
|
||||
set -e
|
||||
if [ "$plugin_update_status" -ne 0 ]; then
|
||||
echo "Plugin update command failed or timed out after ${plugin_update_timeout_seconds}s (status ${plugin_update_status})"
|
||||
echo "--- plugin update output ---"
|
||||
openclaw_e2e_print_log /tmp/plugin-update-output.log
|
||||
echo "--- local registry output ---"
|
||||
openclaw_e2e_print_log /tmp/openclaw-e2e-registry.log
|
||||
exit "$plugin_update_status"
|
||||
fi
|
||||
|
||||
if [ -n "$before_config_hash" ]; then
|
||||
after_config_hash="$(sha256sum "$OPENCLAW_CONFIG_PATH" | awk '{print $1}')"
|
||||
if [ "$before_config_hash" != "$after_config_hash" ]; then
|
||||
echo "Config changed unexpectedly for modern package $package_version"
|
||||
openclaw_e2e_print_log /tmp/plugin-update-output.log
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
node "$probe" assert-snapshot /tmp/plugin-update-before.json
|
||||
node "$probe" assert-output /tmp/plugin-update-output.log
|
||||
openclaw_e2e_print_log /tmp/plugin-update-output.log
|
||||
1051
scripts/e2e/lib/plugins/assertions.mjs
Normal file
1051
scripts/e2e/lib/plugins/assertions.mjs
Normal file
File diff suppressed because it is too large
Load Diff
76
scripts/e2e/lib/plugins/clawhub.sh
Normal file
76
scripts/e2e/lib/plugins/clawhub.sh
Normal file
@@ -0,0 +1,76 @@
|
||||
run_plugins_clawhub_scenario() {
|
||||
if [ "${OPENCLAW_PLUGINS_E2E_CLAWHUB:-1}" = "0" ]; then
|
||||
echo "Skipping ClawHub plugin install and uninstall (OPENCLAW_PLUGINS_E2E_CLAWHUB=0)."
|
||||
else
|
||||
echo "Testing ClawHub plugin install and uninstall..."
|
||||
CLAWHUB_PLUGIN_SPEC="${OPENCLAW_PLUGINS_E2E_CLAWHUB_SPEC:-clawhub:@openclaw/kitchen-sink}"
|
||||
CLAWHUB_PLUGIN_ID="${OPENCLAW_PLUGINS_E2E_CLAWHUB_ID:-openclaw-kitchen-sink-fixture}"
|
||||
export CLAWHUB_PLUGIN_SPEC CLAWHUB_PLUGIN_ID
|
||||
|
||||
start_clawhub_fixture_server() {
|
||||
local fixture_dir="$1"
|
||||
local server_log="$fixture_dir/clawhub-fixture.log"
|
||||
local server_port_file="$fixture_dir/clawhub-fixture-port"
|
||||
local server_pid_file="$fixture_dir/clawhub-fixture-pid"
|
||||
|
||||
openclaw_plugins_validate_fixture_log_print_bytes || return $?
|
||||
|
||||
node scripts/e2e/lib/clawhub-fixture-server.cjs plugins "$server_port_file" >"$server_log" 2>&1 &
|
||||
local server_pid="$!"
|
||||
echo "$server_pid" >"$server_pid_file"
|
||||
openclaw_plugins_register_fixture_pid_file "$server_pid_file"
|
||||
|
||||
for _ in $(seq 1 100); do
|
||||
if [[ -s "$server_port_file" ]]; then
|
||||
export OPENCLAW_CLAWHUB_URL="http://127.0.0.1:$(cat "$server_port_file")"
|
||||
return 0
|
||||
fi
|
||||
if ! kill -0 "$server_pid" 2>/dev/null; then
|
||||
openclaw_plugins_print_fixture_log "$server_log"
|
||||
return 1
|
||||
fi
|
||||
sleep 0.1
|
||||
done
|
||||
|
||||
openclaw_plugins_print_fixture_log "$server_log"
|
||||
echo "Timed out waiting for ClawHub fixture server." >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
if [[ "${OPENCLAW_PLUGINS_E2E_LIVE_CLAWHUB:-0}" = "1" ]]; then
|
||||
export OPENCLAW_CLAWHUB_URL="${OPENCLAW_CLAWHUB_URL:-${CLAWHUB_URL:-https://clawhub.ai}}"
|
||||
export NPM_CONFIG_REGISTRY="${OPENCLAW_PLUGINS_E2E_LIVE_NPM_REGISTRY:-https://registry.npmjs.org/}"
|
||||
else
|
||||
# Keep the release-path smoke hermetic; live ClawHub can rate-limit CI.
|
||||
if [[ -n "${OPENCLAW_CLAWHUB_URL:-}" || -n "${CLAWHUB_URL:-}" ]]; then
|
||||
echo "Ignoring ambient ClawHub URL for fixture-mode plugin E2E; set OPENCLAW_PLUGINS_E2E_LIVE_CLAWHUB=1 for live ClawHub."
|
||||
fi
|
||||
unset OPENCLAW_CLAWHUB_URL CLAWHUB_URL
|
||||
clawhub_fixture_dir="$(mktemp -d "$OPENCLAW_PLUGINS_TMP_DIR/openclaw-clawhub-fixture.XXXXXX")"
|
||||
local fixture_status=0
|
||||
start_clawhub_fixture_server "$clawhub_fixture_dir" || fixture_status="$?"
|
||||
if [[ "$fixture_status" -ne 0 ]]; then
|
||||
return "$fixture_status"
|
||||
fi
|
||||
fi
|
||||
|
||||
node scripts/e2e/lib/plugins/assertions.mjs clawhub-preflight
|
||||
|
||||
run_plugins_openclaw_logged install-clawhub plugins install "$CLAWHUB_PLUGIN_SPEC"
|
||||
run_plugins_openclaw_capture "$OPENCLAW_PLUGINS_TMP_DIR/plugins-clawhub-installed.json" plugins list --json
|
||||
run_plugins_openclaw_capture "$OPENCLAW_PLUGINS_TMP_DIR/plugins-clawhub-inspect.json" plugins inspect "$CLAWHUB_PLUGIN_ID" --json
|
||||
|
||||
node scripts/e2e/lib/plugins/assertions.mjs clawhub-installed
|
||||
|
||||
openclaw_e2e_maybe_timeout "$OPENCLAW_PLUGINS_CLI_TIMEOUT" node "$OPENCLAW_ENTRY" plugins update "$CLAWHUB_PLUGIN_ID" >"$OPENCLAW_PLUGINS_TMP_DIR/plugins-clawhub-update.log" 2>&1
|
||||
run_plugins_openclaw_capture "$OPENCLAW_PLUGINS_TMP_DIR/plugins-clawhub-updated.json" plugins list --json
|
||||
run_plugins_openclaw_capture "$OPENCLAW_PLUGINS_TMP_DIR/plugins-clawhub-updated-inspect.json" plugins inspect "$CLAWHUB_PLUGIN_ID" --json
|
||||
|
||||
node scripts/e2e/lib/plugins/assertions.mjs clawhub-updated
|
||||
|
||||
run_plugins_openclaw_logged uninstall-clawhub plugins uninstall "$CLAWHUB_PLUGIN_SPEC" --force
|
||||
run_plugins_openclaw_capture "$OPENCLAW_PLUGINS_TMP_DIR/plugins-clawhub-uninstalled.json" plugins list --json
|
||||
|
||||
node scripts/e2e/lib/plugins/assertions.mjs clawhub-removed
|
||||
fi
|
||||
}
|
||||
284
scripts/e2e/lib/plugins/fixtures.sh
Normal file
284
scripts/e2e/lib/plugins/fixtures.sh
Normal file
@@ -0,0 +1,284 @@
|
||||
OPENCLAW_PLUGINS_FIXTURE_PID_FILES=()
|
||||
OPENCLAW_PLUGINS_FIXTURE_EXIT_TRAP_INSTALLED=0
|
||||
OPENCLAW_PLUGINS_FIXTURE_PREVIOUS_EXIT_ACTION=""
|
||||
|
||||
openclaw_plugins_read_positive_int_env() {
|
||||
local name="${1:?missing environment variable name}"
|
||||
local fallback="${2:?missing fallback value}"
|
||||
local value="${!name-}"
|
||||
if [[ -z "${!name+x}" ]]; then
|
||||
value="$fallback"
|
||||
fi
|
||||
if [[ ! "$value" =~ ^[0-9]+$ ]] || (( 10#$value < 1 )); then
|
||||
echo "invalid $name: $value" >&2
|
||||
return 2
|
||||
fi
|
||||
printf "%s\n" "$((10#$value))"
|
||||
}
|
||||
|
||||
openclaw_plugins_read_nonnegative_decimal_env() {
|
||||
local name="${1:?missing environment variable name}"
|
||||
local fallback="${2:?missing fallback value}"
|
||||
local value="${!name-}"
|
||||
if [[ -z "${!name+x}" ]]; then
|
||||
value="$fallback"
|
||||
fi
|
||||
if [[ ! "$value" =~ ^[0-9]+([.][0-9]+)?$ ]]; then
|
||||
echo "invalid $name: $value" >&2
|
||||
return 2
|
||||
fi
|
||||
printf "%s\n" "$value"
|
||||
}
|
||||
|
||||
openclaw_plugins_cleanup_fixture_servers() {
|
||||
local pid_file
|
||||
local pid
|
||||
for pid_file in "${OPENCLAW_PLUGINS_FIXTURE_PID_FILES[@]:-}"; do
|
||||
[[ -f "$pid_file" ]] || continue
|
||||
pid="$(cat "$pid_file" 2>/dev/null || true)"
|
||||
if [[ "$pid" =~ ^[0-9]+$ ]]; then
|
||||
openclaw_plugins_stop_fixture_process "$pid"
|
||||
fi
|
||||
rm -f "$pid_file"
|
||||
done
|
||||
}
|
||||
|
||||
openclaw_plugins_signal_fixture_process() {
|
||||
local pid="$1"
|
||||
local signal="$2"
|
||||
if kill -0 -- "-$pid" >/dev/null 2>&1; then
|
||||
kill "-$signal" -- "-$pid" >/dev/null 2>&1 || true
|
||||
return
|
||||
fi
|
||||
kill "-$signal" "$pid" >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
openclaw_plugins_fixture_process_alive() {
|
||||
local pid="$1"
|
||||
kill -0 "$pid" >/dev/null 2>&1 || kill -0 -- "-$pid" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
openclaw_plugins_stop_fixture_process() {
|
||||
local pid="$1"
|
||||
local _
|
||||
local attempts interval
|
||||
attempts="$(openclaw_plugins_read_positive_int_env OPENCLAW_PLUGINS_FIXTURE_STOP_ATTEMPTS 40)" || return $?
|
||||
interval="$(openclaw_plugins_read_nonnegative_decimal_env OPENCLAW_PLUGINS_FIXTURE_STOP_INTERVAL_SECONDS 0.25)" || return $?
|
||||
if declare -F openclaw_e2e_stop_process >/dev/null 2>&1; then
|
||||
openclaw_e2e_stop_process "$pid"
|
||||
return
|
||||
fi
|
||||
openclaw_plugins_signal_fixture_process "$pid" TERM
|
||||
for _ in $(seq 1 "$attempts"); do
|
||||
! openclaw_plugins_fixture_process_alive "$pid" && { wait "$pid" >/dev/null 2>&1 || true; return; }
|
||||
sleep "$interval"
|
||||
done
|
||||
openclaw_plugins_signal_fixture_process "$pid" KILL
|
||||
wait "$pid" >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
openclaw_plugins_print_fixture_log() {
|
||||
local log_file="$1"
|
||||
if declare -F docker_e2e_print_log >/dev/null 2>&1; then
|
||||
docker_e2e_print_log "$log_file"
|
||||
return
|
||||
fi
|
||||
if [ ! -f "$log_file" ]; then
|
||||
return
|
||||
fi
|
||||
|
||||
local max_bytes
|
||||
max_bytes="$(openclaw_plugins_read_positive_int_env OPENCLAW_DOCKER_E2E_LOG_PRINT_BYTES 65536)" || return $?
|
||||
|
||||
local log_bytes
|
||||
log_bytes="$(wc -c <"$log_file" 2>/dev/null || echo 0)"
|
||||
log_bytes="${log_bytes//[[:space:]]/}"
|
||||
if ! [[ "$log_bytes" =~ ^[0-9]+$ ]]; then
|
||||
log_bytes="0"
|
||||
fi
|
||||
if [ "$log_bytes" -le "$max_bytes" ]; then
|
||||
cat "$log_file"
|
||||
return
|
||||
fi
|
||||
echo "--- ${log_file} truncated: showing last ${max_bytes} of ${log_bytes} bytes ---"
|
||||
tail -c "$max_bytes" "$log_file"
|
||||
}
|
||||
|
||||
openclaw_plugins_validate_fixture_log_print_bytes() {
|
||||
openclaw_plugins_read_positive_int_env OPENCLAW_DOCKER_E2E_LOG_PRINT_BYTES 65536 >/dev/null
|
||||
}
|
||||
|
||||
openclaw_plugins_register_fixture_pid_file() {
|
||||
local pid_file="$1"
|
||||
OPENCLAW_PLUGINS_FIXTURE_PID_FILES+=("$pid_file")
|
||||
openclaw_plugins_install_fixture_cleanup_trap
|
||||
}
|
||||
|
||||
openclaw_plugins_install_fixture_cleanup_trap() {
|
||||
if [[ "${OPENCLAW_PLUGINS_FIXTURE_EXIT_TRAP_INSTALLED:-0}" = "1" ]]; then
|
||||
return
|
||||
fi
|
||||
|
||||
local existing_trap
|
||||
existing_trap="$(trap -p EXIT || true)"
|
||||
if [[ -n "$existing_trap" && "$existing_trap" != *openclaw_plugins_fixture_exit_trap* ]]; then
|
||||
local existing_action="${existing_trap#trap -- }"
|
||||
existing_action="${existing_action% EXIT}"
|
||||
eval "OPENCLAW_PLUGINS_FIXTURE_PREVIOUS_EXIT_ACTION=$existing_action"
|
||||
fi
|
||||
|
||||
OPENCLAW_PLUGINS_FIXTURE_EXIT_TRAP_INSTALLED=1
|
||||
trap openclaw_plugins_fixture_exit_trap EXIT
|
||||
}
|
||||
|
||||
openclaw_plugins_fixture_exit_trap() {
|
||||
local status="$?"
|
||||
openclaw_plugins_cleanup_fixture_servers
|
||||
if [[ -n "${OPENCLAW_PLUGINS_FIXTURE_PREVIOUS_EXIT_ACTION:-}" ]]; then
|
||||
eval "$OPENCLAW_PLUGINS_FIXTURE_PREVIOUS_EXIT_ACTION"
|
||||
fi
|
||||
exit "$status"
|
||||
}
|
||||
|
||||
record_fixture_plugin_trust() {
|
||||
local plugin_id="$1"
|
||||
local plugin_root="$2"
|
||||
local enabled="$3"
|
||||
node scripts/e2e/lib/plugins/assertions.mjs record-fixture-plugin-trust "$plugin_id" "$plugin_root" "$enabled"
|
||||
}
|
||||
|
||||
write_demo_fixture_plugin() {
|
||||
local dir="$1"
|
||||
node scripts/e2e/lib/fixture.mjs plugin-demo "$dir"
|
||||
}
|
||||
|
||||
write_fixture_plugin() {
|
||||
local dir="$1"
|
||||
local id="$2"
|
||||
local version="$3"
|
||||
local method="$4"
|
||||
local name="$5"
|
||||
|
||||
node scripts/e2e/lib/fixture.mjs plugin "$dir" "$id" "$version" "$method" "$name"
|
||||
}
|
||||
|
||||
write_fixture_plugin_with_cli() {
|
||||
local dir="$1"
|
||||
local id="$2"
|
||||
local version="$3"
|
||||
local method="$4"
|
||||
local name="$5"
|
||||
local cli_root="$6"
|
||||
local cli_output="$7"
|
||||
|
||||
node scripts/e2e/lib/fixture.mjs plugin-cli "$dir" "$id" "$version" "$method" "$name" "$cli_root" "$cli_output"
|
||||
}
|
||||
|
||||
pack_fixture_plugin_with_cli_registry_dependency() {
|
||||
local pack_dir="$1"
|
||||
local output_tgz="$2"
|
||||
local id="$3"
|
||||
local version="$4"
|
||||
local method="$5"
|
||||
local name="$6"
|
||||
local cli_root="$7"
|
||||
local cli_output="$8"
|
||||
|
||||
mkdir -p "$pack_dir/package"
|
||||
node scripts/e2e/lib/fixture.mjs plugin-cli-registry-dep "$pack_dir/package" "$id" "$version" "$method" "$name" "$cli_root" "$cli_output"
|
||||
tar -czf "$output_tgz" -C "$pack_dir" package
|
||||
}
|
||||
|
||||
pack_fake_is_number_package() {
|
||||
local pack_dir="$1"
|
||||
local output_tgz="$2"
|
||||
|
||||
mkdir -p "$pack_dir/package"
|
||||
node scripts/e2e/lib/fixture.mjs fake-is-number-package "$pack_dir/package"
|
||||
tar -czf "$output_tgz" -C "$pack_dir" package
|
||||
}
|
||||
|
||||
write_fixture_plugin_with_vendored_dependency() {
|
||||
local dir="$1"
|
||||
local id="$2"
|
||||
local version="$3"
|
||||
local method="$4"
|
||||
local name="$5"
|
||||
|
||||
node scripts/e2e/lib/fixture.mjs plugin-vendored-dep "$dir" "$id" "$version" "$method" "$name"
|
||||
}
|
||||
|
||||
pack_fixture_plugin() {
|
||||
local pack_dir="$1"
|
||||
local output_tgz="$2"
|
||||
local id="$3"
|
||||
local version="$4"
|
||||
local method="$5"
|
||||
local name="$6"
|
||||
|
||||
mkdir -p "$pack_dir/package"
|
||||
write_fixture_plugin "$pack_dir/package" "$id" "$version" "$method" "$name"
|
||||
tar -czf "$output_tgz" -C "$pack_dir" package
|
||||
}
|
||||
|
||||
pack_fixture_plugin_with_invalid_extension_entry() {
|
||||
local pack_dir="$1"
|
||||
local output_tgz="$2"
|
||||
local id="$3"
|
||||
local version="$4"
|
||||
local method="$5"
|
||||
local name="$6"
|
||||
|
||||
mkdir -p "$pack_dir/package"
|
||||
write_fixture_plugin "$pack_dir/package" "$id" "$version" "$method" "$name"
|
||||
node --input-type=module - "$pack_dir/package/package.json" <<'NODE'
|
||||
import fs from "node:fs";
|
||||
|
||||
const packageJsonPath = process.argv[2];
|
||||
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8"));
|
||||
packageJson.openclaw.extensions = ["./index.js", " "];
|
||||
fs.writeFileSync(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`, "utf8");
|
||||
NODE
|
||||
tar -czf "$output_tgz" -C "$pack_dir" package
|
||||
}
|
||||
|
||||
start_npm_fixture_registry() {
|
||||
local package_name="$1"
|
||||
local version="$2"
|
||||
local tarball="$3"
|
||||
local fixture_dir="$4"
|
||||
local server_log="$fixture_dir/npm-registry.log"
|
||||
local server_port_file="$fixture_dir/npm-registry-port"
|
||||
local server_pid_file="$fixture_dir/npm-registry-pid"
|
||||
|
||||
shift 4
|
||||
|
||||
openclaw_plugins_validate_fixture_log_print_bytes || return $?
|
||||
|
||||
node scripts/e2e/lib/plugins/npm-registry-server.mjs "$server_port_file" "$package_name" "$version" "$tarball" "$@" >"$server_log" 2>&1 &
|
||||
local server_pid="$!"
|
||||
echo "$server_pid" >"$server_pid_file"
|
||||
openclaw_plugins_register_fixture_pid_file "$server_pid_file"
|
||||
|
||||
for _ in $(seq 1 100); do
|
||||
if [[ -s "$server_port_file" ]]; then
|
||||
export NPM_CONFIG_REGISTRY="http://127.0.0.1:$(cat "$server_port_file")"
|
||||
return 0
|
||||
fi
|
||||
if ! kill -0 "$server_pid" 2>/dev/null; then
|
||||
openclaw_plugins_print_fixture_log "$server_log"
|
||||
return 1
|
||||
fi
|
||||
sleep 0.1
|
||||
done
|
||||
|
||||
openclaw_plugins_print_fixture_log "$server_log"
|
||||
echo "Timed out waiting for npm fixture registry." >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
write_claude_bundle_fixture() {
|
||||
local bundle_root="$1"
|
||||
|
||||
node scripts/e2e/lib/fixture.mjs claude-bundle "$bundle_root"
|
||||
}
|
||||
45
scripts/e2e/lib/plugins/marketplace.sh
Normal file
45
scripts/e2e/lib/plugins/marketplace.sh
Normal file
@@ -0,0 +1,45 @@
|
||||
run_plugins_marketplace_scenario() {
|
||||
echo "Testing marketplace install and update flows..."
|
||||
marketplace_root="$HOME/.claude/plugins/marketplaces/fixture-marketplace"
|
||||
mkdir -p "$HOME/.claude/plugins" "$marketplace_root/.claude-plugin"
|
||||
write_fixture_plugin \
|
||||
"$marketplace_root/plugins/marketplace-shortcut" \
|
||||
"marketplace-shortcut" \
|
||||
"0.0.1" \
|
||||
"demo.marketplace.shortcut.v1" \
|
||||
"Marketplace Shortcut"
|
||||
write_fixture_plugin \
|
||||
"$marketplace_root/plugins/marketplace-direct" \
|
||||
"marketplace-direct" \
|
||||
"0.0.1" \
|
||||
"demo.marketplace.direct.v1" \
|
||||
"Marketplace Direct"
|
||||
node scripts/e2e/lib/fixture.mjs marketplace "$marketplace_root"
|
||||
|
||||
run_plugins_openclaw_capture "$OPENCLAW_PLUGINS_TMP_DIR/marketplace-list.json" plugins marketplace list claude-fixtures --json
|
||||
|
||||
node scripts/e2e/lib/plugins/assertions.mjs marketplace-list
|
||||
|
||||
run_plugins_openclaw_logged install-marketplace-shortcut plugins install marketplace-shortcut@claude-fixtures
|
||||
run_plugins_openclaw_logged install-marketplace-direct plugins install marketplace-direct --marketplace claude-fixtures
|
||||
run_plugins_openclaw_capture "$OPENCLAW_PLUGINS_TMP_DIR/plugins-marketplace.json" plugins list --json
|
||||
run_plugins_openclaw_capture "$OPENCLAW_PLUGINS_TMP_DIR/plugins-marketplace-shortcut-inspect.json" plugins inspect marketplace-shortcut --runtime --json
|
||||
run_plugins_openclaw_capture "$OPENCLAW_PLUGINS_TMP_DIR/plugins-marketplace-direct-inspect.json" plugins inspect marketplace-direct --runtime --json
|
||||
|
||||
node scripts/e2e/lib/plugins/assertions.mjs marketplace-installed
|
||||
|
||||
node scripts/e2e/lib/plugins/assertions.mjs marketplace-records
|
||||
|
||||
write_fixture_plugin \
|
||||
"$marketplace_root/plugins/marketplace-shortcut" \
|
||||
"marketplace-shortcut" \
|
||||
"0.0.2" \
|
||||
"demo.marketplace.shortcut.v2" \
|
||||
"Marketplace Shortcut"
|
||||
run_plugins_openclaw_logged update-marketplace-shortcut-dry-run plugins update marketplace-shortcut --dry-run
|
||||
run_plugins_openclaw_logged update-marketplace-shortcut plugins update marketplace-shortcut
|
||||
run_plugins_openclaw_capture "$OPENCLAW_PLUGINS_TMP_DIR/plugins-marketplace-updated.json" plugins list --json
|
||||
run_plugins_openclaw_capture "$OPENCLAW_PLUGINS_TMP_DIR/plugins-marketplace-updated-inspect.json" plugins inspect marketplace-shortcut --runtime --json
|
||||
|
||||
node scripts/e2e/lib/plugins/assertions.mjs marketplace-updated
|
||||
}
|
||||
120
scripts/e2e/lib/plugins/npm-registry-server.mjs
Normal file
120
scripts/e2e/lib/plugins/npm-registry-server.mjs
Normal file
@@ -0,0 +1,120 @@
|
||||
// Fixture npm registry server for plugin E2E scenarios.
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import http from "node:http";
|
||||
import path from "node:path";
|
||||
|
||||
const [portFile, ...packageArgs] = process.argv.slice(2);
|
||||
|
||||
if (!portFile || packageArgs.length === 0 || packageArgs.length % 3 !== 0) {
|
||||
console.error(
|
||||
"usage: npm-registry-server.mjs <port-file> <package-name> <version> <tarball-path> [...]",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const packages = new Map();
|
||||
for (let index = 0; index < packageArgs.length; index += 3) {
|
||||
const packageName = packageArgs[index];
|
||||
const version = packageArgs[index + 1];
|
||||
const tarballPath = packageArgs[index + 2];
|
||||
const archive = fs.readFileSync(tarballPath);
|
||||
const existing = packages.get(packageName) ?? {
|
||||
encodedPackageName: encodeURIComponent(packageName).replace("%40", "@"),
|
||||
packageName,
|
||||
latestVersion: version,
|
||||
versions: new Map(),
|
||||
};
|
||||
existing.latestVersion = version;
|
||||
existing.versions.set(version, {
|
||||
archive,
|
||||
dependencies: packageName === "@openclaw/demo-plugin-npm" ? { "is-number": "7.0.0" } : {},
|
||||
integrity: `sha512-${crypto.createHash("sha512").update(archive).digest("base64")}`,
|
||||
shasum: crypto.createHash("sha1").update(archive).digest("hex"),
|
||||
tarballName: path.basename(tarballPath),
|
||||
version,
|
||||
});
|
||||
packages.set(packageName, existing);
|
||||
}
|
||||
|
||||
const metadataFor = (entry, baseUrl) => ({
|
||||
name: entry.packageName,
|
||||
"dist-tags": { latest: entry.latestVersion },
|
||||
versions: Object.fromEntries(
|
||||
[...entry.versions.entries()].map(([version, versionEntry]) => [
|
||||
version,
|
||||
{
|
||||
dependencies: versionEntry.dependencies,
|
||||
name: entry.packageName,
|
||||
version,
|
||||
dist: {
|
||||
integrity: versionEntry.integrity,
|
||||
shasum: versionEntry.shasum,
|
||||
tarball: `${baseUrl}/${entry.encodedPackageName}/-/${versionEntry.tarballName}`,
|
||||
},
|
||||
},
|
||||
]),
|
||||
),
|
||||
});
|
||||
|
||||
function decodePackagePath(pathname) {
|
||||
try {
|
||||
return decodeURIComponent(pathname.slice(1));
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function findPackageForPath(pathname) {
|
||||
const packageName = decodePackagePath(pathname);
|
||||
return packageName === undefined ? undefined : packages.get(packageName);
|
||||
}
|
||||
|
||||
function findTarballForPath(pathname) {
|
||||
for (const entry of packages.values()) {
|
||||
const prefix = `/${entry.encodedPackageName}/-/`;
|
||||
if (!pathname.toLowerCase().startsWith(prefix.toLowerCase())) {
|
||||
continue;
|
||||
}
|
||||
for (const versionEntry of entry.versions.values()) {
|
||||
if (pathname.endsWith(`/${versionEntry.tarballName}`)) {
|
||||
return versionEntry;
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const server = http.createServer((request, response) => {
|
||||
const url = new URL(request.url ?? "/", "http://127.0.0.1");
|
||||
const baseUrl = `http://127.0.0.1:${server.address().port}`;
|
||||
if (request.method !== "GET") {
|
||||
response.writeHead(405, { "content-type": "text/plain" });
|
||||
response.end("method not allowed");
|
||||
return;
|
||||
}
|
||||
|
||||
const packageEntry = findPackageForPath(url.pathname);
|
||||
if (packageEntry) {
|
||||
response.writeHead(200, { "content-type": "application/json" });
|
||||
response.end(`${JSON.stringify(metadataFor(packageEntry, baseUrl))}\n`);
|
||||
return;
|
||||
}
|
||||
|
||||
const tarballEntry = findTarballForPath(url.pathname);
|
||||
if (tarballEntry) {
|
||||
response.writeHead(200, {
|
||||
"content-type": "application/octet-stream",
|
||||
"content-length": String(tarballEntry.archive.length),
|
||||
});
|
||||
response.end(tarballEntry.archive);
|
||||
return;
|
||||
}
|
||||
|
||||
response.writeHead(404, { "content-type": "text/plain" });
|
||||
response.end(`not found: ${url.pathname}`);
|
||||
});
|
||||
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
fs.writeFileSync(portFile, String(server.address().port));
|
||||
});
|
||||
236
scripts/e2e/lib/plugins/sweep.sh
Normal file
236
scripts/e2e/lib/plugins/sweep.sh
Normal file
@@ -0,0 +1,236 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
source scripts/lib/openclaw-e2e-instance.sh
|
||||
source scripts/lib/docker-e2e-logs.sh
|
||||
OPENCLAW_PLUGINS_SWEEP_SOURCE_ONLY="${OPENCLAW_PLUGINS_SWEEP_SOURCE_ONLY:-0}"
|
||||
if [[ -z "${OPENCLAW_ENTRY:-}" && "$OPENCLAW_PLUGINS_SWEEP_SOURCE_ONLY" != "1" ]]; then
|
||||
OPENCLAW_ENTRY="$(openclaw_e2e_resolve_entrypoint)"
|
||||
fi
|
||||
export OPENCLAW_ENTRY
|
||||
OPENCLAW_PLUGINS_CREATED_TMP_DIR=0
|
||||
if [[ -z "${OPENCLAW_PLUGINS_TMP_DIR:-}" ]]; then
|
||||
OPENCLAW_PLUGINS_TMP_DIR="$(mktemp -d "/tmp/openclaw-plugins.XXXXXX")"
|
||||
OPENCLAW_PLUGINS_CREATED_TMP_DIR=1
|
||||
fi
|
||||
export OPENCLAW_PLUGINS_TMP_DIR
|
||||
OPENCLAW_PLUGINS_CLI_TIMEOUT="${OPENCLAW_PLUGINS_CLI_TIMEOUT:-180s}"
|
||||
mkdir -p "$OPENCLAW_PLUGINS_TMP_DIR"
|
||||
|
||||
run_plugins_openclaw_logged() {
|
||||
local label="$1"
|
||||
shift
|
||||
run_logged "$label" openclaw_e2e_maybe_timeout "$OPENCLAW_PLUGINS_CLI_TIMEOUT" node "$OPENCLAW_ENTRY" "$@"
|
||||
}
|
||||
|
||||
run_plugins_openclaw_capture() {
|
||||
local output_file="$1"
|
||||
shift
|
||||
openclaw_e2e_maybe_timeout "$OPENCLAW_PLUGINS_CLI_TIMEOUT" node "$OPENCLAW_ENTRY" "$@" >"$output_file"
|
||||
}
|
||||
|
||||
run_plugins_shell_logged() {
|
||||
local label="$1"
|
||||
shift
|
||||
local command="$1"
|
||||
run_logged "$label" openclaw_e2e_maybe_timeout "$OPENCLAW_PLUGINS_CLI_TIMEOUT" bash -c "$command"
|
||||
}
|
||||
|
||||
source scripts/e2e/lib/plugins/fixtures.sh
|
||||
source scripts/e2e/lib/plugins/marketplace.sh
|
||||
source scripts/e2e/lib/plugins/clawhub.sh
|
||||
|
||||
cleanup_openclaw_plugins_sweep() {
|
||||
openclaw_plugins_cleanup_fixture_servers
|
||||
if [[ "${OPENCLAW_PLUGINS_CREATED_TMP_DIR:-0}" = "1" ]]; then
|
||||
rm -rf "$OPENCLAW_PLUGINS_TMP_DIR"
|
||||
fi
|
||||
}
|
||||
|
||||
if [[ "$OPENCLAW_PLUGINS_SWEEP_SOURCE_ONLY" = "1" ]]; then
|
||||
return 0 2>/dev/null || { cleanup_openclaw_plugins_sweep; exit 0; }
|
||||
fi
|
||||
|
||||
trap cleanup_openclaw_plugins_sweep EXIT
|
||||
|
||||
openclaw_e2e_eval_test_state_from_b64 "${OPENCLAW_TEST_STATE_SCRIPT_B64:?missing OPENCLAW_TEST_STATE_SCRIPT_B64}"
|
||||
PACKAGE_VERSION="$(node -p 'require("./package.json").version')"
|
||||
OPENCLAW_PACKAGE_ACCEPTANCE_LEGACY_COMPAT="$(node scripts/e2e/lib/package-compat.mjs "$PACKAGE_VERSION")"
|
||||
export OPENCLAW_PACKAGE_ACCEPTANCE_LEGACY_COMPAT
|
||||
BUNDLED_PLUGIN_ROOT_DIR="extensions"
|
||||
OPENCLAW_PLUGIN_HOME="$HOME/.openclaw/$BUNDLED_PLUGIN_ROOT_DIR"
|
||||
|
||||
demo_plugin_id="demo-plugin"
|
||||
demo_plugin_root="$OPENCLAW_PLUGIN_HOME/$demo_plugin_id"
|
||||
write_demo_fixture_plugin "$demo_plugin_root"
|
||||
record_fixture_plugin_trust "$demo_plugin_id" "$demo_plugin_root" 1
|
||||
|
||||
run_plugins_openclaw_capture "$OPENCLAW_PLUGINS_TMP_DIR/plugins.json" plugins list --json
|
||||
run_plugins_openclaw_capture "$OPENCLAW_PLUGINS_TMP_DIR/plugins-inspect.json" plugins inspect demo-plugin --runtime --json
|
||||
|
||||
node scripts/e2e/lib/plugins/assertions.mjs demo-plugin
|
||||
|
||||
echo "Testing tgz install flow..."
|
||||
pack_dir="$(mktemp -d "$OPENCLAW_PLUGINS_TMP_DIR/openclaw-plugin-pack.XXXXXX")"
|
||||
pack_fixture_plugin "$pack_dir" "$OPENCLAW_PLUGINS_TMP_DIR/demo-plugin-tgz.tgz" demo-plugin-tgz 0.0.1 demo.tgz "Demo Plugin TGZ"
|
||||
|
||||
run_plugins_openclaw_logged install-tgz plugins install "$OPENCLAW_PLUGINS_TMP_DIR/demo-plugin-tgz.tgz"
|
||||
run_plugins_openclaw_capture "$OPENCLAW_PLUGINS_TMP_DIR/plugins2.json" plugins list --json
|
||||
run_plugins_openclaw_capture "$OPENCLAW_PLUGINS_TMP_DIR/plugins2-inspect.json" plugins inspect demo-plugin-tgz --runtime --json
|
||||
|
||||
node scripts/e2e/lib/plugins/assertions.mjs plugin-tgz
|
||||
|
||||
run_plugins_openclaw_logged uninstall-tgz plugins uninstall demo-plugin-tgz --force
|
||||
run_plugins_openclaw_capture "$OPENCLAW_PLUGINS_TMP_DIR/plugins2-uninstalled.json" plugins list --json
|
||||
node scripts/e2e/lib/plugins/assertions.mjs plugin-tgz-removed
|
||||
|
||||
echo "Testing install from local folder (plugins.load.paths)..."
|
||||
dir_plugin="$(mktemp -d "$OPENCLAW_PLUGINS_TMP_DIR/openclaw-plugin-dir.XXXXXX")"
|
||||
write_fixture_plugin "$dir_plugin" demo-plugin-dir 0.0.1 demo.dir "Demo Plugin DIR"
|
||||
|
||||
run_plugins_openclaw_logged install-dir plugins install "$dir_plugin"
|
||||
run_plugins_openclaw_capture "$OPENCLAW_PLUGINS_TMP_DIR/plugins3.json" plugins list --json
|
||||
run_plugins_openclaw_capture "$OPENCLAW_PLUGINS_TMP_DIR/plugins3-inspect.json" plugins inspect demo-plugin-dir --runtime --json
|
||||
|
||||
node scripts/e2e/lib/plugins/assertions.mjs plugin-dir "$dir_plugin"
|
||||
|
||||
openclaw_e2e_maybe_timeout "$OPENCLAW_PLUGINS_CLI_TIMEOUT" node "$OPENCLAW_ENTRY" plugins update demo-plugin-dir >"$OPENCLAW_PLUGINS_TMP_DIR/plugins-dir-update.log" 2>&1
|
||||
node scripts/e2e/lib/plugins/assertions.mjs plugin-dir-update-skipped
|
||||
|
||||
run_plugins_openclaw_logged uninstall-dir plugins uninstall demo-plugin-dir --force
|
||||
run_plugins_openclaw_capture "$OPENCLAW_PLUGINS_TMP_DIR/plugins3-uninstalled.json" plugins list --json
|
||||
node scripts/e2e/lib/plugins/assertions.mjs plugin-dir-removed
|
||||
|
||||
echo "Testing install from local folder with preinstalled dependencies..."
|
||||
dir_deps_plugin="$(mktemp -d "$OPENCLAW_PLUGINS_TMP_DIR/openclaw-plugin-dir-deps.XXXXXX")"
|
||||
write_fixture_plugin_with_vendored_dependency "$dir_deps_plugin" demo-plugin-dir-deps 0.0.1 demo.dir.deps "Demo Plugin DIR Deps"
|
||||
|
||||
run_plugins_openclaw_logged install-dir-deps plugins install "$dir_deps_plugin"
|
||||
run_plugins_openclaw_capture "$OPENCLAW_PLUGINS_TMP_DIR/plugins-dir-deps.json" plugins list --json
|
||||
run_plugins_openclaw_capture "$OPENCLAW_PLUGINS_TMP_DIR/plugins-dir-deps-inspect.json" plugins inspect demo-plugin-dir-deps --runtime --json
|
||||
|
||||
node scripts/e2e/lib/plugins/assertions.mjs plugin-dir-deps "$dir_deps_plugin"
|
||||
|
||||
run_plugins_openclaw_logged uninstall-dir-deps plugins uninstall demo-plugin-dir-deps --force
|
||||
run_plugins_openclaw_capture "$OPENCLAW_PLUGINS_TMP_DIR/plugins-dir-deps-uninstalled.json" plugins list --json
|
||||
node scripts/e2e/lib/plugins/assertions.mjs plugin-dir-deps-removed
|
||||
|
||||
echo "Testing install from npm spec (file:)..."
|
||||
file_pack_dir="$(mktemp -d "$OPENCLAW_PLUGINS_TMP_DIR/openclaw-plugin-filepack.XXXXXX")"
|
||||
write_fixture_plugin "$file_pack_dir/package" demo-plugin-file 0.0.1 demo.file "Demo Plugin FILE"
|
||||
|
||||
run_plugins_openclaw_logged install-file plugins install "file:$file_pack_dir/package"
|
||||
run_plugins_openclaw_capture "$OPENCLAW_PLUGINS_TMP_DIR/plugins4.json" plugins list --json
|
||||
run_plugins_openclaw_capture "$OPENCLAW_PLUGINS_TMP_DIR/plugins4-inspect.json" plugins inspect demo-plugin-file --runtime --json
|
||||
|
||||
node scripts/e2e/lib/plugins/assertions.mjs plugin-file "$file_pack_dir/package"
|
||||
|
||||
run_plugins_openclaw_logged uninstall-file plugins uninstall demo-plugin-file --force
|
||||
run_plugins_openclaw_capture "$OPENCLAW_PLUGINS_TMP_DIR/plugins4-uninstalled.json" plugins list --json
|
||||
node scripts/e2e/lib/plugins/assertions.mjs plugin-file-removed
|
||||
|
||||
echo "Testing install and update from npm registry..."
|
||||
npm_pack_dir="$(mktemp -d "$OPENCLAW_PLUGINS_TMP_DIR/openclaw-plugin-npm-pack.XXXXXX")"
|
||||
npm_dep_pack_dir="$(mktemp -d "$OPENCLAW_PLUGINS_TMP_DIR/openclaw-plugin-npm-dep-pack.XXXXXX")"
|
||||
invalid_npm_pack_dir="$(mktemp -d "$OPENCLAW_PLUGINS_TMP_DIR/openclaw-plugin-invalid-metadata-pack.XXXXXX")"
|
||||
npm_registry_dir="$(mktemp -d "$OPENCLAW_PLUGINS_TMP_DIR/openclaw-plugin-npm-registry.XXXXXX")"
|
||||
pack_fixture_plugin_with_cli_registry_dependency "$npm_pack_dir" "$OPENCLAW_PLUGINS_TMP_DIR/demo-plugin-npm.tgz" demo-plugin-npm 0.0.1 demo.npm "Demo Plugin NPM" demo-npm "demo-plugin-npm:pong"
|
||||
pack_fake_is_number_package "$npm_dep_pack_dir" "$OPENCLAW_PLUGINS_TMP_DIR/is-number-7.0.0.tgz"
|
||||
pack_fixture_plugin_with_invalid_extension_entry "$invalid_npm_pack_dir" "$OPENCLAW_PLUGINS_TMP_DIR/demo-plugin-invalid-metadata.tgz" demo-plugin-invalid-metadata 0.0.1 demo.invalid.metadata "Demo Plugin Invalid Metadata"
|
||||
start_npm_fixture_registry "@openclaw/demo-plugin-npm" "0.0.1" "$OPENCLAW_PLUGINS_TMP_DIR/demo-plugin-npm.tgz" "$npm_registry_dir" "is-number" "7.0.0" "$OPENCLAW_PLUGINS_TMP_DIR/is-number-7.0.0.tgz" "@openclaw/demo-plugin-invalid-metadata" "0.0.1" "$OPENCLAW_PLUGINS_TMP_DIR/demo-plugin-invalid-metadata.tgz"
|
||||
|
||||
run_plugins_openclaw_logged install-npm plugins install "npm:@openclaw/demo-plugin-npm@0.0.1"
|
||||
run_plugins_openclaw_capture "$OPENCLAW_PLUGINS_TMP_DIR/plugins-npm.json" plugins list --json
|
||||
run_plugins_openclaw_capture "$OPENCLAW_PLUGINS_TMP_DIR/plugins-npm-inspect.json" plugins inspect demo-plugin-npm --runtime --json
|
||||
run_plugins_shell_logged exec-npm-plugin-cli 'node "$OPENCLAW_ENTRY" demo-npm ping >"$OPENCLAW_PLUGINS_TMP_DIR/plugins-npm-cli.txt"'
|
||||
|
||||
node scripts/e2e/lib/plugins/assertions.mjs plugin-npm
|
||||
|
||||
openclaw_e2e_maybe_timeout "$OPENCLAW_PLUGINS_CLI_TIMEOUT" node "$OPENCLAW_ENTRY" plugins update demo-plugin-npm >"$OPENCLAW_PLUGINS_TMP_DIR/plugins-npm-update.log" 2>&1
|
||||
node scripts/e2e/lib/plugins/assertions.mjs plugin-npm-update
|
||||
|
||||
run_plugins_openclaw_logged uninstall-npm plugins uninstall demo-plugin-npm --force
|
||||
run_plugins_openclaw_capture "$OPENCLAW_PLUGINS_TMP_DIR/plugins-npm-uninstalled.json" plugins list --json
|
||||
node scripts/e2e/lib/plugins/assertions.mjs plugin-npm-removed
|
||||
|
||||
echo "Testing npm install rejects malformed package metadata..."
|
||||
if openclaw_e2e_maybe_timeout "$OPENCLAW_PLUGINS_CLI_TIMEOUT" node "$OPENCLAW_ENTRY" plugins install "npm:@openclaw/demo-plugin-invalid-metadata@0.0.1" >"$OPENCLAW_PLUGINS_TMP_DIR/plugins-invalid-openclaw-extensions.log" 2>&1; then
|
||||
cat "$OPENCLAW_PLUGINS_TMP_DIR/plugins-invalid-openclaw-extensions.log"
|
||||
echo "Expected malformed package metadata install to fail." >&2
|
||||
exit 1
|
||||
fi
|
||||
run_plugins_openclaw_capture "$OPENCLAW_PLUGINS_TMP_DIR/plugins-invalid-openclaw-extensions-list.json" plugins list --json
|
||||
node scripts/e2e/lib/plugins/assertions.mjs invalid-openclaw-extensions
|
||||
|
||||
echo "Testing install from git repo and plugin CLI execution..."
|
||||
git_fixture_root="$(mktemp -d "$OPENCLAW_PLUGINS_TMP_DIR/openclaw-plugin-git.XXXXXX")"
|
||||
git_repo="$git_fixture_root/repo"
|
||||
git_repo_url="file://$git_repo"
|
||||
write_fixture_plugin_with_cli "$git_repo" demo-plugin-git 0.0.1 demo.git "Demo Plugin Git" demo-git "demo-plugin-git:pong"
|
||||
git -C "$git_repo" init -q
|
||||
git -C "$git_repo" config user.email "docker-e2e@openclaw.local"
|
||||
git -C "$git_repo" config user.name "OpenClaw Docker E2E"
|
||||
git -C "$git_repo" add -A
|
||||
git -C "$git_repo" commit -qm "test fixture"
|
||||
git_ref="$(git -C "$git_repo" rev-parse HEAD)"
|
||||
|
||||
run_plugins_openclaw_logged install-git plugins install "git:$git_repo_url@$git_ref"
|
||||
run_plugins_openclaw_capture "$OPENCLAW_PLUGINS_TMP_DIR/plugins-git.json" plugins list --json
|
||||
run_plugins_openclaw_capture "$OPENCLAW_PLUGINS_TMP_DIR/plugins-git-inspect.json" plugins inspect demo-plugin-git --runtime --json
|
||||
run_plugins_shell_logged exec-git-plugin-cli 'node "$OPENCLAW_ENTRY" demo-git ping >"$OPENCLAW_PLUGINS_TMP_DIR/plugins-git-cli.txt"'
|
||||
|
||||
node scripts/e2e/lib/plugins/assertions.mjs plugin-git "$git_repo_url" "$git_ref"
|
||||
|
||||
run_plugins_openclaw_logged uninstall-git plugins uninstall demo-plugin-git --force
|
||||
run_plugins_openclaw_capture "$OPENCLAW_PLUGINS_TMP_DIR/plugins-git-uninstalled.json" plugins list --json
|
||||
node scripts/e2e/lib/plugins/assertions.mjs plugin-git-removed
|
||||
|
||||
echo "Testing git plugin update from moving ref..."
|
||||
git_update_fixture_root="$(mktemp -d "$OPENCLAW_PLUGINS_TMP_DIR/openclaw-plugin-git-update.XXXXXX")"
|
||||
git_update_repo="$git_update_fixture_root/repo"
|
||||
git_update_repo_url="file://$git_update_repo"
|
||||
write_fixture_plugin_with_cli "$git_update_repo" demo-plugin-git-update 0.0.1 demo.git.update.v1 "Demo Plugin Git Update" demo-git-update "demo-plugin-git-update:pong-v1"
|
||||
git -C "$git_update_repo" init -q
|
||||
git -C "$git_update_repo" config user.email "docker-e2e@openclaw.local"
|
||||
git -C "$git_update_repo" config user.name "OpenClaw Docker E2E"
|
||||
git -C "$git_update_repo" checkout -qb main
|
||||
git -C "$git_update_repo" add -A
|
||||
git -C "$git_update_repo" commit -qm "test fixture v1"
|
||||
git_update_ref_v1="$(git -C "$git_update_repo" rev-parse HEAD)"
|
||||
|
||||
run_plugins_openclaw_logged install-git-update plugins install "git:$git_update_repo_url@main"
|
||||
write_fixture_plugin_with_cli "$git_update_repo" demo-plugin-git-update 0.0.2 demo.git.update.v2 "Demo Plugin Git Update" demo-git-update "demo-plugin-git-update:pong-v2"
|
||||
git -C "$git_update_repo" add -A
|
||||
git -C "$git_update_repo" commit -qm "test fixture v2"
|
||||
|
||||
openclaw_e2e_maybe_timeout "$OPENCLAW_PLUGINS_CLI_TIMEOUT" node "$OPENCLAW_ENTRY" plugins update demo-plugin-git-update >"$OPENCLAW_PLUGINS_TMP_DIR/plugins-git-update.log" 2>&1
|
||||
run_plugins_openclaw_capture "$OPENCLAW_PLUGINS_TMP_DIR/plugins-git-update.json" plugins list --json
|
||||
run_plugins_openclaw_capture "$OPENCLAW_PLUGINS_TMP_DIR/plugins-git-update-inspect.json" plugins inspect demo-plugin-git-update --runtime --json
|
||||
run_plugins_shell_logged exec-updated-git-plugin-cli 'node "$OPENCLAW_ENTRY" demo-git-update ping >"$OPENCLAW_PLUGINS_TMP_DIR/plugins-git-update-cli.txt"'
|
||||
|
||||
node scripts/e2e/lib/plugins/assertions.mjs plugin-git-updated "$git_update_ref_v1"
|
||||
|
||||
echo "Testing Claude bundle enable and inspect flow..."
|
||||
bundle_plugin_id="claude-bundle-e2e"
|
||||
bundle_root="$OPENCLAW_PLUGIN_HOME/$bundle_plugin_id"
|
||||
write_claude_bundle_fixture "$bundle_root"
|
||||
record_fixture_plugin_trust "$bundle_plugin_id" "$bundle_root" 0
|
||||
|
||||
run_plugins_openclaw_capture "$OPENCLAW_PLUGINS_TMP_DIR/plugins-bundle-disabled.json" plugins list --json
|
||||
node scripts/e2e/lib/plugins/assertions.mjs bundle-disabled
|
||||
|
||||
run_plugins_openclaw_logged enable-claude-bundle plugins enable claude-bundle-e2e
|
||||
run_plugins_openclaw_capture "$OPENCLAW_PLUGINS_TMP_DIR/plugins-bundle-inspect.json" plugins inspect claude-bundle-e2e --json
|
||||
node scripts/e2e/lib/plugins/assertions.mjs bundle-inspect
|
||||
|
||||
echo "Testing plugin install visible after explicit restart..."
|
||||
slash_install_dir="$(mktemp -d "$OPENCLAW_PLUGINS_TMP_DIR/openclaw-plugin-slash-install.XXXXXX")"
|
||||
write_fixture_plugin "$slash_install_dir" slash-install-plugin 0.0.1 demo.slash.install "Slash Install Plugin"
|
||||
|
||||
run_plugins_openclaw_logged install-slash-plugin plugins install "$slash_install_dir"
|
||||
run_plugins_openclaw_capture "$OPENCLAW_PLUGINS_TMP_DIR/plugin-command-install-show.json" plugins inspect slash-install-plugin --runtime --json
|
||||
node scripts/e2e/lib/plugins/assertions.mjs slash-install
|
||||
|
||||
run_plugins_marketplace_scenario
|
||||
|
||||
run_plugins_clawhub_scenario
|
||||
69
scripts/e2e/lib/release-assertion-files.mjs
Normal file
69
scripts/e2e/lib/release-assertion-files.mjs
Normal file
@@ -0,0 +1,69 @@
|
||||
// Shared bounded file readers for release E2E assertion scripts.
|
||||
import fs from "node:fs";
|
||||
import { readTextFileTail } from "./text-file-utils.mjs";
|
||||
|
||||
const SCAN_CHUNK_BYTES = 64 * 1024;
|
||||
const SCAN_CARRY_CHARS = 256;
|
||||
export const ERROR_DETAIL_TAIL_BYTES = 16 * 1024;
|
||||
const JSON_ARTIFACT_MAX_BYTES = 2 * 1024 * 1024;
|
||||
|
||||
export function readJson(file, maxBytes = JSON_ARTIFACT_MAX_BYTES) {
|
||||
const stat = fs.statSync(file);
|
||||
if (!stat.isFile()) {
|
||||
throw new Error(`${file} is not a file`);
|
||||
}
|
||||
if (stat.size > maxBytes) {
|
||||
throw new Error(
|
||||
`JSON artifact exceeded ${maxBytes} bytes: ${file} (${stat.size} bytes). Tail: ${readTextFileTail(
|
||||
file,
|
||||
ERROR_DETAIL_TAIL_BYTES,
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
const text = fs.readFileSync(file, "utf8");
|
||||
const bytes = Buffer.byteLength(text, "utf8");
|
||||
if (bytes > maxBytes) {
|
||||
throw new Error(
|
||||
`JSON artifact exceeded ${maxBytes} bytes: ${file} (${bytes} bytes). Tail: ${readTextFileTail(
|
||||
file,
|
||||
ERROR_DETAIL_TAIL_BYTES,
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
return JSON.parse(text);
|
||||
}
|
||||
|
||||
export function fileContainsText(file, needle) {
|
||||
let stat;
|
||||
try {
|
||||
stat = fs.statSync(file);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (!stat.isFile() || stat.size <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const fd = fs.openSync(file, "r");
|
||||
try {
|
||||
const buffer = Buffer.alloc(Math.min(SCAN_CHUNK_BYTES, stat.size));
|
||||
let carry = "";
|
||||
let offset = 0;
|
||||
while (offset < stat.size) {
|
||||
const bytesToRead = Math.min(buffer.length, stat.size - offset);
|
||||
const bytesRead = fs.readSync(fd, buffer, 0, bytesToRead, offset);
|
||||
if (bytesRead <= 0) {
|
||||
break;
|
||||
}
|
||||
offset += bytesRead;
|
||||
const text = carry + buffer.subarray(0, bytesRead).toString("utf8");
|
||||
if (text.includes(needle)) {
|
||||
return true;
|
||||
}
|
||||
carry = text.slice(-Math.max(SCAN_CARRY_CHARS, needle.length - 1));
|
||||
}
|
||||
return false;
|
||||
} finally {
|
||||
fs.closeSync(fd);
|
||||
}
|
||||
}
|
||||
170
scripts/e2e/lib/release-media-memory/scenario.sh
Executable file
170
scripts/e2e/lib/release-media-memory/scenario.sh
Executable file
@@ -0,0 +1,170 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
trap "" PIPE
|
||||
export TERM=xterm-256color
|
||||
export NO_COLOR=1
|
||||
|
||||
source scripts/lib/openclaw-e2e-instance.sh
|
||||
|
||||
openclaw_e2e_eval_test_state_from_b64 "${OPENCLAW_TEST_STATE_SCRIPT_B64:?missing OPENCLAW_TEST_STATE_SCRIPT_B64}"
|
||||
openclaw_e2e_install_trash_shim
|
||||
|
||||
export NPM_CONFIG_PREFIX="$HOME/.npm-global"
|
||||
export PATH="$NPM_CONFIG_PREFIX/bin:$PATH"
|
||||
export npm_config_loglevel=error
|
||||
export npm_config_fund=false
|
||||
export npm_config_audit=false
|
||||
export OPENAI_API_KEY="sk-openclaw-release-media-memory"
|
||||
export OPENCLAW_QA_ALLOW_LOCAL_IMAGE_PROVIDER=1
|
||||
|
||||
PORT="18789"
|
||||
MOCK_PORT="44200"
|
||||
SUCCESS_MARKER="OPENCLAW_E2E_OK_MEDIA_MEMORY"
|
||||
MEMORY_MARKER="release-media-memory-saffron-$(date +%s)"
|
||||
media_root="$(mktemp -d /tmp/openclaw-release-media-memory.XXXXXX)"
|
||||
INSTALL_LOG="$media_root/install.log"
|
||||
ONBOARD_LOG="$media_root/onboard.log"
|
||||
ENV_LOG="$media_root/env.log"
|
||||
CONFIG_JSON="$media_root/config.json"
|
||||
PACKAGE_FILES_LOG="$media_root/package-files.log"
|
||||
PLUGINS_JSON="$media_root/plugins.json"
|
||||
PLUGINS_STDERR_LOG="$media_root/plugins.stderr.log"
|
||||
MOCK_OPENAI_LOG="$media_root/openai.log"
|
||||
MOCK_REQUEST_LOG="$media_root/openai-requests.jsonl"
|
||||
DESCRIBE_JSON="$media_root/describe.json"
|
||||
DESCRIBE_STDERR_LOG="$media_root/describe.stderr.log"
|
||||
GENERATE_JSON="$media_root/generate.json"
|
||||
GENERATE_STDERR_LOG="$media_root/generate.stderr.log"
|
||||
INDEX_LOG="$media_root/index.log"
|
||||
SEARCH_BEFORE_JSON="$media_root/search-before.json"
|
||||
SEARCH_BEFORE_STDERR_LOG="$media_root/search-before.stderr.log"
|
||||
SEARCH_AFTER_JSON="$media_root/search-after.json"
|
||||
SEARCH_AFTER_STDERR_LOG="$media_root/search-after.stderr.log"
|
||||
GATEWAY_1_LOG="$media_root/gateway-1.log"
|
||||
GATEWAY_2_LOG="$media_root/gateway-2.log"
|
||||
export SUCCESS_MARKER MOCK_REQUEST_LOG
|
||||
|
||||
mock_pid=""
|
||||
gateway_pid=""
|
||||
cleanup() {
|
||||
openclaw_e2e_terminate_gateways "${gateway_pid:-}"
|
||||
openclaw_e2e_stop_process "${mock_pid:-}"
|
||||
if [ -n "${media_root:-}" ]; then
|
||||
rm -rf "$media_root"
|
||||
fi
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
dump_debug_logs() {
|
||||
local status="$1"
|
||||
echo "release media memory failed with exit code $status" >&2
|
||||
openclaw_e2e_dump_logs \
|
||||
"$INSTALL_LOG" \
|
||||
"$ONBOARD_LOG" \
|
||||
"$ENV_LOG" \
|
||||
"$CONFIG_JSON" \
|
||||
"$PACKAGE_FILES_LOG" \
|
||||
"$PLUGINS_JSON" \
|
||||
"$PLUGINS_STDERR_LOG" \
|
||||
"$MOCK_OPENAI_LOG" \
|
||||
"$MOCK_REQUEST_LOG" \
|
||||
"$DESCRIBE_JSON" \
|
||||
"$DESCRIBE_STDERR_LOG" \
|
||||
"$GENERATE_JSON" \
|
||||
"$GENERATE_STDERR_LOG" \
|
||||
"$INDEX_LOG" \
|
||||
"$SEARCH_BEFORE_JSON" \
|
||||
"$SEARCH_BEFORE_STDERR_LOG" \
|
||||
"$SEARCH_AFTER_JSON" \
|
||||
"$SEARCH_AFTER_STDERR_LOG" \
|
||||
"$GATEWAY_1_LOG" \
|
||||
"$GATEWAY_2_LOG"
|
||||
}
|
||||
trap 'status=$?; dump_debug_logs "$status"; exit "$status"' ERR
|
||||
|
||||
start_gateway() {
|
||||
local log_path="$1"
|
||||
gateway_pid="$(openclaw_e2e_start_gateway "$entry" "$PORT" "$log_path")"
|
||||
openclaw_e2e_wait_gateway_ready "$gateway_pid" "$log_path" 300 "$PORT"
|
||||
}
|
||||
|
||||
stop_gateway() {
|
||||
openclaw_e2e_terminate_gateways "${gateway_pid:-}"
|
||||
gateway_pid=""
|
||||
}
|
||||
|
||||
openclaw_e2e_install_package "$INSTALL_LOG"
|
||||
command -v openclaw >/dev/null
|
||||
package_root="$(openclaw_e2e_package_root)"
|
||||
entry="$(openclaw_e2e_package_entrypoint "$package_root")"
|
||||
{
|
||||
printf 'openclaw=%s\n' "$(command -v openclaw)"
|
||||
printf 'package_root=%s\n' "$package_root"
|
||||
printf 'entry=%s\n' "$entry"
|
||||
printf 'HOME=%s\n' "$HOME"
|
||||
printf 'OPENCLAW_HOME=%s\n' "$OPENCLAW_HOME"
|
||||
printf 'OPENCLAW_STATE_DIR=%s\n' "$OPENCLAW_STATE_DIR"
|
||||
printf 'OPENCLAW_CONFIG_PATH=%s\n' "$OPENCLAW_CONFIG_PATH"
|
||||
} >"$ENV_LOG"
|
||||
openclaw_e2e_enable_openclaw_cli_timeout
|
||||
(
|
||||
cd "$package_root/dist/extensions/memory-core"
|
||||
find . -type f | sed 's#^\./##' | sort
|
||||
) >"$PACKAGE_FILES_LOG"
|
||||
|
||||
mock_pid="$(openclaw_e2e_start_mock_openai "$MOCK_PORT" "$MOCK_OPENAI_LOG")"
|
||||
openclaw_e2e_wait_mock_openai "$MOCK_PORT"
|
||||
|
||||
openclaw onboard \
|
||||
--non-interactive \
|
||||
--accept-risk \
|
||||
--flow quickstart \
|
||||
--mode local \
|
||||
--auth-choice skip \
|
||||
--gateway-port "$PORT" \
|
||||
--gateway-bind loopback \
|
||||
--skip-daemon \
|
||||
--skip-ui \
|
||||
--skip-channels \
|
||||
--skip-skills \
|
||||
--skip-health >"$ONBOARD_LOG" 2>&1
|
||||
cp "$OPENCLAW_CONFIG_PATH" "$CONFIG_JSON"
|
||||
openclaw plugins list --json >"$PLUGINS_JSON" 2>"$PLUGINS_STDERR_LOG"
|
||||
node scripts/e2e/lib/release-scenarios/assertions.mjs assert-file-contains "$PLUGINS_JSON" memory-core
|
||||
node scripts/e2e/lib/release-scenarios/assertions.mjs configure-mock-openai "$MOCK_PORT"
|
||||
|
||||
mkdir -p "$OPENCLAW_STATE_DIR/workspace/memory"
|
||||
printf '%s' 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+yf7kAAAAASUVORK5CYII=' | base64 -d >"$media_root/input.png"
|
||||
|
||||
openclaw infer image describe \
|
||||
--file "$media_root/input.png" \
|
||||
--model openai/gpt-5.5 \
|
||||
--prompt "Describe this image and return marker $SUCCESS_MARKER" \
|
||||
--json >"$DESCRIBE_JSON" 2>"$DESCRIBE_STDERR_LOG"
|
||||
node scripts/e2e/lib/release-scenarios/assertions.mjs assert-image-describe "$DESCRIBE_JSON" "$MOCK_REQUEST_LOG"
|
||||
|
||||
openclaw infer image generate \
|
||||
--model openai/gpt-image-1 \
|
||||
--prompt "Generate a tiny test image for $SUCCESS_MARKER" \
|
||||
--output "$media_root/generated.png" \
|
||||
--json >"$GENERATE_JSON" 2>"$GENERATE_STDERR_LOG"
|
||||
node scripts/e2e/lib/release-scenarios/assertions.mjs assert-image-generate "$GENERATE_JSON" "$MOCK_REQUEST_LOG"
|
||||
|
||||
cat >"$OPENCLAW_STATE_DIR/workspace/MEMORY.md" <<EOF
|
||||
# Long-term memory
|
||||
|
||||
- The release media memory marker is $MEMORY_MARKER.
|
||||
EOF
|
||||
|
||||
openclaw memory index --force >"$INDEX_LOG" 2>&1
|
||||
openclaw memory search "$MEMORY_MARKER" --json >"$SEARCH_BEFORE_JSON" 2>"$SEARCH_BEFORE_STDERR_LOG"
|
||||
node scripts/e2e/lib/release-scenarios/assertions.mjs assert-memory-search "$SEARCH_BEFORE_JSON" "$MEMORY_MARKER"
|
||||
|
||||
start_gateway "$GATEWAY_1_LOG"
|
||||
stop_gateway
|
||||
start_gateway "$GATEWAY_2_LOG"
|
||||
openclaw memory search "$MEMORY_MARKER" --json >"$SEARCH_AFTER_JSON" 2>"$SEARCH_AFTER_STDERR_LOG"
|
||||
node scripts/e2e/lib/release-scenarios/assertions.mjs assert-memory-search "$SEARCH_AFTER_JSON" "$MEMORY_MARKER"
|
||||
stop_gateway
|
||||
|
||||
echo "Release media memory scenario passed."
|
||||
102
scripts/e2e/lib/release-plugin-marketplace/scenario.sh
Executable file
102
scripts/e2e/lib/release-plugin-marketplace/scenario.sh
Executable file
@@ -0,0 +1,102 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
trap "" PIPE
|
||||
export TERM=xterm-256color
|
||||
export NO_COLOR=1
|
||||
|
||||
source scripts/lib/openclaw-e2e-instance.sh
|
||||
|
||||
openclaw_e2e_eval_test_state_from_b64 "${OPENCLAW_TEST_STATE_SCRIPT_B64:?missing OPENCLAW_TEST_STATE_SCRIPT_B64}"
|
||||
openclaw_e2e_install_trash_shim
|
||||
|
||||
export NPM_CONFIG_PREFIX="$HOME/.npm-global"
|
||||
export PATH="$NPM_CONFIG_PREFIX/bin:$PATH"
|
||||
export npm_config_loglevel=error
|
||||
export npm_config_fund=false
|
||||
export npm_config_audit=false
|
||||
|
||||
dump_debug_logs() {
|
||||
local status="$1"
|
||||
echo "release plugin marketplace failed with exit code $status" >&2
|
||||
openclaw_e2e_dump_logs \
|
||||
/tmp/openclaw-release-plugin-marketplace-install.log \
|
||||
/tmp/openclaw-release-plugin-marketplace-onboard.log \
|
||||
/tmp/openclaw-release-plugin-marketplace-list.json \
|
||||
/tmp/openclaw-release-plugin-marketplace-install-plugin.log \
|
||||
/tmp/openclaw-release-plugin-marketplace-cli-v1.log \
|
||||
/tmp/openclaw-release-plugin-marketplace-update-dry-run.log \
|
||||
/tmp/openclaw-release-plugin-marketplace-update.log \
|
||||
/tmp/openclaw-release-plugin-marketplace-cli-v2.log \
|
||||
/tmp/openclaw-release-plugin-marketplace-uninstall.log \
|
||||
/tmp/openclaw-release-plugin-marketplace-cli-after-uninstall.log
|
||||
}
|
||||
trap 'status=$?; dump_debug_logs "$status"; exit "$status"' ERR
|
||||
|
||||
openclaw_e2e_install_package /tmp/openclaw-release-plugin-marketplace-install.log
|
||||
command -v openclaw >/dev/null
|
||||
openclaw_e2e_enable_openclaw_cli_timeout
|
||||
|
||||
openclaw onboard \
|
||||
--non-interactive \
|
||||
--accept-risk \
|
||||
--flow quickstart \
|
||||
--mode local \
|
||||
--auth-choice skip \
|
||||
--skip-daemon \
|
||||
--skip-ui \
|
||||
--skip-channels \
|
||||
--skip-skills \
|
||||
--skip-health >/tmp/openclaw-release-plugin-marketplace-onboard.log 2>&1
|
||||
|
||||
marketplace_root="$HOME/.claude/plugins/marketplaces/release-fixture-marketplace"
|
||||
mkdir -p "$HOME/.claude/plugins" "$marketplace_root/.claude-plugin"
|
||||
node scripts/e2e/lib/release-scenarios/write-cli-plugin.mjs \
|
||||
"$marketplace_root/plugins/release-marketplace-plugin" \
|
||||
release-marketplace-plugin \
|
||||
0.0.1 \
|
||||
release.marketplace.v1 \
|
||||
"Release Marketplace Plugin" \
|
||||
release-market \
|
||||
"release-marketplace-plugin:v1"
|
||||
node scripts/e2e/lib/release-scenarios/write-cli-plugin.mjs \
|
||||
"$marketplace_root/plugins/release-marketplace-other" \
|
||||
release-marketplace-other \
|
||||
0.0.1 \
|
||||
release.marketplace.other \
|
||||
"Release Marketplace Other" \
|
||||
release-market-other \
|
||||
"release-marketplace-other:v1"
|
||||
node scripts/e2e/lib/release-scenarios/write-marketplace.mjs \
|
||||
"$marketplace_root" \
|
||||
release-fixtures \
|
||||
release-marketplace-plugin \
|
||||
release-marketplace-other
|
||||
|
||||
openclaw plugins marketplace list release-fixtures --json >/tmp/openclaw-release-plugin-marketplace-list.json
|
||||
node scripts/e2e/lib/release-scenarios/assertions.mjs assert-file-contains /tmp/openclaw-release-plugin-marketplace-list.json release-marketplace-plugin
|
||||
|
||||
openclaw plugins install release-marketplace-plugin@release-fixtures >/tmp/openclaw-release-plugin-marketplace-install-plugin.log 2>&1
|
||||
openclaw release-market ping >/tmp/openclaw-release-plugin-marketplace-cli-v1.log 2>&1
|
||||
node scripts/e2e/lib/release-scenarios/assertions.mjs assert-file-contains /tmp/openclaw-release-plugin-marketplace-cli-v1.log "release-marketplace-plugin:v1"
|
||||
|
||||
node scripts/e2e/lib/release-scenarios/write-cli-plugin.mjs \
|
||||
"$marketplace_root/plugins/release-marketplace-plugin" \
|
||||
release-marketplace-plugin \
|
||||
0.0.2 \
|
||||
release.marketplace.v2 \
|
||||
"Release Marketplace Plugin" \
|
||||
release-market \
|
||||
"release-marketplace-plugin:v2"
|
||||
openclaw plugins update release-marketplace-plugin --dry-run >/tmp/openclaw-release-plugin-marketplace-update-dry-run.log 2>&1
|
||||
openclaw plugins update release-marketplace-plugin >/tmp/openclaw-release-plugin-marketplace-update.log 2>&1
|
||||
openclaw release-market ping >/tmp/openclaw-release-plugin-marketplace-cli-v2.log 2>&1
|
||||
node scripts/e2e/lib/release-scenarios/assertions.mjs assert-file-contains /tmp/openclaw-release-plugin-marketplace-cli-v2.log "release-marketplace-plugin:v2"
|
||||
|
||||
openclaw plugins uninstall release-marketplace-plugin --force >/tmp/openclaw-release-plugin-marketplace-uninstall.log 2>&1
|
||||
if openclaw release-market ping >/tmp/openclaw-release-plugin-marketplace-cli-after-uninstall.log 2>&1; then
|
||||
echo "release-market CLI should be gone after uninstall" >&2
|
||||
exit 1
|
||||
fi
|
||||
node scripts/e2e/lib/release-scenarios/assertions.mjs assert-plugin-uninstalled release-marketplace-plugin release-market
|
||||
|
||||
echo "Release plugin marketplace scenario passed."
|
||||
224
scripts/e2e/lib/release-scenarios/assertions.mjs
Normal file
224
scripts/e2e/lib/release-scenarios/assertions.mjs
Normal file
@@ -0,0 +1,224 @@
|
||||
// Assertions for release scenario E2E packages and plugin state.
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
import {
|
||||
assertAgentReplyContainsMarker,
|
||||
assertOpenAiRequestLogUsed,
|
||||
} from "../agent-turn-output.mjs";
|
||||
import { assertOpenAiEnvAuthProfileStore } from "../auth-profile-store-assertions.mjs";
|
||||
import {
|
||||
applyMockOpenAiModelConfig,
|
||||
parseMockOpenAiPort,
|
||||
} from "../fixtures/mock-openai-config.mjs";
|
||||
import { readPluginInstallRecords } from "../plugin-index-sqlite.mjs";
|
||||
import {
|
||||
ERROR_DETAIL_TAIL_BYTES,
|
||||
fileContainsText,
|
||||
readJson,
|
||||
} from "../release-assertion-files.mjs";
|
||||
import { readTextFileTail } from "../text-file-utils.mjs";
|
||||
|
||||
const command = process.argv[2];
|
||||
|
||||
function assert(condition, message) {
|
||||
if (!condition) {
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
function configPath() {
|
||||
return (
|
||||
process.env.OPENCLAW_CONFIG_PATH ??
|
||||
path.join(process.env.HOME ?? "", ".openclaw", "openclaw.json")
|
||||
);
|
||||
}
|
||||
|
||||
function writeConfig(cfg) {
|
||||
fs.writeFileSync(configPath(), `${JSON.stringify(cfg, null, 2)}\n`);
|
||||
}
|
||||
|
||||
function authProfilesPath() {
|
||||
return path.join(
|
||||
process.env.HOME ?? "",
|
||||
".openclaw",
|
||||
"agents",
|
||||
"main",
|
||||
"agent",
|
||||
"auth-profiles.json",
|
||||
);
|
||||
}
|
||||
|
||||
function authProfilesDatabasePath() {
|
||||
return path.join(
|
||||
process.env.HOME ?? "",
|
||||
".openclaw",
|
||||
"agents",
|
||||
"main",
|
||||
"agent",
|
||||
"openclaw-agent.sqlite",
|
||||
);
|
||||
}
|
||||
|
||||
function readAuthProfileStoreSqliteText() {
|
||||
const dbPath = authProfilesDatabasePath();
|
||||
if (!fs.existsSync(dbPath)) {
|
||||
return "";
|
||||
}
|
||||
let db;
|
||||
try {
|
||||
db = new DatabaseSync(dbPath, { readOnly: true });
|
||||
const row = db
|
||||
.prepare("SELECT store_json FROM auth_profile_store WHERE store_key = ?")
|
||||
.get("primary");
|
||||
return typeof row?.store_json === "string" ? row.store_json : "";
|
||||
} catch {
|
||||
return "";
|
||||
} finally {
|
||||
db?.close();
|
||||
}
|
||||
}
|
||||
|
||||
function readStateText() {
|
||||
const paths = [configPath(), authProfilesPath()].filter((file) => fs.existsSync(file));
|
||||
return [...paths.map((file) => fs.readFileSync(file, "utf8")), readAuthProfileStoreSqliteText()]
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function configureMockOpenAi() {
|
||||
const mockPort = parseMockOpenAiPort(process.argv[3]);
|
||||
const cfg = readJson(configPath());
|
||||
applyMockOpenAiModelConfig(cfg, { mockPort, includeImageDefaults: true });
|
||||
writeConfig(cfg);
|
||||
}
|
||||
|
||||
function assertOpenAiEnvRef() {
|
||||
const rawKey = process.argv[3];
|
||||
assert(fs.existsSync(configPath()), "openclaw.json missing");
|
||||
assertOpenAiEnvAuthProfileStore(readAuthProfileStoreSqliteText(), {
|
||||
missingMessage: "OpenAI env ref was not persisted",
|
||||
envRefMessage: "OpenAI env ref was not persisted",
|
||||
rawKeyMessage: "raw OpenAI key was persisted",
|
||||
rawKeyNeedle: rawKey,
|
||||
});
|
||||
assert(!readStateText().includes(rawKey), "raw OpenAI key was persisted");
|
||||
}
|
||||
|
||||
function assertSessionMemoryHookEnabled() {
|
||||
const cfg = readJson(configPath());
|
||||
assert(
|
||||
cfg.hooks?.internal?.entries?.["session-memory"]?.enabled === true,
|
||||
"session-memory hook was not enabled",
|
||||
);
|
||||
}
|
||||
|
||||
function assertAgentTurn() {
|
||||
const marker = process.argv[3];
|
||||
const outputPath = process.argv[4];
|
||||
const requestLogPath = process.argv[5];
|
||||
assertAgentReplyContainsMarker(marker, outputPath);
|
||||
assertOpenAiRequestLogUsed(requestLogPath, "mock OpenAI");
|
||||
}
|
||||
|
||||
function assertFileContains() {
|
||||
const file = process.argv[3];
|
||||
const needle = process.argv[4];
|
||||
assert(
|
||||
fileContainsText(file, needle),
|
||||
`${file} did not contain ${needle}. Output tail: ${readTextFileTail(file, ERROR_DETAIL_TAIL_BYTES)}`,
|
||||
);
|
||||
}
|
||||
|
||||
function assertPackageVersion() {
|
||||
const packageRoot = process.argv[3];
|
||||
const expectedVersion = process.argv[4];
|
||||
const label = process.argv[5] ?? "package";
|
||||
assert(packageRoot, "missing package root");
|
||||
assert(expectedVersion, "missing expected package version");
|
||||
const packageJsonPath = path.join(packageRoot, "package.json");
|
||||
const packageJson = readJson(packageJsonPath);
|
||||
assert(
|
||||
packageJson.version === expectedVersion,
|
||||
`${label} package version mismatch: expected ${expectedVersion}, got ${packageJson.version}`,
|
||||
);
|
||||
}
|
||||
|
||||
function assertImageDescribe() {
|
||||
const outputPath = process.argv[3];
|
||||
const requestLogPath = process.argv[4];
|
||||
const payload = readJson(outputPath);
|
||||
assert(payload.ok === true, `image describe failed: ${JSON.stringify(payload)}`);
|
||||
assert(payload.capability === "image.describe", "wrong image describe capability");
|
||||
const output = payload.outputs?.[0];
|
||||
assert(output?.text?.includes("OPENCLAW_E2E_OK"), "image description marker missing");
|
||||
assert(output.provider === "openai", `unexpected image provider: ${output?.provider}`);
|
||||
assert(
|
||||
fileContainsText(requestLogPath, "/v1/responses"),
|
||||
"image describe did not hit Responses API",
|
||||
);
|
||||
}
|
||||
|
||||
function assertImageGenerate() {
|
||||
const outputPath = process.argv[3];
|
||||
const requestLogPath = process.argv[4];
|
||||
const payload = readJson(outputPath);
|
||||
assert(payload.ok === true, `image generation failed: ${JSON.stringify(payload)}`);
|
||||
assert(payload.capability === "image.generate", "wrong image generation capability");
|
||||
const output = payload.outputs?.[0];
|
||||
assert(output?.path && fs.existsSync(output.path), `generated image missing: ${output?.path}`);
|
||||
assert(output.mimeType === "image/png", `unexpected generated mime type: ${output.mimeType}`);
|
||||
assert(payload.provider === "openai", `unexpected generation provider: ${payload.provider}`);
|
||||
assert(
|
||||
fileContainsText(requestLogPath, "/v1/images/generations"),
|
||||
"image generation endpoint was not used",
|
||||
);
|
||||
}
|
||||
|
||||
function assertMemorySearch() {
|
||||
const outputPath = process.argv[3];
|
||||
const needle = process.argv[4];
|
||||
const payload = readJson(outputPath);
|
||||
const haystack = JSON.stringify(payload);
|
||||
assert(haystack.includes(needle), `memory search missed ${needle}: ${haystack}`);
|
||||
}
|
||||
|
||||
function assertPluginUninstalled() {
|
||||
const pluginId = process.argv[3];
|
||||
const cliRoot = process.argv[4];
|
||||
const cfg = readJson(configPath());
|
||||
const installRecords = readPluginInstallRecords({ configPath: configPath() });
|
||||
assert(!installRecords[pluginId], `install record still present for ${pluginId}`);
|
||||
assert(!cfg.plugins?.entries?.[pluginId], `plugin config entry still present for ${pluginId}`);
|
||||
const managedRoot = path.join(
|
||||
process.env.HOME ?? "",
|
||||
".openclaw",
|
||||
"plugins",
|
||||
"installed",
|
||||
pluginId,
|
||||
);
|
||||
assert(!fs.existsSync(managedRoot), `managed plugin directory still present: ${managedRoot}`);
|
||||
if (cliRoot) {
|
||||
const list = JSON.stringify(installRecords);
|
||||
assert(!list.includes(cliRoot), `install records still mention CLI root ${cliRoot}`);
|
||||
}
|
||||
}
|
||||
|
||||
const commands = {
|
||||
"configure-mock-openai": configureMockOpenAi,
|
||||
"assert-openai-env-ref": assertOpenAiEnvRef,
|
||||
"assert-session-memory-hook-enabled": assertSessionMemoryHookEnabled,
|
||||
"assert-agent-turn": assertAgentTurn,
|
||||
"assert-file-contains": assertFileContains,
|
||||
"assert-package-version": assertPackageVersion,
|
||||
"assert-image-describe": assertImageDescribe,
|
||||
"assert-image-generate": assertImageGenerate,
|
||||
"assert-memory-search": assertMemorySearch,
|
||||
"assert-plugin-uninstalled": assertPluginUninstalled,
|
||||
};
|
||||
|
||||
const fn = commands[command];
|
||||
if (!fn) {
|
||||
throw new Error(`unknown release scenario assertion command: ${command ?? "<missing>"}`);
|
||||
}
|
||||
await fn();
|
||||
33
scripts/e2e/lib/release-scenarios/write-cli-plugin.mjs
Normal file
33
scripts/e2e/lib/release-scenarios/write-cli-plugin.mjs
Normal file
@@ -0,0 +1,33 @@
|
||||
// Writes a CLI plugin fixture for release scenario E2E tests.
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const [dir, id, version, method, name, cliRoot, cliOutput] = process.argv.slice(2);
|
||||
|
||||
if (!dir || !id || !version || !method || !name || !cliRoot || !cliOutput) {
|
||||
throw new Error(
|
||||
"usage: write-cli-plugin.mjs <dir> <id> <version> <method> <name> <cliRoot> <cliOutput>",
|
||||
);
|
||||
}
|
||||
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(dir, "package.json"),
|
||||
`${JSON.stringify(
|
||||
{
|
||||
name: `@openclaw/${id}`,
|
||||
version,
|
||||
openclaw: { extensions: ["./index.js"] },
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(dir, "index.js"),
|
||||
`module.exports = { id: ${JSON.stringify(id)}, name: ${JSON.stringify(name)}, register(api) { api.registerGatewayMethod(${JSON.stringify(method)}, async () => ({ ok: true, version: ${JSON.stringify(version)} })); api.registerCli(({ program }) => { const root = program.command(${JSON.stringify(cliRoot)}).description(${JSON.stringify(`${name} fixture command`)}); root.command("ping").description("Print fixture ping output").action(() => { console.log(${JSON.stringify(cliOutput)}); }); }, { descriptors: [{ name: ${JSON.stringify(cliRoot)}, description: ${JSON.stringify(`${name} fixture command`)}, hasSubcommands: true }] }); }, };\n`,
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(dir, "openclaw.plugin.json"),
|
||||
`${JSON.stringify({ id, configSchema: { type: "object", properties: {} } }, null, 2)}\n`,
|
||||
);
|
||||
42
scripts/e2e/lib/release-scenarios/write-marketplace.mjs
Normal file
42
scripts/e2e/lib/release-scenarios/write-marketplace.mjs
Normal file
@@ -0,0 +1,42 @@
|
||||
// Writes a marketplace fixture for release scenario E2E tests.
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const [root, alias, ...plugins] = process.argv.slice(2);
|
||||
|
||||
if (!root || !alias || plugins.length === 0) {
|
||||
throw new Error("usage: write-marketplace.mjs <root> <alias> <pluginId>...");
|
||||
}
|
||||
|
||||
fs.mkdirSync(path.join(root, ".claude-plugin"), { recursive: true });
|
||||
fs.mkdirSync(path.join(process.env.HOME, ".claude", "plugins"), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(root, ".claude-plugin", "marketplace.json"),
|
||||
`${JSON.stringify(
|
||||
{
|
||||
name: "Release Fixture Marketplace",
|
||||
version: "1.0.0",
|
||||
plugins: plugins.map((pluginId) => ({
|
||||
name: pluginId,
|
||||
version: "0.0.1",
|
||||
description: `${pluginId} release fixture`,
|
||||
source: { type: "path", path: `./plugins/${pluginId}` },
|
||||
})),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(process.env.HOME, ".claude", "plugins", "known_marketplaces.json"),
|
||||
`${JSON.stringify(
|
||||
{
|
||||
[alias]: {
|
||||
installLocation: root,
|
||||
source: { type: "github", repo: "openclaw/release-fixture-marketplace" },
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
);
|
||||
145
scripts/e2e/lib/release-typed-onboarding/scenario.sh
Executable file
145
scripts/e2e/lib/release-typed-onboarding/scenario.sh
Executable file
@@ -0,0 +1,145 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
trap "" PIPE
|
||||
export TERM=xterm-256color
|
||||
export NO_COLOR=1
|
||||
|
||||
source scripts/lib/openclaw-e2e-instance.sh
|
||||
|
||||
openclaw_e2e_eval_test_state_from_b64 "${OPENCLAW_TEST_STATE_SCRIPT_B64:?missing OPENCLAW_TEST_STATE_SCRIPT_B64}"
|
||||
openclaw_e2e_install_trash_shim
|
||||
|
||||
export NPM_CONFIG_PREFIX="$HOME/.npm-global"
|
||||
export PATH="$NPM_CONFIG_PREFIX/bin:$PATH"
|
||||
export npm_config_loglevel=error
|
||||
export npm_config_fund=false
|
||||
export npm_config_audit=false
|
||||
export OPENAI_API_KEY="sk-openclaw-release-typed-onboarding"
|
||||
|
||||
PORT="18789"
|
||||
MOCK_PORT="44190"
|
||||
SUCCESS_MARKER="OPENCLAW_E2E_OK_TYPED_ONBOARDING"
|
||||
scenario_tmp="$(mktemp -d "${TMPDIR:-/tmp}/openclaw-release-typed-onboarding.XXXXXX")"
|
||||
LOG_DIR="$scenario_tmp/logs"
|
||||
mkdir -p "$LOG_DIR"
|
||||
INSTALL_LOG="$LOG_DIR/install.log"
|
||||
ONBOARD_LOG="$LOG_DIR/onboard.log"
|
||||
OPENAI_LOG="$LOG_DIR/openai.log"
|
||||
AGENT_LOG="$LOG_DIR/agent.log"
|
||||
MOCK_REQUEST_LOG="$scenario_tmp/openai-requests.jsonl"
|
||||
export SUCCESS_MARKER MOCK_REQUEST_LOG
|
||||
|
||||
mock_pid=""
|
||||
wizard_pid=""
|
||||
input_fifo_dir=""
|
||||
cleanup() {
|
||||
exec 3>&- 2>/dev/null || true
|
||||
openclaw_e2e_stop_process "${wizard_pid:-}"
|
||||
openclaw_e2e_stop_process "${mock_pid:-}"
|
||||
if [ -n "${input_fifo_dir:-}" ]; then
|
||||
rm -rf "$input_fifo_dir"
|
||||
fi
|
||||
rm -rf "$scenario_tmp"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
dump_debug_logs() {
|
||||
local status="$1"
|
||||
echo "release typed onboarding failed with exit code $status" >&2
|
||||
openclaw_e2e_dump_logs \
|
||||
"$INSTALL_LOG" \
|
||||
"$ONBOARD_LOG" \
|
||||
"$OPENAI_LOG" \
|
||||
"$MOCK_REQUEST_LOG" \
|
||||
"$AGENT_LOG" \
|
||||
"$OPENCLAW_CONFIG_PATH" \
|
||||
"$HOME/.openclaw/agents/main/agent/auth-profiles.json"
|
||||
}
|
||||
trap 'status=$?; dump_debug_logs "$status"; exit "$status"' ERR
|
||||
|
||||
send() {
|
||||
local payload="$1"
|
||||
local delay="${2:-0.4}"
|
||||
sleep "$delay"
|
||||
printf "%b" "$payload" >&3 2>/dev/null || true
|
||||
}
|
||||
|
||||
wait_for_log() {
|
||||
local needle="$1"
|
||||
local timeout_s="${2:-60}"
|
||||
local start_s
|
||||
start_s="$(date +%s)"
|
||||
while true; do
|
||||
if [ -f "$ONBOARD_LOG" ]; then
|
||||
if grep -a -F -q "$needle" "$ONBOARD_LOG"; then
|
||||
return 0
|
||||
fi
|
||||
if node scripts/e2e/lib/onboard/log-contains.mjs "$ONBOARD_LOG" "$needle"; then
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
if [ $(($(date +%s) - start_s)) -ge "$timeout_s" ]; then
|
||||
echo "Timeout waiting for log: $needle" >&2
|
||||
tail -n 120 "$ONBOARD_LOG" 2>/dev/null || true
|
||||
return 1
|
||||
fi
|
||||
sleep 0.2
|
||||
done
|
||||
}
|
||||
|
||||
openclaw_e2e_install_package "$INSTALL_LOG"
|
||||
command -v openclaw >/dev/null
|
||||
package_root="$(openclaw_e2e_package_root)"
|
||||
entry="$(openclaw_e2e_package_entrypoint "$package_root")"
|
||||
openclaw_e2e_enable_openclaw_cli_timeout
|
||||
|
||||
mock_pid="$(openclaw_e2e_start_mock_openai "$MOCK_PORT" "$OPENAI_LOG")"
|
||||
openclaw_e2e_wait_mock_openai "$MOCK_PORT"
|
||||
|
||||
input_fifo_dir="$(mktemp -d "$scenario_tmp/input.XXXXXX")"
|
||||
input_fifo="$input_fifo_dir/stdin.fifo"
|
||||
mkfifo "$input_fifo"
|
||||
openclaw_e2e_run_script_with_pty "node \"$entry\" onboard --flow quickstart --mode local --auth-choice skip --gateway-port \"$PORT\" --gateway-bind loopback --skip-daemon --skip-ui --skip-channels --skip-skills --skip-health" "$ONBOARD_LOG" <"$input_fifo" >/dev/null 2>&1 &
|
||||
wizard_pid="$!"
|
||||
exec 3>"$input_fifo"
|
||||
|
||||
wait_for_log "Continue?" 60
|
||||
send $'y\r' 0.4
|
||||
wait_for_log "to search" 60
|
||||
send $'ollama\r' 0.4
|
||||
|
||||
wait "$wizard_pid"
|
||||
wizard_pid=""
|
||||
exec 3>&-
|
||||
rm -rf "$input_fifo_dir"
|
||||
input_fifo_dir=""
|
||||
|
||||
node scripts/e2e/lib/release-scenarios/assertions.mjs assert-session-memory-hook-enabled
|
||||
|
||||
openclaw onboard \
|
||||
--non-interactive \
|
||||
--accept-risk \
|
||||
--flow quickstart \
|
||||
--mode local \
|
||||
--auth-choice openai-api-key \
|
||||
--secret-input-mode ref \
|
||||
--gateway-port "$PORT" \
|
||||
--gateway-bind loopback \
|
||||
--skip-daemon \
|
||||
--skip-ui \
|
||||
--skip-channels \
|
||||
--skip-skills \
|
||||
--skip-health >>"$ONBOARD_LOG" 2>&1
|
||||
|
||||
node scripts/e2e/lib/release-scenarios/assertions.mjs assert-openai-env-ref "$OPENAI_API_KEY"
|
||||
node scripts/e2e/lib/release-scenarios/assertions.mjs configure-mock-openai "$MOCK_PORT"
|
||||
|
||||
openclaw agent --local \
|
||||
--agent main \
|
||||
--session-id release-typed-onboarding-agent \
|
||||
--message "Return marker $SUCCESS_MARKER" \
|
||||
--thinking off \
|
||||
--json >"$AGENT_LOG" 2>&1
|
||||
node scripts/e2e/lib/release-scenarios/assertions.mjs assert-agent-turn "$SUCCESS_MARKER" "$AGENT_LOG" "$MOCK_REQUEST_LOG"
|
||||
|
||||
echo "Release typed onboarding scenario passed."
|
||||
185
scripts/e2e/lib/release-upgrade-user-journey/scenario.sh
Executable file
185
scripts/e2e/lib/release-upgrade-user-journey/scenario.sh
Executable file
@@ -0,0 +1,185 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
trap "" PIPE
|
||||
export TERM=xterm-256color
|
||||
export NO_COLOR=1
|
||||
|
||||
source scripts/lib/openclaw-e2e-instance.sh
|
||||
|
||||
openclaw_e2e_eval_test_state_from_b64 "${OPENCLAW_TEST_STATE_SCRIPT_B64:?missing OPENCLAW_TEST_STATE_SCRIPT_B64}"
|
||||
openclaw_e2e_install_trash_shim
|
||||
|
||||
export NPM_CONFIG_PREFIX="$HOME/.npm-global"
|
||||
export PATH="$NPM_CONFIG_PREFIX/bin:$PATH"
|
||||
export npm_config_loglevel=error
|
||||
export npm_config_fund=false
|
||||
export npm_config_audit=false
|
||||
export OPENAI_API_KEY="sk-openclaw-release-upgrade-user-journey"
|
||||
export CLICKCLACK_BOT_TOKEN="clickclack-release-upgrade-token"
|
||||
|
||||
PORT="18789"
|
||||
MOCK_PORT="44210"
|
||||
CLICKCLACK_PORT="44211"
|
||||
SUCCESS_MARKER="OPENCLAW_E2E_OK_RELEASE_UPGRADE"
|
||||
scenario_tmp="$(mktemp -d "${TMPDIR:-/tmp}/openclaw-release-upgrade-user-journey.XXXXXX")"
|
||||
LOG_DIR="$scenario_tmp/logs"
|
||||
mkdir -p "$LOG_DIR"
|
||||
BASELINE_INSTALL_LOG="$LOG_DIR/baseline-install.log"
|
||||
CANDIDATE_INSTALL_LOG="$LOG_DIR/candidate-install.log"
|
||||
ONBOARD_LOG="$LOG_DIR/onboard.log"
|
||||
OPENAI_LOG="$LOG_DIR/openai.log"
|
||||
PLUGIN_INSTALL_LOG="$LOG_DIR/plugin-install.log"
|
||||
PLUGIN_CLI_BEFORE_LOG="$LOG_DIR/plugin-cli-before.log"
|
||||
PLUGIN_CLI_AFTER_LOG="$LOG_DIR/plugin-cli-after.log"
|
||||
AGENT_LOG="$LOG_DIR/agent.log"
|
||||
STATUS_JSON="$LOG_DIR/status.json"
|
||||
STATUS_ERR="$LOG_DIR/status.err"
|
||||
CLICKCLACK_PLUGIN_INSTALL_LOG="$LOG_DIR/clickclack-plugin-install.log"
|
||||
CLICKCLACK_OUTBOUND_JSON="$LOG_DIR/clickclack-outbound.json"
|
||||
CLICKCLACK_OUTBOUND_ERR="$LOG_DIR/clickclack-outbound.err"
|
||||
CLICKCLACK_SERVER_LOG="$LOG_DIR/clickclack-server.log"
|
||||
GATEWAY_LOG="$LOG_DIR/gateway.log"
|
||||
MOCK_REQUEST_LOG="$scenario_tmp/openai-requests.jsonl"
|
||||
CLICKCLACK_STATE="$scenario_tmp/clickclack.json"
|
||||
export SUCCESS_MARKER MOCK_REQUEST_LOG CLICKCLACK_STATE
|
||||
|
||||
candidate_version="$(
|
||||
tar -xOf "${OPENCLAW_CURRENT_PACKAGE_TGZ:?missing OPENCLAW_CURRENT_PACKAGE_TGZ}" package/package.json |
|
||||
node -e 'let raw = ""; process.stdin.setEncoding("utf8"); process.stdin.on("data", (chunk) => { raw += chunk; }); process.stdin.on("end", () => { process.stdout.write(JSON.parse(raw).version); });'
|
||||
)"
|
||||
if [ -n "${OPENCLAW_RELEASE_UPGRADE_BASELINE_SPEC:-}" ]; then
|
||||
BASELINE_SPEC="$OPENCLAW_RELEASE_UPGRADE_BASELINE_SPEC"
|
||||
else
|
||||
BASELINE_SPEC="$(node scripts/lib/release-upgrade-baseline.mjs --candidate-version "$candidate_version")"
|
||||
fi
|
||||
|
||||
mock_pid=""
|
||||
clickclack_pid=""
|
||||
gateway_pid=""
|
||||
cleanup() {
|
||||
openclaw_e2e_terminate_gateways "${gateway_pid:-}"
|
||||
openclaw_e2e_stop_process "${clickclack_pid:-}"
|
||||
openclaw_e2e_stop_process "${mock_pid:-}"
|
||||
rm -rf "$scenario_tmp"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
dump_debug_logs() {
|
||||
local status="$1"
|
||||
echo "release upgrade user journey failed with exit code $status" >&2
|
||||
openclaw_e2e_dump_logs \
|
||||
"$BASELINE_INSTALL_LOG" \
|
||||
"$CANDIDATE_INSTALL_LOG" \
|
||||
"$ONBOARD_LOG" \
|
||||
"$OPENAI_LOG" \
|
||||
"$MOCK_REQUEST_LOG" \
|
||||
"$PLUGIN_INSTALL_LOG" \
|
||||
"$PLUGIN_CLI_BEFORE_LOG" \
|
||||
"$PLUGIN_CLI_AFTER_LOG" \
|
||||
"$AGENT_LOG" \
|
||||
"$STATUS_JSON" \
|
||||
"$CLICKCLACK_PLUGIN_INSTALL_LOG" \
|
||||
"$CLICKCLACK_OUTBOUND_JSON" \
|
||||
"$CLICKCLACK_SERVER_LOG" \
|
||||
"$GATEWAY_LOG" \
|
||||
"$CLICKCLACK_STATE"
|
||||
}
|
||||
trap 'status=$?; dump_debug_logs "$status"; exit "$status"' ERR
|
||||
|
||||
start_gateway() {
|
||||
local log_path="$1"
|
||||
gateway_pid="$(openclaw_e2e_start_gateway "$entry" "$PORT" "$log_path")"
|
||||
openclaw_e2e_wait_gateway_ready "$gateway_pid" "$log_path" 300 "$PORT"
|
||||
}
|
||||
|
||||
echo "Installing published baseline $BASELINE_SPEC..."
|
||||
if ! openclaw_e2e_maybe_timeout "${OPENCLAW_E2E_NPM_INSTALL_TIMEOUT:-600s}" npm install -g "$BASELINE_SPEC" --no-fund --no-audit >"$BASELINE_INSTALL_LOG" 2>&1; then
|
||||
cat "$BASELINE_INSTALL_LOG" >&2 || true
|
||||
exit 1
|
||||
fi
|
||||
command -v openclaw >/dev/null
|
||||
baseline_root="$(openclaw_e2e_package_root)"
|
||||
baseline_entry="$(openclaw_e2e_package_entrypoint "$baseline_root")"
|
||||
openclaw_e2e_enable_openclaw_cli_timeout
|
||||
|
||||
mock_pid="$(openclaw_e2e_start_mock_openai "$MOCK_PORT" "$OPENAI_LOG")"
|
||||
openclaw_e2e_wait_mock_openai "$MOCK_PORT"
|
||||
|
||||
CLICKCLACK_FIXTURE_PORT="$CLICKCLACK_PORT" \
|
||||
CLICKCLACK_FIXTURE_TOKEN="$CLICKCLACK_BOT_TOKEN" \
|
||||
CLICKCLACK_FIXTURE_STATE="$CLICKCLACK_STATE" \
|
||||
node scripts/e2e/lib/release-user-journey/clickclack-fixture.mjs >"$CLICKCLACK_SERVER_LOG" 2>&1 &
|
||||
clickclack_pid="$!"
|
||||
for _ in $(seq 1 100); do
|
||||
if openclaw_e2e_probe_http_status "http://127.0.0.1:$CLICKCLACK_PORT/health" 200 >/dev/null 2>&1; then
|
||||
break
|
||||
fi
|
||||
sleep 0.1
|
||||
done
|
||||
openclaw_e2e_probe_http_status "http://127.0.0.1:$CLICKCLACK_PORT/health" 200
|
||||
|
||||
openclaw_e2e_run_command node "$baseline_entry" onboard \
|
||||
--non-interactive \
|
||||
--accept-risk \
|
||||
--flow quickstart \
|
||||
--mode local \
|
||||
--auth-choice skip \
|
||||
--gateway-port "$PORT" \
|
||||
--gateway-bind loopback \
|
||||
--skip-daemon \
|
||||
--skip-ui \
|
||||
--skip-channels \
|
||||
--skip-skills \
|
||||
--skip-health >"$ONBOARD_LOG" 2>&1
|
||||
node scripts/e2e/lib/release-scenarios/assertions.mjs configure-mock-openai "$MOCK_PORT"
|
||||
|
||||
plugin_dir="$(mktemp -d "$scenario_tmp/plugin.XXXXXX")"
|
||||
node scripts/e2e/lib/release-scenarios/write-cli-plugin.mjs \
|
||||
"$plugin_dir" \
|
||||
release-upgrade-plugin \
|
||||
0.0.1 \
|
||||
release.upgrade.plugin \
|
||||
"Release Upgrade Plugin" \
|
||||
release-upgrade \
|
||||
"release-upgrade-plugin:pong"
|
||||
openclaw plugins install "$plugin_dir" >"$PLUGIN_INSTALL_LOG" 2>&1
|
||||
openclaw release-upgrade ping >"$PLUGIN_CLI_BEFORE_LOG" 2>&1
|
||||
node scripts/e2e/lib/release-scenarios/assertions.mjs assert-file-contains "$PLUGIN_CLI_BEFORE_LOG" "release-upgrade-plugin:pong"
|
||||
node scripts/e2e/lib/release-user-journey/assertions.mjs configure-clickclack "http://127.0.0.1:$CLICKCLACK_PORT"
|
||||
|
||||
openclaw_e2e_install_package "$CANDIDATE_INSTALL_LOG" "candidate OpenClaw package"
|
||||
package_root="$(openclaw_e2e_package_root)"
|
||||
entry="$(openclaw_e2e_package_entrypoint "$package_root")"
|
||||
openclaw_e2e_enable_openclaw_cli_timeout
|
||||
node scripts/e2e/lib/release-scenarios/assertions.mjs assert-package-version "$package_root" "$candidate_version" candidate
|
||||
|
||||
openclaw agent --local \
|
||||
--agent main \
|
||||
--session-id release-upgrade-user-journey-agent \
|
||||
--message "Return marker $SUCCESS_MARKER" \
|
||||
--thinking off \
|
||||
--json >"$AGENT_LOG" 2>&1
|
||||
node scripts/e2e/lib/release-scenarios/assertions.mjs assert-agent-turn "$SUCCESS_MARKER" "$AGENT_LOG" "$MOCK_REQUEST_LOG"
|
||||
|
||||
openclaw release-upgrade ping >"$PLUGIN_CLI_AFTER_LOG" 2>&1
|
||||
node scripts/e2e/lib/release-scenarios/assertions.mjs assert-file-contains "$PLUGIN_CLI_AFTER_LOG" "release-upgrade-plugin:pong"
|
||||
|
||||
clickclack_plugin_dir="$(mktemp -d "$scenario_tmp/clickclack-plugin.XXXXXX")"
|
||||
node scripts/e2e/lib/release-user-journey/write-clickclack-plugin.mjs "$clickclack_plugin_dir"
|
||||
openclaw plugins install "$clickclack_plugin_dir" >"$CLICKCLACK_PLUGIN_INSTALL_LOG" 2>&1
|
||||
|
||||
openclaw channels status --json >"$STATUS_JSON" 2>"$STATUS_ERR"
|
||||
node scripts/e2e/lib/release-user-journey/assertions.mjs assert-channel-status clickclack "$STATUS_JSON"
|
||||
openclaw message send \
|
||||
--channel clickclack \
|
||||
--target channel:general \
|
||||
--message "release upgrade outbound" \
|
||||
--json >"$CLICKCLACK_OUTBOUND_JSON" 2>"$CLICKCLACK_OUTBOUND_ERR"
|
||||
node scripts/e2e/lib/release-user-journey/assertions.mjs assert-clickclack-state outbound "$CLICKCLACK_STATE" "release upgrade outbound"
|
||||
|
||||
start_gateway "$GATEWAY_LOG"
|
||||
node scripts/e2e/lib/release-user-journey/assertions.mjs wait-clickclack-socket "http://127.0.0.1:$CLICKCLACK_PORT" 45
|
||||
node scripts/e2e/lib/release-user-journey/assertions.mjs post-clickclack-inbound "http://127.0.0.1:$CLICKCLACK_PORT" "Return marker $SUCCESS_MARKER"
|
||||
node scripts/e2e/lib/release-user-journey/assertions.mjs wait-clickclack-reply "$CLICKCLACK_STATE" "$SUCCESS_MARKER" 45
|
||||
|
||||
echo "Release upgrade user journey scenario passed."
|
||||
398
scripts/e2e/lib/release-user-journey/assertions.mjs
Normal file
398
scripts/e2e/lib/release-user-journey/assertions.mjs
Normal file
@@ -0,0 +1,398 @@
|
||||
// Assertions for release user-journey E2E scenarios.
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
assertAgentReplyContainsMarker,
|
||||
assertOpenAiRequestLogUsed,
|
||||
} from "../agent-turn-output.mjs";
|
||||
import { readBoundedResponseText as readBoundedResponseTextWithLimit } from "../bounded-response-text.mjs";
|
||||
import {
|
||||
applyMockOpenAiModelConfig,
|
||||
parseMockOpenAiPort,
|
||||
} from "../fixtures/mock-openai-config.mjs";
|
||||
import { readPluginInstallRecords } from "../plugin-index-sqlite.mjs";
|
||||
import {
|
||||
ERROR_DETAIL_TAIL_BYTES,
|
||||
fileContainsText,
|
||||
readJson,
|
||||
} from "../release-assertion-files.mjs";
|
||||
import { readTextFileTail } from "../text-file-utils.mjs";
|
||||
|
||||
function clickClackHttpTimeoutMs() {
|
||||
return readPositiveInt(
|
||||
process.env.OPENCLAW_RELEASE_USER_JOURNEY_HTTP_TIMEOUT_MS,
|
||||
5000,
|
||||
"OPENCLAW_RELEASE_USER_JOURNEY_HTTP_TIMEOUT_MS",
|
||||
);
|
||||
}
|
||||
|
||||
function clickClackHttpBodyMaxBytes() {
|
||||
return readPositiveInt(
|
||||
process.env.OPENCLAW_RELEASE_USER_JOURNEY_HTTP_BODY_MAX_BYTES,
|
||||
1024 * 1024,
|
||||
"OPENCLAW_RELEASE_USER_JOURNEY_HTTP_BODY_MAX_BYTES",
|
||||
);
|
||||
}
|
||||
|
||||
function readPositiveInt(raw, fallback, label) {
|
||||
const text = String(raw ?? "").trim();
|
||||
if (!text) {
|
||||
return fallback;
|
||||
}
|
||||
if (!/^\d+$/u.test(text)) {
|
||||
throw new Error(`${label} must be a positive integer. Got: ${JSON.stringify(text)}`);
|
||||
}
|
||||
const parsed = Number(text);
|
||||
if (!Number.isSafeInteger(parsed) || parsed <= 0) {
|
||||
throw new Error(`${label} must be a positive integer. Got: ${JSON.stringify(text)}`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
async function withClickClackFixtureResponse(url, init, consume, options = {}) {
|
||||
const timeoutMs = options.timeoutMs ?? clickClackHttpTimeoutMs();
|
||||
const controller = new AbortController();
|
||||
const timeoutError = new Error(`${url} timed out after ${timeoutMs}ms`);
|
||||
let timer;
|
||||
let response;
|
||||
const timeoutPromise = new Promise((_resolve, reject) => {
|
||||
timer = setTimeout(() => {
|
||||
controller.abort(timeoutError);
|
||||
reject(timeoutError);
|
||||
}, timeoutMs);
|
||||
});
|
||||
try {
|
||||
response = await Promise.race([
|
||||
fetch(url, {
|
||||
...init,
|
||||
signal: controller.signal,
|
||||
}),
|
||||
timeoutPromise,
|
||||
]);
|
||||
return await consume(response, { timeoutPromise });
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
await response?.body?.cancel?.().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
async function readBoundedResponseText(
|
||||
response,
|
||||
label,
|
||||
byteLimit = clickClackHttpBodyMaxBytes(),
|
||||
options = {},
|
||||
) {
|
||||
return await readBoundedResponseTextWithLimit(response, label, byteLimit, options.timeoutPromise);
|
||||
}
|
||||
|
||||
async function readBoundedResponseJson(response, label, options = {}) {
|
||||
return JSON.parse(await readBoundedResponseText(response, label, undefined, options));
|
||||
}
|
||||
|
||||
function resolveHomePath(value) {
|
||||
if (value === "~") {
|
||||
return process.env.HOME;
|
||||
}
|
||||
if (value?.startsWith("~/") || value?.startsWith("~\\")) {
|
||||
return path.join(process.env.HOME ?? "", value.slice(2));
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function comparablePath(value) {
|
||||
const resolved = path.resolve(resolveHomePath(value));
|
||||
try {
|
||||
return fs.realpathSync.native(resolved);
|
||||
} catch {
|
||||
return resolved;
|
||||
}
|
||||
}
|
||||
|
||||
function pathsEqual(left, right) {
|
||||
return comparablePath(left) === comparablePath(right);
|
||||
}
|
||||
|
||||
function configPath() {
|
||||
return (
|
||||
process.env.OPENCLAW_CONFIG_PATH ??
|
||||
path.join(process.env.HOME ?? "", ".openclaw", "openclaw.json")
|
||||
);
|
||||
}
|
||||
|
||||
function assert(condition, message) {
|
||||
if (!condition) {
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
function writeConfig(cfg) {
|
||||
fs.writeFileSync(configPath(), `${JSON.stringify(cfg, null, 2)}\n`);
|
||||
}
|
||||
|
||||
function installRecords() {
|
||||
return readPluginInstallRecords({ configPath: configPath() });
|
||||
}
|
||||
|
||||
function assertOnboard() {
|
||||
const home = process.argv[3];
|
||||
const stateDir = path.join(home, ".openclaw");
|
||||
const authPath = path.join(stateDir, "agents", "main", "agent", "auth-profiles.json");
|
||||
assert(fs.existsSync(configPath()), "onboard did not write openclaw.json");
|
||||
const stateRaw =
|
||||
fs.readFileSync(configPath(), "utf8") +
|
||||
(fs.existsSync(authPath) ? fs.readFileSync(authPath, "utf8") : "");
|
||||
assert(
|
||||
!stateRaw.includes("sk-openclaw-release-user-journey"),
|
||||
"onboard persisted raw OpenAI key",
|
||||
);
|
||||
}
|
||||
|
||||
function configureMockModel() {
|
||||
const mockPort = parseMockOpenAiPort(process.argv[3]);
|
||||
const cfg = readJson(configPath());
|
||||
applyMockOpenAiModelConfig(cfg, { mockPort });
|
||||
writeConfig(cfg);
|
||||
}
|
||||
|
||||
function assertAgentTurn() {
|
||||
const marker = process.argv[3];
|
||||
const outputPath = process.argv[4];
|
||||
const requestLogPath = process.argv[5];
|
||||
assertAgentReplyContainsMarker(marker, outputPath);
|
||||
assertOpenAiRequestLogUsed(requestLogPath);
|
||||
}
|
||||
|
||||
function assertFileContains() {
|
||||
const file = process.argv[3];
|
||||
const needle = process.argv[4];
|
||||
assert(
|
||||
fileContainsText(file, needle),
|
||||
`${file} did not contain ${needle}. Output tail: ${readTextFileTail(file, ERROR_DETAIL_TAIL_BYTES)}`,
|
||||
);
|
||||
}
|
||||
|
||||
function rememberPluginInstallPath() {
|
||||
const pluginId = process.argv[3];
|
||||
const installPathFile = process.argv[4];
|
||||
const sourcePathFile = process.argv[5];
|
||||
const expectedSourcePath = process.argv[6];
|
||||
assert(pluginId, "missing plugin id");
|
||||
assert(installPathFile, "missing install path file");
|
||||
const record = installRecords()[pluginId];
|
||||
assert(record, `missing install record for ${pluginId}`);
|
||||
const installPath = resolveHomePath(record.installPath);
|
||||
assert(installPath, `install path missing for ${pluginId}`);
|
||||
assert(
|
||||
fs.existsSync(installPath),
|
||||
`install path missing on disk for ${pluginId}: ${installPath}`,
|
||||
);
|
||||
if (expectedSourcePath && record.sourcePath) {
|
||||
assert(
|
||||
pathsEqual(record.sourcePath, expectedSourcePath),
|
||||
`unexpected source path for ${pluginId}: ${record.sourcePath}, expected ${expectedSourcePath}`,
|
||||
);
|
||||
}
|
||||
fs.writeFileSync(installPathFile, installPath, "utf8");
|
||||
if (sourcePathFile && (expectedSourcePath || record.sourcePath)) {
|
||||
fs.writeFileSync(
|
||||
sourcePathFile,
|
||||
expectedSourcePath || resolveHomePath(record.sourcePath),
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function assertPluginUninstalled() {
|
||||
const pluginId = process.argv[3];
|
||||
const installPathFile = process.argv[4];
|
||||
const sourcePathFile = process.argv[5];
|
||||
const cfg = readJson(configPath());
|
||||
const records = installRecords();
|
||||
assert(!records[pluginId], `install record still present for ${pluginId}`);
|
||||
assert(!cfg.plugins?.entries?.[pluginId], `plugin config entry still present for ${pluginId}`);
|
||||
assert(!(cfg.plugins?.allow ?? []).includes(pluginId), `allowlist still contains ${pluginId}`);
|
||||
assert(!(cfg.plugins?.deny ?? []).includes(pluginId), `denylist still contains ${pluginId}`);
|
||||
if (!installPathFile) {
|
||||
return;
|
||||
}
|
||||
const installPath = fs.readFileSync(installPathFile, "utf8").trim();
|
||||
const sourcePath =
|
||||
sourcePathFile && fs.existsSync(sourcePathFile)
|
||||
? fs.readFileSync(sourcePathFile, "utf8").trim()
|
||||
: "";
|
||||
if (sourcePath) {
|
||||
assert(
|
||||
fs.existsSync(sourcePath),
|
||||
`source path was deleted during uninstall for ${pluginId}: ${sourcePath}`,
|
||||
);
|
||||
}
|
||||
const installPathIsSourcePath = sourcePath ? pathsEqual(installPath, sourcePath) : false;
|
||||
assert(
|
||||
installPathIsSourcePath || !fs.existsSync(installPath),
|
||||
`managed plugin directory still present: ${installPath}`,
|
||||
);
|
||||
}
|
||||
|
||||
function configureClickClack() {
|
||||
const baseUrl = process.argv[3];
|
||||
const cfg = readJson(configPath());
|
||||
cfg.plugins = {
|
||||
...cfg.plugins,
|
||||
enabled: true,
|
||||
entries: {
|
||||
...cfg.plugins?.entries,
|
||||
clickclack: {
|
||||
...cfg.plugins?.entries?.clickclack,
|
||||
enabled: true,
|
||||
llm: {
|
||||
...cfg.plugins?.entries?.clickclack?.llm,
|
||||
allowAgentIdOverride: true,
|
||||
allowModelOverride: true,
|
||||
allowedModels: ["openai/gpt-5.5"],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
cfg.channels = {
|
||||
...cfg.channels,
|
||||
clickclack: {
|
||||
...cfg.channels?.clickclack,
|
||||
enabled: true,
|
||||
baseUrl,
|
||||
token: { source: "env", provider: "default", id: "CLICKCLACK_BOT_TOKEN" },
|
||||
workspace: "release",
|
||||
defaultTo: "channel:general",
|
||||
replyMode: "model",
|
||||
model: "openai/gpt-5.5",
|
||||
reconnectMs: 250,
|
||||
},
|
||||
};
|
||||
writeConfig(cfg);
|
||||
}
|
||||
|
||||
function assertChannelStatus() {
|
||||
const channel = process.argv[3];
|
||||
const statusPath = process.argv[4];
|
||||
const status = readJson(statusPath);
|
||||
const configured = Array.isArray(status.configuredChannels) ? status.configuredChannels : [];
|
||||
const liveStatus = status.channels?.[channel];
|
||||
assert(
|
||||
configured.includes(channel) || liveStatus?.ok === true,
|
||||
`${channel} missing from channels status: ${JSON.stringify(status)}`,
|
||||
);
|
||||
}
|
||||
|
||||
async function postClickClackInbound() {
|
||||
const baseUrl = process.argv[3];
|
||||
const body = process.argv[4];
|
||||
await withClickClackFixtureResponse(
|
||||
`${baseUrl}/fixture/inbound`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ body }),
|
||||
},
|
||||
async (response, options) => {
|
||||
const text = response.ok
|
||||
? ""
|
||||
: await readBoundedResponseText(response, "ClickClack inbound", undefined, options);
|
||||
assert(response.ok, `fixture inbound failed: ${response.status} ${text}`);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function waitClickClackSocket() {
|
||||
const baseUrl = process.argv[3];
|
||||
const timeoutSeconds = readPositiveInt(
|
||||
process.argv[4],
|
||||
30,
|
||||
"ClickClack websocket timeout seconds",
|
||||
);
|
||||
const deadline = Date.now() + timeoutSeconds * 1000;
|
||||
while (Date.now() < deadline) {
|
||||
const remainingMs = Math.max(1, deadline - Date.now());
|
||||
const state = await withClickClackFixtureResponse(
|
||||
`${baseUrl}/fixture/state`,
|
||||
{},
|
||||
async (response, options) =>
|
||||
response.ok
|
||||
? await readBoundedResponseJson(response, "ClickClack fixture state", options)
|
||||
: undefined,
|
||||
{
|
||||
timeoutMs: Math.min(clickClackHttpTimeoutMs(), remainingMs),
|
||||
},
|
||||
).catch(() => undefined);
|
||||
if (state) {
|
||||
if (Number(state.socketCount ?? 0) > 0) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 250);
|
||||
});
|
||||
}
|
||||
throw new Error(`Timed out waiting for ClickClack websocket connection at ${baseUrl}`);
|
||||
}
|
||||
|
||||
function assertClickClackState() {
|
||||
const mode = process.argv[3];
|
||||
const statePath = process.argv[4];
|
||||
const needle = process.argv[5];
|
||||
const state = readJson(statePath);
|
||||
const haystack = JSON.stringify(mode === "outbound" ? state.outboundMessages : state);
|
||||
assert(haystack.includes(needle), `ClickClack state did not contain ${needle}: ${haystack}`);
|
||||
}
|
||||
|
||||
async function waitClickClackReply() {
|
||||
const statePath = process.argv[3];
|
||||
const marker = process.argv[4];
|
||||
const timeoutSeconds = readPositiveInt(process.argv[5], 30, "ClickClack reply timeout seconds");
|
||||
const deadline = Date.now() + timeoutSeconds * 1000;
|
||||
while (Date.now() < deadline) {
|
||||
if (fs.existsSync(statePath)) {
|
||||
const state = readJson(statePath);
|
||||
if (JSON.stringify(state.threadReplies ?? []).includes(marker)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 250);
|
||||
});
|
||||
}
|
||||
const state = fs.existsSync(statePath) ? fs.readFileSync(statePath, "utf8") : "<missing>";
|
||||
throw new Error(`Timed out waiting for ClickClack reply marker ${marker}. State: ${state}`);
|
||||
}
|
||||
|
||||
const commands = {
|
||||
"assert-onboard": assertOnboard,
|
||||
"remember-plugin-install-path": rememberPluginInstallPath,
|
||||
"configure-mock-model": configureMockModel,
|
||||
"assert-agent-turn": assertAgentTurn,
|
||||
"assert-file-contains": assertFileContains,
|
||||
"assert-plugin-uninstalled": assertPluginUninstalled,
|
||||
"configure-clickclack": configureClickClack,
|
||||
"assert-channel-status": assertChannelStatus,
|
||||
"post-clickclack-inbound": postClickClackInbound,
|
||||
"wait-clickclack-socket": waitClickClackSocket,
|
||||
"assert-clickclack-state": assertClickClackState,
|
||||
"wait-clickclack-reply": waitClickClackReply,
|
||||
};
|
||||
|
||||
export async function runReleaseUserJourneyAssertion(command, args = []) {
|
||||
const fn = commands[command];
|
||||
if (!fn) {
|
||||
throw new Error(`unknown release-user-journey assertion command: ${command ?? "<missing>"}`);
|
||||
}
|
||||
const previousArgv = process.argv;
|
||||
process.argv = [previousArgv[0] ?? "node", fileURLToPath(import.meta.url), command, ...args];
|
||||
try {
|
||||
await fn();
|
||||
} finally {
|
||||
process.argv = previousArgv;
|
||||
}
|
||||
}
|
||||
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
await runReleaseUserJourneyAssertion(process.argv[2], process.argv.slice(3));
|
||||
}
|
||||
345
scripts/e2e/lib/release-user-journey/clickclack-fixture.mjs
Normal file
345
scripts/e2e/lib/release-user-journey/clickclack-fixture.mjs
Normal file
@@ -0,0 +1,345 @@
|
||||
// ClickClack fixture server for release user-journey E2E scenarios.
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import http from "node:http";
|
||||
import { readPositiveIntEnv, readTcpPortEnv } from "../env-limits.mjs";
|
||||
|
||||
const port = readTcpPortEnv("CLICKCLACK_FIXTURE_PORT", 44181);
|
||||
const requestMaxBytes = readPositiveIntEnv("CLICKCLACK_FIXTURE_REQUEST_MAX_BYTES", 4 * 1024 * 1024);
|
||||
const token = process.env.CLICKCLACK_FIXTURE_TOKEN ?? "clickclack-release-token";
|
||||
const statePath = process.env.CLICKCLACK_FIXTURE_STATE ?? "/tmp/openclaw-clickclack-fixture.json";
|
||||
const workspace = {
|
||||
id: "ws_release",
|
||||
name: "Release Workspace",
|
||||
slug: "release",
|
||||
created_at: new Date(0).toISOString(),
|
||||
};
|
||||
const channel = {
|
||||
id: "ch_general",
|
||||
workspace_id: workspace.id,
|
||||
name: "general",
|
||||
kind: "text",
|
||||
created_at: new Date(0).toISOString(),
|
||||
};
|
||||
const botUser = {
|
||||
id: "usr_bot",
|
||||
kind: "bot",
|
||||
display_name: "OpenClaw Bot",
|
||||
handle: "openclaw",
|
||||
avatar_url: "",
|
||||
created_at: new Date(0).toISOString(),
|
||||
};
|
||||
const humanUser = {
|
||||
id: "usr_human",
|
||||
kind: "human",
|
||||
display_name: "Release User",
|
||||
handle: "release-user",
|
||||
avatar_url: "",
|
||||
created_at: new Date(0).toISOString(),
|
||||
};
|
||||
|
||||
let messageSeq = 0;
|
||||
let eventSeq = 0;
|
||||
const messages = [];
|
||||
const threadReplies = [];
|
||||
const outboundMessages = [];
|
||||
const sockets = new Set();
|
||||
|
||||
function persist() {
|
||||
fs.writeFileSync(
|
||||
statePath,
|
||||
`${JSON.stringify(
|
||||
{
|
||||
messages,
|
||||
threadReplies,
|
||||
outboundMessages,
|
||||
socketCount: sockets.size,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
);
|
||||
}
|
||||
|
||||
function now() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function json(res, status, body) {
|
||||
res.writeHead(status, { "content-type": "application/json" });
|
||||
res.end(JSON.stringify(body));
|
||||
}
|
||||
|
||||
function unauthorized(res) {
|
||||
json(res, 401, { error: "unauthorized" });
|
||||
}
|
||||
|
||||
function checkAuth(req, res) {
|
||||
if (req.url?.startsWith("/fixture/") || req.url === "/health") {
|
||||
return true;
|
||||
}
|
||||
if (req.headers.authorization !== `Bearer ${token}`) {
|
||||
unauthorized(res);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function readBody(req) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let body = "";
|
||||
let bytes = 0;
|
||||
let settled = false;
|
||||
req.setEncoding("utf8");
|
||||
req.on("data", (chunk) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
bytes += Buffer.byteLength(chunk, "utf8");
|
||||
if (bytes > requestMaxBytes) {
|
||||
settled = true;
|
||||
body = "";
|
||||
req.resume();
|
||||
reject(requestBodyTooLargeError());
|
||||
return;
|
||||
}
|
||||
body += chunk;
|
||||
});
|
||||
req.on("end", () => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
try {
|
||||
resolve(body ? JSON.parse(body) : {});
|
||||
} catch {
|
||||
resolve({});
|
||||
}
|
||||
});
|
||||
req.on("error", (error) => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
reject(error instanceof Error ? error : new Error(String(error)));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function requestBodyTooLargeError() {
|
||||
return Object.assign(
|
||||
new Error(`ClickClack fixture request body exceeded ${requestMaxBytes} bytes`),
|
||||
{
|
||||
code: "ETOOBIG",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function isRequestBodyTooLargeError(error) {
|
||||
return error instanceof Error && error.code === "ETOOBIG";
|
||||
}
|
||||
|
||||
function handleRequestError(res, error) {
|
||||
if (res.headersSent) {
|
||||
res.destroy();
|
||||
return;
|
||||
}
|
||||
if (isRequestBodyTooLargeError(error)) {
|
||||
json(res, 413, { error: error.message });
|
||||
return;
|
||||
}
|
||||
json(res, 500, { error: String(error instanceof Error ? error.message : error) });
|
||||
}
|
||||
|
||||
function createMessage({ body, author = humanUser, parentMessageId }) {
|
||||
messageSeq += 1;
|
||||
const id = `msg_${messageSeq}`;
|
||||
const message = {
|
||||
id,
|
||||
workspace_id: workspace.id,
|
||||
channel_id: channel.id,
|
||||
author_id: author.id,
|
||||
...(parentMessageId ? { parent_message_id: parentMessageId } : {}),
|
||||
thread_root_id: parentMessageId ?? id,
|
||||
channel_seq: messageSeq,
|
||||
thread_seq: parentMessageId ? threadReplies.length + 1 : 0,
|
||||
body,
|
||||
body_format: "markdown",
|
||||
created_at: now(),
|
||||
author,
|
||||
};
|
||||
if (parentMessageId) {
|
||||
threadReplies.push(message);
|
||||
} else {
|
||||
messages.push(message);
|
||||
}
|
||||
persist();
|
||||
return message;
|
||||
}
|
||||
|
||||
function eventFor(message) {
|
||||
eventSeq += 1;
|
||||
return {
|
||||
id: `evt_${eventSeq}`,
|
||||
cursor: String(eventSeq),
|
||||
type: message.parent_message_id ? "thread.reply_created" : "message.created",
|
||||
workspace_id: workspace.id,
|
||||
channel_id: channel.id,
|
||||
seq: message.channel_seq,
|
||||
created_at: now(),
|
||||
payload: {
|
||||
message_id: message.id,
|
||||
author_id: message.author_id,
|
||||
...(message.parent_message_id ? { root_message_id: message.thread_root_id } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function frameText(text) {
|
||||
const payload = Buffer.from(text);
|
||||
if (payload.length < 126) {
|
||||
return Buffer.concat([Buffer.from([0x81, payload.length]), payload]);
|
||||
}
|
||||
if (payload.length < 65536) {
|
||||
const header = Buffer.alloc(4);
|
||||
header[0] = 0x81;
|
||||
header[1] = 126;
|
||||
header.writeUInt16BE(payload.length, 2);
|
||||
return Buffer.concat([header, payload]);
|
||||
}
|
||||
const header = Buffer.alloc(10);
|
||||
header[0] = 0x81;
|
||||
header[1] = 127;
|
||||
header.writeBigUInt64BE(BigInt(payload.length), 2);
|
||||
return Buffer.concat([header, payload]);
|
||||
}
|
||||
|
||||
function broadcast(event) {
|
||||
const frame = frameText(JSON.stringify(event));
|
||||
for (const socket of sockets) {
|
||||
socket.write(frame);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRequest(req, res) {
|
||||
try {
|
||||
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
||||
if (!checkAuth(req, res)) {
|
||||
return;
|
||||
}
|
||||
if (req.method === "GET" && url.pathname === "/health") {
|
||||
json(res, 200, { ok: true });
|
||||
return;
|
||||
}
|
||||
if (req.method === "GET" && url.pathname === "/api/me") {
|
||||
json(res, 200, { user: botUser });
|
||||
return;
|
||||
}
|
||||
if (req.method === "GET" && url.pathname === "/api/workspaces") {
|
||||
json(res, 200, { workspaces: [workspace] });
|
||||
return;
|
||||
}
|
||||
if (req.method === "GET" && url.pathname === `/api/workspaces/${workspace.id}/channels`) {
|
||||
json(res, 200, { channels: [channel] });
|
||||
return;
|
||||
}
|
||||
if (req.method === "GET" && url.pathname === `/api/channels/${channel.id}/messages`) {
|
||||
const afterSeq = Number(url.searchParams.get("after_seq") ?? 0);
|
||||
json(res, 200, {
|
||||
messages: messages.filter((message) => (message.channel_seq ?? 0) > afterSeq),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (req.method === "POST" && url.pathname === `/api/channels/${channel.id}/messages`) {
|
||||
const body = await readBody(req);
|
||||
const message = createMessage({ body: String(body.body ?? ""), author: botUser });
|
||||
outboundMessages.push(message);
|
||||
persist();
|
||||
json(res, 200, { message });
|
||||
return;
|
||||
}
|
||||
const threadReplyMatch = url.pathname.match(/^\/api\/messages\/([^/]+)\/thread\/replies$/u);
|
||||
if (req.method === "POST" && threadReplyMatch) {
|
||||
const body = await readBody(req);
|
||||
const message = createMessage({
|
||||
body: String(body.body ?? ""),
|
||||
author: botUser,
|
||||
parentMessageId: decodeURIComponent(threadReplyMatch[1]),
|
||||
});
|
||||
json(res, 200, { message });
|
||||
return;
|
||||
}
|
||||
const threadMatch = url.pathname.match(/^\/api\/messages\/([^/]+)\/thread$/u);
|
||||
if (req.method === "GET" && threadMatch) {
|
||||
const rootId = decodeURIComponent(threadMatch[1]);
|
||||
json(res, 200, {
|
||||
root: messages.find((message) => message.id === rootId) ?? null,
|
||||
replies: threadReplies.filter((message) => message.thread_root_id === rootId),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (req.method === "GET" && url.pathname === "/api/realtime/events") {
|
||||
json(res, 200, { events: [] });
|
||||
return;
|
||||
}
|
||||
if (req.method === "POST" && url.pathname === "/fixture/inbound") {
|
||||
const body = await readBody(req);
|
||||
const message = createMessage({ body: String(body.body ?? ""), author: humanUser });
|
||||
broadcast(eventFor(message));
|
||||
json(res, 200, { message });
|
||||
return;
|
||||
}
|
||||
if (req.method === "GET" && url.pathname === "/fixture/state") {
|
||||
json(res, 200, { messages, threadReplies, outboundMessages, socketCount: sockets.size });
|
||||
return;
|
||||
}
|
||||
json(res, 404, { error: `unhandled ${req.method} ${url.pathname}` });
|
||||
} catch (error) {
|
||||
handleRequestError(res, error);
|
||||
}
|
||||
}
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
void handleRequest(req, res);
|
||||
});
|
||||
|
||||
server.on("upgrade", (req, socket) => {
|
||||
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
||||
if (url.pathname !== "/api/realtime/ws" || req.headers.authorization !== `Bearer ${token}`) {
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
const key = req.headers["sec-websocket-key"];
|
||||
if (typeof key !== "string") {
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
const accept = crypto
|
||||
.createHash("sha1")
|
||||
.update(`${key}258EAFA5-E914-47DA-95CA-C5AB0DC85B11`)
|
||||
.digest("base64");
|
||||
socket.write(
|
||||
[
|
||||
"HTTP/1.1 101 Switching Protocols",
|
||||
"Upgrade: websocket",
|
||||
"Connection: Upgrade",
|
||||
`Sec-WebSocket-Accept: ${accept}`,
|
||||
"",
|
||||
"",
|
||||
].join("\r\n"),
|
||||
);
|
||||
sockets.add(socket);
|
||||
persist();
|
||||
socket.on("close", () => {
|
||||
sockets.delete(socket);
|
||||
persist();
|
||||
});
|
||||
socket.on("error", () => {
|
||||
sockets.delete(socket);
|
||||
persist();
|
||||
});
|
||||
});
|
||||
|
||||
persist();
|
||||
server.listen(port, "127.0.0.1", () => {
|
||||
console.log(`clickclack fixture listening on ${port}`);
|
||||
});
|
||||
256
scripts/e2e/lib/release-user-journey/scenario.sh
Executable file
256
scripts/e2e/lib/release-user-journey/scenario.sh
Executable file
@@ -0,0 +1,256 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
trap "" PIPE
|
||||
export TERM=xterm-256color
|
||||
export NO_COLOR=1
|
||||
|
||||
source scripts/lib/openclaw-e2e-instance.sh
|
||||
|
||||
openclaw_e2e_eval_test_state_from_b64 "${OPENCLAW_TEST_STATE_SCRIPT_B64:?missing OPENCLAW_TEST_STATE_SCRIPT_B64}"
|
||||
openclaw_e2e_install_trash_shim
|
||||
|
||||
export NPM_CONFIG_PREFIX="$HOME/.npm-global"
|
||||
export PATH="$NPM_CONFIG_PREFIX/bin:$PATH"
|
||||
export npm_config_loglevel=error
|
||||
export npm_config_fund=false
|
||||
export npm_config_audit=false
|
||||
export OPENAI_API_KEY="sk-openclaw-release-user-journey"
|
||||
export OPENCLAW_GATEWAY_TOKEN="release-user-journey-token"
|
||||
export CLICKCLACK_BOT_TOKEN="clickclack-release-token"
|
||||
|
||||
PORT="18789"
|
||||
MOCK_PORT="44180"
|
||||
CLICKCLACK_PORT="44181"
|
||||
SUCCESS_MARKER="OPENCLAW_E2E_OK_RELEASE_USER_JOURNEY"
|
||||
scenario_tmp="$(mktemp -d "${TMPDIR:-/tmp}/openclaw-release-user-journey.XXXXXX")"
|
||||
LOG_DIR="$scenario_tmp/logs"
|
||||
mkdir -p "$LOG_DIR"
|
||||
INSTALL_LOG="$LOG_DIR/install.log"
|
||||
ONBOARD_LOG="$LOG_DIR/onboard.log"
|
||||
OPENAI_LOG="$LOG_DIR/openai.log"
|
||||
AGENT_LOG="$LOG_DIR/agent.log"
|
||||
PLUGIN_A_INSTALL_LOG="$LOG_DIR/plugin-a-install.log"
|
||||
PLUGIN_A_CLI_LOG="$LOG_DIR/plugin-a-cli.log"
|
||||
PLUGIN_A_UNINSTALL_LOG="$LOG_DIR/plugin-a-uninstall.log"
|
||||
PLUGIN_B_INSTALL_LOG="$LOG_DIR/plugin-b-install.log"
|
||||
PLUGIN_B_CLI_LOG="$LOG_DIR/plugin-b-cli.log"
|
||||
PLUGIN_B_AFTER_RESTART_JSON="$LOG_DIR/plugin-b-after-restart.json"
|
||||
CLICKCLACK_PLUGIN_INSTALL_LOG="$LOG_DIR/clickclack-plugin-install.log"
|
||||
CLICKCLACK_SERVER_LOG="$LOG_DIR/clickclack-server.log"
|
||||
CLICKCLACK_OUTBOUND_JSON="$LOG_DIR/clickclack-outbound.json"
|
||||
CLICKCLACK_OUTBOUND_ERR="$LOG_DIR/clickclack-outbound.err"
|
||||
GATEWAY_1_LOG="$LOG_DIR/gateway-1.log"
|
||||
GATEWAY_2_LOG="$LOG_DIR/gateway-2.log"
|
||||
STATUS_JSON="$LOG_DIR/status.json"
|
||||
STATUS_ERR="$LOG_DIR/status.err"
|
||||
STATUS_AFTER_RESTART_JSON="$LOG_DIR/status-after-restart.json"
|
||||
STATUS_AFTER_RESTART_ERR="$LOG_DIR/status-after-restart.err"
|
||||
DOCTOR_LOG="$LOG_DIR/doctor.log"
|
||||
PLUGIN_A_INSTALL_PATH_FILE="$scenario_tmp/plugin-a-install-path.txt"
|
||||
PLUGIN_A_SOURCE_PATH_FILE="$scenario_tmp/plugin-a-source-path.txt"
|
||||
MOCK_REQUEST_LOG="$scenario_tmp/openai-requests.jsonl"
|
||||
CLICKCLACK_STATE="$scenario_tmp/clickclack.json"
|
||||
export SUCCESS_MARKER MOCK_REQUEST_LOG CLICKCLACK_STATE
|
||||
|
||||
mock_pid=""
|
||||
clickclack_pid=""
|
||||
gateway_pid=""
|
||||
|
||||
cleanup() {
|
||||
openclaw_e2e_terminate_gateways "${gateway_pid:-}"
|
||||
openclaw_e2e_stop_process "${clickclack_pid:-}"
|
||||
openclaw_e2e_stop_process "${mock_pid:-}"
|
||||
rm -rf "$scenario_tmp"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
dump_debug_logs() {
|
||||
local status="$1"
|
||||
echo "release user journey failed with exit code $status" >&2
|
||||
openclaw_e2e_dump_logs \
|
||||
"$INSTALL_LOG" \
|
||||
"$ONBOARD_LOG" \
|
||||
"$OPENAI_LOG" \
|
||||
"$MOCK_REQUEST_LOG" \
|
||||
"$AGENT_LOG" \
|
||||
"$PLUGIN_A_INSTALL_LOG" \
|
||||
"$PLUGIN_A_CLI_LOG" \
|
||||
"$PLUGIN_A_UNINSTALL_LOG" \
|
||||
"$PLUGIN_B_INSTALL_LOG" \
|
||||
"$PLUGIN_B_CLI_LOG" \
|
||||
"$CLICKCLACK_PLUGIN_INSTALL_LOG" \
|
||||
"$CLICKCLACK_SERVER_LOG" \
|
||||
"$CLICKCLACK_OUTBOUND_JSON" \
|
||||
"$GATEWAY_1_LOG" \
|
||||
"$GATEWAY_2_LOG" \
|
||||
"$STATUS_JSON" \
|
||||
"$STATUS_AFTER_RESTART_JSON" \
|
||||
"$DOCTOR_LOG" \
|
||||
"$CLICKCLACK_STATE"
|
||||
}
|
||||
trap 'status=$?; dump_debug_logs "$status"; exit "$status"' ERR
|
||||
|
||||
start_gateway() {
|
||||
local log_path="$1"
|
||||
gateway_pid="$(openclaw_e2e_start_gateway "$entry" "$PORT" "$log_path")"
|
||||
openclaw_e2e_wait_gateway_ready "$gateway_pid" "$log_path" 300 "$PORT"
|
||||
}
|
||||
|
||||
stop_gateway() {
|
||||
openclaw_e2e_terminate_gateways "${gateway_pid:-}"
|
||||
gateway_pid=""
|
||||
}
|
||||
|
||||
write_journey_plugin() {
|
||||
local dir="$1"
|
||||
local id="$2"
|
||||
local version="$3"
|
||||
local method="$4"
|
||||
local name="$5"
|
||||
local cli_root="$6"
|
||||
local cli_output="$7"
|
||||
|
||||
mkdir -p "$dir"
|
||||
node - "$dir" "$id" "$version" "$method" "$name" "$cli_root" "$cli_output" <<'NODE'
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const [dir, id, version, method, name, cliRoot, cliOutput] = process.argv.slice(2);
|
||||
fs.writeFileSync(
|
||||
path.join(dir, "package.json"),
|
||||
`${JSON.stringify(
|
||||
{
|
||||
name: `@openclaw/${id}`,
|
||||
version,
|
||||
openclaw: { extensions: ["./index.js"] },
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(dir, "index.js"),
|
||||
`module.exports = { id: ${JSON.stringify(id)}, name: ${JSON.stringify(name)}, register(api) { api.registerGatewayMethod(${JSON.stringify(method)}, async () => ({ ok: true })); api.registerCli(({ program }) => { const root = program.command(${JSON.stringify(cliRoot)}).description(${JSON.stringify(`${name} fixture command`)}); root.command("ping").description("Print fixture ping output").action(() => { console.log(${JSON.stringify(cliOutput)}); }); }, { descriptors: [{ name: ${JSON.stringify(cliRoot)}, description: ${JSON.stringify(`${name} fixture command`)}, hasSubcommands: true }] }); }, };\n`,
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(dir, "openclaw.plugin.json"),
|
||||
`${JSON.stringify({ id, configSchema: { type: "object", properties: {} } }, null, 2)}\n`,
|
||||
);
|
||||
NODE
|
||||
}
|
||||
|
||||
openclaw_e2e_install_package "$INSTALL_LOG"
|
||||
command -v openclaw >/dev/null
|
||||
package_root="$(openclaw_e2e_package_root)"
|
||||
entry="$(openclaw_e2e_package_entrypoint "$package_root")"
|
||||
openclaw_e2e_enable_openclaw_cli_timeout
|
||||
|
||||
mock_pid="$(openclaw_e2e_start_mock_openai "$MOCK_PORT" "$OPENAI_LOG")"
|
||||
openclaw_e2e_wait_mock_openai "$MOCK_PORT"
|
||||
|
||||
CLICKCLACK_FIXTURE_PORT="$CLICKCLACK_PORT" \
|
||||
CLICKCLACK_FIXTURE_TOKEN="$CLICKCLACK_BOT_TOKEN" \
|
||||
CLICKCLACK_FIXTURE_STATE="$CLICKCLACK_STATE" \
|
||||
node scripts/e2e/lib/release-user-journey/clickclack-fixture.mjs >"$CLICKCLACK_SERVER_LOG" 2>&1 &
|
||||
clickclack_pid="$!"
|
||||
for _ in $(seq 1 100); do
|
||||
if openclaw_e2e_probe_http_status "http://127.0.0.1:$CLICKCLACK_PORT/health" 200 >/dev/null 2>&1; then
|
||||
break
|
||||
fi
|
||||
sleep 0.1
|
||||
done
|
||||
openclaw_e2e_probe_http_status "http://127.0.0.1:$CLICKCLACK_PORT/health" 200
|
||||
|
||||
echo "Running non-interactive onboarding..."
|
||||
openclaw onboard \
|
||||
--non-interactive \
|
||||
--accept-risk \
|
||||
--flow quickstart \
|
||||
--mode local \
|
||||
--auth-choice skip \
|
||||
--gateway-port "$PORT" \
|
||||
--gateway-bind loopback \
|
||||
--skip-daemon \
|
||||
--skip-ui \
|
||||
--skip-channels \
|
||||
--skip-skills \
|
||||
--skip-health >"$ONBOARD_LOG" 2>&1
|
||||
node scripts/e2e/lib/release-user-journey/assertions.mjs assert-onboard "$HOME"
|
||||
node scripts/e2e/lib/release-user-journey/assertions.mjs configure-mock-model "$MOCK_PORT"
|
||||
|
||||
echo "Running package-installed agent turn..."
|
||||
openclaw agent --local \
|
||||
--agent main \
|
||||
--session-id release-user-journey-agent \
|
||||
--message "Return marker $SUCCESS_MARKER" \
|
||||
--thinking off \
|
||||
--json >"$AGENT_LOG" 2>&1
|
||||
node scripts/e2e/lib/release-user-journey/assertions.mjs assert-agent-turn "$SUCCESS_MARKER" "$AGENT_LOG" "$MOCK_REQUEST_LOG"
|
||||
|
||||
echo "Installing first external plugin..."
|
||||
plugin_a_dir="$(mktemp -d "$scenario_tmp/plugin-a.XXXXXX")"
|
||||
plugin_a_install_path_file="$PLUGIN_A_INSTALL_PATH_FILE"
|
||||
plugin_a_source_path_file="$PLUGIN_A_SOURCE_PATH_FILE"
|
||||
write_journey_plugin "$plugin_a_dir" journey-plugin-a 0.0.1 journey.a "Journey Plugin A" journey-a "journey-plugin-a:pong"
|
||||
openclaw plugins install "$plugin_a_dir" >"$PLUGIN_A_INSTALL_LOG" 2>&1
|
||||
node scripts/e2e/lib/release-user-journey/assertions.mjs \
|
||||
remember-plugin-install-path \
|
||||
journey-plugin-a \
|
||||
"$plugin_a_install_path_file" \
|
||||
"$plugin_a_source_path_file" \
|
||||
"$plugin_a_dir"
|
||||
openclaw journey-a ping >"$PLUGIN_A_CLI_LOG" 2>&1
|
||||
node scripts/e2e/lib/release-user-journey/assertions.mjs assert-file-contains "$PLUGIN_A_CLI_LOG" "journey-plugin-a:pong"
|
||||
|
||||
echo "Uninstalling first external plugin..."
|
||||
openclaw plugins uninstall journey-plugin-a --force >"$PLUGIN_A_UNINSTALL_LOG" 2>&1
|
||||
node scripts/e2e/lib/release-user-journey/assertions.mjs \
|
||||
assert-plugin-uninstalled \
|
||||
journey-plugin-a \
|
||||
"$plugin_a_install_path_file" \
|
||||
"$plugin_a_source_path_file"
|
||||
|
||||
echo "Installing replacement external plugin..."
|
||||
plugin_b_dir="$(mktemp -d "$scenario_tmp/plugin-b.XXXXXX")"
|
||||
write_journey_plugin "$plugin_b_dir" journey-plugin-b 0.0.1 journey.b "Journey Plugin B" journey-b "journey-plugin-b:pong"
|
||||
openclaw plugins install "$plugin_b_dir" >"$PLUGIN_B_INSTALL_LOG" 2>&1
|
||||
openclaw journey-b ping >"$PLUGIN_B_CLI_LOG" 2>&1
|
||||
node scripts/e2e/lib/release-user-journey/assertions.mjs assert-file-contains "$PLUGIN_B_CLI_LOG" "journey-plugin-b:pong"
|
||||
|
||||
echo "Installing ClickClack fixture plugin..."
|
||||
clickclack_plugin_dir="$(mktemp -d "$scenario_tmp/clickclack-plugin.XXXXXX")"
|
||||
node scripts/e2e/lib/release-user-journey/write-clickclack-plugin.mjs "$clickclack_plugin_dir"
|
||||
openclaw plugins install "$clickclack_plugin_dir" >"$CLICKCLACK_PLUGIN_INSTALL_LOG" 2>&1
|
||||
|
||||
echo "Configuring ClickClack..."
|
||||
node scripts/e2e/lib/release-user-journey/assertions.mjs configure-clickclack "http://127.0.0.1:$CLICKCLACK_PORT"
|
||||
openclaw channels status --json >"$STATUS_JSON" 2>"$STATUS_ERR"
|
||||
node scripts/e2e/lib/release-user-journey/assertions.mjs assert-channel-status clickclack "$STATUS_JSON"
|
||||
|
||||
echo "Sending ClickClack outbound message..."
|
||||
openclaw message send \
|
||||
--channel clickclack \
|
||||
--target channel:general \
|
||||
--message "release journey outbound" \
|
||||
--json >"$CLICKCLACK_OUTBOUND_JSON" 2>"$CLICKCLACK_OUTBOUND_ERR"
|
||||
node scripts/e2e/lib/release-user-journey/assertions.mjs assert-clickclack-state outbound "$CLICKCLACK_STATE" "release journey outbound"
|
||||
|
||||
echo "Starting Gateway for ClickClack inbound..."
|
||||
start_gateway "$GATEWAY_1_LOG"
|
||||
node scripts/e2e/lib/release-user-journey/assertions.mjs wait-clickclack-socket "http://127.0.0.1:$CLICKCLACK_PORT" 45
|
||||
node scripts/e2e/lib/release-user-journey/assertions.mjs post-clickclack-inbound "http://127.0.0.1:$CLICKCLACK_PORT" "Return marker $SUCCESS_MARKER"
|
||||
node scripts/e2e/lib/release-user-journey/assertions.mjs wait-clickclack-reply "$CLICKCLACK_STATE" "$SUCCESS_MARKER" 45
|
||||
|
||||
echo "Restarting Gateway and checking state survival..."
|
||||
stop_gateway
|
||||
start_gateway "$GATEWAY_2_LOG"
|
||||
openclaw plugins inspect journey-plugin-b --runtime --json >"$PLUGIN_B_AFTER_RESTART_JSON" 2>&1
|
||||
openclaw channels status --json >"$STATUS_AFTER_RESTART_JSON" 2>"$STATUS_AFTER_RESTART_ERR"
|
||||
node scripts/e2e/lib/release-user-journey/assertions.mjs assert-channel-status clickclack "$STATUS_AFTER_RESTART_JSON"
|
||||
node scripts/e2e/lib/release-user-journey/assertions.mjs assert-file-contains "$PLUGIN_B_AFTER_RESTART_JSON" "journey-plugin-b"
|
||||
stop_gateway
|
||||
|
||||
echo "Running doctor at end of release journey..."
|
||||
openclaw doctor --repair --non-interactive >"$DOCTOR_LOG" 2>&1
|
||||
|
||||
echo "Release user journey scenario passed."
|
||||
427
scripts/e2e/lib/release-user-journey/write-clickclack-plugin.mjs
Normal file
427
scripts/e2e/lib/release-user-journey/write-clickclack-plugin.mjs
Normal file
@@ -0,0 +1,427 @@
|
||||
#!/usr/bin/env node
|
||||
// Writes the external ClickClack channel fixture used by release journey E2Es.
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const pluginDir = process.argv[2];
|
||||
if (!pluginDir) {
|
||||
console.error("usage: write-clickclack-plugin.mjs <plugin-dir>");
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
fs.mkdirSync(pluginDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(pluginDir, "package.json"),
|
||||
`${JSON.stringify(
|
||||
{
|
||||
name: "clickclack",
|
||||
version: "0.0.1",
|
||||
type: "module",
|
||||
openclaw: { extensions: ["./index.mjs"] },
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(pluginDir, "openclaw.plugin.json"),
|
||||
`${JSON.stringify(
|
||||
{
|
||||
id: "clickclack",
|
||||
activation: { onStartup: false },
|
||||
channels: ["clickclack"],
|
||||
channelEnvVars: { clickclack: ["CLICKCLACK_BOT_TOKEN"] },
|
||||
channelConfigs: {
|
||||
clickclack: {
|
||||
schema: {
|
||||
type: "object",
|
||||
additionalProperties: true,
|
||||
properties: {
|
||||
enabled: { type: "boolean", default: true },
|
||||
baseUrl: { type: "string" },
|
||||
workspace: { type: "string" },
|
||||
defaultTo: { type: "string" },
|
||||
token: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
configSchema: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
properties: {},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
);
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(pluginDir, "index.mjs"),
|
||||
`import crypto from "node:crypto";
|
||||
import net from "node:net";
|
||||
|
||||
const CHANNEL_ID = "clickclack";
|
||||
const DEFAULT_ACCOUNT_ID = "default";
|
||||
|
||||
function configFor(cfg) {
|
||||
return cfg?.channels?.clickclack ?? {};
|
||||
}
|
||||
|
||||
function readToken(raw) {
|
||||
if (typeof raw === "string") {
|
||||
return raw.trim();
|
||||
}
|
||||
if (raw && typeof raw === "object" && raw.source === "env" && typeof raw.id === "string") {
|
||||
return String(process.env[raw.id] ?? "").trim();
|
||||
}
|
||||
return String(process.env.CLICKCLACK_BOT_TOKEN ?? "").trim();
|
||||
}
|
||||
|
||||
function resolveAccount(cfg, accountId = DEFAULT_ACCOUNT_ID) {
|
||||
const config = configFor(cfg);
|
||||
const token = readToken(config.token);
|
||||
const baseUrl = typeof config.baseUrl === "string" ? config.baseUrl : "";
|
||||
return {
|
||||
accountId: accountId ?? DEFAULT_ACCOUNT_ID,
|
||||
enabled: config.enabled !== false,
|
||||
configured: Boolean(baseUrl && token),
|
||||
baseUrl,
|
||||
token,
|
||||
workspace: typeof config.workspace === "string" && config.workspace ? config.workspace : "release",
|
||||
defaultTo: typeof config.defaultTo === "string" ? config.defaultTo : "channel:general",
|
||||
reconnectMs: Number.isFinite(config.reconnectMs) ? Math.max(50, Number(config.reconnectMs)) : 250,
|
||||
};
|
||||
}
|
||||
|
||||
async function requestJson(account, method, pathname, body) {
|
||||
const response = await fetch(new URL(pathname, account.baseUrl), {
|
||||
method,
|
||||
headers: {
|
||||
authorization: \`Bearer \${account.token}\`,
|
||||
...(body == null ? {} : { "content-type": "application/json" }),
|
||||
},
|
||||
...(body == null ? {} : { body: JSON.stringify(body) }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(\`ClickClack fixture \${response.status}: \${await response.text()}\`);
|
||||
}
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
async function resolveWorkspaceId(account) {
|
||||
const data = await requestJson(account, "GET", "/api/workspaces");
|
||||
const workspaces = Array.isArray(data.workspaces) ? data.workspaces : [];
|
||||
const match = workspaces.find((workspace) =>
|
||||
workspace?.id === account.workspace ||
|
||||
workspace?.slug === account.workspace ||
|
||||
workspace?.name === account.workspace
|
||||
);
|
||||
if (!match?.id) {
|
||||
throw new Error(\`ClickClack workspace not found: \${account.workspace}\`);
|
||||
}
|
||||
return match.id;
|
||||
}
|
||||
|
||||
async function resolveChannelId(account, workspaceId, rawTarget) {
|
||||
const target = String(rawTarget ?? "").trim();
|
||||
const channelName = target.startsWith("channel:") ? target.slice("channel:".length) : target;
|
||||
const data = await requestJson(account, "GET", \`/api/workspaces/\${encodeURIComponent(workspaceId)}/channels\`);
|
||||
const channels = Array.isArray(data.channels) ? data.channels : [];
|
||||
const match = channels.find((channel) => channel?.id === channelName || channel?.name === channelName);
|
||||
if (!match?.id) {
|
||||
throw new Error(\`ClickClack channel not found: \${channelName}\`);
|
||||
}
|
||||
return match.id;
|
||||
}
|
||||
|
||||
async function sendText(cfg, to, text, accountId, threadId, replyToId) {
|
||||
const account = resolveAccount(cfg, accountId);
|
||||
if (!account.configured) {
|
||||
throw new Error("ClickClack is not configured");
|
||||
}
|
||||
const workspaceId = await resolveWorkspaceId(account);
|
||||
const rootId = threadId == null ? String(replyToId ?? "") : String(threadId);
|
||||
if (rootId) {
|
||||
const data = await requestJson(
|
||||
account,
|
||||
"POST",
|
||||
\`/api/messages/\${encodeURIComponent(rootId)}/thread/replies\`,
|
||||
{ body: text },
|
||||
);
|
||||
return data.message;
|
||||
}
|
||||
const channelId = await resolveChannelId(account, workspaceId, to);
|
||||
const data = await requestJson(account, "POST", \`/api/channels/\${encodeURIComponent(channelId)}/messages\`, {
|
||||
body: text,
|
||||
});
|
||||
return data.message;
|
||||
}
|
||||
|
||||
function decodeFrame(buffer) {
|
||||
if (buffer.length < 2) {
|
||||
return null;
|
||||
}
|
||||
const opcode = buffer[0] & 0x0f;
|
||||
let length = buffer[1] & 0x7f;
|
||||
let offset = 2;
|
||||
if (length === 126) {
|
||||
if (buffer.length < 4) {
|
||||
return null;
|
||||
}
|
||||
length = buffer.readUInt16BE(2);
|
||||
offset = 4;
|
||||
} else if (length === 127) {
|
||||
if (buffer.length < 10) {
|
||||
return null;
|
||||
}
|
||||
length = Number(buffer.readBigUInt64BE(2));
|
||||
offset = 10;
|
||||
}
|
||||
if (buffer.length < offset + length) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
opcode,
|
||||
text: buffer.subarray(offset, offset + length).toString("utf8"),
|
||||
rest: buffer.subarray(offset + length),
|
||||
};
|
||||
}
|
||||
|
||||
function openEventSocket(account, workspaceId, afterCursor, onEvent, signal) {
|
||||
const base = new URL(account.baseUrl);
|
||||
const key = crypto.randomBytes(16).toString("base64");
|
||||
const socket = net.createConnection({
|
||||
host: base.hostname,
|
||||
port: Number(base.port || (base.protocol === "https:" ? 443 : 80)),
|
||||
});
|
||||
let buffer = Buffer.alloc(0);
|
||||
let upgraded = false;
|
||||
const close = () => socket.destroy();
|
||||
signal.addEventListener("abort", close, { once: true });
|
||||
socket.on("connect", () => {
|
||||
const query = new URLSearchParams({ workspace_id: workspaceId });
|
||||
if (afterCursor) {
|
||||
query.set("after_cursor", afterCursor);
|
||||
}
|
||||
socket.write(
|
||||
[
|
||||
\`GET /api/realtime/ws?\${query.toString()} HTTP/1.1\`,
|
||||
\`Host: \${base.host}\`,
|
||||
"Upgrade: websocket",
|
||||
"Connection: Upgrade",
|
||||
\`Sec-WebSocket-Key: \${key}\`,
|
||||
"Sec-WebSocket-Version: 13",
|
||||
\`Authorization: Bearer \${account.token}\`,
|
||||
"",
|
||||
"",
|
||||
].join("\\r\\n"),
|
||||
);
|
||||
});
|
||||
socket.on("data", (chunk) => {
|
||||
buffer = Buffer.concat([buffer, chunk]);
|
||||
if (!upgraded) {
|
||||
const headerEnd = buffer.indexOf("\\r\\n\\r\\n");
|
||||
if (headerEnd === -1) {
|
||||
return;
|
||||
}
|
||||
const headers = buffer.subarray(0, headerEnd).toString("utf8");
|
||||
if (!headers.startsWith("HTTP/1.1 101")) {
|
||||
socket.destroy(new Error(headers.split("\\r\\n")[0] || "websocket upgrade failed"));
|
||||
return;
|
||||
}
|
||||
upgraded = true;
|
||||
buffer = buffer.subarray(headerEnd + 4);
|
||||
}
|
||||
for (;;) {
|
||||
const frame = decodeFrame(buffer);
|
||||
if (!frame) {
|
||||
return;
|
||||
}
|
||||
buffer = frame.rest;
|
||||
if (frame.opcode === 1) {
|
||||
onEvent(JSON.parse(frame.text));
|
||||
} else if (frame.opcode === 8) {
|
||||
socket.end();
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
socket.on("close", () => signal.removeEventListener("abort", close));
|
||||
return socket;
|
||||
}
|
||||
|
||||
async function resolveEventMessage(account, event) {
|
||||
if (event?.type !== "message.created" || !event.channel_id || typeof event.seq !== "number") {
|
||||
return null;
|
||||
}
|
||||
const data = await requestJson(
|
||||
account,
|
||||
"GET",
|
||||
\`/api/channels/\${encodeURIComponent(event.channel_id)}/messages?after_seq=\${Math.max(0, event.seq - 1)}\`,
|
||||
);
|
||||
const messages = Array.isArray(data.messages) ? data.messages : [];
|
||||
return messages.find((message) => message?.id === event.payload?.message_id) ?? null;
|
||||
}
|
||||
|
||||
async function dispatchInbound(ctx, account, message) {
|
||||
const runtime = ctx.channelRuntime;
|
||||
if (!runtime) {
|
||||
throw new Error("ClickClack fixture requires channel runtime");
|
||||
}
|
||||
const target = \`channel:\${message.channel_id}\`;
|
||||
const route = runtime.routing.resolveAgentRoute({
|
||||
cfg: ctx.cfg,
|
||||
channel: CHANNEL_ID,
|
||||
accountId: account.accountId,
|
||||
peer: { kind: "channel", id: target },
|
||||
});
|
||||
const storePath = runtime.session.resolveStorePath(ctx.cfg.session?.store, {
|
||||
agentId: route.agentId,
|
||||
});
|
||||
const previousTimestamp = runtime.session.readSessionUpdatedAt({
|
||||
storePath,
|
||||
sessionKey: route.sessionKey,
|
||||
});
|
||||
const senderName = message.author?.display_name || message.author_id || "Release User";
|
||||
const body = runtime.reply.formatAgentEnvelope({
|
||||
channel: "ClickClack",
|
||||
from: senderName,
|
||||
timestamp: new Date(message.created_at),
|
||||
previousTimestamp,
|
||||
envelope: runtime.reply.resolveEnvelopeFormatOptions(ctx.cfg),
|
||||
body: message.body,
|
||||
});
|
||||
const ctxPayload = runtime.reply.finalizeInboundContext({
|
||||
Body: body,
|
||||
BodyForAgent: message.body,
|
||||
RawBody: message.body,
|
||||
CommandBody: message.body,
|
||||
From: target,
|
||||
To: target,
|
||||
SessionKey: route.sessionKey,
|
||||
AccountId: route.accountId ?? account.accountId,
|
||||
ChatType: "group",
|
||||
WasMentioned: true,
|
||||
ConversationLabel: message.channel_id,
|
||||
GroupChannel: message.channel_id,
|
||||
NativeChannelId: message.channel_id,
|
||||
MessageSid: message.id,
|
||||
MessageSidFull: message.id,
|
||||
ReplyToId: message.id,
|
||||
Timestamp: message.created_at,
|
||||
OriginatingChannel: CHANNEL_ID,
|
||||
OriginatingTo: target,
|
||||
CommandAuthorized: true,
|
||||
});
|
||||
await runtime.reply.dispatchReplyWithBufferedBlockDispatcher({
|
||||
ctx: ctxPayload,
|
||||
cfg: ctx.cfg,
|
||||
dispatcherOptions: {
|
||||
deliver: async (payload) => {
|
||||
const text = payload && typeof payload === "object" ? String(payload.text ?? "") : "";
|
||||
if (text.trim()) {
|
||||
await sendText(ctx.cfg, target, text, account.accountId, message.id, message.id);
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
throw error instanceof Error ? error : new Error(String(error));
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const clickclackPlugin = {
|
||||
id: CHANNEL_ID,
|
||||
meta: {
|
||||
id: CHANNEL_ID,
|
||||
label: "ClickClack",
|
||||
selectionLabel: "ClickClack",
|
||||
docsPath: "/channels/clickclack",
|
||||
blurb: "Release journey ClickClack fixture.",
|
||||
},
|
||||
capabilities: { chatTypes: ["group"], threads: true },
|
||||
config: {
|
||||
listAccountIds: () => [DEFAULT_ACCOUNT_ID],
|
||||
defaultAccountId: () => DEFAULT_ACCOUNT_ID,
|
||||
resolveAccount,
|
||||
isConfigured: (account) => account.configured,
|
||||
isEnabled: (account) => account.enabled,
|
||||
resolveDefaultTo: ({ cfg }) => resolveAccount(cfg).defaultTo,
|
||||
},
|
||||
status: {
|
||||
buildChannelSummary: ({ snapshot }) => ({
|
||||
ok: snapshot.configured === true,
|
||||
label: snapshot.configured ? "configured" : "missing config",
|
||||
detail: snapshot.baseUrl ?? "",
|
||||
}),
|
||||
buildAccountSnapshot: ({ account }) => ({
|
||||
accountId: account.accountId,
|
||||
enabled: account.enabled,
|
||||
configured: account.configured,
|
||||
baseUrl: account.baseUrl,
|
||||
}),
|
||||
},
|
||||
outbound: {
|
||||
deliveryMode: "direct",
|
||||
sendText: async (ctx) => {
|
||||
const message = await sendText(ctx.cfg, ctx.to, ctx.text, ctx.accountId, ctx.threadId, ctx.replyToId);
|
||||
return { channel: CHANNEL_ID, messageId: message.id };
|
||||
},
|
||||
},
|
||||
gateway: {
|
||||
startAccount: async (ctx) => {
|
||||
const account = resolveAccount(ctx.cfg, ctx.account.accountId);
|
||||
if (!account.configured) {
|
||||
throw new Error("ClickClack is not configured");
|
||||
}
|
||||
const workspaceId = await resolveWorkspaceId(account);
|
||||
ctx.setStatus({
|
||||
accountId: account.accountId,
|
||||
running: true,
|
||||
configured: true,
|
||||
enabled: account.enabled,
|
||||
baseUrl: account.baseUrl,
|
||||
});
|
||||
try {
|
||||
while (!ctx.abortSignal.aborted) {
|
||||
const socket = openEventSocket(
|
||||
account,
|
||||
workspaceId,
|
||||
"",
|
||||
(event) => {
|
||||
void (async () => {
|
||||
const message = await resolveEventMessage(account, event);
|
||||
if (message && message.author?.kind !== "bot") {
|
||||
await dispatchInbound(ctx, account, message);
|
||||
}
|
||||
})().catch((error) => {
|
||||
ctx.log?.error?.(error instanceof Error ? error.message : String(error));
|
||||
});
|
||||
},
|
||||
ctx.abortSignal,
|
||||
);
|
||||
await new Promise((resolve) => {
|
||||
socket.once("close", resolve);
|
||||
socket.once("error", resolve);
|
||||
});
|
||||
if (!ctx.abortSignal.aborted) {
|
||||
await new Promise((resolve) => setTimeout(resolve, account.reconnectMs));
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
ctx.setStatus({ accountId: account.accountId, running: false });
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default {
|
||||
id: CHANNEL_ID,
|
||||
register(api) {
|
||||
api.registerChannel({ plugin: clickclackPlugin });
|
||||
},
|
||||
};
|
||||
`,
|
||||
);
|
||||
229
scripts/e2e/lib/run-with-pty.mjs
Normal file
229
scripts/e2e/lib/run-with-pty.mjs
Normal file
@@ -0,0 +1,229 @@
|
||||
#!/usr/bin/env node
|
||||
import { spawnSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import process from "node:process";
|
||||
import { spawn as spawnPty } from "@lydell/node-pty";
|
||||
import { readPositiveIntEnv } from "./env-limits.mjs";
|
||||
|
||||
const [logPath, command, ...args] = process.argv.slice(2);
|
||||
const OUTPUT_MAX_BYTES = readPositiveIntEnv("OPENCLAW_E2E_PTY_OUTPUT_MAX_BYTES", 16 * 1024 * 1024);
|
||||
const FORCE_KILL_MS = readPositiveIntEnv("OPENCLAW_E2E_PTY_FORCE_KILL_MS", 5_000);
|
||||
|
||||
if (!logPath || !command) {
|
||||
console.error("usage: run-with-pty.mjs <log-path> <command> [args...]");
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
let exiting = false;
|
||||
let forwardedSignal = null;
|
||||
let forceKillTimer = null;
|
||||
let terminationDrainTimer = null;
|
||||
let terminationPids = [];
|
||||
let pendingExitCode = null;
|
||||
let logFailed = false;
|
||||
const outputLimitMarker = `\n[run-with-pty output truncated after ${OUTPUT_MAX_BYTES} bytes]\n`;
|
||||
const outputState = {
|
||||
bytes: 0,
|
||||
truncated: false,
|
||||
};
|
||||
|
||||
const log = fs.createWriteStream(logPath, { flags: "w" });
|
||||
const pty = spawnPty(command, args, {
|
||||
name: process.env.TERM || "xterm-256color",
|
||||
cols: readPositiveIntEnv("COLUMNS", 120),
|
||||
rows: readPositiveIntEnv("LINES", 40),
|
||||
cwd: process.cwd(),
|
||||
env: process.env,
|
||||
});
|
||||
|
||||
log.on("error", (error) => {
|
||||
if (logFailed) {
|
||||
return;
|
||||
}
|
||||
logFailed = true;
|
||||
console.error(`run-with-pty transcript log failed: ${error.message}`);
|
||||
if (exiting) {
|
||||
process.exit(1);
|
||||
}
|
||||
if (!exiting) {
|
||||
terminatePtyTree("SIGTERM");
|
||||
}
|
||||
});
|
||||
|
||||
function writeCappedOutput(data) {
|
||||
if (outputState.truncated) {
|
||||
return;
|
||||
}
|
||||
const buffer = Buffer.from(data);
|
||||
const remainingBytes = OUTPUT_MAX_BYTES - outputState.bytes;
|
||||
if (buffer.byteLength <= remainingBytes) {
|
||||
outputState.bytes += buffer.byteLength;
|
||||
if (!logFailed) {
|
||||
log.write(buffer);
|
||||
}
|
||||
process.stdout.write(buffer);
|
||||
return;
|
||||
}
|
||||
if (remainingBytes > 0) {
|
||||
const head = buffer.subarray(0, remainingBytes);
|
||||
if (!logFailed) {
|
||||
log.write(head);
|
||||
}
|
||||
process.stdout.write(head);
|
||||
}
|
||||
outputState.bytes = OUTPUT_MAX_BYTES;
|
||||
outputState.truncated = true;
|
||||
if (!logFailed) {
|
||||
log.write(outputLimitMarker);
|
||||
}
|
||||
process.stdout.write(outputLimitMarker);
|
||||
}
|
||||
|
||||
pty.onData((data) => {
|
||||
writeCappedOutput(data);
|
||||
});
|
||||
|
||||
pty.onExit(({ exitCode, signal }) => {
|
||||
exiting = true;
|
||||
if (terminationPids.length === 0) {
|
||||
clearTerminationTimers();
|
||||
}
|
||||
if (logFailed) {
|
||||
exitWhenTerminationDrains(1);
|
||||
return;
|
||||
}
|
||||
log.end(() => {
|
||||
if (forwardedSignal) {
|
||||
exitWhenTerminationDrains(signalExitCode(forwardedSignal));
|
||||
return;
|
||||
}
|
||||
if (typeof exitCode === "number") {
|
||||
exitWhenTerminationDrains(exitCode);
|
||||
return;
|
||||
}
|
||||
exitWhenTerminationDrains(signal ? 128 + signal : 1);
|
||||
});
|
||||
});
|
||||
|
||||
process.stdin.on("data", (chunk) => {
|
||||
pty.write(chunk.toString("utf8"));
|
||||
});
|
||||
|
||||
for (const signal of ["SIGHUP", "SIGINT", "SIGTERM"]) {
|
||||
process.on(signal, () => {
|
||||
if (!exiting) {
|
||||
forwardedSignal ??= signal;
|
||||
terminatePtyTree(signal);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function terminatePtyTree(signal) {
|
||||
// node-pty kill() targets only pty.pid on Unix; wrapper-owned shutdowns
|
||||
// keep the captured child tree alive until ignored descendants drain.
|
||||
if (terminationPids.length === 0) {
|
||||
terminationPids = collectPtyProcessTreePids();
|
||||
}
|
||||
signalPtyProcessTree(signal);
|
||||
forceKillTimer ??= setTimeout(() => {
|
||||
signalPtyProcessTree("SIGKILL");
|
||||
}, FORCE_KILL_MS);
|
||||
forceKillTimer.unref?.();
|
||||
}
|
||||
|
||||
function exitWhenTerminationDrains(exitCode) {
|
||||
pendingExitCode = exitCode;
|
||||
if (processTreeIsAlive(terminationPids)) {
|
||||
terminationDrainTimer ??= setInterval(finishIfTerminationDrained, 25);
|
||||
return;
|
||||
}
|
||||
finishIfTerminationDrained();
|
||||
}
|
||||
|
||||
function finishIfTerminationDrained() {
|
||||
if (processTreeIsAlive(terminationPids)) {
|
||||
return;
|
||||
}
|
||||
clearTerminationTimers();
|
||||
process.exit(pendingExitCode ?? 1);
|
||||
}
|
||||
|
||||
function clearTerminationTimers() {
|
||||
if (forceKillTimer) {
|
||||
clearTimeout(forceKillTimer);
|
||||
forceKillTimer = null;
|
||||
}
|
||||
if (terminationDrainTimer) {
|
||||
clearInterval(terminationDrainTimer);
|
||||
terminationDrainTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function collectPtyProcessTreePids() {
|
||||
if (process.platform === "win32" || typeof pty.pid !== "number") {
|
||||
return typeof pty.pid === "number" ? [pty.pid] : [];
|
||||
}
|
||||
const ps = spawnSync("ps", ["-axo", "pid=,ppid="], { encoding: "utf8" });
|
||||
if (ps.status !== 0) {
|
||||
return [pty.pid];
|
||||
}
|
||||
const childrenByParent = new Map();
|
||||
for (const line of ps.stdout.split("\n")) {
|
||||
const match = line.trim().match(/^(\d+)\s+(\d+)$/u);
|
||||
if (!match) {
|
||||
continue;
|
||||
}
|
||||
const pid = Number(match[1]);
|
||||
const ppid = Number(match[2]);
|
||||
const siblings = childrenByParent.get(ppid) ?? [];
|
||||
siblings.push(pid);
|
||||
childrenByParent.set(ppid, siblings);
|
||||
}
|
||||
const pids = [pty.pid];
|
||||
for (const parentPid of pids) {
|
||||
for (const pid of childrenByParent.get(parentPid) ?? []) {
|
||||
pids.push(pid);
|
||||
}
|
||||
}
|
||||
return [...new Set(pids)];
|
||||
}
|
||||
|
||||
function processTreeIsAlive(pids) {
|
||||
return pids.some((pid) => {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch (error) {
|
||||
return error?.code === "EPERM";
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function signalPtyProcessTree(signal) {
|
||||
if (process.platform === "win32" || terminationPids.length === 0) {
|
||||
pty.kill(signal);
|
||||
return;
|
||||
}
|
||||
for (const pid of terminationPids.toReversed()) {
|
||||
try {
|
||||
process.kill(pid, signal);
|
||||
} catch (error) {
|
||||
if (error?.code !== "ESRCH") {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function signalExitCode(signal) {
|
||||
switch (signal) {
|
||||
case "SIGHUP":
|
||||
return 129;
|
||||
case "SIGINT":
|
||||
return 130;
|
||||
case "SIGTERM":
|
||||
return 143;
|
||||
default:
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
152
scripts/e2e/lib/session-log-mentions.ts
Normal file
152
scripts/e2e/lib/session-log-mentions.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
// Session Log Mentions script supports OpenClaw repository automation.
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { readPositiveIntEnv } from "./env-limits.mjs";
|
||||
|
||||
export type SessionLogMentionLimits = {
|
||||
fileMaxBytes: number;
|
||||
totalMaxBytes: number;
|
||||
};
|
||||
|
||||
export type SessionLogNeedles = Record<string, string>;
|
||||
|
||||
const DEFAULT_FILE_MAX_BYTES = 4 * 1024 * 1024;
|
||||
const DEFAULT_TOTAL_MAX_BYTES = 16 * 1024 * 1024;
|
||||
|
||||
export function readSessionLogMentionLimits(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): SessionLogMentionLimits {
|
||||
return {
|
||||
fileMaxBytes: readPositiveIntEnv(
|
||||
"OPENCLAW_SESSION_LOG_MENTION_FILE_MAX_BYTES",
|
||||
DEFAULT_FILE_MAX_BYTES,
|
||||
env,
|
||||
),
|
||||
totalMaxBytes: readPositiveIntEnv(
|
||||
"OPENCLAW_SESSION_LOG_MENTION_TOTAL_MAX_BYTES",
|
||||
DEFAULT_TOTAL_MAX_BYTES,
|
||||
env,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function taggedError(message: string, code: string) {
|
||||
return Object.assign(new Error(message), { code });
|
||||
}
|
||||
|
||||
function countOccurrences(haystack: string, needle: string): number {
|
||||
if (!needle) {
|
||||
return 0;
|
||||
}
|
||||
let count = 0;
|
||||
let offset = 0;
|
||||
for (;;) {
|
||||
const next = haystack.indexOf(needle, offset);
|
||||
if (next < 0) {
|
||||
return count;
|
||||
}
|
||||
count += 1;
|
||||
offset = next + needle.length;
|
||||
}
|
||||
}
|
||||
|
||||
function createCounts(needles: SessionLogNeedles): Record<string, number> {
|
||||
return Object.fromEntries(Object.keys(needles).map((key) => [key, 0]));
|
||||
}
|
||||
|
||||
function recordRole(record: unknown): string | undefined {
|
||||
if (!record || typeof record !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
const candidate = record as { message?: unknown; role?: unknown };
|
||||
if (typeof candidate.role === "string") {
|
||||
return candidate.role;
|
||||
}
|
||||
if (!candidate.message || typeof candidate.message !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
const message = candidate.message as { role?: unknown };
|
||||
return typeof message.role === "string" ? message.role : undefined;
|
||||
}
|
||||
|
||||
function shouldScanSessionLogLine(line: string): boolean {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return recordRole(JSON.parse(trimmed)) !== "user";
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function assertWithinLimit(params: {
|
||||
byteCount: number;
|
||||
filePath?: string;
|
||||
label: string;
|
||||
limit: number;
|
||||
}) {
|
||||
if (params.byteCount <= params.limit) {
|
||||
return;
|
||||
}
|
||||
const source = params.filePath ? ` ${params.filePath}` : "";
|
||||
throw taggedError(
|
||||
`session log mention scan exceeded ${params.label} limit${source}: ${params.byteCount} > ${params.limit}`,
|
||||
"ETOOBIG",
|
||||
);
|
||||
}
|
||||
|
||||
export async function countSessionLogMentions(params: {
|
||||
limits?: SessionLogMentionLimits;
|
||||
needles: SessionLogNeedles;
|
||||
sessionsDir: string;
|
||||
}): Promise<Record<string, number>> {
|
||||
const limits = params.limits ?? readSessionLogMentionLimits();
|
||||
const counts = createCounts(params.needles);
|
||||
let files: string[];
|
||||
try {
|
||||
files = await fs.readdir(params.sessionsDir);
|
||||
} catch {
|
||||
return counts;
|
||||
}
|
||||
|
||||
let totalBytes = 0;
|
||||
for (const file of files.filter((candidate) => candidate.endsWith(".jsonl")).toSorted()) {
|
||||
const filePath = path.join(params.sessionsDir, file);
|
||||
const stat = await fs.stat(filePath).catch(() => null);
|
||||
if (!stat?.isFile()) {
|
||||
continue;
|
||||
}
|
||||
assertWithinLimit({
|
||||
byteCount: stat.size,
|
||||
filePath,
|
||||
label: "per-file",
|
||||
limit: limits.fileMaxBytes,
|
||||
});
|
||||
totalBytes += stat.size;
|
||||
assertWithinLimit({
|
||||
byteCount: totalBytes,
|
||||
label: "total",
|
||||
limit: limits.totalMaxBytes,
|
||||
});
|
||||
|
||||
const raw = await fs.readFile(filePath, "utf8").catch(() => "");
|
||||
const actualBytes = Buffer.byteLength(raw, "utf8");
|
||||
assertWithinLimit({
|
||||
byteCount: actualBytes,
|
||||
filePath,
|
||||
label: "per-file",
|
||||
limit: limits.fileMaxBytes,
|
||||
});
|
||||
for (const line of raw.split(/\r?\n/u)) {
|
||||
if (!shouldScanSessionLogLine(line)) {
|
||||
continue;
|
||||
}
|
||||
for (const [key, needle] of Object.entries(params.needles)) {
|
||||
counts[key] += countOccurrences(line, needle);
|
||||
}
|
||||
}
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
161
scripts/e2e/lib/skills/clawhub-install-proof.sh
Normal file
161
scripts/e2e/lib/skills/clawhub-install-proof.sh
Normal file
@@ -0,0 +1,161 @@
|
||||
#!/usr/bin/env bash
|
||||
# Live ClawHub skill install proof for package-backed Docker/Testbox lanes.
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../../.." && pwd)"
|
||||
cd "$ROOT_DIR"
|
||||
|
||||
source "$ROOT_DIR/scripts/lib/openclaw-e2e-instance.sh"
|
||||
|
||||
OPENCLAW_TEST_STATE_SCRIPT_B64="${OPENCLAW_TEST_STATE_SCRIPT_B64:-}"
|
||||
openclaw_skill_install_owns_home=0
|
||||
cleanup_clawhub_skill_install_home() {
|
||||
if [ "$openclaw_skill_install_owns_home" = "1" ] && [ -n "${HOME:-}" ]; then
|
||||
rm -rf "$HOME"
|
||||
fi
|
||||
}
|
||||
trap cleanup_clawhub_skill_install_home EXIT
|
||||
|
||||
if [ -n "$OPENCLAW_TEST_STATE_SCRIPT_B64" ]; then
|
||||
openclaw_e2e_eval_test_state_from_b64 "$OPENCLAW_TEST_STATE_SCRIPT_B64"
|
||||
else
|
||||
export HOME="$(mktemp -d "${TMPDIR:-/tmp}/openclaw-skill-install-home.XXXXXX")"
|
||||
openclaw_skill_install_owns_home=1
|
||||
export USERPROFILE="$HOME"
|
||||
export OPENCLAW_HOME="$HOME"
|
||||
export OPENCLAW_STATE_DIR="$HOME/.openclaw"
|
||||
export OPENCLAW_CONFIG_PATH="$OPENCLAW_STATE_DIR/openclaw.json"
|
||||
mkdir -p "$OPENCLAW_STATE_DIR"
|
||||
fi
|
||||
|
||||
if [ -n "${OPENCLAW_CURRENT_PACKAGE_TGZ:-}" ]; then
|
||||
export NPM_CONFIG_PREFIX="${NPM_CONFIG_PREFIX:-$HOME/.npm-global}"
|
||||
export PATH="$NPM_CONFIG_PREFIX/bin:$PATH"
|
||||
openclaw_e2e_install_package /tmp/openclaw-skill-install-npm.log
|
||||
fi
|
||||
|
||||
if [ -n "${OPENCLAW_CURRENT_PACKAGE_TGZ:-}" ] && command -v openclaw >/dev/null 2>&1; then
|
||||
OPENCLAW_CMD=(openclaw)
|
||||
elif command -v pnpm >/dev/null 2>&1 && [ -f package.json ]; then
|
||||
if [ "${OPENCLAW_SKILL_INSTALL_E2E_BUILD_SOURCE:-0}" = "1" ]; then
|
||||
pnpm build >/tmp/openclaw-skill-install-build.log 2>&1
|
||||
fi
|
||||
OPENCLAW_CMD=(pnpm --silent openclaw)
|
||||
elif command -v openclaw >/dev/null 2>&1; then
|
||||
OPENCLAW_CMD=(openclaw)
|
||||
else
|
||||
echo "openclaw command not found; install package first or run from repo with pnpm" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$(dirname "$OPENCLAW_CONFIG_PATH")"
|
||||
node --input-type=module - "$OPENCLAW_CONFIG_PATH" <<'NODE'
|
||||
import fs from "node:fs";
|
||||
const configPath = process.argv[2];
|
||||
let config = {};
|
||||
try {
|
||||
config = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
||||
} catch {}
|
||||
config.skills ??= {};
|
||||
config.skills.install ??= {};
|
||||
config.skills.install.allowUploadedArchives = false;
|
||||
fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`);
|
||||
NODE
|
||||
|
||||
query="${OPENCLAW_SKILL_INSTALL_E2E_QUERY:-homeassistant}"
|
||||
requested_slug="${OPENCLAW_SKILL_INSTALL_E2E_SLUG:-}"
|
||||
preferred_slug="${OPENCLAW_SKILL_INSTALL_E2E_PREFERRED_SLUG:-homeassistant-skill}"
|
||||
search_json="/tmp/openclaw-skill-install-search.json"
|
||||
resolve_json="/tmp/openclaw-skill-install-resolved.json"
|
||||
install_log="/tmp/openclaw-skill-install.log"
|
||||
info_json="/tmp/openclaw-skill-install-info.json"
|
||||
|
||||
echo "Searching live ClawHub skills for: $query"
|
||||
"${OPENCLAW_CMD[@]}" skills search "$query" --limit 8 --json >"$search_json"
|
||||
|
||||
node --input-type=module - "$search_json" "$resolve_json" "$requested_slug" "$preferred_slug" <<'NODE'
|
||||
import fs from "node:fs";
|
||||
const [searchPath, resolvePath, requestedSlug, preferredSlug] = process.argv.slice(2);
|
||||
const payload = JSON.parse(fs.readFileSync(searchPath, "utf8"));
|
||||
const results = Array.isArray(payload) ? payload : Array.isArray(payload.results) ? payload.results : [];
|
||||
const slugs = results.map((entry) => String(entry.slug ?? "")).filter(Boolean);
|
||||
let chosen;
|
||||
if (requestedSlug) {
|
||||
chosen = results.find((entry) => entry.slug === requestedSlug);
|
||||
if (!chosen) {
|
||||
throw new Error(`Requested skill slug ${requestedSlug} not found. Search returned: ${slugs.join(", ") || "(none)"}`);
|
||||
}
|
||||
} else {
|
||||
chosen =
|
||||
results.find((entry) => entry.slug === preferredSlug) ??
|
||||
results.find((entry) => String(entry.slug ?? "").includes("homeassistant")) ??
|
||||
results[0];
|
||||
}
|
||||
if (!chosen?.slug) {
|
||||
throw new Error(`No installable skill slug found. Search returned: ${slugs.join(", ") || "(none)"}`);
|
||||
}
|
||||
fs.writeFileSync(resolvePath, `${JSON.stringify({
|
||||
slug: chosen.slug,
|
||||
version: chosen.version ?? null,
|
||||
displayName: chosen.displayName ?? chosen.name ?? chosen.slug,
|
||||
})}\n`);
|
||||
NODE
|
||||
|
||||
slug="$(node -e 'process.stdout.write(JSON.parse(require("node:fs").readFileSync(process.argv[1], "utf8")).slug)' "$resolve_json")"
|
||||
echo "Installing live ClawHub skill: $slug"
|
||||
if ! "${OPENCLAW_CMD[@]}" skills install "$slug" --force >"$install_log" 2>&1; then
|
||||
echo "Skill install failed" >&2
|
||||
openclaw_e2e_dump_logs /tmp/openclaw-skill-install-npm.log "$search_json" "$resolve_json" "$install_log"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
workspace_dir="$HOME/.openclaw/workspace"
|
||||
skill_dir="$workspace_dir/skills/$slug"
|
||||
origin_json="$skill_dir/.clawhub/origin.json"
|
||||
lock_json="$workspace_dir/.clawhub/lock.json"
|
||||
|
||||
openclaw_e2e_assert_file "$skill_dir/SKILL.md"
|
||||
openclaw_e2e_assert_file "$origin_json"
|
||||
openclaw_e2e_assert_file "$lock_json"
|
||||
|
||||
"${OPENCLAW_CMD[@]}" skills info "$slug" --json >"$info_json"
|
||||
|
||||
node --input-type=module - "$OPENCLAW_CONFIG_PATH" "$skill_dir" "$origin_json" "$lock_json" "$info_json" "$slug" <<'NODE'
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
const [configPath, skillDir, originPath, lockPath, infoPath, slug] = process.argv.slice(2);
|
||||
const read = (file) => JSON.parse(fs.readFileSync(file, "utf8"));
|
||||
function isPathInside(parentPath, childPath) {
|
||||
const relative = path.relative(path.resolve(parentPath), path.resolve(childPath));
|
||||
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
||||
}
|
||||
const config = read(configPath);
|
||||
if (config.skills?.install?.allowUploadedArchives !== false) {
|
||||
throw new Error("skills.install.allowUploadedArchives must remain false during ClawHub install proof");
|
||||
}
|
||||
const origin = read(originPath);
|
||||
if (origin.slug !== slug || origin.registry !== "https://clawhub.ai" || !origin.installedVersion) {
|
||||
throw new Error(`Unexpected origin metadata: ${JSON.stringify(origin)}`);
|
||||
}
|
||||
const lock = read(lockPath);
|
||||
if (lock.skills?.[slug]?.version !== origin.installedVersion) {
|
||||
throw new Error(`Lockfile missing ${slug}@${origin.installedVersion}`);
|
||||
}
|
||||
const info = read(infoPath);
|
||||
const infoFilePath = info.filePath ?? info.skill?.filePath;
|
||||
const infoBaseDir = info.baseDir ?? info.skill?.baseDir;
|
||||
if (
|
||||
info.skillKey !== slug &&
|
||||
(!infoFilePath || !isPathInside(skillDir, infoFilePath))
|
||||
) {
|
||||
throw new Error(`skills info did not report installed skill ${slug}: ${JSON.stringify(info)}`);
|
||||
}
|
||||
if (infoBaseDir && path.resolve(infoBaseDir) !== path.resolve(skillDir)) {
|
||||
throw new Error(`skills info reported unexpected baseDir: ${infoBaseDir}`);
|
||||
}
|
||||
const skillText = fs.readFileSync(path.join(skillDir, "SKILL.md"), "utf8");
|
||||
if (!/^name:\s*/m.test(skillText)) {
|
||||
throw new Error("Installed SKILL.md is missing frontmatter name");
|
||||
}
|
||||
process.stdout.write(`E2E_OK installed=${slug} version=${origin.installedVersion} uploadArchives=false\n`);
|
||||
NODE
|
||||
60
scripts/e2e/lib/temp-state-dir.ts
Normal file
60
scripts/e2e/lib/temp-state-dir.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
// Temp State Dir script supports OpenClaw repository automation.
|
||||
import { rmSync } from "node:fs";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
const cleanupSignals = ["SIGINT", "SIGTERM", "SIGHUP"] as const;
|
||||
|
||||
type CleanupSignal = (typeof cleanupSignals)[number];
|
||||
|
||||
export type E2eStateDir = {
|
||||
stateDir: string;
|
||||
created: boolean;
|
||||
cleanup: () => void;
|
||||
registerExitCleanup: () => void;
|
||||
};
|
||||
|
||||
export async function createE2eStateDir(prefix: string, env = process.env): Promise<E2eStateDir> {
|
||||
const configuredStateDir = env.OPENCLAW_STATE_DIR?.trim();
|
||||
const created = !configuredStateDir;
|
||||
const stateDir = configuredStateDir || (await fs.mkdtemp(path.join(os.tmpdir(), prefix)));
|
||||
const signalHandlers = new Map<CleanupSignal, () => void>();
|
||||
let cleaned = false;
|
||||
let cleanupRegistered = false;
|
||||
|
||||
const cleanup = () => {
|
||||
if (created && !cleaned) {
|
||||
rmSync(stateDir, { force: true, recursive: true });
|
||||
cleaned = true;
|
||||
}
|
||||
};
|
||||
|
||||
const unregisterSignalCleanup = () => {
|
||||
for (const [signal, handler] of signalHandlers) {
|
||||
process.removeListener(signal, handler);
|
||||
}
|
||||
signalHandlers.clear();
|
||||
};
|
||||
|
||||
const registerExitCleanup = () => {
|
||||
if (!created || cleanupRegistered) {
|
||||
return;
|
||||
}
|
||||
cleanupRegistered = true;
|
||||
process.once("exit", cleanup);
|
||||
for (const signal of cleanupSignals) {
|
||||
const handleSignal = () => {
|
||||
cleanup();
|
||||
unregisterSignalCleanup();
|
||||
if (process.listenerCount(signal) === 0) {
|
||||
process.kill(process.pid, signal);
|
||||
}
|
||||
};
|
||||
signalHandlers.set(signal, handleSignal);
|
||||
process.once(signal, handleSignal);
|
||||
}
|
||||
};
|
||||
|
||||
return { stateDir, created, cleanup, registerExitCleanup };
|
||||
}
|
||||
72
scripts/e2e/lib/text-file-utils.mjs
Normal file
72
scripts/e2e/lib/text-file-utils.mjs
Normal file
@@ -0,0 +1,72 @@
|
||||
// Text file tail helpers for E2E assertions.
|
||||
import fs from "node:fs";
|
||||
|
||||
export function tailText(text, maxBytes) {
|
||||
if (Buffer.byteLength(text, "utf8") <= maxBytes) {
|
||||
return text;
|
||||
}
|
||||
return Buffer.from(text, "utf8").subarray(-maxBytes).toString("utf8");
|
||||
}
|
||||
|
||||
export function readTextFileTail(file, maxBytes) {
|
||||
let stat;
|
||||
try {
|
||||
stat = fs.statSync(file);
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
if (!stat.isFile() || stat.size <= 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const length = Math.min(maxBytes, stat.size);
|
||||
const start = stat.size - length;
|
||||
let fd;
|
||||
try {
|
||||
fd = fs.openSync(file, "r");
|
||||
const buffer = Buffer.alloc(length);
|
||||
const bytesRead = fs.readSync(fd, buffer, 0, length, start);
|
||||
return buffer.subarray(0, bytesRead).toString("utf8");
|
||||
} catch {
|
||||
return "";
|
||||
} finally {
|
||||
if (fd !== undefined) {
|
||||
try {
|
||||
fs.closeSync(fd);
|
||||
} catch {
|
||||
// Tail diagnostics are best-effort; callers may be preserving a richer error.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function textFileTooLargeError(message) {
|
||||
return Object.assign(new Error(message), { code: "ETOOBIG" });
|
||||
}
|
||||
|
||||
export function readTextFileBounded(file, label, maxBytes, options = {}) {
|
||||
const tailBytes = options.tailBytes ?? 16 * 1024;
|
||||
const stat = fs.statSync(file);
|
||||
if (!stat.isFile()) {
|
||||
throw new Error(`${label} is not a file: ${file}`);
|
||||
}
|
||||
if (stat.size > maxBytes) {
|
||||
throw textFileTooLargeError(
|
||||
`${label} exceeded ${maxBytes} bytes: ${file} (${stat.size} bytes). Tail: ${readTextFileTail(
|
||||
file,
|
||||
tailBytes,
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
const text = fs.readFileSync(file, "utf8");
|
||||
const bytes = Buffer.byteLength(text, "utf8");
|
||||
if (bytes > maxBytes) {
|
||||
throw textFileTooLargeError(
|
||||
`${label} exceeded ${maxBytes} bytes: ${file} (${bytes} bytes). Tail: ${readTextFileTail(
|
||||
file,
|
||||
tailBytes,
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
return text;
|
||||
}
|
||||
217
scripts/e2e/lib/update-channel-switch/assertions.mjs
Normal file
217
scripts/e2e/lib/update-channel-switch/assertions.mjs
Normal file
@@ -0,0 +1,217 @@
|
||||
// Assertions for update-channel switch E2E scenarios.
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { legacyPackageAcceptanceCompat } from "../package-compat.mjs";
|
||||
|
||||
const [command, ...args] = process.argv.slice(2);
|
||||
const controlUiHtml = "<!doctype html><title>fixture</title>\n";
|
||||
|
||||
function usage() {
|
||||
console.error(
|
||||
"usage: assertions.mjs <prepare-git-fixture|write-control-ui|assert-update|assert-config-channel|assert-status-kind> [...]",
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
function readJson(file) {
|
||||
return JSON.parse(fs.readFileSync(file, "utf8"));
|
||||
}
|
||||
|
||||
// Runs inside the bare Docker E2E image, before package dependencies are installed.
|
||||
// Keep this to the small pnpm-workspace.yaml surface the fixture mutates.
|
||||
function findTopLevelBlock(lines, key) {
|
||||
const start = lines.findIndex((line) => new RegExp(`^${key}:\\s*(?:#.*)?$`).test(line));
|
||||
if (start === -1) {
|
||||
return null;
|
||||
}
|
||||
let end = start + 1;
|
||||
while (end < lines.length && !/^[A-Za-z0-9_-]+:\s*/.test(lines[end])) {
|
||||
end += 1;
|
||||
}
|
||||
return { start, end };
|
||||
}
|
||||
|
||||
function parseYamlScalar(raw) {
|
||||
const trimmed = raw.trim();
|
||||
const withoutComment = trimmed.replace(/\s+#.*$/, "");
|
||||
if (withoutComment.startsWith('"') && withoutComment.endsWith('"')) {
|
||||
return withoutComment.slice(1, -1);
|
||||
}
|
||||
if (withoutComment.startsWith("'") && withoutComment.endsWith("'")) {
|
||||
return withoutComment.slice(1, -1);
|
||||
}
|
||||
return withoutComment;
|
||||
}
|
||||
|
||||
function readWorkspacePatchedDependencies(file) {
|
||||
const lines = fs.readFileSync(file, "utf8").split("\n");
|
||||
const block = findTopLevelBlock(lines, "patchedDependencies");
|
||||
if (!block) {
|
||||
return { patches: undefined };
|
||||
}
|
||||
|
||||
const patches = {};
|
||||
for (const line of lines.slice(block.start + 1, block.end)) {
|
||||
const match = line.match(/^\s+(.+?):\s+(.+?)\s*$/);
|
||||
if (!match) {
|
||||
continue;
|
||||
}
|
||||
patches[parseYamlScalar(match[1])] = parseYamlScalar(match[2]);
|
||||
}
|
||||
return { patches };
|
||||
}
|
||||
|
||||
function writeWorkspacePnpmConfig(file, keptPatches) {
|
||||
const original = fs.readFileSync(file, "utf8");
|
||||
const hadTrailingNewline = original.endsWith("\n");
|
||||
const lines = original.replace(/\n$/, "").split("\n");
|
||||
const patchBlock = findTopLevelBlock(lines, "patchedDependencies");
|
||||
|
||||
if (patchBlock) {
|
||||
const nextLines = [];
|
||||
nextLines.push(...lines.slice(0, patchBlock.start));
|
||||
if (Object.keys(keptPatches).length > 0) {
|
||||
nextLines.push("patchedDependencies:");
|
||||
for (const [dependency, patchFile] of Object.entries(keptPatches)) {
|
||||
nextLines.push(` ${JSON.stringify(dependency)}: ${JSON.stringify(patchFile)}`);
|
||||
}
|
||||
}
|
||||
nextLines.push(...lines.slice(patchBlock.end));
|
||||
lines.length = 0;
|
||||
lines.push(...nextLines);
|
||||
}
|
||||
|
||||
const allowUnusedIndex = lines.findIndex((line) => /^allowUnusedPatches:\s*/.test(line));
|
||||
if (allowUnusedIndex === -1) {
|
||||
lines.push("allowUnusedPatches: true");
|
||||
} else {
|
||||
lines[allowUnusedIndex] = "allowUnusedPatches: true";
|
||||
}
|
||||
|
||||
const minimumReleaseAgeIndex = lines.findIndex((line) => /^minimumReleaseAge:\s*/.test(line));
|
||||
if (minimumReleaseAgeIndex === -1) {
|
||||
lines.push("minimumReleaseAge: 0");
|
||||
} else {
|
||||
lines[minimumReleaseAgeIndex] = "minimumReleaseAge: 0";
|
||||
}
|
||||
|
||||
fs.writeFileSync(file, `${lines.join("\n")}${hadTrailingNewline ? "\n" : ""}`);
|
||||
}
|
||||
|
||||
function writeControlUi(root) {
|
||||
const file = path.join(root, "dist", "control-ui", "index.html");
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
fs.writeFileSync(file, controlUiHtml);
|
||||
}
|
||||
|
||||
function prepareGitFixture(root) {
|
||||
const packageJsonPath = path.join(root, "package.json");
|
||||
const packageJson = readJson(packageJsonPath);
|
||||
const pnpmWorkspacePath = path.join(root, "pnpm-workspace.yaml");
|
||||
const workspaceConfig = fs.existsSync(pnpmWorkspacePath)
|
||||
? readWorkspacePatchedDependencies(pnpmWorkspacePath)
|
||||
: undefined;
|
||||
const pnpmConfig = workspaceConfig ? {} : { ...packageJson.pnpm };
|
||||
const patches = workspaceConfig?.patches ?? pnpmConfig.patchedDependencies;
|
||||
const keptPatches = {};
|
||||
if (patches && typeof patches === "object" && !Array.isArray(patches)) {
|
||||
const missing = [];
|
||||
for (const [dependency, patchFile] of Object.entries(patches)) {
|
||||
const exists =
|
||||
typeof patchFile === "string" &&
|
||||
fs.existsSync(path.resolve(path.dirname(packageJsonPath), patchFile));
|
||||
if (exists) {
|
||||
keptPatches[dependency] = patchFile;
|
||||
} else {
|
||||
missing.push(`${dependency} -> ${String(patchFile)}`);
|
||||
}
|
||||
}
|
||||
if (missing.length > 0 && !legacyPackageAcceptanceCompat(packageJson.version)) {
|
||||
throw new Error(
|
||||
`package ${packageJson.version} has missing pnpm patchedDependencies in package fixture: ${missing.join(", ")}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (workspaceConfig) {
|
||||
writeWorkspacePnpmConfig(pnpmWorkspacePath, keptPatches);
|
||||
} else {
|
||||
pnpmConfig.allowUnusedPatches = true;
|
||||
pnpmConfig.minimumReleaseAge = 0;
|
||||
if (Object.keys(keptPatches).length > 0) {
|
||||
pnpmConfig.patchedDependencies = keptPatches;
|
||||
} else {
|
||||
delete pnpmConfig.patchedDependencies;
|
||||
}
|
||||
packageJson.pnpm = pnpmConfig;
|
||||
}
|
||||
const fixtureUiBuildSource = `const fs=require("node:fs");fs.mkdirSync("dist/control-ui",{recursive:true});fs.writeFileSync("dist/control-ui/index.html",${JSON.stringify(controlUiHtml)})`;
|
||||
packageJson.scripts = {
|
||||
...packageJson.scripts,
|
||||
build: 'node -e "console.log(\\"fixture build skipped\\")"',
|
||||
lint: 'node -e "console.log(\\"fixture lint skipped\\")"',
|
||||
"ui:build": `node -e ${JSON.stringify(fixtureUiBuildSource)}`,
|
||||
};
|
||||
fs.writeFileSync(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`);
|
||||
writeControlUi(root);
|
||||
}
|
||||
|
||||
function assertUpdate(channel) {
|
||||
const payload = JSON.parse(process.env.UPDATE_JSON ?? "");
|
||||
if (payload.status !== "ok") {
|
||||
throw new Error(`expected ${channel} update status ok, got ${payload.status}`);
|
||||
}
|
||||
if (channel === "dev" && payload.mode !== "git") {
|
||||
throw new Error(`expected dev update mode git, got ${payload.mode}`);
|
||||
}
|
||||
if (channel === "stable" && !["npm", "pnpm", "bun"].includes(payload.mode)) {
|
||||
throw new Error(`expected package-manager mode after stable switch, got ${payload.mode}`);
|
||||
}
|
||||
if (payload.postUpdate?.plugins && payload.postUpdate.plugins.status !== "ok") {
|
||||
throw new Error(
|
||||
`expected plugin post-update ok, got ${JSON.stringify(payload.postUpdate?.plugins)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function assertConfigChannel(channel) {
|
||||
const config = readJson(path.join(process.env.HOME, ".openclaw", "openclaw.json"));
|
||||
if (config.update?.channel === channel) {
|
||||
return;
|
||||
}
|
||||
if (process.env.OPENCLAW_PACKAGE_ACCEPTANCE_LEGACY_COMPAT === "1") {
|
||||
console.log(
|
||||
`legacy package did not persist update.channel ${channel}; got ${JSON.stringify(config.update?.channel)}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
throw new Error(
|
||||
`expected persisted update.channel ${channel}, got ${JSON.stringify(config.update?.channel)}`,
|
||||
);
|
||||
}
|
||||
|
||||
function assertStatusKind(kind) {
|
||||
const payload = JSON.parse(process.env.STATUS_JSON ?? "");
|
||||
if (payload.update?.installKind !== kind) {
|
||||
throw new Error(`expected ${kind} install after switch, got ${payload.update?.installKind}`);
|
||||
}
|
||||
}
|
||||
|
||||
switch (command) {
|
||||
case "prepare-git-fixture":
|
||||
prepareGitFixture(args[0] ?? "/tmp/openclaw-git");
|
||||
break;
|
||||
case "write-control-ui":
|
||||
writeControlUi(args[0] ?? "/tmp/openclaw-git");
|
||||
break;
|
||||
case "assert-update":
|
||||
assertUpdate(args[0]);
|
||||
break;
|
||||
case "assert-config-channel":
|
||||
assertConfigChannel(args[0]);
|
||||
break;
|
||||
case "assert-status-kind":
|
||||
assertStatusKind(args[0]);
|
||||
break;
|
||||
default:
|
||||
usage();
|
||||
}
|
||||
671
scripts/e2e/lib/upgrade-survivor/assertions.mjs
Normal file
671
scripts/e2e/lib/upgrade-survivor/assertions.mjs
Normal file
@@ -0,0 +1,671 @@
|
||||
// Assertions for upgrade-survivor E2E scenarios.
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
import { readPluginInstallIndex } from "../plugin-index-sqlite.mjs";
|
||||
|
||||
const command = process.argv[2];
|
||||
const SCENARIOS = new Set([
|
||||
"base",
|
||||
"acpx-openclaw-tools-bridge",
|
||||
"feishu-channel",
|
||||
"bootstrap-persona",
|
||||
"channel-post-core-restore",
|
||||
"plugin-deps-cleanup",
|
||||
"configured-plugin-installs",
|
||||
"stale-source-plugin-shadow",
|
||||
"tilde-log-path",
|
||||
"versioned-runtime-deps",
|
||||
]);
|
||||
|
||||
const PERSONA_FILES = new Map([
|
||||
["BOOTSTRAP.md", "# Existing Bootstrap\n\nDo not overwrite me during update.\n"],
|
||||
["SOUL.md", "# Existing Soul\n\nKeep this voice intact.\n"],
|
||||
["USER.md", "# Existing User\n\nPrefers survivor tests.\n"],
|
||||
["MEMORY.md", "# Existing Memory\n\nUpgrade reports came from real users.\n"],
|
||||
]);
|
||||
|
||||
const LEGACY_SESSION_MAIN_ID = "upgrade-main-session";
|
||||
const LEGACY_SESSION_DIRECT_ID = "upgrade-direct-session";
|
||||
const LEGACY_SESSION_GROUP_ID = "upgrade-group-session";
|
||||
|
||||
function requireEnv(name) {
|
||||
const value = process.env[name];
|
||||
if (!value) {
|
||||
throw new Error(`${name} is required`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function readJson(file) {
|
||||
return JSON.parse(fs.readFileSync(file, "utf8"));
|
||||
}
|
||||
|
||||
function resolveHomePath(value) {
|
||||
if (typeof value !== "string" || value.length === 0) {
|
||||
return "";
|
||||
}
|
||||
if (value === "~") {
|
||||
return process.env.HOME || value;
|
||||
}
|
||||
if (value.startsWith("~/")) {
|
||||
return path.join(process.env.HOME || "", value.slice(2));
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function isPathInside(parent, child) {
|
||||
const relative = path.relative(parent, child);
|
||||
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
||||
}
|
||||
|
||||
function isPathInsideManagedNpmProjectPackageRoot(params) {
|
||||
const relative = path.relative(path.join(params.stateDir, "npm", "projects"), params.installPath);
|
||||
if (relative.startsWith("..") || path.isAbsolute(relative)) {
|
||||
return false;
|
||||
}
|
||||
const segments = relative.split(path.sep);
|
||||
const packageSegments = params.packageName.split("/");
|
||||
return (
|
||||
segments.length === 2 + packageSegments.length &&
|
||||
Boolean(segments[0]) &&
|
||||
segments[1] === "node_modules" &&
|
||||
packageSegments.every((segment, index) => segments[index + 2] === segment)
|
||||
);
|
||||
}
|
||||
|
||||
function write(file, contents) {
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
fs.writeFileSync(file, contents);
|
||||
}
|
||||
|
||||
function writeJson(file, value) {
|
||||
write(file, `${JSON.stringify(value, null, 2)}\n`);
|
||||
}
|
||||
|
||||
function assert(condition, message) {
|
||||
if (!condition) {
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
function seedLegacySessionMetadata(stateDir) {
|
||||
const legacySessionsDir = path.join(stateDir, "sessions");
|
||||
writeJson(path.join(legacySessionsDir, "sessions.json"), {
|
||||
main: {
|
||||
sessionId: LEGACY_SESSION_MAIN_ID,
|
||||
sessionFile: path.join(legacySessionsDir, `${LEGACY_SESSION_MAIN_ID}.jsonl`),
|
||||
provider: "openai",
|
||||
model: "gpt-5.5",
|
||||
updatedAt: 1710000000000,
|
||||
skillsSnapshot: {
|
||||
prompt: "legacy prompt survives as metadata",
|
||||
resolvedSkills: [
|
||||
{
|
||||
name: "legacy-heavy-skill-cache",
|
||||
filePath: "/tmp/openclaw-old-package/skills/legacy-heavy-skill-cache/SKILL.md",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
"+15551234567": {
|
||||
sessionId: LEGACY_SESSION_DIRECT_ID,
|
||||
sessionFile: path.join(legacySessionsDir, `${LEGACY_SESSION_DIRECT_ID}.jsonl`),
|
||||
provider: "openai",
|
||||
model: "gpt-5.5",
|
||||
updatedAt: 1710000000100,
|
||||
},
|
||||
"slack:channel:CUPGRADE": {
|
||||
sessionId: LEGACY_SESSION_GROUP_ID,
|
||||
sessionFile: path.join(legacySessionsDir, `${LEGACY_SESSION_GROUP_ID}.jsonl`),
|
||||
provider: "openai",
|
||||
model: "gpt-5.5",
|
||||
updatedAt: 1710000000200,
|
||||
lastChannel: "slack",
|
||||
lastTo: "CUPGRADE",
|
||||
},
|
||||
});
|
||||
for (const sessionId of [
|
||||
LEGACY_SESSION_MAIN_ID,
|
||||
LEGACY_SESSION_DIRECT_ID,
|
||||
LEGACY_SESSION_GROUP_ID,
|
||||
]) {
|
||||
write(
|
||||
path.join(legacySessionsDir, `${sessionId}.jsonl`),
|
||||
`${JSON.stringify({ type: "session", id: sessionId })}\n`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function getScenario() {
|
||||
const scenario = process.env.OPENCLAW_UPGRADE_SURVIVOR_SCENARIO || "base";
|
||||
assert(SCENARIOS.has(scenario), `unknown upgrade survivor scenario: ${scenario}`);
|
||||
return scenario;
|
||||
}
|
||||
|
||||
function getConfig() {
|
||||
return readJson(requireEnv("OPENCLAW_CONFIG_PATH"));
|
||||
}
|
||||
|
||||
function getCoverage() {
|
||||
const file = process.env.OPENCLAW_UPGRADE_SURVIVOR_CONFIG_COVERAGE_JSON;
|
||||
if (!file || !fs.existsSync(file)) {
|
||||
return null;
|
||||
}
|
||||
return readJson(file);
|
||||
}
|
||||
|
||||
function acceptsIntent(coverage, id) {
|
||||
if (!coverage) {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
Array.isArray(coverage.acceptedIntents) &&
|
||||
coverage.acceptedIntents.includes(id) &&
|
||||
!coverage.skippedIntents?.includes(id)
|
||||
);
|
||||
}
|
||||
|
||||
function hasCoverage(coverage) {
|
||||
return Boolean(coverage);
|
||||
}
|
||||
|
||||
function seedState() {
|
||||
const stateDir = requireEnv("OPENCLAW_STATE_DIR");
|
||||
const workspace = requireEnv("OPENCLAW_TEST_WORKSPACE_DIR");
|
||||
const scenario = getScenario();
|
||||
|
||||
write(
|
||||
path.join(workspace, "IDENTITY.md"),
|
||||
"# Upgrade Survivor\n\nThis workspace must survive package update and doctor repair.\n",
|
||||
);
|
||||
if (scenario === "bootstrap-persona") {
|
||||
for (const [fileName, contents] of PERSONA_FILES) {
|
||||
write(path.join(workspace, fileName), contents);
|
||||
}
|
||||
}
|
||||
writeJson(path.join(workspace, ".openclaw", "workspace-state.json"), {
|
||||
version: 1,
|
||||
setupCompletedAt: "2026-04-01T00:00:00.000Z",
|
||||
});
|
||||
writeJson(path.join(stateDir, "agents", "main", "sessions", "legacy-session.json"), {
|
||||
id: "legacy-session",
|
||||
agentId: "main",
|
||||
title: "Existing user session",
|
||||
});
|
||||
seedLegacySessionMetadata(stateDir);
|
||||
|
||||
const runtimeRoot = path.join(stateDir, "plugin-runtime-deps");
|
||||
for (const plugin of ["discord", "telegram", "whatsapp"]) {
|
||||
writeJson(path.join(runtimeRoot, plugin, ".openclaw-runtime-deps-stamp.json"), {
|
||||
version: 0,
|
||||
plugin,
|
||||
stale: true,
|
||||
});
|
||||
write(
|
||||
path.join(
|
||||
runtimeRoot,
|
||||
plugin,
|
||||
".openclaw-runtime-deps-copy-stale",
|
||||
"node_modules",
|
||||
"stale-sentinel",
|
||||
"package.json",
|
||||
),
|
||||
`${JSON.stringify({ name: "stale-sentinel", version: "0.0.0" }, null, 2)}\n`,
|
||||
);
|
||||
}
|
||||
if (scenario === "versioned-runtime-deps") {
|
||||
const version = process.env.OPENCLAW_UPGRADE_SURVIVOR_BASELINE_VERSION || "2026.4.24";
|
||||
for (const plugin of ["discord", "feishu", "telegram", "whatsapp"]) {
|
||||
writeJson(
|
||||
path.join(
|
||||
runtimeRoot,
|
||||
`openclaw-${version}-${plugin}`,
|
||||
".openclaw-runtime-deps-stamp.json",
|
||||
),
|
||||
{
|
||||
packageVersion: version,
|
||||
plugin,
|
||||
stale: true,
|
||||
},
|
||||
);
|
||||
write(
|
||||
path.join(
|
||||
runtimeRoot,
|
||||
`openclaw-${version}-${plugin}`,
|
||||
"node_modules",
|
||||
"stale-sentinel",
|
||||
"package.json",
|
||||
),
|
||||
`${JSON.stringify({ name: "stale-sentinel", version: "0.0.0" }, null, 2)}\n`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
writeJson(path.join(stateDir, "survivor-baseline.json"), {
|
||||
agents: ["main", "ops"],
|
||||
discordGuild: "222222222222222222",
|
||||
discordChannel: "333333333333333333",
|
||||
telegramGroup: "-1001234567890",
|
||||
whatsappGroup: "120363000000000000@g.us",
|
||||
workspaceIdentity: path.join(workspace, "IDENTITY.md"),
|
||||
scenario,
|
||||
});
|
||||
}
|
||||
|
||||
function assertConfigSurvived() {
|
||||
const config = getConfig();
|
||||
const coverage = getCoverage();
|
||||
|
||||
if (acceptsIntent(coverage, "update")) {
|
||||
assert(config.update?.channel === "stable", "update.channel was not preserved");
|
||||
}
|
||||
if (acceptsIntent(coverage, "gateway")) {
|
||||
assert(config.gateway?.auth?.mode === "token", "gateway auth mode was not preserved");
|
||||
}
|
||||
|
||||
if (acceptsIntent(coverage, "models")) {
|
||||
assert(config.models?.providers?.openai, "OpenAI model provider missing");
|
||||
}
|
||||
|
||||
if (acceptsIntent(coverage, "agents")) {
|
||||
const agents = config.agents?.list ?? [];
|
||||
assert(Array.isArray(agents), "agents.list missing after update/doctor");
|
||||
assert(
|
||||
agents.some((agent) => agent?.id === "main"),
|
||||
"main agent missing",
|
||||
);
|
||||
assert(
|
||||
agents.some((agent) => agent?.id === "ops"),
|
||||
"ops agent missing",
|
||||
);
|
||||
if (hasCoverage(coverage)) {
|
||||
assert(config.agents?.defaults?.contextTokens === 64000, "default contextTokens changed");
|
||||
} else {
|
||||
assert(
|
||||
agents.find((agent) => agent?.id === "main")?.contextTokens === 64000,
|
||||
"main agent contextTokens changed",
|
||||
);
|
||||
}
|
||||
if (!hasCoverage(coverage) || !coverage.skippedIntents?.includes("agent-modern-preferences")) {
|
||||
assert(
|
||||
agents.find((agent) => agent?.id === "ops")?.fastModeDefault === true,
|
||||
"ops fastModeDefault changed",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (acceptsIntent(coverage, "skills")) {
|
||||
assert(config.skills?.allowBundled?.includes("memory"), "memory skill allowlist changed");
|
||||
}
|
||||
|
||||
if (acceptsIntent(coverage, "plugins")) {
|
||||
const pluginAllow = config.plugins?.allow ?? [];
|
||||
assert(pluginAllow.includes("discord"), "discord plugin allow entry missing");
|
||||
assert(pluginAllow.includes("telegram"), "telegram plugin allow entry missing");
|
||||
if (getScenario() === "configured-plugin-installs") {
|
||||
assert(pluginAllow.includes("matrix"), "matrix plugin allow entry missing");
|
||||
} else {
|
||||
assert(pluginAllow.includes("whatsapp"), "whatsapp plugin allow entry missing");
|
||||
}
|
||||
if (hasCoverage(coverage) && acceptsIntent(coverage, "feishu-channel")) {
|
||||
assert(pluginAllow.includes("feishu"), "feishu plugin allow entry missing");
|
||||
}
|
||||
}
|
||||
|
||||
if (hasCoverage(coverage) && acceptsIntent(coverage, "acpx-openclaw-tools-bridge")) {
|
||||
const pluginAllow = config.plugins?.allow ?? [];
|
||||
assert(pluginAllow.includes("acpx"), "ACPX plugin allow entry missing");
|
||||
assert(config.plugins?.entries?.acpx?.enabled === true, "ACPX plugin entry changed");
|
||||
assert(
|
||||
config.plugins?.entries?.acpx?.config?.openClawToolsMcpBridge === true,
|
||||
"ACPX OpenClaw tools bridge config changed",
|
||||
);
|
||||
}
|
||||
|
||||
if (hasCoverage(coverage) && acceptsIntent(coverage, "configured-plugin-installs")) {
|
||||
const pluginAllow = config.plugins?.allow ?? [];
|
||||
assert(pluginAllow.includes("discord"), "configured install discord allow entry missing");
|
||||
assert(pluginAllow.includes("telegram"), "configured install telegram allow entry missing");
|
||||
assert(pluginAllow.includes("matrix"), "configured install matrix allow entry missing");
|
||||
assert(
|
||||
config.plugins?.entries?.matrix?.enabled === true,
|
||||
"configured install matrix entry changed",
|
||||
);
|
||||
}
|
||||
|
||||
if (acceptsIntent(coverage, "discord-channel")) {
|
||||
const discord = config.channels?.discord;
|
||||
assert(discord?.enabled === true, "discord enabled flag changed");
|
||||
const discordAllowFrom = discord.allowFrom ?? discord.dm?.allowFrom;
|
||||
const discordDmPolicy = discord.dmPolicy ?? discord.dm?.policy;
|
||||
assert(discordDmPolicy === "allowlist", "discord DM policy changed");
|
||||
assert(
|
||||
Array.isArray(discordAllowFrom) && discordAllowFrom.includes("111111111111111111"),
|
||||
"discord allowFrom changed",
|
||||
);
|
||||
assert(
|
||||
discord.guilds?.["222222222222222222"]?.channels?.["333333333333333333"]?.requireMention ===
|
||||
true,
|
||||
"discord guild channel mention policy changed",
|
||||
);
|
||||
assert(discord.threadBindings?.idleHours === 72, "discord thread binding ttl changed");
|
||||
}
|
||||
|
||||
if (acceptsIntent(coverage, "telegram-channel")) {
|
||||
const telegram = config.channels?.telegram;
|
||||
assert(telegram?.enabled === true, "telegram enabled flag changed");
|
||||
assert(
|
||||
telegram.groups?.["-1001234567890"]?.requireMention === true,
|
||||
"telegram group policy changed",
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
acceptsIntent(coverage, "whatsapp-channel") &&
|
||||
getScenario() !== "configured-plugin-installs"
|
||||
) {
|
||||
const whatsapp = config.channels?.whatsapp;
|
||||
assert(whatsapp?.enabled === true, "whatsapp enabled flag changed");
|
||||
const whatsappGroup = whatsapp.groups?.["120363000000000000@g.us"];
|
||||
if (hasCoverage(coverage)) {
|
||||
assert(whatsappGroup?.requireMention === true, "whatsapp group policy changed");
|
||||
} else {
|
||||
assert(
|
||||
whatsappGroup?.systemPrompt === "Use the existing WhatsApp group prompt.",
|
||||
"whatsapp group policy changed",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (getScenario() === "channel-post-core-restore") {
|
||||
const whatsapp = config.channels?.whatsapp;
|
||||
assert(whatsapp?.enabled === true, "post-core channel restore dropped WhatsApp");
|
||||
assert(
|
||||
whatsapp.groups?.["120363000000000000@g.us"]?.requireMention === true,
|
||||
"post-core channel restore changed WhatsApp group config",
|
||||
);
|
||||
}
|
||||
|
||||
if (hasCoverage(coverage) && acceptsIntent(coverage, "configured-plugin-installs")) {
|
||||
const matrix = config.channels?.matrix;
|
||||
assert(matrix?.enabled === true, "matrix enabled flag changed");
|
||||
assert(matrix?.homeserver === "https://matrix.example.invalid", "matrix homeserver changed");
|
||||
assert(matrix?.userId === "@upgrade-survivor:matrix.example.invalid", "matrix userId changed");
|
||||
assert(
|
||||
!config.channels?.whatsapp,
|
||||
"whatsapp channel config should be absent in matrix scenario",
|
||||
);
|
||||
}
|
||||
|
||||
if (hasCoverage(coverage) && acceptsIntent(coverage, "feishu-channel")) {
|
||||
const feishu = config.channels?.feishu;
|
||||
assert(feishu?.enabled === true, "feishu enabled flag changed");
|
||||
assert(feishu?.connectionMode === "webhook", "feishu connection mode changed");
|
||||
assert(feishu?.defaultAccount === "default", "feishu default account changed");
|
||||
assert(feishu?.accounts?.default?.appId === "cli_upgrade_survivor", "feishu account changed");
|
||||
assert(
|
||||
feishu.groups?.oc_upgrade_survivor?.requireMention === true,
|
||||
"feishu group mention policy changed",
|
||||
);
|
||||
}
|
||||
|
||||
if (hasCoverage(coverage) && acceptsIntent(coverage, "logging")) {
|
||||
assert(
|
||||
config.logging?.file === "~/openclaw-upgrade-survivor/gateway.jsonl",
|
||||
"logging.file tilde path changed",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function assertStateSurvived() {
|
||||
const stateDir = requireEnv("OPENCLAW_STATE_DIR");
|
||||
const workspace = requireEnv("OPENCLAW_TEST_WORKSPACE_DIR");
|
||||
const scenario = getScenario();
|
||||
const stage = process.env.OPENCLAW_UPGRADE_SURVIVOR_ASSERT_STAGE || "survival";
|
||||
assert(fs.existsSync(path.join(workspace, "IDENTITY.md")), "workspace identity file missing");
|
||||
assert(
|
||||
fs.existsSync(path.join(stateDir, "agents", "main", "sessions", "legacy-session.json")),
|
||||
"legacy session file missing",
|
||||
);
|
||||
if (stage !== "baseline") {
|
||||
assertSessionMetadataMigrated(stateDir);
|
||||
}
|
||||
const legacyRuntimeRoot = path.join(stateDir, "plugin-runtime-deps");
|
||||
if (stage === "baseline") {
|
||||
if (fs.existsSync(legacyRuntimeRoot)) {
|
||||
assert(
|
||||
fs.existsSync(path.join(legacyRuntimeRoot, "discord")),
|
||||
"legacy plugin runtime deps root exists but discord debris is missing before doctor cleanup",
|
||||
);
|
||||
}
|
||||
} else {
|
||||
assert(
|
||||
!fs.existsSync(legacyRuntimeRoot),
|
||||
`legacy plugin runtime deps root survived update/doctor: ${legacyRuntimeRoot}`,
|
||||
);
|
||||
}
|
||||
if (scenario === "bootstrap-persona") {
|
||||
for (const [fileName, contents] of PERSONA_FILES) {
|
||||
const actual = fs.readFileSync(path.join(workspace, fileName), "utf8");
|
||||
assert(actual === contents, `${fileName} was changed during update/doctor`);
|
||||
}
|
||||
}
|
||||
if (scenario === "stale-source-plugin-shadow") {
|
||||
const staleRoot = path.join(stateDir, "extensions", "opik-openclaw");
|
||||
assert(
|
||||
fs.existsSync(path.join(staleRoot, "src", "index.ts")),
|
||||
"source-only plugin shadow fixture missing",
|
||||
);
|
||||
}
|
||||
if (scenario === "versioned-runtime-deps") {
|
||||
if (stage === "baseline") {
|
||||
return;
|
||||
}
|
||||
const version = process.env.OPENCLAW_UPGRADE_SURVIVOR_BASELINE_VERSION || "2026.4.24";
|
||||
const runtimeRoot = path.join(stateDir, "plugin-runtime-deps");
|
||||
const staleVersionedRoots = fs.existsSync(runtimeRoot)
|
||||
? fs.readdirSync(runtimeRoot).filter((entry) => entry.startsWith(`openclaw-${version}-`))
|
||||
: [];
|
||||
assert(
|
||||
staleVersionedRoots.length === 0,
|
||||
`stale versioned runtime deps survived update/doctor: ${staleVersionedRoots.join(", ")}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function assertSessionMetadataMigrated(stateDir) {
|
||||
const legacyStorePath = path.join(stateDir, "sessions", "sessions.json");
|
||||
const agentSessionsDir = path.join(stateDir, "agents", "main", "sessions");
|
||||
const targetStorePath = path.join(agentSessionsDir, "sessions.json");
|
||||
assert(
|
||||
!fs.existsSync(legacyStorePath),
|
||||
`legacy sessions.json survived migration: ${legacyStorePath}`,
|
||||
);
|
||||
for (const sessionId of [
|
||||
LEGACY_SESSION_MAIN_ID,
|
||||
LEGACY_SESSION_DIRECT_ID,
|
||||
LEGACY_SESSION_GROUP_ID,
|
||||
]) {
|
||||
assert(
|
||||
fs.existsSync(path.join(agentSessionsDir, `${sessionId}.jsonl`)),
|
||||
`legacy session transcript was not moved for ${sessionId}`,
|
||||
);
|
||||
}
|
||||
|
||||
const store = readMigratedSessionStore(stateDir, targetStorePath);
|
||||
const main = store["agent:main:main"];
|
||||
const direct = store["agent:main:+15551234567"];
|
||||
const group = store["agent:main:slack:channel:cupgrade"];
|
||||
assert(main?.sessionId === LEGACY_SESSION_MAIN_ID, "main legacy session row missing");
|
||||
assert(direct?.sessionId === LEGACY_SESSION_DIRECT_ID, "direct legacy session row missing");
|
||||
assert(group?.sessionId === LEGACY_SESSION_GROUP_ID, "channel legacy session row missing");
|
||||
assert(
|
||||
main?.sessionFile === path.join(agentSessionsDir, `${LEGACY_SESSION_MAIN_ID}.jsonl`),
|
||||
"main legacy session row still points at the old sessions directory",
|
||||
);
|
||||
assert(
|
||||
direct?.sessionFile === path.join(agentSessionsDir, `${LEGACY_SESSION_DIRECT_ID}.jsonl`),
|
||||
"direct legacy session row still points at the old sessions directory",
|
||||
);
|
||||
assert(
|
||||
group?.sessionFile === path.join(agentSessionsDir, `${LEGACY_SESSION_GROUP_ID}.jsonl`),
|
||||
"channel legacy session row still points at the old sessions directory",
|
||||
);
|
||||
assert(
|
||||
main.skillsSnapshot?.prompt === "legacy prompt survives as metadata",
|
||||
"legacy session metadata prompt was not preserved",
|
||||
);
|
||||
assert(
|
||||
main.skillsSnapshot?.resolvedSkills === undefined,
|
||||
"heavy resolvedSkills cache was persisted into migrated session metadata",
|
||||
);
|
||||
}
|
||||
|
||||
function readMigratedSessionStore(stateDir, targetStorePath) {
|
||||
if (fs.existsSync(targetStorePath)) {
|
||||
return readJson(targetStorePath);
|
||||
}
|
||||
|
||||
const dbPath = path.join(stateDir, "agents", "main", "agent", "openclaw-agent.sqlite");
|
||||
assert(fs.existsSync(dbPath), `agent session store missing: ${targetStorePath} or ${dbPath}`);
|
||||
|
||||
let db;
|
||||
try {
|
||||
db = new DatabaseSync(dbPath, { readOnly: true });
|
||||
const rows = db
|
||||
.prepare("SELECT key, value_json FROM cache_entries WHERE scope = ?")
|
||||
.all("session_entries");
|
||||
const store = {};
|
||||
for (const row of rows) {
|
||||
if (typeof row?.key !== "string" || typeof row?.value_json !== "string") {
|
||||
continue;
|
||||
}
|
||||
store[row.key] = JSON.parse(row.value_json);
|
||||
}
|
||||
return store;
|
||||
} finally {
|
||||
db?.close();
|
||||
}
|
||||
}
|
||||
|
||||
function readInstalledPluginIndex() {
|
||||
const stateDir = requireEnv("OPENCLAW_STATE_DIR");
|
||||
const index = readPluginInstallIndex({ stateDir });
|
||||
assert(index.installRecords, "installed plugin index missing");
|
||||
return index;
|
||||
}
|
||||
|
||||
function assertExternalPluginInstall(records, pluginId, packageName) {
|
||||
const record = records[pluginId];
|
||||
assert(record, `configured external ${pluginId} plugin install record missing`);
|
||||
const installedFromNpm = record.source === "npm";
|
||||
const installedFromOfficialClawHubNpmPack =
|
||||
record.source === "clawhub" &&
|
||||
record.clawhubChannel === "official" &&
|
||||
record.artifactKind === "npm-pack";
|
||||
assert(
|
||||
installedFromNpm || installedFromOfficialClawHubNpmPack,
|
||||
`configured external ${pluginId} plugin must be installed from npm or official ClawHub npm-pack, got: ${record.source}`,
|
||||
);
|
||||
const installPath = resolveHomePath(record.installPath);
|
||||
assert(
|
||||
installPath,
|
||||
`configured external ${pluginId} plugin installPath missing: ${JSON.stringify(record)}`,
|
||||
);
|
||||
assert(
|
||||
fs.existsSync(installPath),
|
||||
`configured external ${pluginId} plugin installPath missing on disk: ${installPath}`,
|
||||
);
|
||||
assert(
|
||||
fs.existsSync(path.join(installPath, "package.json")),
|
||||
`configured external ${pluginId} plugin package.json missing: ${installPath}`,
|
||||
);
|
||||
const packageJson = readJson(path.join(installPath, "package.json"));
|
||||
assert(
|
||||
packageJson.name === packageName,
|
||||
`configured external ${pluginId} package name changed: ${packageJson.name}`,
|
||||
);
|
||||
if (installedFromNpm) {
|
||||
const stateDir = requireEnv("OPENCLAW_STATE_DIR");
|
||||
assert(
|
||||
isPathInsideManagedNpmProjectPackageRoot({ stateDir, installPath, packageName }),
|
||||
`configured external ${pluginId} npm install path outside managed npm project root: ${installPath}`,
|
||||
);
|
||||
assert(
|
||||
String(record.spec ?? record.resolvedSpec ?? "").startsWith(packageName),
|
||||
`configured external ${pluginId} plugin npm spec changed`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
assert(
|
||||
record.clawhubPackage === packageName,
|
||||
`configured external ${pluginId} ClawHub package changed: ${record.clawhubPackage}`,
|
||||
);
|
||||
const extensionsRoot = path.join(requireEnv("OPENCLAW_STATE_DIR"), "extensions");
|
||||
assert(
|
||||
isPathInside(extensionsRoot, installPath),
|
||||
`configured external ${pluginId} ClawHub install path outside managed extensions root: ${installPath}`,
|
||||
);
|
||||
}
|
||||
|
||||
function assertConfiguredPluginInstalls() {
|
||||
const coverage = getCoverage();
|
||||
const stage = process.env.OPENCLAW_UPGRADE_SURVIVOR_ASSERT_STAGE || "survival";
|
||||
if (!hasCoverage(coverage) || !acceptsIntent(coverage, "configured-plugin-installs")) {
|
||||
return;
|
||||
}
|
||||
if (stage === "baseline") {
|
||||
return;
|
||||
}
|
||||
const index = readInstalledPluginIndex();
|
||||
const records = index.installRecords ?? {};
|
||||
assertOptionalConfiguredPluginIndex(records, index.plugins ?? [], {
|
||||
bundled: true,
|
||||
packageName: "@openclaw/matrix",
|
||||
pluginId: "matrix",
|
||||
});
|
||||
assertOptionalConfiguredPluginIndex(records, index.plugins ?? [], {
|
||||
packageName: "@openclaw/brave-plugin",
|
||||
pluginId: "brave",
|
||||
});
|
||||
assert(!records.telegram, "internal telegram plugin should not be installed externally");
|
||||
}
|
||||
|
||||
function assertOptionalConfiguredPluginIndex(
|
||||
records,
|
||||
plugins,
|
||||
{ bundled = false, packageName, pluginId },
|
||||
) {
|
||||
const record = records[pluginId];
|
||||
const plugin = plugins.find((entry) => entry?.pluginId === pluginId);
|
||||
if (record) {
|
||||
assertExternalPluginInstall(records, pluginId, packageName);
|
||||
}
|
||||
if (plugin) {
|
||||
assert(
|
||||
plugin.enabled !== false,
|
||||
`configured ${bundled ? "bundled" : "external"} ${pluginId} plugin is disabled`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function assertStatusJson([file]) {
|
||||
const status = readJson(file);
|
||||
assert(status && typeof status === "object", "gateway status JSON was not an object");
|
||||
const text = JSON.stringify(status);
|
||||
assert(/running|connected|ok|ready/u.test(text), "gateway status did not report a healthy state");
|
||||
}
|
||||
|
||||
if (command === "seed") {
|
||||
seedState();
|
||||
} else if (command === "assert-config") {
|
||||
assertConfigSurvived();
|
||||
} else if (command === "assert-state") {
|
||||
assertStateSurvived();
|
||||
assertConfiguredPluginInstalls();
|
||||
} else if (command === "assert-status-json") {
|
||||
assertStatusJson(process.argv.slice(3));
|
||||
} else {
|
||||
throw new Error(`unknown upgrade-survivor assertion command: ${command ?? "<missing>"}`);
|
||||
}
|
||||
339
scripts/e2e/lib/upgrade-survivor/config-recipe.mjs
Normal file
339
scripts/e2e/lib/upgrade-survivor/config-recipe.mjs
Normal file
@@ -0,0 +1,339 @@
|
||||
#!/usr/bin/env node
|
||||
// Builds config recipes for upgrade-survivor E2E scenarios.
|
||||
import { spawnSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { parseReleaseVersion } from "../../../lib/npm-publish-plan.mjs";
|
||||
import { buildCmdExeCommandLine, resolveWindowsCmdExePath } from "../../../windows-cmd-helpers.mjs";
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const command = args.shift();
|
||||
export const CONFIG_COMMAND_TIMEOUT_MS = 120_000;
|
||||
export const CONFIG_COMMAND_MAX_BUFFER_BYTES = 4 * 1024 * 1024;
|
||||
|
||||
function option(name, fallback) {
|
||||
const index = args.indexOf(name);
|
||||
if (index === -1) {
|
||||
return fallback;
|
||||
}
|
||||
const value = args[index + 1];
|
||||
if (!value) {
|
||||
throw new Error(`missing value for ${name}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function tail(value, max = 2400) {
|
||||
const text = String(value || "");
|
||||
return text.length <= max ? text : text.slice(-max);
|
||||
}
|
||||
|
||||
function writeJson(file, value) {
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`);
|
||||
}
|
||||
|
||||
const configSectionDir = new URL("./config-recipe/", import.meta.url);
|
||||
|
||||
function readConfigSection(fileName) {
|
||||
const fileUrl = new URL(fileName, configSectionDir);
|
||||
return JSON.stringify(JSON.parse(fs.readFileSync(fileUrl, "utf8")));
|
||||
}
|
||||
|
||||
export function isReleaseBefore(version, minimum) {
|
||||
const parsed = parseReleaseVersion(String(version ?? ""));
|
||||
const minimumParsed = parseReleaseFloor(minimum);
|
||||
if (!parsed || !minimumParsed) {
|
||||
return false;
|
||||
}
|
||||
for (const key of ["year", "month", "patch"]) {
|
||||
const delta = parsed[key] - minimumParsed[key];
|
||||
if (delta !== 0) {
|
||||
return delta < 0;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function parseReleaseFloor(version) {
|
||||
const match = /^([0-9]{4})\.([1-9][0-9]?)\.([0-9]+)$/u.exec(String(version ?? ""));
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
const [year, month, patch] = match.slice(1).map((part) => Number(part));
|
||||
if (
|
||||
!Number.isSafeInteger(year) ||
|
||||
!Number.isSafeInteger(month) ||
|
||||
!Number.isSafeInteger(patch) ||
|
||||
month < 1 ||
|
||||
month > 12
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return { year, month, patch };
|
||||
}
|
||||
|
||||
function configSetJsonFile(id, intent, configPath, fileName) {
|
||||
return {
|
||||
id,
|
||||
intent,
|
||||
argv: ["config", "set", configPath, readConfigSection(fileName), "--strict-json"],
|
||||
};
|
||||
}
|
||||
|
||||
const representativeConfigSteps = [
|
||||
configSetJsonFile("models-openai", "models", "models.providers.openai", "models-openai.json"),
|
||||
configSetJsonFile("agents", "agents", "agents", "agents.json"),
|
||||
configSetJsonFile("skills", "skills", "skills", "skills.json"),
|
||||
configSetJsonFile("plugins", "plugins", "plugins", "plugins.json"),
|
||||
configSetJsonFile(
|
||||
"channels-discord",
|
||||
"discord-channel",
|
||||
"channels.discord",
|
||||
"channels-discord.json",
|
||||
),
|
||||
configSetJsonFile(
|
||||
"channels-telegram",
|
||||
"telegram-channel",
|
||||
"channels.telegram",
|
||||
"channels-telegram.json",
|
||||
),
|
||||
configSetJsonFile(
|
||||
"channels-whatsapp",
|
||||
"whatsapp-channel",
|
||||
"channels.whatsapp",
|
||||
"channels-whatsapp.json",
|
||||
),
|
||||
];
|
||||
|
||||
const scenarioConfigSteps = new Map([
|
||||
[
|
||||
"acpx-openclaw-tools-bridge",
|
||||
[
|
||||
configSetJsonFile(
|
||||
"plugins-acpx-openclaw-tools-bridge",
|
||||
"acpx-openclaw-tools-bridge",
|
||||
"plugins",
|
||||
"plugins-acpx-openclaw-tools-bridge.json",
|
||||
),
|
||||
],
|
||||
],
|
||||
[
|
||||
"feishu-channel",
|
||||
[
|
||||
configSetJsonFile("plugins-feishu", "plugins", "plugins", "plugins-feishu.json"),
|
||||
configSetJsonFile(
|
||||
"channels-feishu",
|
||||
"feishu-channel",
|
||||
"channels.feishu",
|
||||
"channels-feishu.json",
|
||||
),
|
||||
],
|
||||
],
|
||||
[
|
||||
"tilde-log-path",
|
||||
[
|
||||
{
|
||||
id: "logging-file",
|
||||
intent: "logging",
|
||||
argv: ["config", "set", "logging.file", "~/openclaw-upgrade-survivor/gateway.jsonl"],
|
||||
},
|
||||
],
|
||||
],
|
||||
[
|
||||
"configured-plugin-installs",
|
||||
[
|
||||
configSetJsonFile(
|
||||
"plugins-configured-installs",
|
||||
"configured-plugin-installs",
|
||||
"plugins",
|
||||
"plugins-configured-installs.json",
|
||||
),
|
||||
{
|
||||
id: "channels-whatsapp-unset",
|
||||
intent: "configured-plugin-installs",
|
||||
argv: ["config", "unset", "channels.whatsapp"],
|
||||
},
|
||||
configSetJsonFile(
|
||||
"channels-matrix",
|
||||
"configured-plugin-installs",
|
||||
"channels.matrix",
|
||||
"channels-matrix.json",
|
||||
),
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
const recipe = [
|
||||
{
|
||||
id: "update-channel",
|
||||
intent: "update",
|
||||
argv: ["config", "set", "update.channel", "stable"],
|
||||
},
|
||||
configSetJsonFile("gateway", "gateway", "gateway", "gateway.json"),
|
||||
...representativeConfigSteps,
|
||||
{
|
||||
id: "validate",
|
||||
intent: "validate",
|
||||
argv: ["config", "validate"],
|
||||
},
|
||||
];
|
||||
|
||||
function selectedScenario() {
|
||||
return process.env.OPENCLAW_UPGRADE_SURVIVOR_SCENARIO || "base";
|
||||
}
|
||||
|
||||
function adaptStepForBaseline(step, baselineVersion, summary) {
|
||||
if (
|
||||
step.intent === "acpx-openclaw-tools-bridge" &&
|
||||
isReleaseBefore(baselineVersion, "2026.4.22")
|
||||
) {
|
||||
if (!summary.skippedIntents.includes("acpx-openclaw-tools-bridge")) {
|
||||
summary.skippedIntents.push("acpx-openclaw-tools-bridge");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (!isReleaseBefore(baselineVersion, "2026.4.0")) {
|
||||
return step;
|
||||
}
|
||||
if (step.id === "plugins-feishu" || step.id === "channels-feishu") {
|
||||
if (!summary.skippedIntents.includes("feishu-channel")) {
|
||||
summary.skippedIntents.push("feishu-channel");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (step.id === "agents") {
|
||||
const agents = JSON.parse(step.argv[3]);
|
||||
delete agents.defaults?.skills;
|
||||
for (const agent of agents.list ?? []) {
|
||||
delete agent.thinkingDefault;
|
||||
delete agent.fastModeDefault;
|
||||
delete agent.skills;
|
||||
}
|
||||
summary.skippedIntents.push("agent-modern-preferences");
|
||||
return {
|
||||
...step,
|
||||
argv: [...step.argv.slice(0, 3), JSON.stringify(agents), ...step.argv.slice(4)],
|
||||
};
|
||||
}
|
||||
if (step.intent === "plugins") {
|
||||
const plugins = JSON.parse(step.argv[3]);
|
||||
plugins.allow = (plugins.allow ?? []).filter((id) => id !== "memory");
|
||||
delete plugins.entries?.memory;
|
||||
if (!summary.skippedIntents.includes("memory-plugin-allow")) {
|
||||
summary.skippedIntents.push("memory-plugin-allow");
|
||||
}
|
||||
return {
|
||||
...step,
|
||||
argv: [...step.argv.slice(0, 3), JSON.stringify(plugins), ...step.argv.slice(4)],
|
||||
};
|
||||
}
|
||||
return step;
|
||||
}
|
||||
|
||||
export function resolveUpgradeSurvivorOpenClawCommand(argv, params = {}) {
|
||||
const platform = params.platform ?? process.platform;
|
||||
if (platform === "win32") {
|
||||
const comSpec = params.comSpec ?? resolveWindowsCmdExePath(params.env ?? process.env);
|
||||
return {
|
||||
command: comSpec,
|
||||
args: ["/d", "/s", "/c", buildCmdExeCommandLine("openclaw.cmd", argv)],
|
||||
commandLabel: ["openclaw", ...argv].join(" "),
|
||||
shell: false,
|
||||
windowsVerbatimArguments: true,
|
||||
};
|
||||
}
|
||||
return {
|
||||
command: "openclaw",
|
||||
args: argv,
|
||||
commandLabel: ["openclaw", ...argv].join(" "),
|
||||
shell: false,
|
||||
};
|
||||
}
|
||||
|
||||
function errorCode(error) {
|
||||
return error && typeof error === "object" && "code" in error ? String(error.code) : undefined;
|
||||
}
|
||||
|
||||
export function runUpgradeSurvivorOpenClawStep(step, params = {}) {
|
||||
const invocation = resolveUpgradeSurvivorOpenClawCommand(step.argv);
|
||||
const run = params.spawnSyncCommand ?? spawnSync;
|
||||
const timeoutMs = params.timeoutMs ?? CONFIG_COMMAND_TIMEOUT_MS;
|
||||
const maxBuffer = params.maxBufferBytes ?? CONFIG_COMMAND_MAX_BUFFER_BYTES;
|
||||
const result = run(invocation.command, invocation.args, {
|
||||
encoding: "utf8",
|
||||
env: process.env,
|
||||
killSignal: "SIGTERM",
|
||||
maxBuffer,
|
||||
shell: invocation.shell,
|
||||
timeout: timeoutMs,
|
||||
windowsVerbatimArguments: invocation.windowsVerbatimArguments,
|
||||
});
|
||||
const code = errorCode(result.error);
|
||||
return {
|
||||
id: step.id,
|
||||
intent: step.intent,
|
||||
command: invocation.commandLabel,
|
||||
status: result.status,
|
||||
signal: result.signal,
|
||||
ok: result.status === 0 && !result.error,
|
||||
errorCode: code,
|
||||
errorMessage: result.error?.message ? tail(result.error.message) : undefined,
|
||||
stdout: tail(result.stdout),
|
||||
stderr: tail(result.stderr),
|
||||
};
|
||||
}
|
||||
|
||||
function applyRecipe() {
|
||||
const summaryPath = option("--summary");
|
||||
const baselineVersion = option("--baseline-version", null);
|
||||
const scenario = selectedScenario();
|
||||
const scenarioSteps = scenarioConfigSteps.get(scenario) ?? [];
|
||||
const summary = {
|
||||
source: "baseline-cli-command-recipe",
|
||||
recipe: "upgrade-survivor-v1",
|
||||
baselineVersion,
|
||||
scenario,
|
||||
acceptedIntents: [
|
||||
"update",
|
||||
"gateway",
|
||||
"models",
|
||||
"agents",
|
||||
"skills",
|
||||
"plugins",
|
||||
"discord-channel",
|
||||
"telegram-channel",
|
||||
"whatsapp-channel",
|
||||
...scenarioSteps.map((step) => step.intent),
|
||||
],
|
||||
skippedIntents: [],
|
||||
steps: [],
|
||||
};
|
||||
|
||||
for (const step of [...recipe.slice(0, -1), ...scenarioSteps, recipe.at(-1)]) {
|
||||
const adaptedStep = adaptStepForBaseline(step, baselineVersion, summary);
|
||||
if (!adaptedStep) {
|
||||
continue;
|
||||
}
|
||||
const outcome = runUpgradeSurvivorOpenClawStep(adaptedStep);
|
||||
summary.steps.push(outcome);
|
||||
writeJson(summaryPath, summary);
|
||||
if (!outcome.ok) {
|
||||
const detail = outcome.errorCode ?? outcome.signal ?? outcome.status ?? "unknown";
|
||||
throw new Error(`baseline config recipe failed at ${step.id}: ${detail}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function main() {
|
||||
if (command === "apply") {
|
||||
applyRecipe();
|
||||
} else {
|
||||
throw new Error(`unknown upgrade-survivor config-recipe command: ${command ?? "<missing>"}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
main();
|
||||
}
|
||||
30
scripts/e2e/lib/upgrade-survivor/config-recipe/agents.json
Normal file
30
scripts/e2e/lib/upgrade-survivor/config-recipe/agents.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"defaults": {
|
||||
"model": {
|
||||
"primary": "openai/gpt-5.5"
|
||||
},
|
||||
"contextTokens": 64000
|
||||
},
|
||||
"list": [
|
||||
{
|
||||
"id": "main",
|
||||
"default": true,
|
||||
"name": "Main",
|
||||
"workspace": "~/workspace",
|
||||
"model": {
|
||||
"primary": "openai/gpt-5.5"
|
||||
},
|
||||
"thinkingDefault": "low",
|
||||
"skills": ["memory"]
|
||||
},
|
||||
{
|
||||
"id": "ops",
|
||||
"name": "Ops",
|
||||
"workspace": "~/workspace/ops",
|
||||
"model": {
|
||||
"primary": "openai/gpt-5.5"
|
||||
},
|
||||
"fastModeDefault": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"enabled": true,
|
||||
"token": {
|
||||
"source": "env",
|
||||
"provider": "default",
|
||||
"id": "DISCORD_BOT_TOKEN"
|
||||
},
|
||||
"dm": {
|
||||
"policy": "allowlist",
|
||||
"allowFrom": ["111111111111111111"]
|
||||
},
|
||||
"groupPolicy": "allowlist",
|
||||
"guilds": {
|
||||
"222222222222222222": {
|
||||
"slug": "survivor-guild",
|
||||
"channels": {
|
||||
"333333333333333333": {
|
||||
"enabled": true,
|
||||
"requireMention": true,
|
||||
"tools": {
|
||||
"allow": ["message_send"],
|
||||
"deny": ["exec"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"threadBindings": {
|
||||
"enabled": true,
|
||||
"idleHours": 72
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"enabled": true,
|
||||
"domain": "feishu",
|
||||
"connectionMode": "webhook",
|
||||
"defaultAccount": "default",
|
||||
"verificationToken": "upgrade-survivor-feishu-verification",
|
||||
"encryptKey": "upgrade-survivor-feishu-encrypt",
|
||||
"webhookPath": "/feishu/events",
|
||||
"webhookHost": "127.0.0.1",
|
||||
"webhookPort": 3000,
|
||||
"accounts": {
|
||||
"default": {
|
||||
"enabled": true,
|
||||
"name": "Upgrade Survivor Feishu",
|
||||
"appId": "cli_upgrade_survivor",
|
||||
"appSecret": {
|
||||
"source": "env",
|
||||
"provider": "default",
|
||||
"id": "FEISHU_APP_SECRET"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dmPolicy": "allowlist",
|
||||
"allowFrom": ["ou_upgrade_survivor"],
|
||||
"groupPolicy": "allowlist",
|
||||
"groupAllowFrom": ["oc_upgrade_survivor"],
|
||||
"groups": {
|
||||
"oc_upgrade_survivor": {
|
||||
"enabled": true,
|
||||
"requireMention": true,
|
||||
"tools": {
|
||||
"allow": ["message_send"],
|
||||
"deny": ["exec"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"enabled": true,
|
||||
"homeserver": "https://matrix.example.invalid",
|
||||
"userId": "@upgrade-survivor:matrix.example.invalid",
|
||||
"accessToken": {
|
||||
"source": "env",
|
||||
"provider": "default",
|
||||
"id": "MATRIX_ACCESS_TOKEN"
|
||||
},
|
||||
"dm": {
|
||||
"policy": "allowlist",
|
||||
"allowFrom": ["@driver:matrix.example.invalid"]
|
||||
},
|
||||
"groups": {
|
||||
"!upgrade-survivor:matrix.example.invalid": {
|
||||
"enabled": true,
|
||||
"requireMention": true,
|
||||
"tools": {
|
||||
"allow": ["message_send"],
|
||||
"deny": ["exec"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user