diff --git a/scripts/e2e/browser-cdp-snapshot-docker.sh b/scripts/e2e/browser-cdp-snapshot-docker.sh index ec1acfc21ea..5f5e357beef 100755 --- a/scripts/e2e/browser-cdp-snapshot-docker.sh +++ b/scripts/e2e/browser-cdp-snapshot-docker.sh @@ -118,26 +118,13 @@ NODE openclaw_e2e_exec_gateway \"\$entry\" $PORT loopback /tmp/browser-cdp-gateway.log" >/dev/null echo "Waiting for Chromium and Gateway..." -ready=0 -for _ in $(seq 1 180); do - if [ "$(docker_cmd docker inspect -f '{{.State.Running}}' "$CONTAINER_NAME" 2>/dev/null || echo false)" != "true" ]; then - break - fi - if docker_cmd docker exec "$CONTAINER_NAME" bash -lc " +if ! docker_e2e_wait_container_bash "$CONTAINER_NAME" 180 0.5 " source scripts/lib/openclaw-e2e-instance.sh openclaw_e2e_probe_http_status http://127.0.0.1:$CDP_PORT/json/version openclaw_e2e_probe_tcp 127.0.0.1 $PORT - " >/dev/null 2>&1; then - ready=1 - break - fi - sleep 0.5 -done - -if [ "$ready" -ne 1 ]; then +"; then echo "Browser CDP snapshot container failed to become ready" - docker_cmd docker logs "$CONTAINER_NAME" 2>&1 | tail -n 120 || true - docker_cmd docker exec "$CONTAINER_NAME" bash -lc "tail -n 120 /tmp/browser-cdp-chromium.log /tmp/browser-cdp-gateway.log /tmp/browser-cdp-fixture.log" || true + docker_e2e_tail_container_file_if_running "$CONTAINER_NAME" "/tmp/browser-cdp-chromium.log /tmp/browser-cdp-gateway.log /tmp/browser-cdp-fixture.log" 120 exit 1 fi diff --git a/scripts/e2e/gateway-network-docker.sh b/scripts/e2e/gateway-network-docker.sh index 7ef54eb167f..6b9af960c49 100644 --- a/scripts/e2e/gateway-network-docker.sh +++ b/scripts/e2e/gateway-network-docker.sh @@ -43,25 +43,9 @@ docker_cmd docker run -d \ bash -lc "set -euo pipefail; source scripts/lib/openclaw-e2e-instance.sh; entry=\"\$(openclaw_e2e_resolve_entrypoint)\"; node \"\$entry\" config set gateway.controlUi.enabled false >/dev/null; openclaw_e2e_exec_gateway \"\$entry\" $PORT lan /tmp/gateway-net-e2e.log" >/dev/null echo "Waiting for gateway to come up..." -ready=0 -for _ in $(seq 1 180); do - if [ "$(docker_cmd docker inspect -f '{{.State.Running}}' "$GW_NAME" 2>/dev/null || echo false)" != "true" ]; then - break - fi - if docker_cmd docker exec "$GW_NAME" bash -lc "source scripts/lib/openclaw-e2e-instance.sh; openclaw_e2e_probe_tcp 127.0.0.1 $PORT || grep -q \"listening on ws://\" /tmp/gateway-net-e2e.log 2>/dev/null"; then - ready=1 - break - fi - sleep 0.5 -done - -if [ "$ready" -ne 1 ]; then +if ! docker_e2e_wait_container_bash "$GW_NAME" 180 0.5 "source scripts/lib/openclaw-e2e-instance.sh; openclaw_e2e_probe_tcp 127.0.0.1 $PORT || grep -q \"listening on ws://\" /tmp/gateway-net-e2e.log 2>/dev/null"; then echo "Gateway failed to start" - if [ "$(docker_cmd docker inspect -f '{{.State.Running}}' "$GW_NAME" 2>/dev/null || echo false)" = "true" ]; then - docker_cmd docker exec "$GW_NAME" bash -lc "tail -n 80 /tmp/gateway-net-e2e.log" || true - else - docker_cmd docker logs "$GW_NAME" 2>&1 | tail -n 120 || true - fi + docker_e2e_tail_container_file_if_running "$GW_NAME" /tmp/gateway-net-e2e.log 120 exit 1 fi diff --git a/scripts/e2e/kitchen-sink-plugin-docker.sh b/scripts/e2e/kitchen-sink-plugin-docker.sh index 53e0609c225..2c01a370fd1 100644 --- a/scripts/e2e/kitchen-sink-plugin-docker.sh +++ b/scripts/e2e/kitchen-sink-plugin-docker.sh @@ -8,7 +8,8 @@ IMAGE_NAME="$(docker_e2e_resolve_image "openclaw-kitchen-sink-plugin-e2e" OPENCL docker_e2e_build_or_reuse "$IMAGE_NAME" kitchen-sink-plugin OPENCLAW_TEST_STATE_SCRIPT_B64="$(docker_e2e_test_state_shell_b64 kitchen-sink-plugin empty)" -DEFAULT_KITCHEN_SINK_SCENARIOS="$(cat <<'SCENARIOS' +DEFAULT_KITCHEN_SINK_SCENARIOS="$( + cat <<'SCENARIOS' npm-latest|npm:@openclaw/kitchen-sink@latest|openclaw-kitchen-sink-fixture|npm|success|full npm-beta|npm:@openclaw/kitchen-sink@beta|openclaw-kitchen-sink-fixture|npm|failure|none clawhub-latest|clawhub:openclaw-kitchen-sink@latest|openclaw-kitchen-sink-fixture|clawhub|success|basic @@ -21,660 +22,12 @@ MAX_CPU_PERCENT="${OPENCLAW_KITCHEN_SINK_MAX_CPU_PERCENT:-1200}" CONTAINER_NAME="openclaw-kitchen-sink-plugin-e2e-$$" RUN_LOG="$(mktemp "${TMPDIR:-/tmp}/openclaw-kitchen-sink-plugin.XXXXXX")" STATS_LOG="$(mktemp "${TMPDIR:-/tmp}/openclaw-kitchen-sink-plugin-stats.XXXXXX")" -SCRIPT_FILE="$(mktemp "${TMPDIR:-/tmp}/openclaw-kitchen-sink-plugin-script.XXXXXX")" cleanup() { docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true - rm -f "$SCRIPT_FILE" } trap cleanup EXIT -cat > "$SCRIPT_FILE" <<'EOF' -set -euo pipefail - -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 - -eval "$(printf "%s" "${OPENCLAW_TEST_STATE_SCRIPT_B64:?missing OPENCLAW_TEST_STATE_SCRIPT_B64}" | base64 -d)" - -run_logged() { - local label="$1" - shift - local log_file="/tmp/openclaw-kitchen-sink-${label}.log" - if ! "$@" >"$log_file" 2>&1; then - cat "$log_file" - exit 1 - fi - cat "$log_file" -} - -run_expect_failure() { - local label="$1" - shift - local output_file="/tmp/kitchen-sink-expected-failure-${label}.txt" - set +e - "$@" >"$output_file" 2>&1 - local status="$?" - set -e - cat "$output_file" - if [ "$status" -eq 0 ]; then - echo "Expected ${label} to fail, but it succeeded." >&2 - exit 1 - fi - node - "$output_file" <<'NODE' -const fs = require("node:fs"); - -const output = fs.readFileSync(process.argv[2], "utf8"); -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}`); -} -console.log("ok"); -NODE -} - -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 - <<'NODE' "$server_port_file" >"$server_log" 2>&1 & -const crypto = require("node:crypto"); -const http = require("node:http"); -const path = require("node:path"); -const { createRequire } = require("node:module"); - -const portFile = process.argv[2]; -const requireFromApp = createRequire(path.join(process.cwd(), "package.json")); -const JSZip = requireFromApp("jszip"); -const packageName = "openclaw-kitchen-sink"; -const pluginId = "openclaw-kitchen-sink-fixture"; -const version = "0.1.3"; - -async function main() { - const zip = new JSZip(); - zip.file( - "package/package.json", - `${JSON.stringify( - { - name: packageName, - version, - openclaw: { extensions: ["./index.js"] }, - }, - null, - 2, - )}\n`, - { date: new Date(0) }, - ); - zip.file( - "package/index.js", - `module.exports = { - id: "${pluginId}", - name: "OpenClaw Kitchen Sink", - register(api) { - api.registerProvider({ - id: "kitchen-sink-provider", - label: "Kitchen Sink Provider", - docsPath: "/providers/kitchen-sink", - auth: [], - }); - 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" }, - }, - }); - }, -}; -`, - { date: new Date(0) }, - ); - zip.file( - "package/openclaw.plugin.json", - `${JSON.stringify( - { - id: pluginId, - name: "OpenClaw Kitchen Sink", - channels: ["kitchen-sink-channel"], - providers: ["kitchen-sink-provider"], - configSchema: { - type: "object", - properties: {}, - }, - }, - null, - 2, - )}\n`, - { date: new Date(0) }, - ); - - const archive = await zip.generateAsync({ type: "nodebuffer", compression: "DEFLATE" }); - const sha256hash = crypto.createHash("sha256").update(archive).digest("hex"); - 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: version, - tags: { latest: 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", - }, - }, - }; - const versionDetail = { - package: { - name: packageName, - displayName: "OpenClaw Kitchen Sink", - family: "code-plugin", - }, - version: { - version, - createdAt: 0, - changelog: "Fixture package for kitchen-sink plugin prerelease CI.", - distTags: ["latest"], - sha256hash, - compatibility: packageDetail.package.compatibility, - capabilities: packageDetail.package.capabilities, - verification: packageDetail.package.verification, - }, - }; - - const json = (response, value, status = 200) => { - response.writeHead(status, { "content-type": "application/json" }); - response.end(`${JSON.stringify(value)}\n`); - }; - - 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/${version}` - ) { - json(response, versionDetail); - return; - } - if (url.pathname === `/api/v1/packages/${encodeURIComponent(packageName)}/versions/beta`) { - json(response, { error: "version not found" }, 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; - } - response.writeHead(404, { "content-type": "text/plain" }); - response.end(`not found: ${url.pathname}`); - }); - - server.listen(0, "127.0.0.1", () => { - require("node:fs").writeFileSync(portFile, String(server.address().port)); - }); -} - -main().catch((error) => { - console.error(error); - process.exit(1); -}); -NODE - local server_pid="$!" - echo "$server_pid" > "$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")" - trap 'if [[ -f "'"$server_pid_file"'" ]]; then kill "$(cat "'"$server_pid_file"'")" 2>/dev/null || true; fi' EXIT - return 0 - fi - if ! kill -0 "$server_pid" 2>/dev/null; then - cat "$server_log" - return 1 - fi - sleep 0.1 - done - - cat "$server_log" - echo "Timed out waiting for kitchen-sink ClawHub fixture server." >&2 - return 1 -} - -scan_logs_for_unexpected_errors() { - node - <<'NODE' -const fs = require("node:fs"); -const path = require("node:path"); - -const roots = ["/tmp", path.join(process.env.HOME, ".openclaw")]; -const files = []; -const visit = (entry) => { - if (!fs.existsSync(entry)) { - return; - } - const stat = fs.statSync(entry); - if (stat.isDirectory()) { - for (const child of fs.readdirSync(entry)) { - visit(path.join(entry, child)); - } - return; - } - if (/\.(?:log|jsonl)$/u.test(entry) || /openclaw-kitchen-sink-/u.test(path.basename(entry))) { - if (entry.includes("/.npm/_logs/")) { - return; - } - files.push(entry); - } -}; -for (const root of roots) { - visit(root); -} - -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 = [ - /0 errors?/iu, - /expected no diagnostics errors?/iu, - /diagnostics errors?:\s*$/iu, -]; -const findings = []; -for (const file of files) { - const text = fs.readFileSync(file, "utf8"); - const lines = text.split(/\r?\n/u); - lines.forEach((line, index) => { - if (allow.some((pattern) => pattern.test(line))) { - return; - } - if (deny.some((pattern) => pattern.test(line))) { - findings.push(`${file}:${index + 1}: ${line}`); - } - }); -} -if (findings.length > 0) { - throw new Error(`unexpected error-like log lines:\n${findings.join("\n")}`); -} -console.log(`log scan passed (${files.length} file(s))`); -NODE -} - -configure_kitchen_sink_runtime() { - node - <<'NODE' -const fs = require("node:fs"); -const path = require("node:path"); - -const pluginId = process.env.KITCHEN_SINK_ID; -const configPath = path.join(process.env.HOME, ".openclaw", "openclaw.json"); -const config = fs.existsSync(configPath) ? JSON.parse(fs.readFileSync(configPath, "utf8")) : {}; -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.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`); -NODE -} - -remove_kitchen_sink_channel_config() { - node - <<'NODE' -const fs = require("node:fs"); -const path = require("node:path"); - -const configPath = path.join(process.env.HOME, ".openclaw", "openclaw.json"); -if (fs.existsSync(configPath)) { - const config = JSON.parse(fs.readFileSync(configPath, "utf8")); - delete config.channels?.["kitchen-sink-channel"]; - fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`); -} -NODE -} - -assert_kitchen_sink_installed() { - node - <<'NODE' -const fs = require("node:fs"); -const path = require("node:path"); - -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 = JSON.parse(fs.readFileSync(`/tmp/kitchen-sink-${label}-plugins.json`, "utf8")); -const inspect = JSON.parse(fs.readFileSync(`/tmp/kitchen-sink-${label}-inspect.json`, "utf8")); -const allInspect = JSON.parse(fs.readFileSync(`/tmp/kitchen-sink-${label}-inspect-all.json`, "utf8")); -const plugin = (list.plugins || []).find((entry) => entry.id === pluginId); -if (!plugin) throw new Error(`kitchen-sink plugin not found after install: ${pluginId}`); -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 !== true || inspect.plugin?.status !== "loaded") { - throw new Error( - `expected enabled loaded kitchen-sink plugin, got enabled=${inspect.plugin?.enabled} status=${inspect.plugin?.status}`, - ); -} - -const expectIncludes = (listValue, expected, field) => { - if (!Array.isArray(listValue) || !listValue.includes(expected)) { - throw new Error(`${field} missing ${expected}: ${JSON.stringify(listValue)}`); - } -}; -expectIncludes(inspect.plugin?.channelIds, "kitchen-sink-channel", "channels"); -expectIncludes(inspect.plugin?.providerIds, "kitchen-sink-provider", "providers"); - -const diagnostics = [ - ...(list.diagnostics || []), - ...(inspect.diagnostics || []), - ...(allInspect.diagnostics || []), -]; -const errorMessages = new Set( - diagnostics - .filter((diag) => diag?.level === "error") - .map((diag) => String(diag.message || "")), -); - -if (surfaceMode === "full") { - const toolNames = Array.isArray(inspect.tools) - ? inspect.tools.flatMap((entry) => (Array.isArray(entry?.names) ? entry.names : [])) - : []; - expectIncludes(inspect.plugin?.speechProviderIds, "kitchen-sink-speech-provider", "speech providers"); - expectIncludes( - inspect.plugin?.realtimeTranscriptionProviderIds, - "kitchen-sink-realtime-transcription-provider", - "realtime transcription providers", - ); - expectIncludes( - inspect.plugin?.realtimeVoiceProviderIds, - "kitchen-sink-realtime-voice-provider", - "realtime voice providers", - ); - expectIncludes( - inspect.plugin?.mediaUnderstandingProviderIds, - "kitchen-sink-media-understanding-provider", - "media understanding providers", - ); - expectIncludes( - inspect.plugin?.imageGenerationProviderIds, - "kitchen-sink-image-generation-provider", - "image generation providers", - ); - expectIncludes( - inspect.plugin?.videoGenerationProviderIds, - "kitchen-sink-video-generation-provider", - "video generation providers", - ); - expectIncludes( - inspect.plugin?.musicGenerationProviderIds, - "kitchen-sink-music-generation-provider", - "music generation providers", - ); - expectIncludes(inspect.plugin?.webFetchProviderIds, "kitchen-sink-web-fetch-provider", "web fetch providers"); - expectIncludes(inspect.plugin?.webSearchProviderIds, "kitchen-sink-web-search-provider", "web search providers"); - expectIncludes(inspect.plugin?.migrationProviderIds, "kitchen-sink-migration-provider", "migration providers"); - expectIncludes(inspect.plugin?.agentHarnessIds, "kitchen-sink-agent-harness", "agent harnesses"); - expectIncludes(inspect.services, "kitchen-sink-service", "services"); - expectIncludes(inspect.commands, "kitchen-sink-command", "commands"); - expectIncludes(toolNames, "kitchen-sink-tool", "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}`, - ); - } - - const expectedErrorMessages = new Set([ - "only bundled plugins can register agent tool result middleware", - "cli registration missing explicit commands metadata", - "only bundled plugins can register Codex app-server extension factories", - "http route registration missing or invalid auth: /kitchen-sink/http-route", - "plugin must own memory slot or declare contracts.memoryEmbeddingProviders for adapter: kitchen-sink-memory-embedding-provider", - ]); - for (const message of errorMessages) { - if (!expectedErrorMessages.has(message)) { - throw new Error(`unexpected kitchen-sink diagnostic error: ${message}`); - } - } - for (const message of expectedErrorMessages) { - if (!errorMessages.has(message)) { - throw new Error(`missing expected kitchen-sink diagnostic error: ${message}`); - } - } -} else if (errorMessages.size > 0) { - throw new Error(`unexpected kitchen-sink diagnostic errors: ${[...errorMessages].join(", ")}`); -} - -const indexPath = path.join(process.env.HOME, ".openclaw", "plugins", "installs.json"); -const index = JSON.parse(fs.readFileSync(indexPath, "utf8")); -const record = (index.installRecords ?? index.records ?? {})[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)}`); - } -} -if (typeof record.installPath !== "string" || record.installPath.length === 0) { - throw new Error("missing kitchen-sink install path"); -} -const installPath = record.installPath.replace(/^~(?=$|\/)/u, process.env.HOME); -if (!fs.existsSync(installPath)) { - throw new Error(`kitchen-sink install path missing: ${record.installPath}`); -} -fs.writeFileSync(`/tmp/kitchen-sink-${label}-install-path.txt`, installPath, "utf8"); -console.log("ok"); -NODE -} - -assert_kitchen_sink_removed() { - node - <<'NODE' -const fs = require("node:fs"); -const path = require("node:path"); - -const pluginId = process.env.KITCHEN_SINK_ID; -const label = process.env.KITCHEN_SINK_LABEL; -const list = JSON.parse(fs.readFileSync(`/tmp/kitchen-sink-${label}-uninstalled.json`, "utf8")); -if ((list.plugins || []).some((entry) => entry.id === pluginId)) { - throw new Error(`kitchen-sink plugin still listed after uninstall: ${pluginId}`); -} - -const indexPath = path.join(process.env.HOME, ".openclaw", "plugins", "installs.json"); -const index = fs.existsSync(indexPath) ? JSON.parse(fs.readFileSync(indexPath, "utf8")) : {}; -const records = index.installRecords ?? index.records ?? {}; -if (records[pluginId]) { - throw new Error(`kitchen-sink install record still present after uninstall: ${pluginId}`); -} - -const configPath = path.join(process.env.HOME, ".openclaw", "openclaw.json"); -const config = fs.existsSync(configPath) ? JSON.parse(fs.readFileSync(configPath, "utf8")) : {}; -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 = `/tmp/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}`); - } -} -console.log("ok"); -NODE -} - -run_success_scenario() { - echo "Testing ${KITCHEN_SINK_LABEL} install from ${KITCHEN_SINK_SPEC}..." - run_logged "install-${KITCHEN_SINK_LABEL}" node "$OPENCLAW_ENTRY" plugins install "$KITCHEN_SINK_SPEC" - run_logged "enable-${KITCHEN_SINK_LABEL}" node "$OPENCLAW_ENTRY" plugins enable "$KITCHEN_SINK_ID" - configure_kitchen_sink_runtime - node "$OPENCLAW_ENTRY" plugins list --json > "/tmp/kitchen-sink-${KITCHEN_SINK_LABEL}-plugins.json" - node "$OPENCLAW_ENTRY" plugins inspect "$KITCHEN_SINK_ID" --json > "/tmp/kitchen-sink-${KITCHEN_SINK_LABEL}-inspect.json" - node "$OPENCLAW_ENTRY" plugins inspect --all --json > "/tmp/kitchen-sink-${KITCHEN_SINK_LABEL}-inspect-all.json" - assert_kitchen_sink_installed - if [ "$KITCHEN_SINK_SOURCE" = "clawhub" ]; then - run_logged "uninstall-${KITCHEN_SINK_LABEL}" node "$OPENCLAW_ENTRY" plugins uninstall "$KITCHEN_SINK_SPEC" --force - else - run_logged "uninstall-${KITCHEN_SINK_LABEL}" node "$OPENCLAW_ENTRY" plugins uninstall "$KITCHEN_SINK_ID" --force - fi - remove_kitchen_sink_channel_config - node "$OPENCLAW_ENTRY" plugins list --json > "/tmp/kitchen-sink-${KITCHEN_SINK_LABEL}-uninstalled.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}" node "$OPENCLAW_ENTRY" plugins install "$KITCHEN_SINK_SPEC" - remove_kitchen_sink_channel_config - node "$OPENCLAW_ENTRY" plugins list --json > "/tmp/kitchen-sink-${KITCHEN_SINK_LABEL}-uninstalled.json" - assert_kitchen_sink_removed -} - -if [[ "$KITCHEN_SINK_SCENARIOS" == *"clawhub:"* ]] && - [[ "${OPENCLAW_KITCHEN_SINK_LIVE_CLAWHUB:-0}" != "1" ]] && - [[ -z "${OPENCLAW_CLAWHUB_URL:-}" && -z "${CLAWHUB_URL:-}" ]]; then - clawhub_fixture_dir="$(mktemp -d "/tmp/openclaw-kitchen-sink-clawhub.XXXXXX")" - start_kitchen_sink_clawhub_fixture_server "$clawhub_fixture_dir" -fi - -scenario_count=0 -while IFS='|' read -r label spec plugin_id source expectation surface_mode; 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" - 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))" -EOF - DOCKER_ENV_ARGS=( -e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 -e "OPENCLAW_TEST_STATE_SCRIPT_B64=$OPENCLAW_TEST_STATE_SCRIPT_B64" @@ -695,8 +48,9 @@ done echo "Running kitchen-sink plugin Docker E2E..." docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true -docker run --name "$CONTAINER_NAME" "${DOCKER_ENV_ARGS[@]}" -i "$IMAGE_NAME" bash -s \ - >"$RUN_LOG" 2>&1 < "$SCRIPT_FILE" & +docker_e2e_harness_mount_args +docker run --name "$CONTAINER_NAME" "${DOCKER_E2E_HARNESS_ARGS[@]}" "${DOCKER_ENV_ARGS[@]}" -i "$IMAGE_NAME" bash scripts/e2e/lib/kitchen-sink-plugin/sweep.sh \ + >"$RUN_LOG" 2>&1 & docker_pid="$!" while kill -0 "$docker_pid" 2>/dev/null; do diff --git a/scripts/e2e/lib/kitchen-sink-plugin/sweep.sh b/scripts/e2e/lib/kitchen-sink-plugin/sweep.sh new file mode 100644 index 00000000000..409a11c871c --- /dev/null +++ b/scripts/e2e/lib/kitchen-sink-plugin/sweep.sh @@ -0,0 +1,628 @@ +#!/usr/bin/env bash +set -euo pipefail + +source scripts/lib/openclaw-e2e-instance.sh +source scripts/lib/docker-e2e-logs.sh + +OPENCLAW_ENTRY="$(openclaw_e2e_resolve_entrypoint)" +export OPENCLAW_ENTRY + +openclaw_e2e_eval_test_state_from_b64 "${OPENCLAW_TEST_STATE_SCRIPT_B64:?missing OPENCLAW_TEST_STATE_SCRIPT_B64}" + +run_expect_failure() { + local label="$1" + shift + local output_file="/tmp/kitchen-sink-expected-failure-${label}.txt" + set +e + "$@" >"$output_file" 2>&1 + local status="$?" + set -e + cat "$output_file" + if [ "$status" -eq 0 ]; then + echo "Expected ${label} to fail, but it succeeded." >&2 + exit 1 + fi + node - "$output_file" <<'NODE' +const fs = require("node:fs"); + +const output = fs.readFileSync(process.argv[2], "utf8"); +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}`); +} +console.log("ok"); +NODE +} + +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 - "$server_port_file" <<'NODE' >"$server_log" 2>&1 & +const crypto = require("node:crypto"); +const http = require("node:http"); +const path = require("node:path"); +const { createRequire } = require("node:module"); + +const portFile = process.argv[2]; +const requireFromApp = createRequire(path.join(process.cwd(), "package.json")); +const JSZip = requireFromApp("jszip"); +const packageName = "openclaw-kitchen-sink"; +const pluginId = "openclaw-kitchen-sink-fixture"; +const version = "0.1.3"; + +async function main() { + const zip = new JSZip(); + zip.file( + "package/package.json", + `${JSON.stringify( + { + name: packageName, + version, + openclaw: { extensions: ["./index.js"] }, + }, + null, + 2, + )}\n`, + { date: new Date(0) }, + ); + zip.file( + "package/index.js", + `module.exports = { + id: "${pluginId}", + name: "OpenClaw Kitchen Sink", + register(api) { + api.registerProvider({ + id: "kitchen-sink-provider", + label: "Kitchen Sink Provider", + docsPath: "/providers/kitchen-sink", + auth: [], + }); + 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" }, + }, + }); + }, +}; +`, + { date: new Date(0) }, + ); + zip.file( + "package/openclaw.plugin.json", + `${JSON.stringify( + { + id: pluginId, + name: "OpenClaw Kitchen Sink", + channels: ["kitchen-sink-channel"], + providers: ["kitchen-sink-provider"], + configSchema: { + type: "object", + properties: {}, + }, + }, + null, + 2, + )}\n`, + { date: new Date(0) }, + ); + + const archive = await zip.generateAsync({ type: "nodebuffer", compression: "DEFLATE" }); + const sha256hash = crypto.createHash("sha256").update(archive).digest("hex"); + 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: version, + tags: { latest: 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", + }, + }, + }; + const versionDetail = { + package: { + name: packageName, + displayName: "OpenClaw Kitchen Sink", + family: "code-plugin", + }, + version: { + version, + createdAt: 0, + changelog: "Fixture package for kitchen-sink plugin prerelease CI.", + distTags: ["latest"], + sha256hash, + compatibility: packageDetail.package.compatibility, + capabilities: packageDetail.package.capabilities, + verification: packageDetail.package.verification, + }, + }; + + const json = (response, value, status = 200) => { + response.writeHead(status, { "content-type": "application/json" }); + response.end(`${JSON.stringify(value)}\n`); + }; + + 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/${version}` + ) { + json(response, versionDetail); + return; + } + if (url.pathname === `/api/v1/packages/${encodeURIComponent(packageName)}/versions/beta`) { + json(response, { error: "version not found" }, 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; + } + response.writeHead(404, { "content-type": "text/plain" }); + response.end(`not found: ${url.pathname}`); + }); + + server.listen(0, "127.0.0.1", () => { + require("node:fs").writeFileSync(portFile, String(server.address().port)); + }); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); +NODE + local server_pid="$!" + echo "$server_pid" >"$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")" + trap 'if [[ -f "'"$server_pid_file"'" ]]; then kill "$(cat "'"$server_pid_file"'")" 2>/dev/null || true; fi' EXIT + return 0 + fi + if ! kill -0 "$server_pid" 2>/dev/null; then + cat "$server_log" + return 1 + fi + sleep 0.1 + done + + cat "$server_log" + echo "Timed out waiting for kitchen-sink ClawHub fixture server." >&2 + return 1 +} + +scan_logs_for_unexpected_errors() { + node - <<'NODE' +const fs = require("node:fs"); +const path = require("node:path"); + +const roots = ["/tmp", path.join(process.env.HOME, ".openclaw")]; +const files = []; +const visit = (entry) => { + if (!fs.existsSync(entry)) { + return; + } + const stat = fs.statSync(entry); + if (stat.isDirectory()) { + for (const child of fs.readdirSync(entry)) { + visit(path.join(entry, child)); + } + return; + } + if (/\.(?:log|jsonl)$/u.test(entry) || /openclaw-kitchen-sink-/u.test(path.basename(entry))) { + if (entry.includes("/.npm/_logs/")) { + return; + } + files.push(entry); + } +}; +for (const root of roots) { + visit(root); +} + +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 = [ + /0 errors?/iu, + /expected no diagnostics errors?/iu, + /diagnostics errors?:\s*$/iu, +]; +const findings = []; +for (const file of files) { + const text = fs.readFileSync(file, "utf8"); + const lines = text.split(/\r?\n/u); + lines.forEach((line, index) => { + if (allow.some((pattern) => pattern.test(line))) { + return; + } + if (deny.some((pattern) => pattern.test(line))) { + findings.push(`${file}:${index + 1}: ${line}`); + } + }); +} +if (findings.length > 0) { + throw new Error(`unexpected error-like log lines:\n${findings.join("\n")}`); +} +console.log(`log scan passed (${files.length} file(s))`); +NODE +} + +configure_kitchen_sink_runtime() { + node - <<'NODE' +const fs = require("node:fs"); +const path = require("node:path"); + +const pluginId = process.env.KITCHEN_SINK_ID; +const configPath = path.join(process.env.HOME, ".openclaw", "openclaw.json"); +const config = fs.existsSync(configPath) ? JSON.parse(fs.readFileSync(configPath, "utf8")) : {}; +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.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`); +NODE +} + +remove_kitchen_sink_channel_config() { + node - <<'NODE' +const fs = require("node:fs"); +const path = require("node:path"); + +const configPath = path.join(process.env.HOME, ".openclaw", "openclaw.json"); +if (fs.existsSync(configPath)) { + const config = JSON.parse(fs.readFileSync(configPath, "utf8")); + delete config.channels?.["kitchen-sink-channel"]; + fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`); +} +NODE +} + +assert_kitchen_sink_installed() { + node - <<'NODE' +const fs = require("node:fs"); +const path = require("node:path"); + +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 = JSON.parse(fs.readFileSync(`/tmp/kitchen-sink-${label}-plugins.json`, "utf8")); +const inspect = JSON.parse(fs.readFileSync(`/tmp/kitchen-sink-${label}-inspect.json`, "utf8")); +const allInspect = JSON.parse(fs.readFileSync(`/tmp/kitchen-sink-${label}-inspect-all.json`, "utf8")); +const plugin = (list.plugins || []).find((entry) => entry.id === pluginId); +if (!plugin) throw new Error(`kitchen-sink plugin not found after install: ${pluginId}`); +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 !== true || inspect.plugin?.status !== "loaded") { + throw new Error( + `expected enabled loaded kitchen-sink plugin, got enabled=${inspect.plugin?.enabled} status=${inspect.plugin?.status}`, + ); +} + +const expectIncludes = (listValue, expected, field) => { + if (!Array.isArray(listValue) || !listValue.includes(expected)) { + throw new Error(`${field} missing ${expected}: ${JSON.stringify(listValue)}`); + } +}; +expectIncludes(inspect.plugin?.channelIds, "kitchen-sink-channel", "channels"); +expectIncludes(inspect.plugin?.providerIds, "kitchen-sink-provider", "providers"); + +const diagnostics = [ + ...(list.diagnostics || []), + ...(inspect.diagnostics || []), + ...(allInspect.diagnostics || []), +]; +const errorMessages = new Set( + diagnostics + .filter((diag) => diag?.level === "error") + .map((diag) => String(diag.message || "")), +); + +if (surfaceMode === "full") { + const toolNames = Array.isArray(inspect.tools) + ? inspect.tools.flatMap((entry) => (Array.isArray(entry?.names) ? entry.names : [])) + : []; + expectIncludes(inspect.plugin?.speechProviderIds, "kitchen-sink-speech-provider", "speech providers"); + expectIncludes( + inspect.plugin?.realtimeTranscriptionProviderIds, + "kitchen-sink-realtime-transcription-provider", + "realtime transcription providers", + ); + expectIncludes( + inspect.plugin?.realtimeVoiceProviderIds, + "kitchen-sink-realtime-voice-provider", + "realtime voice providers", + ); + expectIncludes( + inspect.plugin?.mediaUnderstandingProviderIds, + "kitchen-sink-media-understanding-provider", + "media understanding providers", + ); + expectIncludes( + inspect.plugin?.imageGenerationProviderIds, + "kitchen-sink-image-generation-provider", + "image generation providers", + ); + expectIncludes( + inspect.plugin?.videoGenerationProviderIds, + "kitchen-sink-video-generation-provider", + "video generation providers", + ); + expectIncludes( + inspect.plugin?.musicGenerationProviderIds, + "kitchen-sink-music-generation-provider", + "music generation providers", + ); + expectIncludes(inspect.plugin?.webFetchProviderIds, "kitchen-sink-web-fetch-provider", "web fetch providers"); + expectIncludes(inspect.plugin?.webSearchProviderIds, "kitchen-sink-web-search-provider", "web search providers"); + expectIncludes(inspect.plugin?.migrationProviderIds, "kitchen-sink-migration-provider", "migration providers"); + expectIncludes(inspect.plugin?.agentHarnessIds, "kitchen-sink-agent-harness", "agent harnesses"); + expectIncludes(inspect.services, "kitchen-sink-service", "services"); + expectIncludes(inspect.commands, "kitchen-sink-command", "commands"); + expectIncludes(toolNames, "kitchen-sink-tool", "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}`, + ); + } + + const expectedErrorMessages = new Set([ + "only bundled plugins can register agent tool result middleware", + "cli registration missing explicit commands metadata", + "only bundled plugins can register Codex app-server extension factories", + "http route registration missing or invalid auth: /kitchen-sink/http-route", + "plugin must own memory slot or declare contracts.memoryEmbeddingProviders for adapter: kitchen-sink-memory-embedding-provider", + ]); + for (const message of errorMessages) { + if (!expectedErrorMessages.has(message)) { + throw new Error(`unexpected kitchen-sink diagnostic error: ${message}`); + } + } + for (const message of expectedErrorMessages) { + if (!errorMessages.has(message)) { + throw new Error(`missing expected kitchen-sink diagnostic error: ${message}`); + } + } +} else if (errorMessages.size > 0) { + throw new Error(`unexpected kitchen-sink diagnostic errors: ${[...errorMessages].join(", ")}`); +} + +const indexPath = path.join(process.env.HOME, ".openclaw", "plugins", "installs.json"); +const index = JSON.parse(fs.readFileSync(indexPath, "utf8")); +const record = (index.installRecords ?? index.records ?? {})[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)}`); + } +} +if (typeof record.installPath !== "string" || record.installPath.length === 0) { + throw new Error("missing kitchen-sink install path"); +} +const installPath = record.installPath.replace(/^~(?=$|\/)/u, process.env.HOME); +if (!fs.existsSync(installPath)) { + throw new Error(`kitchen-sink install path missing: ${record.installPath}`); +} +fs.writeFileSync(`/tmp/kitchen-sink-${label}-install-path.txt`, installPath, "utf8"); +console.log("ok"); +NODE +} + +assert_kitchen_sink_removed() { + node - <<'NODE' +const fs = require("node:fs"); +const path = require("node:path"); + +const pluginId = process.env.KITCHEN_SINK_ID; +const label = process.env.KITCHEN_SINK_LABEL; +const list = JSON.parse(fs.readFileSync(`/tmp/kitchen-sink-${label}-uninstalled.json`, "utf8")); +if ((list.plugins || []).some((entry) => entry.id === pluginId)) { + throw new Error(`kitchen-sink plugin still listed after uninstall: ${pluginId}`); +} + +const indexPath = path.join(process.env.HOME, ".openclaw", "plugins", "installs.json"); +const index = fs.existsSync(indexPath) ? JSON.parse(fs.readFileSync(indexPath, "utf8")) : {}; +const records = index.installRecords ?? index.records ?? {}; +if (records[pluginId]) { + throw new Error(`kitchen-sink install record still present after uninstall: ${pluginId}`); +} + +const configPath = path.join(process.env.HOME, ".openclaw", "openclaw.json"); +const config = fs.existsSync(configPath) ? JSON.parse(fs.readFileSync(configPath, "utf8")) : {}; +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 = `/tmp/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}`); + } +} +console.log("ok"); +NODE +} + +run_success_scenario() { + echo "Testing ${KITCHEN_SINK_LABEL} install from ${KITCHEN_SINK_SPEC}..." + run_logged_print "kitchen-sink-install-${KITCHEN_SINK_LABEL}" node "$OPENCLAW_ENTRY" plugins install "$KITCHEN_SINK_SPEC" + run_logged_print "kitchen-sink-enable-${KITCHEN_SINK_LABEL}" node "$OPENCLAW_ENTRY" plugins enable "$KITCHEN_SINK_ID" + configure_kitchen_sink_runtime + node "$OPENCLAW_ENTRY" plugins list --json >"/tmp/kitchen-sink-${KITCHEN_SINK_LABEL}-plugins.json" + node "$OPENCLAW_ENTRY" plugins inspect "$KITCHEN_SINK_ID" --json >"/tmp/kitchen-sink-${KITCHEN_SINK_LABEL}-inspect.json" + node "$OPENCLAW_ENTRY" plugins inspect --all --json >"/tmp/kitchen-sink-${KITCHEN_SINK_LABEL}-inspect-all.json" + assert_kitchen_sink_installed + if [ "$KITCHEN_SINK_SOURCE" = "clawhub" ]; then + run_logged_print "kitchen-sink-uninstall-${KITCHEN_SINK_LABEL}" node "$OPENCLAW_ENTRY" plugins uninstall "$KITCHEN_SINK_SPEC" --force + else + run_logged_print "kitchen-sink-uninstall-${KITCHEN_SINK_LABEL}" node "$OPENCLAW_ENTRY" plugins uninstall "$KITCHEN_SINK_ID" --force + fi + remove_kitchen_sink_channel_config + node "$OPENCLAW_ENTRY" plugins list --json >"/tmp/kitchen-sink-${KITCHEN_SINK_LABEL}-uninstalled.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}" node "$OPENCLAW_ENTRY" plugins install "$KITCHEN_SINK_SPEC" + remove_kitchen_sink_channel_config + node "$OPENCLAW_ENTRY" plugins list --json >"/tmp/kitchen-sink-${KITCHEN_SINK_LABEL}-uninstalled.json" + assert_kitchen_sink_removed +} + +if [[ "$KITCHEN_SINK_SCENARIOS" == *"clawhub:"* ]] && + [[ "${OPENCLAW_KITCHEN_SINK_LIVE_CLAWHUB:-0}" != "1" ]] && + [[ -z "${OPENCLAW_CLAWHUB_URL:-}" && -z "${CLAWHUB_URL:-}" ]]; then + clawhub_fixture_dir="$(mktemp -d "/tmp/openclaw-kitchen-sink-clawhub.XXXXXX")" + start_kitchen_sink_clawhub_fixture_server "$clawhub_fixture_dir" +fi + +scenario_count=0 +while IFS='|' read -r label spec plugin_id source expectation surface_mode; 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" + 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))" diff --git a/scripts/e2e/lib/onboard/assert-config.mjs b/scripts/e2e/lib/onboard/assert-config.mjs new file mode 100644 index 00000000000..3e3ce59ef02 --- /dev/null +++ b/scripts/e2e/lib/onboard/assert-config.mjs @@ -0,0 +1,78 @@ +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 [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"); +}; + +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)})`, + ); + } + expectEqual("wizard.lastRunCommand", cfg?.wizard?.lastRunCommand, "configure"); + 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"); + } + assertLocalWizard(); + break; + default: + throw new Error(`unknown onboard assertion scenario: ${scenario}`); +} + +if (errors.length > 0) { + console.error(errors.join("\n")); + process.exit(1); +} diff --git a/scripts/e2e/lib/onboard/scenario.sh b/scripts/e2e/lib/onboard/scenario.sh new file mode 100644 index 00000000000..09e8a193ad1 --- /dev/null +++ b/scripts/e2e/lib/onboard/scenario.sh @@ -0,0 +1,336 @@ +#!/usr/bin/env bash +set -euo pipefail +trap "" PIPE +export TERM=xterm-256color +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}" +ONBOARD_FLAGS="--flow quickstart --auth-choice skip --skip-channels --skip-skills --skip-daemon --skip-ui" +OPENCLAW_ENTRY="$(openclaw_e2e_resolve_entrypoint)" +export OPENCLAW_ENTRY + +# Provide a minimal trash shim to avoid noisy "missing trash" logs in containers. +openclaw_e2e_install_trash_shim + +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 needle_compact + needle_compact="$(printf "%s" "$needle" | tr -cd "[:alpha:]")" + 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 NEEDLE=\"$needle_compact\" node --input-type=module -e " + import fs from \"node:fs\"; + const file = process.env.WIZARD_LOG_PATH; + const needle = process.env.NEEDLE ?? \"\"; + let text = \"\"; + try { text = fs.readFileSync(file, \"utf8\"); } catch { process.exit(1); } + // Clack/script output can include lots of control sequences; keep a larger tail and strip ANSI more robustly. + if (text.length > 120000) text = text.slice(-120000); + const normalizeScriptOutput = (value) => + value + // util-linux script can emit each byte on its own CRLF-delimited line. + // Collapse those first so ANSI/control stripping works on real sequences. + .replace(/\\r?\\n/g, \"\") + .replace(/\\r/g, \"\"); + const stripAnsi = (value) => + normalizeScriptOutput(value) + // OSC: ESC ] ... BEL or ESC \\ + .replace(/\\x1b\\][^\\x07]*(?:\\x07|\\x1b\\\\)/g, \"\") + // CSI: ESC [ ... cmd + .replace(/\\x1b\\[[0-?]*[ -/]*[@-~]/g, \"\"); + // Letters-only: script output sometimes fragments ANSI sequences into digits/letters that + // can otherwise break substring matching. + const compact = (value) => stripAnsi(value).toLowerCase().replace(/[^a-z]+/g, \"\"); + const haystack = compact(text); + const compactNeedle = compact(needle); + if (!compactNeedle) process.exit(1); + process.exit(haystack.includes(compactNeedle) ? 0 : 1); + "; 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 /tmp/gateway-e2e.log)" +} + +wait_for_gateway() { + for _ in $(seq 1 20); do + if openclaw_e2e_probe_tcp 127.0.0.1 18789 500 >/dev/null 2>&1; then + return 0 + fi + if [ -f /tmp/gateway-e2e.log ] && grep -E -q "listening on ws://[^ ]+:18789" /tmp/gateway-e2e.log; then + if [ -n "${GATEWAY_PID:-}" ] && kill -0 "$GATEWAY_PID" 2>/dev/null; then + return 0 + fi + fi + sleep 1 + done + echo "Gateway failed to start" + cat /tmp/gateway-e2e.log || true + return 1 +} + +stop_gateway() { + openclaw_e2e_stop_process "$1" +} + +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:-}" + + echo "== Wizard case: $case_name ==" + set_isolated_openclaw_env "$state_ref" + + input_fifo="$(mktemp -u "/tmp/openclaw-onboard-${case_name}.XXXXXX")" + mkfifo "$input_fifo" + local log_path="/tmp/openclaw-onboard-${case_name}.log" + WIZARD_LOG_PATH="$log_path" + export WIZARD_LOG_PATH + # Run under script to keep an interactive TTY for clack prompts. + script -q -f -c "$command" "$log_path" <"$input_fifo" >/dev/null 2>&1 & + wizard_pid=$! + exec 3>"$input_fifo" + + local gw_pid="" + if [ "$with_gateway" = "true" ]; then + start_gateway + gw_pid="$GATEWAY_PID" + wait_for_gateway + fi + + "$send_fn" + + if ! wait "$wizard_pid"; then + wizard_status=$? + exec 3>&- + rm -f "$input_fifo" + stop_gateway "$gw_pid" + echo "Wizard exited with status $wizard_status" + if [ -f "$log_path" ]; then + tail -n 160 "$log_path" || true + fi + exit "$wizard_status" + fi + exec 3>&- + rm -f "$input_fifo" + stop_gateway "$gw_pid" + if [ -n "$validate_fn" ]; then + "$validate_fn" "$log_path" + fi +} + +run_wizard() { + local case_name="$1" + local state_ref="$2" + local send_fn="$3" + local validate_fn="${4:-}" + + # Default onboarding command wrapper. + run_wizard_cmd "$case_name" "$state_ref" "node \"$OPENCLAW_ENTRY\" onboard $ONBOARD_FLAGS" "$send_fn" true "$validate_fn" +} + +set_isolated_openclaw_env() { + local state_ref="$1" + openclaw_test_state_create "$state_ref" empty +} + +select_skip_hooks() { + # Hooks multiselect: pick "Skip for now". + wait_for_log "Enable hooks?" 60 + send $' \r' 0.6 +} + +send_local_basic() { + # Risk acknowledgement (default is "No"). + wait_for_log "Continue?" 60 + send $'y\r' 0.6 + # Non-interactive flow; no gateway-location prompt. + select_skip_hooks +} + +send_reset_config_only() { + # Risk acknowledgement (default is "No"). + wait_for_log "Continue?" 40 + send $'y\r' 0.8 + # Select reset flow for existing config. + wait_for_log "Config handling" 40 + send $'\e[B' 0.3 + send $'\e[B' 0.3 + send $'\r' 0.4 + # Reset scope -> Config only (default). + wait_for_log "Reset scope" 40 + send $'\r' 0.4 + select_skip_hooks +} + +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. + wait_for_log "Where will the Gateway run?" 120 + send $'\r' 0.6 + wait_for_log "Configure/link" 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. + wait_for_log "Where will the Gateway run?" 120 + send $'\r' 0.6 + 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 + + # Assert config + workspace scaffolding. + workspace_dir="$OPENCLAW_STATE_DIR/workspace" + config_path="$OPENCLAW_CONFIG_PATH" + sessions_dir="$OPENCLAW_STATE_DIR/agents/main/sessions" + + openclaw_e2e_assert_file "$config_path" + 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 + + node scripts/e2e/lib/onboard/assert-config.mjs local-basic "$config_path" "$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 + + config_path="$OPENCLAW_CONFIG_PATH" + openclaw_e2e_assert_file "$config_path" + + node scripts/e2e/lib/onboard/assert-config.mjs remote-non-interactive "$config_path" +} + +run_case_reset() { + set_isolated_openclaw_env reset-config + # Seed a remote config to exercise reset path. + cat >"$OPENCLAW_CONFIG_PATH" <<'JSON' +{ +"meta": {}, +"agents": { "defaults": { "workspace": "/root/old" } }, +"gateway": { + "mode": "remote", + "remote": { "url": "ws://old.example:18789", "token": "old-token" } +} +} +JSON + + 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 + + config_path="$OPENCLAW_CONFIG_PATH" + openclaw_e2e_assert_file "$config_path" + + node scripts/e2e/lib/onboard/assert-config.mjs reset "$config_path" +} + +run_case_channels() { + # Channels-only configure flow. + run_wizard_cmd channels channels "node \"$OPENCLAW_ENTRY\" configure --section channels" send_channels_flow + + config_path="$OPENCLAW_CONFIG_PATH" + openclaw_e2e_assert_file "$config_path" + + node scripts/e2e/lib/onboard/assert-config.mjs channels "$config_path" +} + +run_case_skills() { + local home_dir + set_isolated_openclaw_env skills + home_dir="$HOME" + # Seed skills config to ensure it survives the wizard. + cat >"$OPENCLAW_CONFIG_PATH" <<'JSON' +{ +"meta": {}, +"skills": { + "allowBundled": ["__none__"], + "install": { "nodeManager": "bun" } +} +} +JSON + + run_wizard_cmd skills "$home_dir" "node \"$OPENCLAW_ENTRY\" configure --section skills" send_skills_flow + + config_path="$OPENCLAW_CONFIG_PATH" + openclaw_e2e_assert_file "$config_path" + + node scripts/e2e/lib/onboard/assert-config.mjs skills "$config_path" +} + +validate_local_basic_log() { + local log_path="$1" + openclaw_e2e_assert_log_not_contains "$log_path" "systemctl --user unavailable" +} + +run_case_local_basic +run_case_remote_non_interactive +run_case_reset +run_case_channels +run_case_skills diff --git a/scripts/e2e/onboard-docker.sh b/scripts/e2e/onboard-docker.sh index ecbbd9da874..60c62be012b 100755 --- a/scripts/e2e/onboard-docker.sh +++ b/scripts/e2e/onboard-docker.sh @@ -11,481 +11,6 @@ docker_e2e_build_or_reuse "$IMAGE_NAME" onboard echo "Running onboarding E2E..." docker_e2e_run_with_harness -t \ -e "OPENCLAW_TEST_STATE_FUNCTION_B64=$OPENCLAW_TEST_STATE_FUNCTION_B64" \ - "$IMAGE_NAME" bash -lc ' - set -euo pipefail - trap "" PIPE - export TERM=xterm-256color - source scripts/lib/openclaw-e2e-instance.sh - eval "$(printf "%s" "${OPENCLAW_TEST_STATE_FUNCTION_B64:?missing OPENCLAW_TEST_STATE_FUNCTION_B64}" | base64 -d)" - ONBOARD_FLAGS="--flow quickstart --auth-choice skip --skip-channels --skip-skills --skip-daemon --skip-ui" - OPENCLAW_ENTRY="$(openclaw_e2e_resolve_entrypoint)" - export OPENCLAW_ENTRY - - # Provide a minimal trash shim to avoid noisy "missing trash" logs in containers. - openclaw_e2e_install_trash_shim - - 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 needle_compact - needle_compact="$(printf "%s" "$needle" | tr -cd "[:alpha:]")" - 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 NEEDLE=\"$needle_compact\" node --input-type=module -e " - import fs from \"node:fs\"; - const file = process.env.WIZARD_LOG_PATH; - const needle = process.env.NEEDLE ?? \"\"; - let text = \"\"; - try { text = fs.readFileSync(file, \"utf8\"); } catch { process.exit(1); } - // Clack/script output can include lots of control sequences; keep a larger tail and strip ANSI more robustly. - if (text.length > 120000) text = text.slice(-120000); - const normalizeScriptOutput = (value) => - value - // util-linux script can emit each byte on its own CRLF-delimited line. - // Collapse those first so ANSI/control stripping works on real sequences. - .replace(/\\r?\\n/g, \"\") - .replace(/\\r/g, \"\"); - const stripAnsi = (value) => - normalizeScriptOutput(value) - // OSC: ESC ] ... BEL or ESC \\ - .replace(/\\x1b\\][^\\x07]*(?:\\x07|\\x1b\\\\)/g, \"\") - // CSI: ESC [ ... cmd - .replace(/\\x1b\\[[0-?]*[ -/]*[@-~]/g, \"\"); - // Letters-only: script output sometimes fragments ANSI sequences into digits/letters that - // can otherwise break substring matching. - const compact = (value) => stripAnsi(value).toLowerCase().replace(/[^a-z]+/g, \"\"); - const haystack = compact(text); - const compactNeedle = compact(needle); - if (!compactNeedle) process.exit(1); - process.exit(haystack.includes(compactNeedle) ? 0 : 1); - "; 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 /tmp/gateway-e2e.log)" - } - - wait_for_gateway() { - for _ in $(seq 1 20); do - if openclaw_e2e_probe_tcp 127.0.0.1 18789 500 >/dev/null 2>&1; then - return 0 - fi - if [ -f /tmp/gateway-e2e.log ] && grep -E -q "listening on ws://[^ ]+:18789" /tmp/gateway-e2e.log; then - if [ -n "${GATEWAY_PID:-}" ] && kill -0 "$GATEWAY_PID" 2>/dev/null; then - return 0 - fi - fi - sleep 1 - done - echo "Gateway failed to start" - cat /tmp/gateway-e2e.log || true - return 1 - } - - stop_gateway() { - openclaw_e2e_stop_process "$1" - } - - 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:-}" - - echo "== Wizard case: $case_name ==" - set_isolated_openclaw_env "$state_ref" - - input_fifo="$(mktemp -u "/tmp/openclaw-onboard-${case_name}.XXXXXX")" - mkfifo "$input_fifo" - local log_path="/tmp/openclaw-onboard-${case_name}.log" - WIZARD_LOG_PATH="$log_path" - export WIZARD_LOG_PATH - # Run under script to keep an interactive TTY for clack prompts. - script -q -f -c "$command" "$log_path" < "$input_fifo" >/dev/null 2>&1 & - wizard_pid=$! - exec 3> "$input_fifo" - - local gw_pid="" - if [ "$with_gateway" = "true" ]; then - start_gateway - gw_pid="$GATEWAY_PID" - wait_for_gateway - fi - - "$send_fn" - - if ! wait "$wizard_pid"; then - wizard_status=$? - exec 3>&- - rm -f "$input_fifo" - stop_gateway "$gw_pid" - echo "Wizard exited with status $wizard_status" - if [ -f "$log_path" ]; then - tail -n 160 "$log_path" || true - fi - exit "$wizard_status" - fi - exec 3>&- - rm -f "$input_fifo" - stop_gateway "$gw_pid" - if [ -n "$validate_fn" ]; then - "$validate_fn" "$log_path" - fi - } - - run_wizard() { - local case_name="$1" - local state_ref="$2" - local send_fn="$3" - local validate_fn="${4:-}" - - # Default onboarding command wrapper. - run_wizard_cmd "$case_name" "$state_ref" "node \"$OPENCLAW_ENTRY\" onboard $ONBOARD_FLAGS" "$send_fn" true "$validate_fn" - } - - set_isolated_openclaw_env() { - local state_ref="$1" - openclaw_test_state_create "$state_ref" empty - } - - select_skip_hooks() { - # Hooks multiselect: pick "Skip for now". - wait_for_log "Enable hooks?" 60 - send $'"'"' \r'"'"' 0.6 - } - - send_local_basic() { - # Risk acknowledgement (default is "No"). - wait_for_log "Continue?" 60 - send $'"'"'y\r'"'"' 0.6 - # Non-interactive flow; no gateway-location prompt. - select_skip_hooks - } - - send_reset_config_only() { - # Risk acknowledgement (default is "No"). - wait_for_log "Continue?" 40 - send $'"'"'y\r'"'"' 0.8 - # Select reset flow for existing config. - wait_for_log "Config handling" 40 - send $'"'"'\e[B'"'"' 0.3 - send $'"'"'\e[B'"'"' 0.3 - send $'"'"'\r'"'"' 0.4 - # Reset scope -> Config only (default). - wait_for_log "Reset scope" 40 - send $'"'"'\r'"'"' 0.4 - select_skip_hooks - } - - 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. - wait_for_log "Where will the Gateway run?" 120 - send $'"'"'\r'"'"' 0.6 - wait_for_log "Configure/link" 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. - wait_for_log "Where will the Gateway run?" 120 - send $'"'"'\r'"'"' 0.6 - 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 - - # Assert config + workspace scaffolding. - workspace_dir="$OPENCLAW_STATE_DIR/workspace" - config_path="$OPENCLAW_CONFIG_PATH" - sessions_dir="$OPENCLAW_STATE_DIR/agents/main/sessions" - - openclaw_e2e_assert_file "$config_path" - 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 - - CONFIG_PATH="$config_path" WORKSPACE_DIR="$workspace_dir" node --input-type=module - <<'"'"'NODE'"'"' -import fs from "node:fs"; -import JSON5 from "json5"; - -const cfg = JSON5.parse(fs.readFileSync(process.env.CONFIG_PATH, "utf-8")); -const expectedWorkspace = process.env.WORKSPACE_DIR; -const errors = []; - -if (cfg?.agents?.defaults?.workspace !== expectedWorkspace) { - errors.push( - `agents.defaults.workspace mismatch (got ${cfg?.agents?.defaults?.workspace ?? "unset"})`, - ); -} -if (cfg?.gateway?.mode !== "local") { - errors.push(`gateway.mode mismatch (got ${cfg?.gateway?.mode ?? "unset"})`); -} -if (cfg?.gateway?.bind !== "loopback") { - errors.push(`gateway.bind mismatch (got ${cfg?.gateway?.bind ?? "unset"})`); -} -if ((cfg?.gateway?.tailscale?.mode ?? "off") !== "off") { - errors.push( - `gateway.tailscale.mode mismatch (got ${cfg?.gateway?.tailscale?.mode ?? "unset"})`, - ); -} -if (!cfg?.wizard?.lastRunAt) { - errors.push("wizard.lastRunAt missing"); -} -if (!cfg?.wizard?.lastRunVersion) { - errors.push("wizard.lastRunVersion missing"); -} -if (cfg?.wizard?.lastRunCommand !== "onboard") { - errors.push( - `wizard.lastRunCommand mismatch (got ${cfg?.wizard?.lastRunCommand ?? "unset"})`, - ); -} -if (cfg?.wizard?.lastRunMode !== "local") { - errors.push( - `wizard.lastRunMode mismatch (got ${cfg?.wizard?.lastRunMode ?? "unset"})`, - ); -} - -if (errors.length > 0) { - console.error(errors.join("\n")); - process.exit(1); -} -NODE - - } - - 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 - - config_path="$OPENCLAW_CONFIG_PATH" - openclaw_e2e_assert_file "$config_path" - - CONFIG_PATH="$config_path" node --input-type=module - <<'"'"'NODE'"'"' -import fs from "node:fs"; -import JSON5 from "json5"; - -const cfg = JSON5.parse(fs.readFileSync(process.env.CONFIG_PATH, "utf-8")); -const errors = []; - -if (cfg?.gateway?.mode !== "remote") { - errors.push(`gateway.mode mismatch (got ${cfg?.gateway?.mode ?? "unset"})`); -} -if (cfg?.gateway?.remote?.url !== "ws://gateway.local:18789") { - errors.push(`gateway.remote.url mismatch (got ${cfg?.gateway?.remote?.url ?? "unset"})`); -} -if (cfg?.gateway?.remote?.token !== "remote-token") { - errors.push(`gateway.remote.token mismatch (got ${cfg?.gateway?.remote?.token ?? "unset"})`); -} -if (cfg?.wizard?.lastRunMode !== "remote") { - errors.push(`wizard.lastRunMode mismatch (got ${cfg?.wizard?.lastRunMode ?? "unset"})`); -} - -if (errors.length > 0) { - console.error(errors.join("\n")); - process.exit(1); -} -NODE - } - - run_case_reset() { - set_isolated_openclaw_env reset-config - # Seed a remote config to exercise reset path. - cat > "$OPENCLAW_CONFIG_PATH" <<'"'"'JSON'"'"' -{ - "meta": {}, - "agents": { "defaults": { "workspace": "/root/old" } }, - "gateway": { - "mode": "remote", - "remote": { "url": "ws://old.example:18789", "token": "old-token" } - } -} -JSON - - 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 - - config_path="$OPENCLAW_CONFIG_PATH" - openclaw_e2e_assert_file "$config_path" - - CONFIG_PATH="$config_path" node --input-type=module - <<'"'"'NODE'"'"' -import fs from "node:fs"; -import JSON5 from "json5"; - -const cfg = JSON5.parse(fs.readFileSync(process.env.CONFIG_PATH, "utf-8")); -const errors = []; - -if (cfg?.gateway?.mode !== "local") { - errors.push(`gateway.mode mismatch (got ${cfg?.gateway?.mode ?? "unset"})`); -} -if (cfg?.gateway?.remote?.url) { - errors.push(`gateway.remote.url should be cleared (got ${cfg?.gateway?.remote?.url})`); -} -if (cfg?.wizard?.lastRunMode !== "local") { - errors.push(`wizard.lastRunMode mismatch (got ${cfg?.wizard?.lastRunMode ?? "unset"})`); -} - -if (errors.length > 0) { - console.error(errors.join("\n")); - process.exit(1); -} -NODE - } - - run_case_channels() { - # Channels-only configure flow. - run_wizard_cmd channels channels "node \"$OPENCLAW_ENTRY\" configure --section channels" send_channels_flow - - config_path="$OPENCLAW_CONFIG_PATH" - openclaw_e2e_assert_file "$config_path" - - CONFIG_PATH="$config_path" node --input-type=module - <<'"'"'NODE'"'"' -import fs from "node:fs"; -import JSON5 from "json5"; - -const cfg = JSON5.parse(fs.readFileSync(process.env.CONFIG_PATH, "utf-8")); -const errors = []; - - 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=${cfg?.slack?.botToken ?? "unset"}, app=${cfg?.slack?.appToken ?? "unset"})`, - ); - } - if (cfg?.wizard?.lastRunCommand !== "configure") { - errors.push( - `wizard.lastRunCommand mismatch (got ${cfg?.wizard?.lastRunCommand ?? "unset"})`, - ); - } - -if (errors.length > 0) { - console.error(errors.join("\n")); - process.exit(1); -} -NODE - } - - run_case_skills() { - local home_dir - set_isolated_openclaw_env skills - home_dir="$HOME" - # Seed skills config to ensure it survives the wizard. - cat > "$OPENCLAW_CONFIG_PATH" <<'"'"'JSON'"'"' -{ - "meta": {}, - "skills": { - "allowBundled": ["__none__"], - "install": { "nodeManager": "bun" } - } -} -JSON - - run_wizard_cmd skills "$home_dir" "node \"$OPENCLAW_ENTRY\" configure --section skills" send_skills_flow - - config_path="$OPENCLAW_CONFIG_PATH" - openclaw_e2e_assert_file "$config_path" - - CONFIG_PATH="$config_path" node --input-type=module - <<'"'"'NODE'"'"' -import fs from "node:fs"; -import JSON5 from "json5"; - -const cfg = JSON5.parse(fs.readFileSync(process.env.CONFIG_PATH, "utf-8")); -const errors = []; - -if (cfg?.skills?.install?.nodeManager !== "bun") { - errors.push(`skills.install.nodeManager mismatch (got ${cfg?.skills?.install?.nodeManager ?? "unset"})`); -} -if (!Array.isArray(cfg?.skills?.allowBundled) || cfg.skills.allowBundled[0] !== "__none__") { - errors.push("skills.allowBundled missing"); -} -if (cfg?.wizard?.lastRunMode !== "local") { - errors.push(`wizard.lastRunMode mismatch (got ${cfg?.wizard?.lastRunMode ?? "unset"})`); -} - -if (errors.length > 0) { - console.error(errors.join("\n")); - process.exit(1); -} -NODE - } - - validate_local_basic_log() { - local log_path="$1" - openclaw_e2e_assert_log_not_contains "$log_path" "systemctl --user unavailable" - } - - run_case_local_basic - run_case_remote_non_interactive - run_case_reset - run_case_channels - run_case_skills -' + "$IMAGE_NAME" bash scripts/e2e/lib/onboard/scenario.sh echo "E2E complete." diff --git a/scripts/e2e/openwebui-docker.sh b/scripts/e2e/openwebui-docker.sh index 2edf138dce4..6d7809d995d 100755 --- a/scripts/e2e/openwebui-docker.sh +++ b/scripts/e2e/openwebui-docker.sh @@ -124,30 +124,15 @@ EOF ' >/dev/null echo "Waiting for gateway HTTP surface..." -gateway_ready=0 -for _ in $(seq 1 240); do - if [ "$(docker_cmd docker inspect -f '{{.State.Running}}' "$GW_NAME" 2>/dev/null || echo false)" != "true" ]; then - break - fi - if docker_cmd docker exec "$GW_NAME" bash -lc "node --input-type=module -e ' +if ! docker_e2e_wait_container_bash_while_running "$OW_NAME" "$GW_NAME" 240 1 "node --input-type=module -e ' const res = await fetch(\"http://127.0.0.1:$PORT/v1/models\", { headers: { authorization: \"Bearer $TOKEN\" }, }).catch(() => null); process.exit(res?.status === 200 ? 0 : 1); - ' >/dev/null 2>&1"; then - gateway_ready=1 - break - fi - sleep 1 -done - -if [ "$gateway_ready" -ne 1 ]; then +'"; then echo "Gateway failed to start" - docker_cmd docker inspect "$GW_NAME" --format '{{json .State}}' 2>/dev/null || true - if [ "$(docker_cmd docker inspect -f '{{.State.Running}}' "$GW_NAME" 2>/dev/null || echo false)" = "true" ]; then - docker_cmd docker exec "$GW_NAME" bash -lc 'tail -n 200 /tmp/openwebui-gateway.log' || true - fi - docker_cmd docker logs "$GW_NAME" 2>&1 | tail -n 200 || true + docker_e2e_docker_cmd inspect "$GW_NAME" --format '{{json .State}}' 2>/dev/null || true + docker_e2e_tail_container_file_if_running "$GW_NAME" /tmp/openwebui-gateway.log 200 exit 1 fi @@ -176,34 +161,17 @@ docker_cmd docker run -d \ "$OPENWEBUI_IMAGE" >/dev/null echo "Waiting for Open WebUI..." -ow_ready=0 -for _ in $(seq 1 240); do - if [ "$(docker_cmd docker inspect -f '{{.State.Running}}' "$OW_NAME" 2>/dev/null || echo false)" != "true" ]; then - break - fi - if docker_cmd docker exec "$GW_NAME" bash -lc "node --input-type=module -e ' +if ! docker_e2e_wait_container_bash "$GW_NAME" 240 1 "node --input-type=module -e ' const res = await fetch(\"http://$OW_NAME:$WEBUI_PORT/\").catch(() => null); process.exit(res && res.status < 500 ? 0 : 1); - ' >/dev/null 2>&1"; then - ow_ready=1 - break - fi - sleep 1 -done - -if [ "$ow_ready" -ne 1 ]; then +'"; then echo "Open WebUI failed to start" - docker_cmd docker logs "$OW_NAME" 2>&1 | tail -n 200 || true + docker_e2e_docker_cmd logs "$OW_NAME" 2>&1 | tail -n 200 || true exit 1 fi echo "Waiting for gateway model endpoint after Open WebUI startup..." -gateway_model_ready=0 -for _ in $(seq 1 90); do - if [ "$(docker_cmd docker inspect -f '{{.State.Running}}' "$GW_NAME" 2>/dev/null || echo false)" != "true" ]; then - break - fi - if docker_cmd docker exec "$GW_NAME" bash -lc "node --input-type=module -e ' +if ! docker_e2e_wait_container_bash "$GW_NAME" 90 5 "node --input-type=module -e ' const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 8000); try { @@ -217,21 +185,11 @@ for _ in $(seq 1 90); do } finally { clearTimeout(timeout); } - ' >/dev/null 2>&1"; then - gateway_model_ready=1 - break - fi - sleep 5 -done - -if [ "$gateway_model_ready" -ne 1 ]; then +'"; then echo "Gateway model endpoint did not stay reachable after Open WebUI startup" - docker_cmd docker inspect "$GW_NAME" --format '{{json .State}}' 2>/dev/null || true - if [ "$(docker_cmd docker inspect -f '{{.State.Running}}' "$GW_NAME" 2>/dev/null || echo false)" = "true" ]; then - docker_cmd docker exec "$GW_NAME" bash -lc 'tail -n 200 /tmp/openwebui-gateway.log' || true - fi - docker_cmd docker logs "$GW_NAME" 2>&1 | tail -n 200 || true - docker_cmd docker logs "$OW_NAME" 2>&1 | tail -n 200 || true + docker_e2e_docker_cmd inspect "$GW_NAME" --format '{{json .State}}' 2>/dev/null || true + docker_e2e_tail_container_file_if_running "$GW_NAME" /tmp/openwebui-gateway.log 200 + docker_e2e_docker_cmd logs "$OW_NAME" 2>&1 | tail -n 200 || true exit 1 fi @@ -248,13 +206,10 @@ if ! docker_cmd docker exec \ node /app/scripts/e2e/openwebui-probe.mjs >/tmp/openwebui-probe.log 2>&1; then cat /tmp/openwebui-probe.log 2>/dev/null || true echo "Open WebUI probe failed; gateway log tail:" - docker_cmd docker inspect "$GW_NAME" --format '{{json .State}}' 2>/dev/null || true - if [ "$(docker_cmd docker inspect -f '{{.State.Running}}' "$GW_NAME" 2>/dev/null || echo false)" = "true" ]; then - docker_cmd docker exec "$GW_NAME" bash -lc 'tail -n 200 /tmp/openwebui-gateway.log' || true - fi - docker_cmd docker logs "$GW_NAME" 2>&1 | tail -n 200 || true + docker_e2e_docker_cmd inspect "$GW_NAME" --format '{{json .State}}' 2>/dev/null || true + docker_e2e_tail_container_file_if_running "$GW_NAME" /tmp/openwebui-gateway.log 200 echo "Open WebUI container logs:" - docker_cmd docker logs "$OW_NAME" 2>&1 | tail -n 200 || true + docker_e2e_docker_cmd logs "$OW_NAME" 2>&1 | tail -n 200 || true exit 1 fi diff --git a/scripts/lib/docker-e2e-container.sh b/scripts/lib/docker-e2e-container.sh new file mode 100644 index 00000000000..307bb5aa7b4 --- /dev/null +++ b/scripts/lib/docker-e2e-container.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +# +# Shared helpers for Docker E2E scripts that keep a named container running +# while polling readiness from the host. + +docker_e2e_docker_cmd() { + timeout "${DOCKER_COMMAND_TIMEOUT:-600s}" docker "$@" +} + +docker_e2e_container_running() { + local container_name="$1" + [ "$(docker_e2e_docker_cmd inspect -f '{{.State.Running}}' "$container_name" 2>/dev/null || echo false)" = "true" ] +} + +docker_e2e_container_exec_bash() { + local container_name="$1" + shift + docker_e2e_docker_cmd exec "$container_name" bash -lc "$*" +} + +docker_e2e_wait_container_bash() { + local container_name="$1" + shift + docker_e2e_wait_container_bash_while_running "$container_name" "$container_name" "$@" +} + +docker_e2e_wait_container_bash_while_running() { + local running_container_name="$1" + local exec_container_name="$2" + local attempts="$3" + local sleep_seconds="$4" + shift 4 + local probe="$*" + + for _ in $(seq 1 "$attempts"); do + if ! docker_e2e_container_running "$running_container_name"; then + return 1 + fi + if docker_e2e_container_exec_bash "$exec_container_name" "$probe" >/dev/null 2>&1; then + return 0 + fi + sleep "$sleep_seconds" + done + return 1 +} + +docker_e2e_tail_container_file_if_running() { + local container_name="$1" + local file_path="$2" + local lines="${3:-120}" + if docker_e2e_container_running "$container_name"; then + docker_e2e_container_exec_bash "$container_name" "tail -n $lines $file_path" || true + else + docker_e2e_docker_cmd logs "$container_name" 2>&1 | tail -n "$lines" || true + fi +} diff --git a/scripts/lib/docker-e2e-image.sh b/scripts/lib/docker-e2e-image.sh index 60739767b6c..47ae63144ca 100644 --- a/scripts/lib/docker-e2e-image.sh +++ b/scripts/lib/docker-e2e-image.sh @@ -10,6 +10,7 @@ ROOT_DIR="${ROOT_DIR:-$(cd "$DOCKER_E2E_LIB_DIR/../.." && pwd)}" source "$DOCKER_E2E_LIB_DIR/docker-e2e-logs.sh" source "$DOCKER_E2E_LIB_DIR/docker-build.sh" source "$DOCKER_E2E_LIB_DIR/docker-e2e-package.sh" +source "$DOCKER_E2E_LIB_DIR/docker-e2e-container.sh" docker_e2e_resolve_image() { local default_image="$1" @@ -87,13 +88,13 @@ docker_e2e_test_state_shell_b64() { local scenario="${2:-empty}" node "$ROOT_DIR/scripts/lib/openclaw-test-state.mjs" shell \ --label "$label" \ - --scenario "$scenario" \ - | base64 \ - | tr -d '\n' + --scenario "$scenario" | + base64 | + tr -d '\n' } docker_e2e_test_state_function_b64() { - node "$ROOT_DIR/scripts/lib/openclaw-test-state.mjs" shell-function \ - | base64 \ - | tr -d '\n' + node "$ROOT_DIR/scripts/lib/openclaw-test-state.mjs" shell-function | + base64 | + tr -d '\n' }