mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 09:20:43 +00:00
refactor(test): split docker e2e planner
This commit is contained in:
@@ -1400,6 +1400,7 @@
|
||||
"lint:auth:no-pairing-store-group": "node scripts/check-no-pairing-store-group-auth.mjs",
|
||||
"lint:auth:pairing-account-scope": "node scripts/check-pairing-account-scope.mjs",
|
||||
"lint:core": "node scripts/run-oxlint.mjs --tsconfig tsconfig.oxlint.core.json src ui packages",
|
||||
"lint:docker-e2e": "node scripts/check-docker-e2e-boundaries.mjs",
|
||||
"lint:docs": "pnpm dlx markdownlint-cli2",
|
||||
"lint:docs:fix": "pnpm dlx markdownlint-cli2 --fix",
|
||||
"lint:extensions": "node scripts/run-oxlint.mjs --tsconfig tsconfig.oxlint.extensions.json extensions",
|
||||
@@ -1415,7 +1416,7 @@
|
||||
"lint:plugins:no-monolithic-plugin-sdk-entry-imports": "node --import tsx scripts/check-no-monolithic-plugin-sdk-entry-imports.ts",
|
||||
"lint:plugins:no-register-http-handler": "node scripts/check-no-register-http-handler.mjs",
|
||||
"lint:plugins:plugin-sdk-subpaths-exported": "node scripts/check-plugin-sdk-subpath-exports.mjs",
|
||||
"lint:scripts": "node scripts/run-oxlint.mjs --tsconfig tsconfig.oxlint.scripts.json scripts",
|
||||
"lint:scripts": "pnpm lint:docker-e2e && node scripts/run-oxlint.mjs --tsconfig tsconfig.oxlint.scripts.json scripts",
|
||||
"lint:swift": "swiftlint lint --config .swiftlint.yml && (cd apps/ios && swiftlint lint --config .swiftlint.yml)",
|
||||
"lint:tmp:channel-agnostic-boundaries": "node scripts/check-channel-agnostic-boundaries.mjs",
|
||||
"lint:tmp:dynamic-import-warts": "node scripts/check-dynamic-import-warts.mjs",
|
||||
|
||||
51
scripts/check-docker-e2e-boundaries.mjs
Normal file
51
scripts/check-docker-e2e-boundaries.mjs
Normal file
@@ -0,0 +1,51 @@
|
||||
#!/usr/bin/env node
|
||||
// Cheap guard for Docker E2E test boundaries.
|
||||
// Docker E2E must test packaged npm tarballs and package-installed images, not
|
||||
// the source checkout copied or mounted as the app under test.
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const ROOT_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const errors = [];
|
||||
|
||||
function readText(relativePath) {
|
||||
return fs.readFileSync(path.join(ROOT_DIR, relativePath), "utf8");
|
||||
}
|
||||
|
||||
function walk(dir, out = []) {
|
||||
for (const entry of fs.readdirSync(path.join(ROOT_DIR, dir), { withFileTypes: true })) {
|
||||
const relativePath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
walk(relativePath, out);
|
||||
} else {
|
||||
out.push(relativePath);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
for (const relativePath of walk("scripts/e2e")) {
|
||||
if (!/\.(?:sh|ts|mjs|js)$/u.test(relativePath)) {
|
||||
continue;
|
||||
}
|
||||
const text = readText(relativePath);
|
||||
if (/from\s+["']\.\.\/\.\.\/src\//u.test(text) || /import\(["']\.\.\/\.\.\/src\//u.test(text)) {
|
||||
errors.push(`${relativePath}: Docker E2E harness must import built dist, not ../../src`);
|
||||
}
|
||||
if (/-v\s+["']?\$ROOT_DIR:\/app(?::|["'\s]|$)/u.test(text)) {
|
||||
errors.push(`${relativePath}: do not mount the repo root as /app in Docker E2E`);
|
||||
}
|
||||
}
|
||||
|
||||
const dockerfile = readText("scripts/e2e/Dockerfile");
|
||||
if (/^\s*(?:COPY|ADD)\s+\.\s+\/app(?:\s|$)/imu.test(dockerfile)) {
|
||||
errors.push("scripts/e2e/Dockerfile: do not copy the source checkout into /app");
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
console.error(errors.join("\n"));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log("Docker E2E package boundary guard passed.");
|
||||
86
scripts/docker-e2e.mjs
Normal file
86
scripts/docker-e2e.mjs
Normal file
@@ -0,0 +1,86 @@
|
||||
// Docker E2E CI helper.
|
||||
// Converts scheduler JSON into GitHub Actions outputs and compact markdown
|
||||
// summaries so the workflow does not duplicate Docker E2E planning logic.
|
||||
import fs from "node:fs";
|
||||
|
||||
function usage() {
|
||||
return [
|
||||
"Usage:",
|
||||
" node scripts/docker-e2e.mjs github-outputs <plan.json>",
|
||||
" node scripts/docker-e2e.mjs summary <summary.json> <title>",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function readJson(file) {
|
||||
return JSON.parse(fs.readFileSync(file, "utf8"));
|
||||
}
|
||||
|
||||
function boolOutput(value) {
|
||||
return value ? "1" : "0";
|
||||
}
|
||||
|
||||
function githubOutputs(plan) {
|
||||
const needs = plan.needs ?? {};
|
||||
return [
|
||||
`credentials=${(plan.credentials ?? []).join(",")}`,
|
||||
`needs_bare_image=${boolOutput(needs.bareImage)}`,
|
||||
`needs_e2e_image=${boolOutput(needs.e2eImage)}`,
|
||||
`needs_functional_image=${boolOutput(needs.functionalImage)}`,
|
||||
`needs_live_image=${boolOutput(needs.liveImage)}`,
|
||||
`needs_package=${boolOutput(needs.package)}`,
|
||||
];
|
||||
}
|
||||
|
||||
function markdownCell(value) {
|
||||
return String(value ?? "").replaceAll("|", "\\|");
|
||||
}
|
||||
|
||||
function inlineCode(value) {
|
||||
return `\`${String(value ?? "").replaceAll("`", "\\`")}\``;
|
||||
}
|
||||
|
||||
function summaryMarkdown(summary, title) {
|
||||
const lanes = Array.isArray(summary.lanes) ? summary.lanes : [];
|
||||
const lines = [
|
||||
`### ${title}`,
|
||||
"",
|
||||
`Status: ${inlineCode(summary.status)}`,
|
||||
"",
|
||||
"| Lane | Status | Seconds | Timed out | Rerun |",
|
||||
"| --- | ---: | ---: | --- | --- |",
|
||||
];
|
||||
for (const lane of lanes) {
|
||||
const status = lane.status === 0 ? "pass" : `fail ${lane.status}`;
|
||||
lines.push(
|
||||
`| ${inlineCode(lane.name)} | ${markdownCell(status)} | ${markdownCell(lane.elapsedSeconds)} | ${lane.timedOut ? "yes" : "no"} | ${inlineCode(lane.rerunCommand)} |`,
|
||||
);
|
||||
}
|
||||
|
||||
const phases = Array.isArray(summary.phases) ? summary.phases : [];
|
||||
if (phases.length > 0) {
|
||||
lines.push("", "| Phase | Seconds | Status | Image kind |", "| --- | ---: | --- | --- |");
|
||||
for (const phase of phases) {
|
||||
lines.push(
|
||||
`| ${inlineCode(phase.name)} | ${markdownCell(phase.elapsedSeconds)} | ${markdownCell(phase.status)} | ${markdownCell(phase.imageKind)} |`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
const [command, file, ...args] = process.argv.slice(2);
|
||||
if (!command || !file) {
|
||||
throw new Error(usage());
|
||||
}
|
||||
|
||||
if (command === "github-outputs") {
|
||||
process.stdout.write(`${githubOutputs(readJson(file)).join("\n")}\n`);
|
||||
} else if (command === "summary") {
|
||||
const title = args.join(" ").trim();
|
||||
if (!title) {
|
||||
throw new Error(usage());
|
||||
}
|
||||
process.stdout.write(`${summaryMarkdown(readJson(file), title)}\n`);
|
||||
} else {
|
||||
throw new Error(`unknown command: ${command}\n${usage()}`);
|
||||
}
|
||||
@@ -12,7 +12,7 @@ IMAGE_NAME="$(docker_e2e_resolve_image "openclaw-bundled-channel-deps-e2e" OPENC
|
||||
UPDATE_BASELINE_VERSION="${OPENCLAW_BUNDLED_CHANNEL_UPDATE_BASELINE_VERSION:-2026.4.20}"
|
||||
DOCKER_TARGET="${OPENCLAW_BUNDLED_CHANNEL_DOCKER_TARGET:-bare}"
|
||||
HOST_BUILD="${OPENCLAW_BUNDLED_CHANNEL_HOST_BUILD:-1}"
|
||||
PACKAGE_TGZ="${OPENCLAW_BUNDLED_CHANNEL_PACKAGE_TGZ:-}"
|
||||
PACKAGE_TGZ="${OPENCLAW_CURRENT_PACKAGE_TGZ:-}"
|
||||
RUN_CHANNEL_SCENARIOS="${OPENCLAW_BUNDLED_CHANNEL_SCENARIOS:-1}"
|
||||
RUN_UPDATE_SCENARIO="${OPENCLAW_BUNDLED_CHANNEL_UPDATE_SCENARIO:-1}"
|
||||
RUN_ROOT_OWNED_SCENARIO="${OPENCLAW_BUNDLED_CHANNEL_ROOT_OWNED_SCENARIO:-1}"
|
||||
@@ -30,15 +30,14 @@ prepare_package_tgz() {
|
||||
return 0
|
||||
fi
|
||||
if [ "$HOST_BUILD" = "0" ] && [ -z "${OPENCLAW_CURRENT_PACKAGE_TGZ:-}" ]; then
|
||||
echo "OPENCLAW_BUNDLED_CHANNEL_HOST_BUILD=0 requires OPENCLAW_CURRENT_PACKAGE_TGZ or OPENCLAW_BUNDLED_CHANNEL_PACKAGE_TGZ" >&2
|
||||
echo "OPENCLAW_BUNDLED_CHANNEL_HOST_BUILD=0 requires OPENCLAW_CURRENT_PACKAGE_TGZ" >&2
|
||||
exit 1
|
||||
fi
|
||||
PACKAGE_TGZ="$(docker_e2e_prepare_package_tgz bundled-channel-deps)"
|
||||
}
|
||||
|
||||
prepare_package_tgz
|
||||
DOCKER_PACKAGE_TGZ="/tmp/openclaw-current.tgz"
|
||||
PACKAGE_DOCKER_ARGS=(-v "$PACKAGE_TGZ:$DOCKER_PACKAGE_TGZ:ro" -e "OPENCLAW_CURRENT_PACKAGE_TGZ=$DOCKER_PACKAGE_TGZ")
|
||||
docker_e2e_package_mount_args "$PACKAGE_TGZ"
|
||||
|
||||
run_channel_scenario() {
|
||||
local channel="$1"
|
||||
@@ -51,7 +50,7 @@ run_channel_scenario() {
|
||||
-e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
|
||||
-e OPENCLAW_CHANNEL_UNDER_TEST="$channel" \
|
||||
-e OPENCLAW_DEP_SENTINEL="$dep_sentinel" \
|
||||
"${PACKAGE_DOCKER_ARGS[@]}" \
|
||||
"${DOCKER_E2E_PACKAGE_ARGS[@]}" \
|
||||
-i "$IMAGE_NAME" bash -s >"$run_log" 2>&1 <<'EOF'
|
||||
set -euo pipefail
|
||||
|
||||
@@ -463,7 +462,7 @@ run_root_owned_global_scenario() {
|
||||
echo "Running bundled channel root-owned global install Docker E2E..."
|
||||
if ! timeout "$DOCKER_RUN_TIMEOUT" docker run --rm --user root \
|
||||
-e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
|
||||
"${PACKAGE_DOCKER_ARGS[@]}" \
|
||||
"${DOCKER_E2E_PACKAGE_ARGS[@]}" \
|
||||
-i "$IMAGE_NAME" bash -s >"$run_log" 2>&1 <<'EOF'
|
||||
set -euo pipefail
|
||||
|
||||
@@ -640,7 +639,7 @@ run_setup_entry_scenario() {
|
||||
echo "Running bundled channel setup-entry runtime deps Docker E2E..."
|
||||
if ! timeout "$DOCKER_RUN_TIMEOUT" docker run --rm \
|
||||
-e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
|
||||
"${PACKAGE_DOCKER_ARGS[@]}" \
|
||||
"${DOCKER_E2E_PACKAGE_ARGS[@]}" \
|
||||
-i "$IMAGE_NAME" bash -s >"$run_log" 2>&1 <<'EOF'
|
||||
set -euo pipefail
|
||||
|
||||
@@ -897,7 +896,7 @@ run_disabled_config_scenario() {
|
||||
echo "Running bundled channel disabled-config runtime deps Docker E2E..."
|
||||
if ! timeout "$DOCKER_RUN_TIMEOUT" docker run --rm \
|
||||
-e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
|
||||
"${PACKAGE_DOCKER_ARGS[@]}" \
|
||||
"${DOCKER_E2E_PACKAGE_ARGS[@]}" \
|
||||
-i "$IMAGE_NAME" bash -s >"$run_log" 2>&1 <<'EOF'
|
||||
set -euo pipefail
|
||||
|
||||
@@ -1064,7 +1063,7 @@ run_update_scenario() {
|
||||
-e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
|
||||
-e OPENCLAW_BUNDLED_CHANNEL_UPDATE_BASELINE_VERSION="$UPDATE_BASELINE_VERSION" \
|
||||
-e "OPENCLAW_BUNDLED_CHANNEL_UPDATE_TARGETS=${OPENCLAW_BUNDLED_CHANNEL_UPDATE_TARGETS:-telegram,discord,slack,feishu,memory-lancedb,acpx}" \
|
||||
"${PACKAGE_DOCKER_ARGS[@]}" \
|
||||
"${DOCKER_E2E_PACKAGE_ARGS[@]}" \
|
||||
-i "$IMAGE_NAME" bash -s >"$run_log" 2>&1 <<'EOF'
|
||||
set -euo pipefail
|
||||
|
||||
@@ -1496,7 +1495,7 @@ run_load_failure_scenario() {
|
||||
echo "Running bundled channel load-failure isolation Docker E2E..."
|
||||
if ! timeout "$DOCKER_RUN_TIMEOUT" docker run --rm \
|
||||
-e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
|
||||
"${PACKAGE_DOCKER_ARGS[@]}" \
|
||||
"${DOCKER_E2E_PACKAGE_ARGS[@]}" \
|
||||
-i "$IMAGE_NAME" bash -s >"$run_log" 2>&1 <<'EOF'
|
||||
set -euo pipefail
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ cleanup() {
|
||||
trap cleanup EXIT
|
||||
|
||||
docker_e2e_build_or_reuse "$IMAGE_NAME" crestodian-first-run
|
||||
docker_e2e_harness_mount_args
|
||||
|
||||
echo "Running in-container Crestodian first-run smoke..."
|
||||
# Harness files are mounted read-only; the app under test comes from /app/dist.
|
||||
@@ -24,7 +25,7 @@ docker run --rm \
|
||||
--name "$CONTAINER_NAME" \
|
||||
-e "OPENCLAW_STATE_DIR=/tmp/openclaw-state" \
|
||||
-e "OPENCLAW_CONFIG_PATH=/tmp/openclaw-state/openclaw.json" \
|
||||
-v "$ROOT_DIR/scripts/e2e:/app/scripts/e2e:ro" \
|
||||
"${DOCKER_E2E_HARNESS_ARGS[@]}" \
|
||||
"$IMAGE_NAME" \
|
||||
bash -lc "set -euo pipefail
|
||||
node --import tsx scripts/e2e/crestodian-first-run-docker-client.ts
|
||||
|
||||
@@ -16,6 +16,7 @@ cleanup() {
|
||||
trap cleanup EXIT
|
||||
|
||||
docker_e2e_build_or_reuse "$IMAGE_NAME" crestodian-planner
|
||||
docker_e2e_harness_mount_args
|
||||
|
||||
echo "Running in-container Crestodian planner fallback smoke..."
|
||||
# Harness files are mounted read-only; the app under test comes from /app/dist.
|
||||
@@ -24,7 +25,7 @@ docker run --rm \
|
||||
--name "$CONTAINER_NAME" \
|
||||
-e "OPENCLAW_STATE_DIR=/tmp/openclaw-state" \
|
||||
-e "OPENCLAW_CONFIG_PATH=/tmp/openclaw-state/openclaw.json" \
|
||||
-v "$ROOT_DIR/scripts/e2e:/app/scripts/e2e:ro" \
|
||||
"${DOCKER_E2E_HARNESS_ARGS[@]}" \
|
||||
"$IMAGE_NAME" \
|
||||
bash -lc "set -euo pipefail
|
||||
node --import tsx scripts/e2e/crestodian-planner-docker-client.ts
|
||||
|
||||
@@ -16,6 +16,7 @@ cleanup() {
|
||||
trap cleanup EXIT
|
||||
|
||||
docker_e2e_build_or_reuse "$IMAGE_NAME" crestodian-rescue
|
||||
docker_e2e_harness_mount_args
|
||||
|
||||
echo "Running in-container Crestodian rescue smoke..."
|
||||
# Harness files are mounted read-only; the app under test comes from /app/dist.
|
||||
@@ -24,7 +25,7 @@ docker run --rm \
|
||||
--name "$CONTAINER_NAME" \
|
||||
-e "OPENCLAW_STATE_DIR=/tmp/openclaw-state" \
|
||||
-e "OPENCLAW_CONFIG_PATH=/tmp/openclaw-state/openclaw.json" \
|
||||
-v "$ROOT_DIR/scripts/e2e:/app/scripts/e2e:ro" \
|
||||
"${DOCKER_E2E_HARNESS_ARGS[@]}" \
|
||||
"$IMAGE_NAME" \
|
||||
bash -lc "set -euo pipefail
|
||||
node --import tsx scripts/e2e/crestodian-rescue-docker-client.ts
|
||||
|
||||
@@ -18,6 +18,7 @@ cleanup() {
|
||||
trap cleanup EXIT
|
||||
|
||||
docker_e2e_build_or_reuse "$IMAGE_NAME" cron-mcp-cleanup
|
||||
docker_e2e_harness_mount_args
|
||||
|
||||
echo "Running in-container cron/subagent MCP cleanup smoke..."
|
||||
# Harness files are mounted read-only; the app under test comes from /app/dist.
|
||||
@@ -36,7 +37,7 @@ docker run --rm \
|
||||
-e "GW_URL=ws://127.0.0.1:$PORT" \
|
||||
-e "GW_TOKEN=$TOKEN" \
|
||||
-e "OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1" \
|
||||
-v "$ROOT_DIR/scripts/e2e:/app/scripts/e2e:ro" \
|
||||
"${DOCKER_E2E_HARNESS_ARGS[@]}" \
|
||||
"$IMAGE_NAME" \
|
||||
bash -lc "set -euo pipefail
|
||||
entry=dist/index.mjs
|
||||
|
||||
@@ -18,6 +18,7 @@ cleanup() {
|
||||
trap cleanup EXIT
|
||||
|
||||
docker_e2e_build_or_reuse "$IMAGE_NAME" mcp-channels
|
||||
docker_e2e_harness_mount_args
|
||||
|
||||
echo "Running in-container gateway + MCP smoke..."
|
||||
# Harness files are mounted read-only; the app under test comes from /app/dist.
|
||||
@@ -36,7 +37,7 @@ docker run --rm \
|
||||
-e "GW_URL=ws://127.0.0.1:$PORT" \
|
||||
-e "GW_TOKEN=$TOKEN" \
|
||||
-e "OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1" \
|
||||
-v "$ROOT_DIR/scripts/e2e:/app/scripts/e2e:ro" \
|
||||
"${DOCKER_E2E_HARNESS_ARGS[@]}" \
|
||||
"$IMAGE_NAME" \
|
||||
bash -lc "set -euo pipefail
|
||||
entry=dist/index.mjs
|
||||
|
||||
@@ -10,7 +10,7 @@ source "$ROOT_DIR/scripts/lib/docker-e2e-package.sh"
|
||||
IMAGE_NAME="$(docker_e2e_resolve_image "openclaw-npm-onboard-channel-agent-e2e" OPENCLAW_NPM_ONBOARD_E2E_IMAGE)"
|
||||
DOCKER_TARGET="${OPENCLAW_NPM_ONBOARD_DOCKER_TARGET:-bare}"
|
||||
HOST_BUILD="${OPENCLAW_NPM_ONBOARD_HOST_BUILD:-1}"
|
||||
PACKAGE_TGZ="${OPENCLAW_NPM_ONBOARD_PACKAGE_TGZ:-}"
|
||||
PACKAGE_TGZ="${OPENCLAW_CURRENT_PACKAGE_TGZ:-}"
|
||||
CHANNEL="${OPENCLAW_NPM_ONBOARD_CHANNEL:-telegram}"
|
||||
|
||||
case "$CHANNEL" in
|
||||
@@ -29,7 +29,7 @@ prepare_package_tgz() {
|
||||
return 0
|
||||
fi
|
||||
if [ "$HOST_BUILD" = "0" ] && [ -z "${OPENCLAW_CURRENT_PACKAGE_TGZ:-}" ]; then
|
||||
echo "OPENCLAW_NPM_ONBOARD_HOST_BUILD=0 requires OPENCLAW_CURRENT_PACKAGE_TGZ or OPENCLAW_NPM_ONBOARD_PACKAGE_TGZ" >&2
|
||||
echo "OPENCLAW_NPM_ONBOARD_HOST_BUILD=0 requires OPENCLAW_CURRENT_PACKAGE_TGZ" >&2
|
||||
exit 1
|
||||
fi
|
||||
PACKAGE_TGZ="$(docker_e2e_prepare_package_tgz npm-onboard-channel-agent)"
|
||||
@@ -37,16 +37,16 @@ prepare_package_tgz() {
|
||||
|
||||
prepare_package_tgz
|
||||
|
||||
DOCKER_PACKAGE_TGZ="/tmp/openclaw-current.tgz"
|
||||
docker_e2e_package_mount_args "$PACKAGE_TGZ"
|
||||
docker_e2e_harness_mount_args
|
||||
run_log="$(mktemp "${TMPDIR:-/tmp}/openclaw-npm-onboard-channel-agent.XXXXXX")"
|
||||
|
||||
echo "Running npm tarball onboard/channel/agent Docker E2E ($CHANNEL)..."
|
||||
if ! docker run --rm \
|
||||
-e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
|
||||
-e OPENCLAW_NPM_ONBOARD_CHANNEL="$CHANNEL" \
|
||||
-e OPENCLAW_CURRENT_PACKAGE_TGZ="$DOCKER_PACKAGE_TGZ" \
|
||||
-v "$PACKAGE_TGZ:$DOCKER_PACKAGE_TGZ:ro" \
|
||||
-v "$ROOT_DIR/scripts/e2e:/app/scripts/e2e:ro" \
|
||||
"${DOCKER_E2E_PACKAGE_ARGS[@]}" \
|
||||
"${DOCKER_E2E_HARNESS_ARGS[@]}" \
|
||||
-i "$IMAGE_NAME" bash -s >"$run_log" 2>&1 <<'EOF'
|
||||
set -euo pipefail
|
||||
|
||||
|
||||
@@ -49,6 +49,7 @@ validate_openclaw_package_spec() {
|
||||
validate_openclaw_package_spec "$PACKAGE_SPEC"
|
||||
|
||||
docker_e2e_build_or_reuse "$IMAGE_NAME" npm-telegram-live "$ROOT_DIR/scripts/e2e/Dockerfile" "$ROOT_DIR" "$DOCKER_TARGET"
|
||||
docker_e2e_harness_mount_args
|
||||
|
||||
mkdir -p "$ROOT_DIR/.artifacts/qa-e2e"
|
||||
run_log="$(mktemp "${TMPDIR:-/tmp}/openclaw-npm-telegram-live.XXXXXX")"
|
||||
@@ -147,7 +148,7 @@ EOF
|
||||
run_logged docker run --rm \
|
||||
"${docker_env[@]}" \
|
||||
-v "$ROOT_DIR/.artifacts:/app/.artifacts" \
|
||||
-v "$ROOT_DIR/scripts/e2e:/app/scripts/e2e:ro" \
|
||||
"${DOCKER_E2E_HARNESS_ARGS[@]}" \
|
||||
-v "$ROOT_DIR/extensions:/app/extensions:ro" \
|
||||
-v "$npm_prefix_host:/npm-global" \
|
||||
-i "$IMAGE_NAME" bash -s <<'EOF'
|
||||
|
||||
@@ -10,13 +10,14 @@ IMAGE_NAME="$(docker_e2e_resolve_image "openclaw-openai-image-auth-e2e" OPENCLAW
|
||||
SKIP_BUILD="${OPENCLAW_OPENAI_IMAGE_AUTH_E2E_SKIP_BUILD:-0}"
|
||||
|
||||
docker_e2e_build_or_reuse "$IMAGE_NAME" openai-image-auth "$ROOT_DIR/scripts/e2e/Dockerfile" "$ROOT_DIR" "" "$SKIP_BUILD"
|
||||
docker_e2e_harness_mount_args
|
||||
|
||||
echo "Running OpenAI image auth Docker E2E..."
|
||||
# Harness files are mounted read-only; the app under test comes from /app/dist.
|
||||
run_logged openai-image-auth docker run --rm \
|
||||
-e "OPENAI_API_KEY=sk-openclaw-image-auth-e2e" \
|
||||
-e "OPENCLAW_QA_ALLOW_LOCAL_IMAGE_PROVIDER=1" \
|
||||
-v "$ROOT_DIR/scripts/e2e:/app/scripts/e2e:ro" \
|
||||
"${DOCKER_E2E_HARNESS_ARGS[@]}" \
|
||||
-i "$IMAGE_NAME" bash -lc '
|
||||
set -euo pipefail
|
||||
export HOME="$(mktemp -d "/tmp/openclaw-openai-image-auth.XXXXXX")"
|
||||
|
||||
@@ -49,6 +49,7 @@ cleanup() {
|
||||
trap cleanup EXIT
|
||||
|
||||
docker_e2e_build_or_reuse "$IMAGE_NAME" openwebui
|
||||
docker_e2e_harness_mount_args
|
||||
|
||||
echo "Pulling Open WebUI image: $OPENWEBUI_IMAGE"
|
||||
timeout "$DOCKER_PULL_TIMEOUT" docker pull "$OPENWEBUI_IMAGE" >/dev/null
|
||||
@@ -69,7 +70,7 @@ docker_cmd docker run -d \
|
||||
-e "OPENCLAW_SKIP_CANVAS_HOST=1" \
|
||||
-e OPENAI_API_KEY \
|
||||
${OPENAI_BASE_URL_VALUE:+-e OPENAI_BASE_URL} \
|
||||
-v "$ROOT_DIR/scripts/e2e:/app/scripts/e2e:ro" \
|
||||
"${DOCKER_E2E_HARNESS_ARGS[@]}" \
|
||||
"$IMAGE_NAME" \
|
||||
bash -lc '
|
||||
set -euo pipefail
|
||||
|
||||
@@ -16,6 +16,7 @@ cleanup() {
|
||||
trap cleanup EXIT
|
||||
|
||||
docker_e2e_build_or_reuse "$IMAGE_NAME" pi-bundle-mcp-tools
|
||||
docker_e2e_harness_mount_args
|
||||
|
||||
echo "Running in-container Pi bundle MCP tool availability smoke..."
|
||||
# Harness files are mounted read-only; the app under test comes from /app/dist.
|
||||
@@ -23,7 +24,7 @@ set +e
|
||||
docker run --rm \
|
||||
--name "$CONTAINER_NAME" \
|
||||
-e "OPENCLAW_STATE_DIR=/tmp/openclaw-state" \
|
||||
-v "$ROOT_DIR/scripts/e2e:/app/scripts/e2e:ro" \
|
||||
"${DOCKER_E2E_HARNESS_ARGS[@]}" \
|
||||
"$IMAGE_NAME" \
|
||||
bash -lc "set -euo pipefail
|
||||
node --import tsx scripts/e2e/pi-bundle-mcp-tools-docker-client.ts
|
||||
|
||||
@@ -17,6 +17,7 @@ cleanup() {
|
||||
trap cleanup EXIT
|
||||
|
||||
docker_e2e_build_or_reuse "$IMAGE_NAME" session-runtime-context
|
||||
docker_e2e_harness_mount_args
|
||||
|
||||
echo "Running session runtime context Docker E2E..."
|
||||
# Harness files are mounted read-only; the app under test comes from /app/dist.
|
||||
@@ -24,7 +25,7 @@ set +e
|
||||
docker run --rm \
|
||||
--name "$CONTAINER_NAME" \
|
||||
-e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
|
||||
-v "$ROOT_DIR/scripts/e2e:/app/scripts/e2e:ro" \
|
||||
"${DOCKER_E2E_HARNESS_ARGS[@]}" \
|
||||
"$IMAGE_NAME" \
|
||||
bash -lc 'set -euo pipefail; node --import tsx scripts/e2e/session-runtime-context-docker-client.ts' \
|
||||
>"$RUN_LOG" 2>&1
|
||||
|
||||
@@ -29,16 +29,13 @@ docker_e2e_prepare_package_tgz() {
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo "Building OpenClaw package artifacts..."
|
||||
run_logged "$label-host-build" pnpm build
|
||||
echo "Writing package inventory and packing OpenClaw once..."
|
||||
run_logged "$label-inventory" node --import tsx --input-type=module -e 'const { writePackageDistInventory } = await import("./src/infra/package-dist-inventory.ts"); await writePackageDistInventory(process.cwd());'
|
||||
|
||||
local pack_dir
|
||||
pack_dir="$(mktemp -d "${TMPDIR:-/tmp}/openclaw-docker-e2e-pack.XXXXXX")"
|
||||
run_logged "$label-pack" npm pack --ignore-scripts --pack-destination "$pack_dir"
|
||||
|
||||
package_tgz="$(find "$pack_dir" -maxdepth 1 -name 'openclaw-*.tgz' -print -quit)"
|
||||
package_tgz="$(
|
||||
node "$ROOT_DIR/scripts/package-openclaw-for-docker.mjs" \
|
||||
--output-dir "$pack_dir" \
|
||||
--output-name openclaw-current.tgz
|
||||
)"
|
||||
if [ -z "$package_tgz" ]; then
|
||||
echo "missing packed OpenClaw tarball" >&2
|
||||
return 1
|
||||
@@ -61,3 +58,7 @@ docker_e2e_package_mount_args() {
|
||||
local target="${2:-/tmp/openclaw-current.tgz}"
|
||||
DOCKER_E2E_PACKAGE_ARGS=(-v "$package_tgz:$target:ro" -e "OPENCLAW_CURRENT_PACKAGE_TGZ=$target")
|
||||
}
|
||||
|
||||
docker_e2e_harness_mount_args() {
|
||||
DOCKER_E2E_HARNESS_ARGS=(-v "$ROOT_DIR/scripts/e2e:/app/scripts/e2e:ro")
|
||||
}
|
||||
|
||||
236
scripts/lib/docker-e2e-plan.mjs
Normal file
236
scripts/lib/docker-e2e-plan.mjs
Normal file
@@ -0,0 +1,236 @@
|
||||
// Docker E2E scheduler planning helpers.
|
||||
// This module turns the scenario catalog plus env-driven inputs into a concrete
|
||||
// lane plan. It intentionally does not define scenario commands.
|
||||
import {
|
||||
DEFAULT_LIVE_RETRIES,
|
||||
allReleasePathLanes,
|
||||
mainLanes,
|
||||
releasePathChunkLanes,
|
||||
tailLanes,
|
||||
} from "./docker-e2e-scenarios.mjs";
|
||||
|
||||
export { DEFAULT_LIVE_RETRIES };
|
||||
|
||||
export const DEFAULT_E2E_BARE_IMAGE = "openclaw-docker-e2e-bare:local";
|
||||
export const DEFAULT_E2E_FUNCTIONAL_IMAGE = "openclaw-docker-e2e-functional:local";
|
||||
export const DEFAULT_E2E_IMAGE = DEFAULT_E2E_FUNCTIONAL_IMAGE;
|
||||
export const DEFAULT_PARALLELISM = 10;
|
||||
export const DEFAULT_PROFILE = "all";
|
||||
export const DEFAULT_RESOURCE_LIMITS = {
|
||||
docker: DEFAULT_PARALLELISM,
|
||||
live: 9,
|
||||
"live:claude": 4,
|
||||
"live:codex": 4,
|
||||
"live:droid": 4,
|
||||
"live:gemini": 4,
|
||||
"live:opencode": 4,
|
||||
npm: 10,
|
||||
service: 7,
|
||||
};
|
||||
export const DEFAULT_TAIL_PARALLELISM = 10;
|
||||
export const RELEASE_PATH_PROFILE = "release-path";
|
||||
|
||||
export function parseLaneSelection(raw) {
|
||||
if (!raw) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
...new Set(
|
||||
String(raw)
|
||||
.split(/[,\s]+/u)
|
||||
.map((token) => token.trim())
|
||||
.filter(Boolean),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
export function dedupeLanes(poolLanes) {
|
||||
const byName = new Map();
|
||||
for (const poolLane of poolLanes) {
|
||||
if (!byName.has(poolLane.name)) {
|
||||
byName.set(poolLane.name, poolLane);
|
||||
}
|
||||
}
|
||||
return [...byName.values()];
|
||||
}
|
||||
|
||||
export function selectNamedLanes(poolLanes, selectedNames, label) {
|
||||
const byName = new Map(poolLanes.map((poolLane) => [poolLane.name, poolLane]));
|
||||
const missing = selectedNames.filter((name) => !byName.has(name));
|
||||
if (missing.length > 0) {
|
||||
throw new Error(
|
||||
`${label} unknown lane(s): ${missing.join(", ")}. Available lanes: ${[...byName.keys()]
|
||||
.toSorted((a, b) => a.localeCompare(b))
|
||||
.join(", ")}`,
|
||||
);
|
||||
}
|
||||
return selectedNames.map((name) => byName.get(name));
|
||||
}
|
||||
|
||||
export function parseLiveMode(raw) {
|
||||
const mode = raw || "all";
|
||||
if (mode === "all" || mode === "skip" || mode === "only") {
|
||||
return mode;
|
||||
}
|
||||
throw new Error(
|
||||
`OPENCLAW_DOCKER_ALL_LIVE_MODE must be one of: all, skip, only. Got: ${JSON.stringify(raw)}`,
|
||||
);
|
||||
}
|
||||
|
||||
export function parseProfile(raw) {
|
||||
const profile = raw || DEFAULT_PROFILE;
|
||||
if (profile === DEFAULT_PROFILE || profile === RELEASE_PATH_PROFILE) {
|
||||
return profile;
|
||||
}
|
||||
throw new Error(
|
||||
`OPENCLAW_DOCKER_ALL_PROFILE must be one of: ${DEFAULT_PROFILE}, ${RELEASE_PATH_PROFILE}. Got: ${JSON.stringify(raw)}`,
|
||||
);
|
||||
}
|
||||
|
||||
export function applyLiveMode(poolLanes, mode) {
|
||||
if (mode === "all") {
|
||||
return poolLanes;
|
||||
}
|
||||
return poolLanes.filter((poolLane) => (mode === "only" ? poolLane.live : !poolLane.live));
|
||||
}
|
||||
|
||||
export function applyLiveRetries(poolLanes, retries) {
|
||||
return poolLanes.map((poolLane) => (poolLane.live ? { ...poolLane, retries } : poolLane));
|
||||
}
|
||||
|
||||
export function laneWeight(poolLane) {
|
||||
return Math.max(1, poolLane.weight ?? 1);
|
||||
}
|
||||
|
||||
export function laneResources(poolLane) {
|
||||
return ["docker", ...(poolLane.resources ?? [])];
|
||||
}
|
||||
|
||||
export function laneSummary(poolLane) {
|
||||
const resources = laneResources(poolLane).join(",");
|
||||
const timeout = poolLane.timeoutMs ? ` timeout=${Math.round(poolLane.timeoutMs / 1000)}s` : "";
|
||||
const retries = poolLane.retries > 0 ? ` retries=${poolLane.retries}` : "";
|
||||
const cache = poolLane.cacheKey ? ` cache=${poolLane.cacheKey}` : "";
|
||||
const image = poolLane.e2eImageKind ? ` image=${poolLane.e2eImageKind}` : "";
|
||||
return `${poolLane.name}(w=${laneWeight(poolLane)} r=${resources}${timeout}${retries}${cache}${image})`;
|
||||
}
|
||||
|
||||
export function lanesNeedE2eImageKind(poolLanes, kind) {
|
||||
return poolLanes.some((poolLane) => poolLane.e2eImageKind === kind);
|
||||
}
|
||||
|
||||
export function lanesNeedOpenClawPackage(poolLanes) {
|
||||
return poolLanes.some((poolLane) => poolLane.e2eImageKind);
|
||||
}
|
||||
|
||||
export function findLaneByName(name) {
|
||||
return dedupeLanes([
|
||||
...allReleasePathLanes({ includeOpenWebUI: true }),
|
||||
...mainLanes,
|
||||
...tailLanes,
|
||||
]).find((poolLane) => poolLane.name === name);
|
||||
}
|
||||
|
||||
export function laneCredentialRequirements(poolLane) {
|
||||
const credentials = [];
|
||||
if (poolLane.name === "install-e2e") {
|
||||
credentials.push("openai", "anthropic");
|
||||
}
|
||||
if (poolLane.name === "openwebui" || poolLane.name === "openai-web-search-minimal") {
|
||||
credentials.push("openai");
|
||||
}
|
||||
return credentials;
|
||||
}
|
||||
|
||||
function unique(values) {
|
||||
return [...new Set(values.filter(Boolean))];
|
||||
}
|
||||
|
||||
export function buildPlanJson(params) {
|
||||
const scheduledLanes = [...params.orderedLanes, ...params.orderedTailLanes];
|
||||
const imageKinds = unique(scheduledLanes.map((poolLane) => poolLane.e2eImageKind)).toSorted(
|
||||
(a, b) => a.localeCompare(b),
|
||||
);
|
||||
return {
|
||||
chunk: params.releaseChunk || undefined,
|
||||
credentials: unique(scheduledLanes.flatMap(laneCredentialRequirements)).toSorted((a, b) =>
|
||||
a.localeCompare(b),
|
||||
),
|
||||
imageKinds,
|
||||
includeOpenWebUI: params.includeOpenWebUI,
|
||||
lanes: scheduledLanes.map((poolLane) => ({
|
||||
command: poolLane.command,
|
||||
imageKind: poolLane.e2eImageKind,
|
||||
live: poolLane.live,
|
||||
name: poolLane.name,
|
||||
resources: laneResources(poolLane),
|
||||
timeoutMs: poolLane.timeoutMs,
|
||||
weight: laneWeight(poolLane),
|
||||
})),
|
||||
mainLanes: params.orderedLanes.map((poolLane) => poolLane.name),
|
||||
needs: {
|
||||
bareImage: imageKinds.includes("bare"),
|
||||
e2eImage: imageKinds.length > 0,
|
||||
functionalImage: imageKinds.includes("functional"),
|
||||
liveImage: scheduledLanes.some((poolLane) => poolLane.live),
|
||||
package: lanesNeedOpenClawPackage(scheduledLanes),
|
||||
},
|
||||
profile: params.profile,
|
||||
selectedLanes: params.selectedLaneNames,
|
||||
tailLanes: params.orderedTailLanes.map((poolLane) => poolLane.name),
|
||||
version: 1,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveDockerE2ePlan(options) {
|
||||
const retriedMainLanes = applyLiveRetries(mainLanes, options.liveRetries);
|
||||
const retriedTailLanes = applyLiveRetries(tailLanes, options.liveRetries);
|
||||
const releaseLanes =
|
||||
options.selectedLaneNames.length === 0 && options.profile === RELEASE_PATH_PROFILE
|
||||
? options.planReleaseAll
|
||||
? allReleasePathLanes({ includeOpenWebUI: options.includeOpenWebUI })
|
||||
: releasePathChunkLanes(options.releaseChunk, {
|
||||
includeOpenWebUI: options.includeOpenWebUI,
|
||||
})
|
||||
: undefined;
|
||||
const selectedLanes =
|
||||
options.selectedLaneNames.length > 0
|
||||
? selectNamedLanes(
|
||||
dedupeLanes([
|
||||
...allReleasePathLanes({ includeOpenWebUI: options.includeOpenWebUI }),
|
||||
...retriedMainLanes,
|
||||
...retriedTailLanes,
|
||||
]),
|
||||
options.selectedLaneNames,
|
||||
"OPENCLAW_DOCKER_ALL_LANES",
|
||||
)
|
||||
: undefined;
|
||||
const configuredLanes = selectedLanes
|
||||
? selectedLanes
|
||||
: releaseLanes
|
||||
? releaseLanes
|
||||
: options.liveMode === "only"
|
||||
? applyLiveMode([...retriedMainLanes, ...retriedTailLanes], options.liveMode)
|
||||
: applyLiveMode(retriedMainLanes, options.liveMode);
|
||||
const configuredTailLanes =
|
||||
selectedLanes || releaseLanes
|
||||
? []
|
||||
: options.liveMode === "only"
|
||||
? []
|
||||
: applyLiveMode(retriedTailLanes, options.liveMode);
|
||||
const orderedLanes = options.orderLanes(configuredLanes, options.timingStore);
|
||||
const orderedTailLanes = options.orderLanes(configuredTailLanes, options.timingStore);
|
||||
return {
|
||||
orderedLanes,
|
||||
orderedTailLanes,
|
||||
plan: buildPlanJson({
|
||||
includeOpenWebUI: options.includeOpenWebUI,
|
||||
orderedLanes,
|
||||
orderedTailLanes,
|
||||
profile: options.profile,
|
||||
releaseChunk: options.releaseChunk,
|
||||
selectedLaneNames: options.selectedLaneNames,
|
||||
}),
|
||||
scheduledLanes: [...orderedLanes, ...orderedTailLanes],
|
||||
};
|
||||
}
|
||||
417
scripts/lib/docker-e2e-scenarios.mjs
Normal file
417
scripts/lib/docker-e2e-scenarios.mjs
Normal file
@@ -0,0 +1,417 @@
|
||||
// Docker E2E scenario catalog.
|
||||
// Keep lane names, commands, image kind, timeout, resources, and release chunks
|
||||
// here. Planning and execution live in separate modules.
|
||||
|
||||
const BUNDLED_UPDATE_TIMEOUT_MS = 20 * 60 * 1000;
|
||||
export const DEFAULT_LIVE_RETRIES = 1;
|
||||
const LIVE_ACP_TIMEOUT_MS = 20 * 60 * 1000;
|
||||
const LIVE_CLI_TIMEOUT_MS = 20 * 60 * 1000;
|
||||
const LIVE_PROFILE_TIMEOUT_MS = 20 * 60 * 1000;
|
||||
const OPENWEBUI_TIMEOUT_MS = 20 * 60 * 1000;
|
||||
|
||||
export const LIVE_RETRY_PATTERNS = [
|
||||
/529\b/i,
|
||||
/overloaded/i,
|
||||
/capacity/i,
|
||||
/rate.?limit/i,
|
||||
/gateway closed \(1000 normal closure\)/i,
|
||||
/ECONNRESET|ETIMEDOUT|ENOTFOUND/i,
|
||||
];
|
||||
|
||||
const bundledChannelLaneCommand =
|
||||
"OPENCLAW_SKIP_DOCKER_BUILD=1 OPENCLAW_BUNDLED_CHANNEL_UPDATE_SCENARIO=0 OPENCLAW_BUNDLED_CHANNEL_ROOT_OWNED_SCENARIO=0 OPENCLAW_BUNDLED_CHANNEL_SETUP_ENTRY_SCENARIO=0 OPENCLAW_BUNDLED_CHANNEL_LOAD_FAILURE_SCENARIO=0 OPENCLAW_BUNDLED_CHANNEL_DISABLED_CONFIG_SCENARIO=0 pnpm test:docker:bundled-channel-deps";
|
||||
|
||||
function lane(name, command, options = {}) {
|
||||
return {
|
||||
cacheKey: options.cacheKey,
|
||||
command,
|
||||
e2eImageKind: options.e2eImageKind ?? (options.live ? undefined : "functional"),
|
||||
estimateSeconds: options.estimateSeconds,
|
||||
live: options.live === true,
|
||||
name,
|
||||
retryPatterns: options.retryPatterns ?? [],
|
||||
retries: options.retries ?? 0,
|
||||
resources: options.resources ?? [],
|
||||
timeoutMs: options.timeoutMs,
|
||||
weight: options.weight ?? 1,
|
||||
};
|
||||
}
|
||||
|
||||
function liveProviderResource(provider) {
|
||||
if (!provider) {
|
||||
return undefined;
|
||||
}
|
||||
if (provider === "claude-cli" || provider === "claude") {
|
||||
return "live:claude";
|
||||
}
|
||||
if (provider === "codex-cli" || provider === "codex") {
|
||||
return "live:codex";
|
||||
}
|
||||
if (provider === "droid") {
|
||||
return "live:droid";
|
||||
}
|
||||
if (provider === "google-gemini-cli" || provider === "gemini") {
|
||||
return "live:gemini";
|
||||
}
|
||||
if (provider === "opencode") {
|
||||
return "live:opencode";
|
||||
}
|
||||
if (provider === "openai") {
|
||||
return "live:openai";
|
||||
}
|
||||
return `live:${provider}`;
|
||||
}
|
||||
|
||||
function liveProviderResources(options) {
|
||||
const providers = options.providers ?? (options.provider ? [options.provider] : []);
|
||||
return providers.map(liveProviderResource).filter(Boolean);
|
||||
}
|
||||
|
||||
function liveLane(name, command, options = {}) {
|
||||
return lane(name, command, {
|
||||
...options,
|
||||
live: true,
|
||||
resources: ["live", ...liveProviderResources(options), ...(options.resources ?? [])],
|
||||
retryPatterns: options.retryPatterns ?? LIVE_RETRY_PATTERNS,
|
||||
retries: options.retries ?? DEFAULT_LIVE_RETRIES,
|
||||
weight: options.weight ?? 3,
|
||||
});
|
||||
}
|
||||
|
||||
function npmLane(name, command, options = {}) {
|
||||
return lane(name, command, {
|
||||
...options,
|
||||
e2eImageKind: options.e2eImageKind ?? "bare",
|
||||
resources: ["npm", ...(options.resources ?? [])],
|
||||
weight: options.weight ?? 2,
|
||||
});
|
||||
}
|
||||
|
||||
function serviceLane(name, command, options = {}) {
|
||||
return lane(name, command, {
|
||||
...options,
|
||||
resources: ["service", ...(options.resources ?? [])],
|
||||
weight: options.weight ?? 2,
|
||||
});
|
||||
}
|
||||
|
||||
function bundledChannelScenarioLane(name, env, options = {}) {
|
||||
return npmLane(
|
||||
name,
|
||||
`${env} OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:bundled-channel-deps`,
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
const bundledScenarioLanes = [
|
||||
...["telegram", "discord", "slack", "feishu", "memory-lancedb"].map((channel) =>
|
||||
npmLane(
|
||||
`bundled-channel-${channel}`,
|
||||
`OPENCLAW_BUNDLED_CHANNELS=${channel} ${bundledChannelLaneCommand}`,
|
||||
),
|
||||
),
|
||||
...["telegram", "discord", "slack", "feishu", "memory-lancedb", "acpx"].map((target) =>
|
||||
bundledChannelScenarioLane(
|
||||
`bundled-channel-update-${target}`,
|
||||
`OPENCLAW_BUNDLED_CHANNEL_SCENARIOS=0 OPENCLAW_BUNDLED_CHANNEL_UPDATE_SCENARIO=1 OPENCLAW_BUNDLED_CHANNEL_UPDATE_TARGETS=${target} OPENCLAW_BUNDLED_CHANNEL_ROOT_OWNED_SCENARIO=0 OPENCLAW_BUNDLED_CHANNEL_SETUP_ENTRY_SCENARIO=0 OPENCLAW_BUNDLED_CHANNEL_LOAD_FAILURE_SCENARIO=0 OPENCLAW_BUNDLED_CHANNEL_DISABLED_CONFIG_SCENARIO=0`,
|
||||
{ timeoutMs: BUNDLED_UPDATE_TIMEOUT_MS },
|
||||
),
|
||||
),
|
||||
bundledChannelScenarioLane(
|
||||
"bundled-channel-root-owned",
|
||||
"OPENCLAW_BUNDLED_CHANNEL_SCENARIOS=0 OPENCLAW_BUNDLED_CHANNEL_UPDATE_SCENARIO=0 OPENCLAW_BUNDLED_CHANNEL_ROOT_OWNED_SCENARIO=1 OPENCLAW_BUNDLED_CHANNEL_SETUP_ENTRY_SCENARIO=0 OPENCLAW_BUNDLED_CHANNEL_LOAD_FAILURE_SCENARIO=0 OPENCLAW_BUNDLED_CHANNEL_DISABLED_CONFIG_SCENARIO=0",
|
||||
),
|
||||
bundledChannelScenarioLane(
|
||||
"bundled-channel-setup-entry",
|
||||
"OPENCLAW_BUNDLED_CHANNEL_SCENARIOS=0 OPENCLAW_BUNDLED_CHANNEL_UPDATE_SCENARIO=0 OPENCLAW_BUNDLED_CHANNEL_ROOT_OWNED_SCENARIO=0 OPENCLAW_BUNDLED_CHANNEL_SETUP_ENTRY_SCENARIO=1 OPENCLAW_BUNDLED_CHANNEL_LOAD_FAILURE_SCENARIO=0 OPENCLAW_BUNDLED_CHANNEL_DISABLED_CONFIG_SCENARIO=0",
|
||||
),
|
||||
bundledChannelScenarioLane(
|
||||
"bundled-channel-load-failure",
|
||||
"OPENCLAW_BUNDLED_CHANNEL_SCENARIOS=0 OPENCLAW_BUNDLED_CHANNEL_UPDATE_SCENARIO=0 OPENCLAW_BUNDLED_CHANNEL_ROOT_OWNED_SCENARIO=0 OPENCLAW_BUNDLED_CHANNEL_SETUP_ENTRY_SCENARIO=0 OPENCLAW_BUNDLED_CHANNEL_LOAD_FAILURE_SCENARIO=1 OPENCLAW_BUNDLED_CHANNEL_DISABLED_CONFIG_SCENARIO=0",
|
||||
),
|
||||
bundledChannelScenarioLane(
|
||||
"bundled-channel-disabled-config",
|
||||
"OPENCLAW_BUNDLED_CHANNEL_SCENARIOS=0 OPENCLAW_BUNDLED_CHANNEL_UPDATE_SCENARIO=0 OPENCLAW_BUNDLED_CHANNEL_ROOT_OWNED_SCENARIO=0 OPENCLAW_BUNDLED_CHANNEL_SETUP_ENTRY_SCENARIO=0 OPENCLAW_BUNDLED_CHANNEL_LOAD_FAILURE_SCENARIO=0 OPENCLAW_BUNDLED_CHANNEL_DISABLED_CONFIG_SCENARIO=1",
|
||||
),
|
||||
];
|
||||
|
||||
export const mainLanes = [
|
||||
liveLane("live-models", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:live-models", {
|
||||
providers: ["claude-cli", "codex-cli", "google-gemini-cli"],
|
||||
timeoutMs: LIVE_PROFILE_TIMEOUT_MS,
|
||||
weight: 4,
|
||||
}),
|
||||
liveLane("live-gateway", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:live-gateway", {
|
||||
providers: ["claude-cli", "codex-cli", "google-gemini-cli"],
|
||||
timeoutMs: LIVE_PROFILE_TIMEOUT_MS,
|
||||
weight: 4,
|
||||
}),
|
||||
liveLane(
|
||||
"live-cli-backend-claude",
|
||||
"OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:live-cli-backend:claude",
|
||||
{
|
||||
cacheKey: "cli-backend-claude",
|
||||
provider: "claude-cli",
|
||||
resources: ["npm"],
|
||||
timeoutMs: LIVE_CLI_TIMEOUT_MS,
|
||||
weight: 3,
|
||||
},
|
||||
),
|
||||
liveLane(
|
||||
"live-cli-backend-gemini",
|
||||
"OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:live-cli-backend:gemini",
|
||||
{
|
||||
cacheKey: "cli-backend-gemini",
|
||||
provider: "google-gemini-cli",
|
||||
resources: ["npm"],
|
||||
timeoutMs: LIVE_CLI_TIMEOUT_MS,
|
||||
weight: 3,
|
||||
},
|
||||
),
|
||||
serviceLane("openwebui", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:openwebui", {
|
||||
timeoutMs: OPENWEBUI_TIMEOUT_MS,
|
||||
weight: 5,
|
||||
}),
|
||||
serviceLane("onboard", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:onboard", {
|
||||
weight: 2,
|
||||
}),
|
||||
npmLane(
|
||||
"npm-onboard-channel-agent",
|
||||
"OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:npm-onboard-channel-agent",
|
||||
{ resources: ["service"], weight: 3 },
|
||||
),
|
||||
serviceLane("gateway-network", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:gateway-network"),
|
||||
serviceLane(
|
||||
"agents-delete-shared-workspace",
|
||||
"OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:agents-delete-shared-workspace",
|
||||
),
|
||||
serviceLane("mcp-channels", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:mcp-channels", {
|
||||
resources: ["npm"],
|
||||
weight: 3,
|
||||
}),
|
||||
lane("pi-bundle-mcp-tools", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:pi-bundle-mcp-tools"),
|
||||
lane("crestodian-rescue", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:crestodian-rescue"),
|
||||
lane("crestodian-planner", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:crestodian-planner"),
|
||||
serviceLane(
|
||||
"cron-mcp-cleanup",
|
||||
"OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:cron-mcp-cleanup",
|
||||
{ resources: ["npm"], weight: 3 },
|
||||
),
|
||||
npmLane("doctor-switch", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:doctor-switch", {
|
||||
weight: 3,
|
||||
}),
|
||||
npmLane(
|
||||
"update-channel-switch",
|
||||
"OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:update-channel-switch",
|
||||
{
|
||||
timeoutMs: 30 * 60 * 1000,
|
||||
weight: 3,
|
||||
},
|
||||
),
|
||||
lane("plugins", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:plugins", {
|
||||
resources: ["npm", "service"],
|
||||
weight: 6,
|
||||
}),
|
||||
npmLane("plugin-update", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:plugin-update"),
|
||||
serviceLane("config-reload", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:config-reload"),
|
||||
...bundledScenarioLanes,
|
||||
lane("openai-image-auth", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:openai-image-auth"),
|
||||
lane(
|
||||
"crestodian-first-run",
|
||||
"OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:crestodian-first-run",
|
||||
),
|
||||
lane(
|
||||
"session-runtime-context",
|
||||
"OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:session-runtime-context",
|
||||
),
|
||||
lane("qr", "pnpm test:docker:qr"),
|
||||
];
|
||||
|
||||
export const tailLanes = [
|
||||
serviceLane(
|
||||
"openai-web-search-minimal",
|
||||
"OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:openai-web-search-minimal",
|
||||
{ timeoutMs: 8 * 60 * 1000 },
|
||||
),
|
||||
liveLane(
|
||||
"live-codex-harness",
|
||||
"OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:live-codex-harness",
|
||||
{
|
||||
cacheKey: "codex-harness",
|
||||
provider: "codex-cli",
|
||||
resources: ["npm"],
|
||||
timeoutMs: LIVE_ACP_TIMEOUT_MS,
|
||||
weight: 3,
|
||||
},
|
||||
),
|
||||
liveLane("live-codex-bind", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:live-codex-bind", {
|
||||
cacheKey: "codex-harness",
|
||||
provider: "codex-cli",
|
||||
resources: ["npm"],
|
||||
timeoutMs: LIVE_ACP_TIMEOUT_MS,
|
||||
weight: 3,
|
||||
}),
|
||||
liveLane(
|
||||
"live-cli-backend-codex",
|
||||
"OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:live-cli-backend:codex",
|
||||
{
|
||||
cacheKey: "cli-backend-codex",
|
||||
provider: "codex-cli",
|
||||
resources: ["npm"],
|
||||
timeoutMs: LIVE_CLI_TIMEOUT_MS,
|
||||
weight: 3,
|
||||
},
|
||||
),
|
||||
liveLane(
|
||||
"live-acp-bind-claude",
|
||||
"OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:live-acp-bind:claude",
|
||||
{
|
||||
cacheKey: "acp-bind-claude",
|
||||
provider: "claude-cli",
|
||||
resources: ["npm"],
|
||||
timeoutMs: LIVE_ACP_TIMEOUT_MS,
|
||||
weight: 3,
|
||||
},
|
||||
),
|
||||
liveLane(
|
||||
"live-acp-bind-codex",
|
||||
"OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:live-acp-bind:codex",
|
||||
{
|
||||
cacheKey: "acp-bind-codex",
|
||||
provider: "codex-cli",
|
||||
resources: ["npm"],
|
||||
timeoutMs: LIVE_ACP_TIMEOUT_MS,
|
||||
weight: 3,
|
||||
},
|
||||
),
|
||||
liveLane(
|
||||
"live-acp-bind-droid",
|
||||
"OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:live-acp-bind:droid",
|
||||
{
|
||||
cacheKey: "acp-bind-droid",
|
||||
provider: "droid",
|
||||
resources: ["npm"],
|
||||
timeoutMs: LIVE_ACP_TIMEOUT_MS,
|
||||
weight: 3,
|
||||
},
|
||||
),
|
||||
liveLane(
|
||||
"live-acp-bind-gemini",
|
||||
"OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:live-acp-bind:gemini",
|
||||
{
|
||||
cacheKey: "acp-bind-gemini",
|
||||
provider: "google-gemini-cli",
|
||||
resources: ["npm"],
|
||||
timeoutMs: LIVE_ACP_TIMEOUT_MS,
|
||||
weight: 3,
|
||||
},
|
||||
),
|
||||
liveLane(
|
||||
"live-acp-bind-opencode",
|
||||
"OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:live-acp-bind:opencode",
|
||||
{
|
||||
cacheKey: "acp-bind-opencode",
|
||||
provider: "opencode",
|
||||
resources: ["npm"],
|
||||
timeoutMs: LIVE_ACP_TIMEOUT_MS,
|
||||
weight: 3,
|
||||
},
|
||||
),
|
||||
];
|
||||
|
||||
const releasePathChunks = {
|
||||
core: [
|
||||
lane("qr", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:qr"),
|
||||
serviceLane("onboard", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:onboard", {
|
||||
weight: 2,
|
||||
}),
|
||||
serviceLane("gateway-network", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:gateway-network"),
|
||||
serviceLane("config-reload", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:config-reload"),
|
||||
lane(
|
||||
"session-runtime-context",
|
||||
"OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:session-runtime-context",
|
||||
),
|
||||
lane(
|
||||
"pi-bundle-mcp-tools",
|
||||
"OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:pi-bundle-mcp-tools",
|
||||
),
|
||||
serviceLane("mcp-channels", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:mcp-channels", {
|
||||
resources: ["npm"],
|
||||
weight: 3,
|
||||
}),
|
||||
],
|
||||
"package-update": [
|
||||
npmLane("install-e2e", "OPENCLAW_E2E_MODELS=both pnpm test:install:e2e", {
|
||||
resources: ["service"],
|
||||
weight: 4,
|
||||
}),
|
||||
npmLane(
|
||||
"npm-onboard-channel-agent",
|
||||
"OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:npm-onboard-channel-agent",
|
||||
{ resources: ["service"], weight: 3 },
|
||||
),
|
||||
npmLane("doctor-switch", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:doctor-switch", {
|
||||
weight: 3,
|
||||
}),
|
||||
npmLane(
|
||||
"update-channel-switch",
|
||||
"OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:update-channel-switch",
|
||||
{
|
||||
timeoutMs: 30 * 60 * 1000,
|
||||
weight: 3,
|
||||
},
|
||||
),
|
||||
],
|
||||
"plugins-integrations": [
|
||||
lane("plugins", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:plugins", {
|
||||
resources: ["npm", "service"],
|
||||
weight: 6,
|
||||
}),
|
||||
npmLane("plugin-update", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:plugin-update"),
|
||||
npmLane(
|
||||
"bundled-channel-deps",
|
||||
"OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:bundled-channel-deps",
|
||||
{ resources: ["service"], weight: 3 },
|
||||
),
|
||||
serviceLane(
|
||||
"cron-mcp-cleanup",
|
||||
"OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:cron-mcp-cleanup",
|
||||
{
|
||||
resources: ["npm"],
|
||||
weight: 3,
|
||||
},
|
||||
),
|
||||
serviceLane(
|
||||
"openai-web-search-minimal",
|
||||
"OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:openai-web-search-minimal",
|
||||
{ timeoutMs: 8 * 60 * 1000 },
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
export function releasePathChunkLanes(chunk, options = {}) {
|
||||
const base = releasePathChunks[chunk];
|
||||
if (!base) {
|
||||
throw new Error(
|
||||
`OPENCLAW_DOCKER_ALL_CHUNK must be one of: ${Object.keys(releasePathChunks).join(", ")}. Got: ${JSON.stringify(chunk)}`,
|
||||
);
|
||||
}
|
||||
if (chunk !== "plugins-integrations" || !options.includeOpenWebUI) {
|
||||
return base;
|
||||
}
|
||||
return [
|
||||
...base,
|
||||
serviceLane("openwebui", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:openwebui", {
|
||||
timeoutMs: OPENWEBUI_TIMEOUT_MS,
|
||||
weight: 5,
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
export function allReleasePathLanes(options = {}) {
|
||||
return Object.keys(releasePathChunks).flatMap((chunk) =>
|
||||
releasePathChunkLanes(chunk, {
|
||||
includeOpenWebUI: chunk === "plugins-integrations" && options.includeOpenWebUI,
|
||||
}),
|
||||
);
|
||||
}
|
||||
148
scripts/package-openclaw-for-docker.mjs
Normal file
148
scripts/package-openclaw-for-docker.mjs
Normal file
@@ -0,0 +1,148 @@
|
||||
#!/usr/bin/env node
|
||||
// Builds the OpenClaw package artifact used by Docker E2E.
|
||||
// The script owns the build/inventory/pack sequence so local scheduler, shell
|
||||
// helpers, and GitHub Actions all prepare the exact same npm tarball.
|
||||
import { spawn } from "node:child_process";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const ROOT_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
|
||||
function parseArgs(argv) {
|
||||
const options = {
|
||||
outputDir: "",
|
||||
outputName: "",
|
||||
skipBuild: false,
|
||||
};
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
if (arg === "--output-dir") {
|
||||
options.outputDir = argv[(index += 1)] ?? "";
|
||||
} else if (arg?.startsWith("--output-dir=")) {
|
||||
options.outputDir = arg.slice("--output-dir=".length);
|
||||
} else if (arg === "--output-name") {
|
||||
options.outputName = argv[(index += 1)] ?? "";
|
||||
} else if (arg?.startsWith("--output-name=")) {
|
||||
options.outputName = arg.slice("--output-name=".length);
|
||||
} else if (arg === "--skip-build") {
|
||||
options.skipBuild = true;
|
||||
} else {
|
||||
throw new Error(`unknown argument: ${arg}`);
|
||||
}
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
function run(command, args) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(command, args, {
|
||||
cwd: ROOT_DIR,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
child.stdout.pipe(process.stderr, { end: false });
|
||||
child.stderr.pipe(process.stderr, { end: false });
|
||||
child.on("error", reject);
|
||||
child.on("close", (status, signal) => {
|
||||
if (status === 0) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
reject(new Error(`${command} ${args.join(" ")} failed with ${status ?? signal}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function runCapture(command, args) {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const child = spawn(command, args, {
|
||||
cwd: ROOT_DIR,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
let stdout = "";
|
||||
child.stdout.on("data", (chunk) => {
|
||||
stdout += String(chunk);
|
||||
});
|
||||
child.stderr.pipe(process.stderr, { end: false });
|
||||
child.on("error", reject);
|
||||
child.on("close", (status, signal) => {
|
||||
if (status === 0) {
|
||||
resolve(stdout);
|
||||
return;
|
||||
}
|
||||
reject(new Error(`${command} ${args.join(" ")} failed with ${status ?? signal}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function newestOpenClawTarball(outputDir, packOutput) {
|
||||
let fromOutput = "";
|
||||
for (const line of packOutput.split(/\r?\n/u)) {
|
||||
const trimmed = line.trim();
|
||||
if (/^openclaw-.*\.tgz$/u.test(trimmed)) {
|
||||
fromOutput = trimmed;
|
||||
}
|
||||
}
|
||||
if (fromOutput) {
|
||||
return path.join(outputDir, fromOutput);
|
||||
}
|
||||
|
||||
const entries = await fs.readdir(outputDir);
|
||||
const packed = entries
|
||||
.filter((entry) => /^openclaw-.*\.tgz$/u.test(entry))
|
||||
.toSorted()
|
||||
.at(-1);
|
||||
if (!packed) {
|
||||
throw new Error(`missing packed OpenClaw tarball in ${outputDir}`);
|
||||
}
|
||||
return path.join(outputDir, packed);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
const outputDir = path.resolve(
|
||||
ROOT_DIR,
|
||||
options.outputDir || path.join(".artifacts", "docker-e2e-package"),
|
||||
);
|
||||
await fs.mkdir(outputDir, { recursive: true });
|
||||
|
||||
if (!options.skipBuild) {
|
||||
console.error("==> Building OpenClaw package artifacts");
|
||||
await run("pnpm", ["build"]);
|
||||
}
|
||||
|
||||
console.error("==> Writing OpenClaw package inventory");
|
||||
await run("node", [
|
||||
"--import",
|
||||
"tsx",
|
||||
"--input-type=module",
|
||||
"-e",
|
||||
"const { writePackageDistInventory } = await import('./src/infra/package-dist-inventory.ts'); await writePackageDistInventory(process.cwd());",
|
||||
]);
|
||||
|
||||
console.error("==> Packing OpenClaw package");
|
||||
const packOutput = await runCapture("npm", [
|
||||
"pack",
|
||||
"--silent",
|
||||
"--ignore-scripts",
|
||||
"--pack-destination",
|
||||
outputDir,
|
||||
]);
|
||||
let tarball = await newestOpenClawTarball(outputDir, packOutput);
|
||||
|
||||
if (options.outputName) {
|
||||
const target = path.join(outputDir, options.outputName);
|
||||
if (target !== tarball) {
|
||||
await fs.rm(target, { force: true });
|
||||
await fs.rename(tarball, target);
|
||||
tarball = target;
|
||||
}
|
||||
}
|
||||
|
||||
process.stdout.write(`${tarball}\n`);
|
||||
}
|
||||
|
||||
await main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -6,508 +6,40 @@ import fs from "node:fs";
|
||||
import { mkdir, readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
DEFAULT_E2E_BARE_IMAGE,
|
||||
DEFAULT_E2E_FUNCTIONAL_IMAGE,
|
||||
DEFAULT_E2E_IMAGE,
|
||||
DEFAULT_LIVE_RETRIES,
|
||||
DEFAULT_PARALLELISM,
|
||||
DEFAULT_PROFILE,
|
||||
DEFAULT_RESOURCE_LIMITS,
|
||||
DEFAULT_TAIL_PARALLELISM,
|
||||
RELEASE_PATH_PROFILE,
|
||||
findLaneByName,
|
||||
laneResources,
|
||||
laneSummary,
|
||||
laneWeight,
|
||||
lanesNeedE2eImageKind,
|
||||
lanesNeedOpenClawPackage,
|
||||
parseLaneSelection,
|
||||
parseLiveMode,
|
||||
parseProfile,
|
||||
resolveDockerE2ePlan,
|
||||
} from "./lib/docker-e2e-plan.mjs";
|
||||
|
||||
const ROOT_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const DEFAULT_E2E_BARE_IMAGE = "openclaw-docker-e2e-bare:local";
|
||||
const DEFAULT_E2E_FUNCTIONAL_IMAGE = "openclaw-docker-e2e-functional:local";
|
||||
const DEFAULT_E2E_IMAGE = DEFAULT_E2E_FUNCTIONAL_IMAGE;
|
||||
const DEFAULT_PARALLELISM = 10;
|
||||
const DEFAULT_TAIL_PARALLELISM = 10;
|
||||
const DEFAULT_FAILURE_TAIL_LINES = 80;
|
||||
const DEFAULT_LANE_TIMEOUT_MS = 120 * 60 * 1000;
|
||||
const DEFAULT_LANE_START_STAGGER_MS = 2_000;
|
||||
const DEFAULT_LIVE_RETRIES = 1;
|
||||
const DEFAULT_STATUS_INTERVAL_MS = 30_000;
|
||||
const DEFAULT_PREFLIGHT_RUN_TIMEOUT_MS = 60_000;
|
||||
const DEFAULT_TIMINGS_FILE = path.join(ROOT_DIR, ".artifacts/docker-tests/lane-timings.json");
|
||||
const DEFAULT_PROFILE = "all";
|
||||
const RELEASE_PATH_PROFILE = "release-path";
|
||||
const LIVE_PROFILE_TIMEOUT_MS = 20 * 60 * 1000;
|
||||
const LIVE_CLI_TIMEOUT_MS = 20 * 60 * 1000;
|
||||
const LIVE_ACP_TIMEOUT_MS = 20 * 60 * 1000;
|
||||
const OPENWEBUI_TIMEOUT_MS = 20 * 60 * 1000;
|
||||
const BUNDLED_UPDATE_TIMEOUT_MS = 20 * 60 * 1000;
|
||||
const DEFAULT_RESOURCE_LIMITS = {
|
||||
docker: DEFAULT_PARALLELISM,
|
||||
live: 9,
|
||||
"live:claude": 4,
|
||||
"live:codex": 4,
|
||||
"live:droid": 4,
|
||||
"live:gemini": 4,
|
||||
"live:opencode": 4,
|
||||
npm: 10,
|
||||
service: 7,
|
||||
};
|
||||
const LIVE_RETRY_PATTERNS = [
|
||||
/529\b/i,
|
||||
/overloaded/i,
|
||||
/capacity/i,
|
||||
/rate.?limit/i,
|
||||
/gateway closed \(1000 normal closure\)/i,
|
||||
/ECONNRESET|ETIMEDOUT|ENOTFOUND/i,
|
||||
];
|
||||
|
||||
const bundledChannelLaneCommand =
|
||||
"OPENCLAW_SKIP_DOCKER_BUILD=1 OPENCLAW_BUNDLED_CHANNEL_UPDATE_SCENARIO=0 OPENCLAW_BUNDLED_CHANNEL_ROOT_OWNED_SCENARIO=0 OPENCLAW_BUNDLED_CHANNEL_SETUP_ENTRY_SCENARIO=0 OPENCLAW_BUNDLED_CHANNEL_LOAD_FAILURE_SCENARIO=0 OPENCLAW_BUNDLED_CHANNEL_DISABLED_CONFIG_SCENARIO=0 pnpm test:docker:bundled-channel-deps";
|
||||
|
||||
function lane(name, command, options = {}) {
|
||||
return {
|
||||
cacheKey: options.cacheKey,
|
||||
command,
|
||||
e2eImageKind: options.e2eImageKind ?? (options.live ? undefined : "functional"),
|
||||
estimateSeconds: options.estimateSeconds,
|
||||
live: options.live === true,
|
||||
name,
|
||||
retryPatterns: options.retryPatterns ?? [],
|
||||
retries: options.retries ?? 0,
|
||||
resources: options.resources ?? [],
|
||||
timeoutMs: options.timeoutMs,
|
||||
weight: options.weight ?? 1,
|
||||
};
|
||||
}
|
||||
|
||||
function liveProviderResource(provider) {
|
||||
if (!provider) {
|
||||
return undefined;
|
||||
const cliArgs = new Set(process.argv.slice(2));
|
||||
for (const arg of cliArgs) {
|
||||
if (arg !== "--plan-json") {
|
||||
throw new Error(`unknown argument: ${arg}`);
|
||||
}
|
||||
if (provider === "claude-cli" || provider === "claude") {
|
||||
return "live:claude";
|
||||
}
|
||||
if (provider === "codex-cli" || provider === "codex") {
|
||||
return "live:codex";
|
||||
}
|
||||
if (provider === "droid") {
|
||||
return "live:droid";
|
||||
}
|
||||
if (provider === "google-gemini-cli" || provider === "gemini") {
|
||||
return "live:gemini";
|
||||
}
|
||||
if (provider === "opencode") {
|
||||
return "live:opencode";
|
||||
}
|
||||
if (provider === "openai") {
|
||||
return "live:openai";
|
||||
}
|
||||
return `live:${provider}`;
|
||||
}
|
||||
|
||||
function liveProviderResources(options) {
|
||||
const providers = options.providers ?? (options.provider ? [options.provider] : []);
|
||||
return providers.map(liveProviderResource).filter(Boolean);
|
||||
}
|
||||
|
||||
function liveLane(name, command, options = {}) {
|
||||
return lane(name, command, {
|
||||
...options,
|
||||
live: true,
|
||||
resources: ["live", ...liveProviderResources(options), ...(options.resources ?? [])],
|
||||
retryPatterns: options.retryPatterns ?? LIVE_RETRY_PATTERNS,
|
||||
retries: options.retries ?? DEFAULT_LIVE_RETRIES,
|
||||
weight: options.weight ?? 3,
|
||||
});
|
||||
}
|
||||
|
||||
function npmLane(name, command, options = {}) {
|
||||
return lane(name, command, {
|
||||
...options,
|
||||
e2eImageKind: options.e2eImageKind ?? "bare",
|
||||
resources: ["npm", ...(options.resources ?? [])],
|
||||
weight: options.weight ?? 2,
|
||||
});
|
||||
}
|
||||
|
||||
function serviceLane(name, command, options = {}) {
|
||||
return lane(name, command, {
|
||||
...options,
|
||||
resources: ["service", ...(options.resources ?? [])],
|
||||
weight: options.weight ?? 2,
|
||||
});
|
||||
}
|
||||
|
||||
const bundledScenarioLanes = [
|
||||
npmLane(
|
||||
"bundled-channel-telegram",
|
||||
`OPENCLAW_BUNDLED_CHANNELS=telegram ${bundledChannelLaneCommand}`,
|
||||
),
|
||||
npmLane(
|
||||
"bundled-channel-discord",
|
||||
`OPENCLAW_BUNDLED_CHANNELS=discord ${bundledChannelLaneCommand}`,
|
||||
),
|
||||
npmLane("bundled-channel-slack", `OPENCLAW_BUNDLED_CHANNELS=slack ${bundledChannelLaneCommand}`),
|
||||
npmLane(
|
||||
"bundled-channel-feishu",
|
||||
`OPENCLAW_BUNDLED_CHANNELS=feishu ${bundledChannelLaneCommand}`,
|
||||
),
|
||||
npmLane(
|
||||
"bundled-channel-memory-lancedb",
|
||||
`OPENCLAW_BUNDLED_CHANNELS=memory-lancedb ${bundledChannelLaneCommand}`,
|
||||
),
|
||||
npmLane(
|
||||
"bundled-channel-update-telegram",
|
||||
"OPENCLAW_SKIP_DOCKER_BUILD=1 OPENCLAW_BUNDLED_CHANNEL_SCENARIOS=0 OPENCLAW_BUNDLED_CHANNEL_UPDATE_SCENARIO=1 OPENCLAW_BUNDLED_CHANNEL_UPDATE_TARGETS=telegram OPENCLAW_BUNDLED_CHANNEL_ROOT_OWNED_SCENARIO=0 OPENCLAW_BUNDLED_CHANNEL_SETUP_ENTRY_SCENARIO=0 OPENCLAW_BUNDLED_CHANNEL_LOAD_FAILURE_SCENARIO=0 OPENCLAW_BUNDLED_CHANNEL_DISABLED_CONFIG_SCENARIO=0 pnpm test:docker:bundled-channel-deps",
|
||||
{ timeoutMs: BUNDLED_UPDATE_TIMEOUT_MS },
|
||||
),
|
||||
npmLane(
|
||||
"bundled-channel-update-discord",
|
||||
"OPENCLAW_SKIP_DOCKER_BUILD=1 OPENCLAW_BUNDLED_CHANNEL_SCENARIOS=0 OPENCLAW_BUNDLED_CHANNEL_UPDATE_SCENARIO=1 OPENCLAW_BUNDLED_CHANNEL_UPDATE_TARGETS=discord OPENCLAW_BUNDLED_CHANNEL_ROOT_OWNED_SCENARIO=0 OPENCLAW_BUNDLED_CHANNEL_SETUP_ENTRY_SCENARIO=0 OPENCLAW_BUNDLED_CHANNEL_LOAD_FAILURE_SCENARIO=0 OPENCLAW_BUNDLED_CHANNEL_DISABLED_CONFIG_SCENARIO=0 pnpm test:docker:bundled-channel-deps",
|
||||
{ timeoutMs: BUNDLED_UPDATE_TIMEOUT_MS },
|
||||
),
|
||||
npmLane(
|
||||
"bundled-channel-update-slack",
|
||||
"OPENCLAW_SKIP_DOCKER_BUILD=1 OPENCLAW_BUNDLED_CHANNEL_SCENARIOS=0 OPENCLAW_BUNDLED_CHANNEL_UPDATE_SCENARIO=1 OPENCLAW_BUNDLED_CHANNEL_UPDATE_TARGETS=slack OPENCLAW_BUNDLED_CHANNEL_ROOT_OWNED_SCENARIO=0 OPENCLAW_BUNDLED_CHANNEL_SETUP_ENTRY_SCENARIO=0 OPENCLAW_BUNDLED_CHANNEL_LOAD_FAILURE_SCENARIO=0 OPENCLAW_BUNDLED_CHANNEL_DISABLED_CONFIG_SCENARIO=0 pnpm test:docker:bundled-channel-deps",
|
||||
{ timeoutMs: BUNDLED_UPDATE_TIMEOUT_MS },
|
||||
),
|
||||
npmLane(
|
||||
"bundled-channel-update-feishu",
|
||||
"OPENCLAW_SKIP_DOCKER_BUILD=1 OPENCLAW_BUNDLED_CHANNEL_SCENARIOS=0 OPENCLAW_BUNDLED_CHANNEL_UPDATE_SCENARIO=1 OPENCLAW_BUNDLED_CHANNEL_UPDATE_TARGETS=feishu OPENCLAW_BUNDLED_CHANNEL_ROOT_OWNED_SCENARIO=0 OPENCLAW_BUNDLED_CHANNEL_SETUP_ENTRY_SCENARIO=0 OPENCLAW_BUNDLED_CHANNEL_LOAD_FAILURE_SCENARIO=0 OPENCLAW_BUNDLED_CHANNEL_DISABLED_CONFIG_SCENARIO=0 pnpm test:docker:bundled-channel-deps",
|
||||
{ timeoutMs: BUNDLED_UPDATE_TIMEOUT_MS },
|
||||
),
|
||||
npmLane(
|
||||
"bundled-channel-update-memory-lancedb",
|
||||
"OPENCLAW_SKIP_DOCKER_BUILD=1 OPENCLAW_BUNDLED_CHANNEL_SCENARIOS=0 OPENCLAW_BUNDLED_CHANNEL_UPDATE_SCENARIO=1 OPENCLAW_BUNDLED_CHANNEL_UPDATE_TARGETS=memory-lancedb OPENCLAW_BUNDLED_CHANNEL_ROOT_OWNED_SCENARIO=0 OPENCLAW_BUNDLED_CHANNEL_SETUP_ENTRY_SCENARIO=0 OPENCLAW_BUNDLED_CHANNEL_LOAD_FAILURE_SCENARIO=0 OPENCLAW_BUNDLED_CHANNEL_DISABLED_CONFIG_SCENARIO=0 pnpm test:docker:bundled-channel-deps",
|
||||
{ timeoutMs: BUNDLED_UPDATE_TIMEOUT_MS },
|
||||
),
|
||||
npmLane(
|
||||
"bundled-channel-update-acpx",
|
||||
"OPENCLAW_SKIP_DOCKER_BUILD=1 OPENCLAW_BUNDLED_CHANNEL_SCENARIOS=0 OPENCLAW_BUNDLED_CHANNEL_UPDATE_SCENARIO=1 OPENCLAW_BUNDLED_CHANNEL_UPDATE_TARGETS=acpx OPENCLAW_BUNDLED_CHANNEL_ROOT_OWNED_SCENARIO=0 OPENCLAW_BUNDLED_CHANNEL_SETUP_ENTRY_SCENARIO=0 OPENCLAW_BUNDLED_CHANNEL_LOAD_FAILURE_SCENARIO=0 OPENCLAW_BUNDLED_CHANNEL_DISABLED_CONFIG_SCENARIO=0 pnpm test:docker:bundled-channel-deps",
|
||||
{ timeoutMs: BUNDLED_UPDATE_TIMEOUT_MS },
|
||||
),
|
||||
npmLane(
|
||||
"bundled-channel-root-owned",
|
||||
"OPENCLAW_SKIP_DOCKER_BUILD=1 OPENCLAW_BUNDLED_CHANNEL_SCENARIOS=0 OPENCLAW_BUNDLED_CHANNEL_UPDATE_SCENARIO=0 OPENCLAW_BUNDLED_CHANNEL_ROOT_OWNED_SCENARIO=1 OPENCLAW_BUNDLED_CHANNEL_SETUP_ENTRY_SCENARIO=0 OPENCLAW_BUNDLED_CHANNEL_LOAD_FAILURE_SCENARIO=0 OPENCLAW_BUNDLED_CHANNEL_DISABLED_CONFIG_SCENARIO=0 pnpm test:docker:bundled-channel-deps",
|
||||
),
|
||||
npmLane(
|
||||
"bundled-channel-setup-entry",
|
||||
"OPENCLAW_SKIP_DOCKER_BUILD=1 OPENCLAW_BUNDLED_CHANNEL_SCENARIOS=0 OPENCLAW_BUNDLED_CHANNEL_UPDATE_SCENARIO=0 OPENCLAW_BUNDLED_CHANNEL_ROOT_OWNED_SCENARIO=0 OPENCLAW_BUNDLED_CHANNEL_SETUP_ENTRY_SCENARIO=1 OPENCLAW_BUNDLED_CHANNEL_LOAD_FAILURE_SCENARIO=0 OPENCLAW_BUNDLED_CHANNEL_DISABLED_CONFIG_SCENARIO=0 pnpm test:docker:bundled-channel-deps",
|
||||
),
|
||||
npmLane(
|
||||
"bundled-channel-load-failure",
|
||||
"OPENCLAW_SKIP_DOCKER_BUILD=1 OPENCLAW_BUNDLED_CHANNEL_SCENARIOS=0 OPENCLAW_BUNDLED_CHANNEL_UPDATE_SCENARIO=0 OPENCLAW_BUNDLED_CHANNEL_ROOT_OWNED_SCENARIO=0 OPENCLAW_BUNDLED_CHANNEL_SETUP_ENTRY_SCENARIO=0 OPENCLAW_BUNDLED_CHANNEL_LOAD_FAILURE_SCENARIO=1 OPENCLAW_BUNDLED_CHANNEL_DISABLED_CONFIG_SCENARIO=0 pnpm test:docker:bundled-channel-deps",
|
||||
),
|
||||
npmLane(
|
||||
"bundled-channel-disabled-config",
|
||||
"OPENCLAW_SKIP_DOCKER_BUILD=1 OPENCLAW_BUNDLED_CHANNEL_SCENARIOS=0 OPENCLAW_BUNDLED_CHANNEL_UPDATE_SCENARIO=0 OPENCLAW_BUNDLED_CHANNEL_ROOT_OWNED_SCENARIO=0 OPENCLAW_BUNDLED_CHANNEL_SETUP_ENTRY_SCENARIO=0 OPENCLAW_BUNDLED_CHANNEL_LOAD_FAILURE_SCENARIO=0 OPENCLAW_BUNDLED_CHANNEL_DISABLED_CONFIG_SCENARIO=1 pnpm test:docker:bundled-channel-deps",
|
||||
),
|
||||
];
|
||||
|
||||
const lanes = [
|
||||
liveLane("live-models", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:live-models", {
|
||||
providers: ["claude-cli", "codex-cli", "google-gemini-cli"],
|
||||
timeoutMs: LIVE_PROFILE_TIMEOUT_MS,
|
||||
weight: 4,
|
||||
}),
|
||||
liveLane("live-gateway", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:live-gateway", {
|
||||
providers: ["claude-cli", "codex-cli", "google-gemini-cli"],
|
||||
timeoutMs: LIVE_PROFILE_TIMEOUT_MS,
|
||||
weight: 4,
|
||||
}),
|
||||
liveLane(
|
||||
"live-cli-backend-claude",
|
||||
"OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:live-cli-backend:claude",
|
||||
{
|
||||
cacheKey: "cli-backend-claude",
|
||||
provider: "claude-cli",
|
||||
resources: ["npm"],
|
||||
timeoutMs: LIVE_CLI_TIMEOUT_MS,
|
||||
weight: 3,
|
||||
},
|
||||
),
|
||||
liveLane(
|
||||
"live-cli-backend-gemini",
|
||||
"OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:live-cli-backend:gemini",
|
||||
{
|
||||
cacheKey: "cli-backend-gemini",
|
||||
provider: "google-gemini-cli",
|
||||
resources: ["npm"],
|
||||
timeoutMs: LIVE_CLI_TIMEOUT_MS,
|
||||
weight: 3,
|
||||
},
|
||||
),
|
||||
serviceLane("openwebui", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:openwebui", {
|
||||
timeoutMs: OPENWEBUI_TIMEOUT_MS,
|
||||
weight: 5,
|
||||
}),
|
||||
serviceLane("onboard", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:onboard", {
|
||||
weight: 2,
|
||||
}),
|
||||
npmLane(
|
||||
"npm-onboard-channel-agent",
|
||||
"OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:npm-onboard-channel-agent",
|
||||
{ resources: ["service"], weight: 3 },
|
||||
),
|
||||
serviceLane("gateway-network", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:gateway-network"),
|
||||
serviceLane(
|
||||
"agents-delete-shared-workspace",
|
||||
"OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:agents-delete-shared-workspace",
|
||||
),
|
||||
serviceLane("mcp-channels", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:mcp-channels", {
|
||||
resources: ["npm"],
|
||||
weight: 3,
|
||||
}),
|
||||
lane("pi-bundle-mcp-tools", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:pi-bundle-mcp-tools"),
|
||||
lane("crestodian-rescue", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:crestodian-rescue"),
|
||||
lane("crestodian-planner", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:crestodian-planner"),
|
||||
serviceLane(
|
||||
"cron-mcp-cleanup",
|
||||
"OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:cron-mcp-cleanup",
|
||||
{ resources: ["npm"], weight: 3 },
|
||||
),
|
||||
npmLane("doctor-switch", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:doctor-switch", {
|
||||
weight: 3,
|
||||
}),
|
||||
npmLane(
|
||||
"update-channel-switch",
|
||||
"OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:update-channel-switch",
|
||||
{
|
||||
timeoutMs: 30 * 60 * 1000,
|
||||
weight: 3,
|
||||
},
|
||||
),
|
||||
lane("plugins", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:plugins", {
|
||||
resources: ["npm", "service"],
|
||||
weight: 6,
|
||||
}),
|
||||
npmLane("plugin-update", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:plugin-update"),
|
||||
serviceLane("config-reload", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:config-reload"),
|
||||
...bundledScenarioLanes,
|
||||
lane("openai-image-auth", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:openai-image-auth"),
|
||||
lane(
|
||||
"crestodian-first-run",
|
||||
"OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:crestodian-first-run",
|
||||
),
|
||||
lane(
|
||||
"session-runtime-context",
|
||||
"OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:session-runtime-context",
|
||||
),
|
||||
lane("qr", "pnpm test:docker:qr"),
|
||||
];
|
||||
|
||||
const exclusiveLanes = [
|
||||
serviceLane(
|
||||
"openai-web-search-minimal",
|
||||
"OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:openai-web-search-minimal",
|
||||
{ timeoutMs: 8 * 60 * 1000 },
|
||||
),
|
||||
liveLane(
|
||||
"live-codex-harness",
|
||||
"OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:live-codex-harness",
|
||||
{
|
||||
cacheKey: "codex-harness",
|
||||
provider: "codex-cli",
|
||||
resources: ["npm"],
|
||||
timeoutMs: LIVE_ACP_TIMEOUT_MS,
|
||||
weight: 3,
|
||||
},
|
||||
),
|
||||
liveLane("live-codex-bind", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:live-codex-bind", {
|
||||
cacheKey: "codex-harness",
|
||||
provider: "codex-cli",
|
||||
resources: ["npm"],
|
||||
timeoutMs: LIVE_ACP_TIMEOUT_MS,
|
||||
weight: 3,
|
||||
}),
|
||||
liveLane(
|
||||
"live-cli-backend-codex",
|
||||
"OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:live-cli-backend:codex",
|
||||
{
|
||||
cacheKey: "cli-backend-codex",
|
||||
provider: "codex-cli",
|
||||
resources: ["npm"],
|
||||
timeoutMs: LIVE_CLI_TIMEOUT_MS,
|
||||
weight: 3,
|
||||
},
|
||||
),
|
||||
liveLane(
|
||||
"live-acp-bind-claude",
|
||||
"OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:live-acp-bind:claude",
|
||||
{
|
||||
cacheKey: "acp-bind-claude",
|
||||
provider: "claude-cli",
|
||||
resources: ["npm"],
|
||||
timeoutMs: LIVE_ACP_TIMEOUT_MS,
|
||||
weight: 3,
|
||||
},
|
||||
),
|
||||
liveLane(
|
||||
"live-acp-bind-codex",
|
||||
"OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:live-acp-bind:codex",
|
||||
{
|
||||
cacheKey: "acp-bind-codex",
|
||||
provider: "codex-cli",
|
||||
resources: ["npm"],
|
||||
timeoutMs: LIVE_ACP_TIMEOUT_MS,
|
||||
weight: 3,
|
||||
},
|
||||
),
|
||||
liveLane(
|
||||
"live-acp-bind-droid",
|
||||
"OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:live-acp-bind:droid",
|
||||
{
|
||||
cacheKey: "acp-bind-droid",
|
||||
provider: "droid",
|
||||
resources: ["npm"],
|
||||
timeoutMs: LIVE_ACP_TIMEOUT_MS,
|
||||
weight: 3,
|
||||
},
|
||||
),
|
||||
liveLane(
|
||||
"live-acp-bind-gemini",
|
||||
"OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:live-acp-bind:gemini",
|
||||
{
|
||||
cacheKey: "acp-bind-gemini",
|
||||
provider: "google-gemini-cli",
|
||||
resources: ["npm"],
|
||||
timeoutMs: LIVE_ACP_TIMEOUT_MS,
|
||||
weight: 3,
|
||||
},
|
||||
),
|
||||
liveLane(
|
||||
"live-acp-bind-opencode",
|
||||
"OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:live-acp-bind:opencode",
|
||||
{
|
||||
cacheKey: "acp-bind-opencode",
|
||||
provider: "opencode",
|
||||
resources: ["npm"],
|
||||
timeoutMs: LIVE_ACP_TIMEOUT_MS,
|
||||
weight: 3,
|
||||
},
|
||||
),
|
||||
];
|
||||
|
||||
const tailLanes = exclusiveLanes;
|
||||
|
||||
const releasePathChunks = {
|
||||
core: [
|
||||
lane("qr", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:qr"),
|
||||
serviceLane("onboard", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:onboard", {
|
||||
weight: 2,
|
||||
}),
|
||||
serviceLane("gateway-network", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:gateway-network"),
|
||||
serviceLane("config-reload", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:config-reload"),
|
||||
lane(
|
||||
"session-runtime-context",
|
||||
"OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:session-runtime-context",
|
||||
),
|
||||
lane(
|
||||
"pi-bundle-mcp-tools",
|
||||
"OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:pi-bundle-mcp-tools",
|
||||
),
|
||||
serviceLane("mcp-channels", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:mcp-channels", {
|
||||
resources: ["npm"],
|
||||
weight: 3,
|
||||
}),
|
||||
],
|
||||
"package-update": [
|
||||
npmLane("install-e2e", "OPENCLAW_E2E_MODELS=both pnpm test:install:e2e", {
|
||||
resources: ["service"],
|
||||
weight: 4,
|
||||
}),
|
||||
npmLane(
|
||||
"npm-onboard-channel-agent",
|
||||
"OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:npm-onboard-channel-agent",
|
||||
{ resources: ["service"], weight: 3 },
|
||||
),
|
||||
npmLane("doctor-switch", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:doctor-switch", {
|
||||
weight: 3,
|
||||
}),
|
||||
npmLane(
|
||||
"update-channel-switch",
|
||||
"OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:update-channel-switch",
|
||||
{
|
||||
timeoutMs: 30 * 60 * 1000,
|
||||
weight: 3,
|
||||
},
|
||||
),
|
||||
],
|
||||
"plugins-integrations": [
|
||||
lane("plugins", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:plugins", {
|
||||
resources: ["npm", "service"],
|
||||
weight: 6,
|
||||
}),
|
||||
npmLane("plugin-update", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:plugin-update"),
|
||||
npmLane(
|
||||
"bundled-channel-deps",
|
||||
"OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:bundled-channel-deps",
|
||||
{ resources: ["service"], weight: 3 },
|
||||
),
|
||||
serviceLane(
|
||||
"cron-mcp-cleanup",
|
||||
"OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:cron-mcp-cleanup",
|
||||
{
|
||||
resources: ["npm"],
|
||||
weight: 3,
|
||||
},
|
||||
),
|
||||
serviceLane(
|
||||
"openai-web-search-minimal",
|
||||
"OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:openai-web-search-minimal",
|
||||
{ timeoutMs: 8 * 60 * 1000 },
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
function releasePathChunkLanes(chunk, options = {}) {
|
||||
const base = releasePathChunks[chunk];
|
||||
if (!base) {
|
||||
throw new Error(
|
||||
`OPENCLAW_DOCKER_ALL_CHUNK must be one of: ${Object.keys(releasePathChunks).join(", ")}. Got: ${JSON.stringify(chunk)}`,
|
||||
);
|
||||
}
|
||||
if (chunk !== "plugins-integrations" || !options.includeOpenWebUI) {
|
||||
return base;
|
||||
}
|
||||
return [
|
||||
...base,
|
||||
serviceLane("openwebui", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:openwebui", {
|
||||
timeoutMs: OPENWEBUI_TIMEOUT_MS,
|
||||
weight: 5,
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
function allReleasePathLanes(options = {}) {
|
||||
return Object.keys(releasePathChunks).flatMap((chunk) =>
|
||||
releasePathChunkLanes(chunk, {
|
||||
includeOpenWebUI: chunk === "plugins-integrations" && options.includeOpenWebUI,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function parseLaneSelection(raw) {
|
||||
if (!raw) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
...new Set(
|
||||
String(raw)
|
||||
.split(/[,\s]+/u)
|
||||
.map((token) => token.trim())
|
||||
.filter(Boolean),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
function dedupeLanes(poolLanes) {
|
||||
const byName = new Map();
|
||||
for (const poolLane of poolLanes) {
|
||||
if (!byName.has(poolLane.name)) {
|
||||
byName.set(poolLane.name, poolLane);
|
||||
}
|
||||
}
|
||||
return [...byName.values()];
|
||||
}
|
||||
|
||||
function selectNamedLanes(poolLanes, selectedNames, label) {
|
||||
const byName = new Map(poolLanes.map((poolLane) => [poolLane.name, poolLane]));
|
||||
const missing = selectedNames.filter((name) => !byName.has(name));
|
||||
if (missing.length > 0) {
|
||||
throw new Error(
|
||||
`${label} unknown lane(s): ${missing.join(", ")}. Available lanes: ${[...byName.keys()]
|
||||
.toSorted((a, b) => a.localeCompare(b))
|
||||
.join(", ")}`,
|
||||
);
|
||||
}
|
||||
return selectedNames.map((name) => byName.get(name));
|
||||
}
|
||||
|
||||
function parsePositiveInt(raw, fallback, label) {
|
||||
@@ -539,37 +71,6 @@ function parseBool(raw, fallback) {
|
||||
return !/^(?:0|false|no)$/i.test(raw);
|
||||
}
|
||||
|
||||
function parseLiveMode(raw) {
|
||||
const mode = raw || "all";
|
||||
if (mode === "all" || mode === "skip" || mode === "only") {
|
||||
return mode;
|
||||
}
|
||||
throw new Error(
|
||||
`OPENCLAW_DOCKER_ALL_LIVE_MODE must be one of: all, skip, only. Got: ${JSON.stringify(raw)}`,
|
||||
);
|
||||
}
|
||||
|
||||
function parseProfile(raw) {
|
||||
const profile = raw || DEFAULT_PROFILE;
|
||||
if (profile === DEFAULT_PROFILE || profile === RELEASE_PATH_PROFILE) {
|
||||
return profile;
|
||||
}
|
||||
throw new Error(
|
||||
`OPENCLAW_DOCKER_ALL_PROFILE must be one of: ${DEFAULT_PROFILE}, ${RELEASE_PATH_PROFILE}. Got: ${JSON.stringify(raw)}`,
|
||||
);
|
||||
}
|
||||
|
||||
function applyLiveMode(poolLanes, mode) {
|
||||
if (mode === "all") {
|
||||
return poolLanes;
|
||||
}
|
||||
return poolLanes.filter((poolLane) => (mode === "only" ? poolLane.live : !poolLane.live));
|
||||
}
|
||||
|
||||
function applyLiveRetries(poolLanes, retries) {
|
||||
return poolLanes.map((poolLane) => (poolLane.live ? { ...poolLane, retries } : poolLane));
|
||||
}
|
||||
|
||||
function resourceLimitsSummary(resourceLimits) {
|
||||
return Object.entries(resourceLimits)
|
||||
.map(([resource, limit]) => `${resource}=${String(limit)}`)
|
||||
@@ -601,21 +102,19 @@ function parseSchedulerOptions(env, parallelism) {
|
||||
};
|
||||
}
|
||||
|
||||
function laneWeight(poolLane) {
|
||||
return Math.max(1, poolLane.weight ?? 1);
|
||||
function timingSeconds(timingStore, poolLane) {
|
||||
const fromStore = timingStore?.lanes?.[poolLane.name]?.durationSeconds;
|
||||
if (typeof fromStore === "number" && Number.isFinite(fromStore) && fromStore > 0) {
|
||||
return fromStore;
|
||||
}
|
||||
return poolLane.estimateSeconds ?? 0;
|
||||
}
|
||||
|
||||
function laneResources(poolLane) {
|
||||
return ["docker", ...(poolLane.resources ?? [])];
|
||||
}
|
||||
|
||||
function laneSummary(poolLane) {
|
||||
const resources = laneResources(poolLane).join(",");
|
||||
const timeout = poolLane.timeoutMs ? ` timeout=${Math.round(poolLane.timeoutMs / 1000)}s` : "";
|
||||
const retries = poolLane.retries > 0 ? ` retries=${poolLane.retries}` : "";
|
||||
const cache = poolLane.cacheKey ? ` cache=${poolLane.cacheKey}` : "";
|
||||
const image = poolLane.e2eImageKind ? ` image=${poolLane.e2eImageKind}` : "";
|
||||
return `${poolLane.name}(w=${laneWeight(poolLane)} r=${resources}${timeout}${retries}${cache}${image})`;
|
||||
function orderLanes(poolLanes, timingStore) {
|
||||
return poolLanes
|
||||
.map((poolLane, index) => ({ index, poolLane, seconds: timingSeconds(timingStore, poolLane) }))
|
||||
.toSorted((a, b) => b.seconds - a.seconds || a.index - b.index)
|
||||
.map(({ poolLane }) => poolLane);
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
@@ -672,47 +171,6 @@ function buildLaneRerunCommand(name, baseEnv) {
|
||||
.join(" ")} pnpm test:docker:all`;
|
||||
}
|
||||
|
||||
function findLaneByName(name) {
|
||||
return dedupeLanes([
|
||||
...allReleasePathLanes({ includeOpenWebUI: true }),
|
||||
...lanes,
|
||||
...tailLanes,
|
||||
]).find((poolLane) => poolLane.name === name);
|
||||
}
|
||||
|
||||
function e2eImageForKind(kind, baseEnv) {
|
||||
if (kind === "bare") {
|
||||
return baseEnv.OPENCLAW_DOCKER_E2E_BARE_IMAGE || baseEnv.OPENCLAW_DOCKER_E2E_IMAGE;
|
||||
}
|
||||
if (kind === "functional") {
|
||||
return baseEnv.OPENCLAW_DOCKER_E2E_FUNCTIONAL_IMAGE || baseEnv.OPENCLAW_DOCKER_E2E_IMAGE;
|
||||
}
|
||||
return baseEnv.OPENCLAW_DOCKER_E2E_IMAGE;
|
||||
}
|
||||
|
||||
function e2eImageForLane(poolLane, baseEnv) {
|
||||
return e2eImageForKind(poolLane.e2eImageKind, baseEnv);
|
||||
}
|
||||
|
||||
function lanesNeedE2eImageKind(poolLanes, kind) {
|
||||
return poolLanes.some((poolLane) => poolLane.e2eImageKind === kind);
|
||||
}
|
||||
|
||||
function timingSeconds(timingStore, poolLane) {
|
||||
const fromStore = timingStore?.lanes?.[poolLane.name]?.durationSeconds;
|
||||
if (typeof fromStore === "number" && Number.isFinite(fromStore) && fromStore > 0) {
|
||||
return fromStore;
|
||||
}
|
||||
return poolLane.estimateSeconds ?? 0;
|
||||
}
|
||||
|
||||
function orderLanes(poolLanes, timingStore) {
|
||||
return poolLanes
|
||||
.map((poolLane, index) => ({ index, poolLane, seconds: timingSeconds(timingStore, poolLane) }))
|
||||
.toSorted((a, b) => b.seconds - a.seconds || a.index - b.index)
|
||||
.map(({ poolLane }) => poolLane);
|
||||
}
|
||||
|
||||
async function loadTimingStore(file, enabled) {
|
||||
if (!enabled) {
|
||||
return { enabled: false, file, lanes: {}, version: 1 };
|
||||
@@ -812,10 +270,6 @@ function printLaneManifest(label, poolLanes, timingStore) {
|
||||
}
|
||||
}
|
||||
|
||||
function lanesNeedOpenClawPackage(poolLanes) {
|
||||
return poolLanes.some((poolLane) => poolLane.e2eImageKind);
|
||||
}
|
||||
|
||||
function dockerPreflightContainerNames(raw) {
|
||||
return raw
|
||||
.split(/\r?\n/)
|
||||
@@ -1016,15 +470,10 @@ async function runDockerPreflight(baseEnv, options) {
|
||||
}
|
||||
|
||||
async function prepareOpenClawPackage(baseEnv, logDir) {
|
||||
const existing =
|
||||
baseEnv.OPENCLAW_CURRENT_PACKAGE_TGZ ||
|
||||
baseEnv.OPENCLAW_BUNDLED_CHANNEL_PACKAGE_TGZ ||
|
||||
baseEnv.OPENCLAW_NPM_ONBOARD_PACKAGE_TGZ;
|
||||
const existing = baseEnv.OPENCLAW_CURRENT_PACKAGE_TGZ;
|
||||
if (existing) {
|
||||
const packageTgz = path.resolve(existing);
|
||||
baseEnv.OPENCLAW_CURRENT_PACKAGE_TGZ = packageTgz;
|
||||
baseEnv.OPENCLAW_BUNDLED_CHANNEL_PACKAGE_TGZ ||= packageTgz;
|
||||
baseEnv.OPENCLAW_NPM_ONBOARD_PACKAGE_TGZ ||= packageTgz;
|
||||
baseEnv.OPENCLAW_BUNDLED_CHANNEL_HOST_BUILD = "0";
|
||||
baseEnv.OPENCLAW_NPM_ONBOARD_HOST_BUILD = "0";
|
||||
console.log(`==> OpenClaw package: ${packageTgz}`);
|
||||
@@ -1033,33 +482,29 @@ async function prepareOpenClawPackage(baseEnv, logDir) {
|
||||
|
||||
const packDir = path.join(logDir, "openclaw-package");
|
||||
await mkdir(packDir, { recursive: true });
|
||||
await runForeground("Build OpenClaw package artifacts once", "pnpm build", baseEnv);
|
||||
const packageTgz = path.join(packDir, "openclaw-current.tgz");
|
||||
await runForeground(
|
||||
"Write OpenClaw package inventory",
|
||||
"node --import tsx --input-type=module -e \"const { writePackageDistInventory } = await import('./src/infra/package-dist-inventory.ts'); await writePackageDistInventory(process.cwd());\"",
|
||||
"Prepare OpenClaw package once",
|
||||
`node scripts/package-openclaw-for-docker.mjs --output-dir ${shellQuote(packDir)} --output-name openclaw-current.tgz`,
|
||||
baseEnv,
|
||||
);
|
||||
await runForeground(
|
||||
"Pack OpenClaw package once",
|
||||
`npm pack --silent --ignore-scripts --pack-destination ${shellQuote(packDir)}`,
|
||||
baseEnv,
|
||||
);
|
||||
|
||||
const packed = (await fs.promises.readdir(packDir))
|
||||
.filter((entry) => /^openclaw-.*\.tgz$/.test(entry))
|
||||
.toSorted()
|
||||
.at(-1);
|
||||
if (!packed) {
|
||||
throw new Error(`missing packed OpenClaw tarball in ${packDir}`);
|
||||
}
|
||||
baseEnv.OPENCLAW_CURRENT_PACKAGE_TGZ = path.join(packDir, packed);
|
||||
baseEnv.OPENCLAW_BUNDLED_CHANNEL_PACKAGE_TGZ = baseEnv.OPENCLAW_CURRENT_PACKAGE_TGZ;
|
||||
await fs.promises.access(packageTgz);
|
||||
baseEnv.OPENCLAW_CURRENT_PACKAGE_TGZ = packageTgz;
|
||||
baseEnv.OPENCLAW_BUNDLED_CHANNEL_HOST_BUILD = "0";
|
||||
baseEnv.OPENCLAW_NPM_ONBOARD_PACKAGE_TGZ = baseEnv.OPENCLAW_CURRENT_PACKAGE_TGZ;
|
||||
baseEnv.OPENCLAW_NPM_ONBOARD_HOST_BUILD = "0";
|
||||
console.log(`==> OpenClaw package: ${baseEnv.OPENCLAW_CURRENT_PACKAGE_TGZ}`);
|
||||
}
|
||||
|
||||
function e2eImageForLane(poolLane, baseEnv) {
|
||||
if (poolLane.e2eImageKind === "bare") {
|
||||
return baseEnv.OPENCLAW_DOCKER_E2E_BARE_IMAGE;
|
||||
}
|
||||
if (poolLane.e2eImageKind === "functional") {
|
||||
return baseEnv.OPENCLAW_DOCKER_E2E_FUNCTIONAL_IMAGE;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function laneEnv(poolLane, baseEnv, logDir, cacheKey) {
|
||||
const env = {
|
||||
...baseEnv,
|
||||
@@ -1405,6 +850,9 @@ async function main() {
|
||||
const preflightCleanup = parseBool(process.env.OPENCLAW_DOCKER_ALL_PREFLIGHT_CLEANUP, true);
|
||||
const timingsEnabled = parseBool(process.env.OPENCLAW_DOCKER_ALL_TIMINGS, true);
|
||||
const buildEnabled = parseBool(process.env.OPENCLAW_DOCKER_ALL_BUILD, true);
|
||||
const planJson =
|
||||
cliArgs.has("--plan-json") || parseBool(process.env.OPENCLAW_DOCKER_ALL_PLAN_JSON, false);
|
||||
const planReleaseAll = parseBool(process.env.OPENCLAW_DOCKER_ALL_PLAN_RELEASE_ALL, false);
|
||||
const profile = parseProfile(process.env.OPENCLAW_DOCKER_ALL_PROFILE);
|
||||
const releaseChunk = process.env.OPENCLAW_DOCKER_ALL_CHUNK || process.env.DOCKER_E2E_CHUNK || "";
|
||||
const includeOpenWebUI = parseBool(
|
||||
@@ -1431,7 +879,6 @@ async function main() {
|
||||
process.env.OPENCLAW_DOCKER_ALL_LOG_DIR ||
|
||||
path.join(ROOT_DIR, ".artifacts/docker-tests", runId),
|
||||
);
|
||||
await mkdir(logDir, { recursive: true });
|
||||
|
||||
const baseEnv = commandEnv({
|
||||
OPENCLAW_DOCKER_E2E_BARE_IMAGE:
|
||||
@@ -1450,40 +897,24 @@ async function main() {
|
||||
appendExtension(baseEnv, "codex");
|
||||
|
||||
const timingStore = await loadTimingStore(timingsFile, timingsEnabled);
|
||||
const retriedMainLanes = applyLiveRetries(lanes, liveRetries);
|
||||
const retriedTailLanes = applyLiveRetries(tailLanes, liveRetries);
|
||||
const releaseLanes =
|
||||
selectedLaneNames.length === 0 && profile === RELEASE_PATH_PROFILE
|
||||
? releasePathChunkLanes(releaseChunk, { includeOpenWebUI })
|
||||
: undefined;
|
||||
const selectedLanes =
|
||||
selectedLaneNames.length > 0
|
||||
? selectNamedLanes(
|
||||
dedupeLanes([
|
||||
...allReleasePathLanes({ includeOpenWebUI }),
|
||||
...retriedMainLanes,
|
||||
...retriedTailLanes,
|
||||
]),
|
||||
selectedLaneNames,
|
||||
"OPENCLAW_DOCKER_ALL_LANES",
|
||||
)
|
||||
: undefined;
|
||||
const configuredLanes = selectedLanes
|
||||
? selectedLanes
|
||||
: releaseLanes
|
||||
? releaseLanes
|
||||
: liveMode === "only"
|
||||
? applyLiveMode([...retriedMainLanes, ...retriedTailLanes], liveMode)
|
||||
: applyLiveMode(retriedMainLanes, liveMode);
|
||||
const configuredTailLanes =
|
||||
selectedLanes || releaseLanes
|
||||
? []
|
||||
: liveMode === "only"
|
||||
? []
|
||||
: applyLiveMode(retriedTailLanes, liveMode);
|
||||
const orderedLanes = orderLanes(configuredLanes, timingStore);
|
||||
const orderedTailLanes = orderLanes(configuredTailLanes, timingStore);
|
||||
const { orderedLanes, orderedTailLanes, plan, scheduledLanes } = resolveDockerE2ePlan({
|
||||
includeOpenWebUI,
|
||||
liveMode,
|
||||
liveRetries,
|
||||
orderLanes,
|
||||
planReleaseAll: planJson && planReleaseAll,
|
||||
profile,
|
||||
releaseChunk,
|
||||
selectedLaneNames,
|
||||
timingStore,
|
||||
});
|
||||
|
||||
if (planJson) {
|
||||
process.stdout.write(`${JSON.stringify(plan, null, 2)}\n`);
|
||||
return;
|
||||
}
|
||||
|
||||
await mkdir(logDir, { recursive: true });
|
||||
console.log(`==> Docker test logs: ${logDir}`);
|
||||
console.log(`==> Profile: ${profile}${releaseChunk ? ` chunk=${releaseChunk}` : ""}`);
|
||||
console.log(`==> Parallelism: ${parallelism}`);
|
||||
@@ -1538,7 +969,6 @@ async function main() {
|
||||
});
|
||||
},
|
||||
);
|
||||
const scheduledLanes = [...orderedLanes, ...orderedTailLanes];
|
||||
if (lanesNeedOpenClawPackage(scheduledLanes)) {
|
||||
await runPhase(phases, "prepare-openclaw-package", {}, async () => {
|
||||
await prepareOpenClawPackage(baseEnv, logDir);
|
||||
|
||||
Reference in New Issue
Block a user