Add ez-assistant and kerberos service folders
This commit is contained in:
23
docker-compose/ez-assistant/scripts/e2e/Dockerfile
Normal file
23
docker-compose/ez-assistant/scripts/e2e/Dockerfile
Normal file
@@ -0,0 +1,23 @@
|
||||
FROM node:22-bookworm
|
||||
|
||||
RUN corepack enable
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_OPTIONS="--disable-warning=ExperimentalWarning"
|
||||
|
||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml tsconfig.json vitest.config.ts vitest.e2e.config.ts ./
|
||||
COPY src ./src
|
||||
COPY test ./test
|
||||
COPY scripts ./scripts
|
||||
COPY docs ./docs
|
||||
COPY skills ./skills
|
||||
COPY patches ./patches
|
||||
COPY ui ./ui
|
||||
COPY extensions/memory-core ./extensions/memory-core
|
||||
|
||||
RUN pnpm install --frozen-lockfile
|
||||
RUN pnpm build
|
||||
RUN pnpm ui:build
|
||||
|
||||
CMD ["bash"]
|
||||
@@ -0,0 +1,9 @@
|
||||
FROM node:22-bookworm
|
||||
|
||||
RUN corepack enable
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN pnpm install --frozen-lockfile
|
||||
147
docker-compose/ez-assistant/scripts/e2e/doctor-install-switch-docker.sh
Executable file
147
docker-compose/ez-assistant/scripts/e2e/doctor-install-switch-docker.sh
Executable file
@@ -0,0 +1,147 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
IMAGE_NAME="moltbot-doctor-install-switch-e2e"
|
||||
|
||||
echo "Building Docker image..."
|
||||
docker build -t "$IMAGE_NAME" -f "$ROOT_DIR/scripts/e2e/Dockerfile" "$ROOT_DIR"
|
||||
|
||||
echo "Running doctor install switch E2E..."
|
||||
docker run --rm -t "$IMAGE_NAME" bash -lc '
|
||||
set -euo pipefail
|
||||
|
||||
# Keep logs focused; the npm global install step can emit noisy deprecation warnings.
|
||||
export npm_config_loglevel=error
|
||||
export npm_config_fund=false
|
||||
export npm_config_audit=false
|
||||
|
||||
# Stub systemd/loginctl so doctor + daemon flows work in Docker.
|
||||
export PATH="/tmp/moltbot-bin:$PATH"
|
||||
mkdir -p /tmp/moltbot-bin
|
||||
|
||||
cat > /tmp/moltbot-bin/systemctl <<"SYSTEMCTL"
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
args=("$@")
|
||||
if [[ "${args[0]:-}" == "--user" ]]; then
|
||||
args=("${args[@]:1}")
|
||||
fi
|
||||
cmd="${args[0]:-}"
|
||||
case "$cmd" in
|
||||
status)
|
||||
exit 0
|
||||
;;
|
||||
is-enabled)
|
||||
unit="${args[1]:-}"
|
||||
unit_path="$HOME/.config/systemd/user/${unit}"
|
||||
if [ -f "$unit_path" ]; then
|
||||
exit 0
|
||||
fi
|
||||
exit 1
|
||||
;;
|
||||
show)
|
||||
echo "ActiveState=inactive"
|
||||
echo "SubState=dead"
|
||||
echo "MainPID=0"
|
||||
echo "ExecMainStatus=0"
|
||||
echo "ExecMainCode=0"
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
SYSTEMCTL
|
||||
chmod +x /tmp/moltbot-bin/systemctl
|
||||
|
||||
cat > /tmp/moltbot-bin/loginctl <<"LOGINCTL"
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
if [[ "$*" == *"show-user"* ]]; then
|
||||
echo "Linger=yes"
|
||||
exit 0
|
||||
fi
|
||||
if [[ "$*" == *"enable-linger"* ]]; then
|
||||
exit 0
|
||||
fi
|
||||
exit 0
|
||||
LOGINCTL
|
||||
chmod +x /tmp/moltbot-bin/loginctl
|
||||
|
||||
# Install the npm-global variant from the local /app source.
|
||||
# `npm pack` can emit script output; keep only the tarball name.
|
||||
pkg_tgz="$(npm pack --silent /app | tail -n 1 | tr -d '\r')"
|
||||
if [ ! -f "/app/$pkg_tgz" ]; then
|
||||
echo "npm pack failed (expected /app/$pkg_tgz)"
|
||||
exit 1
|
||||
fi
|
||||
npm install -g --prefix /tmp/npm-prefix "/app/$pkg_tgz"
|
||||
|
||||
npm_bin="/tmp/npm-prefix/bin/moltbot"
|
||||
npm_entry="/tmp/npm-prefix/lib/node_modules/moltbot/dist/entry.js"
|
||||
git_entry="/app/dist/entry.js"
|
||||
|
||||
assert_entrypoint() {
|
||||
local unit_path="$1"
|
||||
local expected="$2"
|
||||
local exec_line=""
|
||||
exec_line=$(grep -m1 "^ExecStart=" "$unit_path" || true)
|
||||
if [ -z "$exec_line" ]; then
|
||||
echo "Missing ExecStart in $unit_path"
|
||||
exit 1
|
||||
fi
|
||||
exec_line="${exec_line#ExecStart=}"
|
||||
entrypoint=$(echo "$exec_line" | awk "{print \$2}")
|
||||
entrypoint="${entrypoint%\"}"
|
||||
entrypoint="${entrypoint#\"}"
|
||||
if [ "$entrypoint" != "$expected" ]; then
|
||||
echo "Expected entrypoint $expected, got $entrypoint"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Each flow: install service with one variant, run doctor from the other,
|
||||
# and verify ExecStart entrypoint switches accordingly.
|
||||
run_flow() {
|
||||
local name="$1"
|
||||
local install_cmd="$2"
|
||||
local install_expected="$3"
|
||||
local doctor_cmd="$4"
|
||||
local doctor_expected="$5"
|
||||
|
||||
echo "== Flow: $name =="
|
||||
home_dir=$(mktemp -d "/tmp/moltbot-switch-${name}.XXXXXX")
|
||||
export HOME="$home_dir"
|
||||
export USER="testuser"
|
||||
|
||||
eval "$install_cmd"
|
||||
|
||||
unit_path="$HOME/.config/systemd/user/moltbot-gateway.service"
|
||||
if [ ! -f "$unit_path" ]; then
|
||||
echo "Missing unit file: $unit_path"
|
||||
exit 1
|
||||
fi
|
||||
assert_entrypoint "$unit_path" "$install_expected"
|
||||
|
||||
eval "$doctor_cmd"
|
||||
|
||||
assert_entrypoint "$unit_path" "$doctor_expected"
|
||||
}
|
||||
|
||||
run_flow \
|
||||
"npm-to-git" \
|
||||
"$npm_bin daemon install --force" \
|
||||
"$npm_entry" \
|
||||
"node $git_entry doctor --repair --force" \
|
||||
"$git_entry"
|
||||
|
||||
run_flow \
|
||||
"git-to-npm" \
|
||||
"node $git_entry daemon install --force" \
|
||||
"$git_entry" \
|
||||
"$npm_bin doctor --repair --force" \
|
||||
"$npm_entry"
|
||||
'
|
||||
@@ -0,0 +1,115 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
IMAGE_NAME="moltbot-gateway-network-e2e"
|
||||
|
||||
PORT="18789"
|
||||
TOKEN="e2e-$(date +%s)-$$"
|
||||
NET_NAME="moltbot-net-e2e-$$"
|
||||
GW_NAME="moltbot-gateway-e2e-$$"
|
||||
|
||||
cleanup() {
|
||||
docker rm -f "$GW_NAME" >/dev/null 2>&1 || true
|
||||
docker network rm "$NET_NAME" >/dev/null 2>&1 || true
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
echo "Building Docker image..."
|
||||
docker build -t "$IMAGE_NAME" -f "$ROOT_DIR/scripts/e2e/Dockerfile" "$ROOT_DIR"
|
||||
|
||||
echo "Creating Docker network..."
|
||||
docker network create "$NET_NAME" >/dev/null
|
||||
|
||||
echo "Starting gateway container..."
|
||||
docker run --rm -d \
|
||||
--name "$GW_NAME" \
|
||||
--network "$NET_NAME" \
|
||||
-e "CLAWDBOT_GATEWAY_TOKEN=$TOKEN" \
|
||||
-e "CLAWDBOT_SKIP_CHANNELS=1" \
|
||||
-e "CLAWDBOT_SKIP_GMAIL_WATCHER=1" \
|
||||
-e "CLAWDBOT_SKIP_CRON=1" \
|
||||
-e "CLAWDBOT_SKIP_CANVAS_HOST=1" \
|
||||
"$IMAGE_NAME" \
|
||||
bash -lc "node dist/index.js gateway --port $PORT --bind lan --allow-unconfigured > /tmp/gateway-net-e2e.log 2>&1"
|
||||
|
||||
echo "Waiting for gateway to come up..."
|
||||
for _ in $(seq 1 20); do
|
||||
if docker exec "$GW_NAME" bash -lc "grep -q \"listening on ws://\" /tmp/gateway-net-e2e.log"; then
|
||||
break
|
||||
fi
|
||||
sleep 0.5
|
||||
done
|
||||
|
||||
docker exec "$GW_NAME" bash -lc "tail -n 50 /tmp/gateway-net-e2e.log"
|
||||
|
||||
echo "Running client container (connect + health)..."
|
||||
docker run --rm \
|
||||
--network "$NET_NAME" \
|
||||
-e "GW_URL=ws://$GW_NAME:$PORT" \
|
||||
-e "GW_TOKEN=$TOKEN" \
|
||||
"$IMAGE_NAME" \
|
||||
bash -lc "node - <<'NODE'
|
||||
import { WebSocket } from \"ws\";
|
||||
import { PROTOCOL_VERSION } from \"./dist/gateway/protocol/index.js\";
|
||||
|
||||
const url = process.env.GW_URL;
|
||||
const token = process.env.GW_TOKEN;
|
||||
if (!url || !token) throw new Error(\"missing GW_URL/GW_TOKEN\");
|
||||
|
||||
const ws = new WebSocket(url);
|
||||
await new Promise((resolve, reject) => {
|
||||
const t = setTimeout(() => reject(new Error(\"ws open timeout\")), 5000);
|
||||
ws.once(\"open\", () => {
|
||||
clearTimeout(t);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
function onceFrame(filter, timeoutMs = 5000) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const t = setTimeout(() => reject(new Error(\"timeout\")), timeoutMs);
|
||||
const handler = (data) => {
|
||||
const obj = JSON.parse(String(data));
|
||||
if (!filter(obj)) return;
|
||||
clearTimeout(t);
|
||||
ws.off(\"message\", handler);
|
||||
resolve(obj);
|
||||
};
|
||||
ws.on(\"message\", handler);
|
||||
});
|
||||
}
|
||||
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: \"req\",
|
||||
id: \"c1\",
|
||||
method: \"connect\",
|
||||
params: {
|
||||
minProtocol: PROTOCOL_VERSION,
|
||||
maxProtocol: PROTOCOL_VERSION,
|
||||
client: {
|
||||
id: \"test\",
|
||||
displayName: \"docker-net-e2e\",
|
||||
version: \"dev\",
|
||||
platform: process.platform,
|
||||
mode: \"test\",
|
||||
},
|
||||
caps: [],
|
||||
auth: { token },
|
||||
},
|
||||
}),
|
||||
);
|
||||
const connectRes = await onceFrame((o) => o?.type === \"res\" && o?.id === \"c1\");
|
||||
if (!connectRes.ok) throw new Error(\"connect failed: \" + (connectRes.error?.message ?? \"unknown\"));
|
||||
|
||||
ws.send(JSON.stringify({ type: \"req\", id: \"h1\", method: \"health\" }));
|
||||
const healthRes = await onceFrame((o) => o?.type === \"res\" && o?.id === \"h1\", 10000);
|
||||
if (!healthRes.ok) throw new Error(\"health failed: \" + (healthRes.error?.message ?? \"unknown\"));
|
||||
if (healthRes.payload?.ok !== true) throw new Error(\"unexpected health payload\");
|
||||
|
||||
ws.close();
|
||||
console.log(\"ok\");
|
||||
NODE"
|
||||
|
||||
echo "OK"
|
||||
545
docker-compose/ez-assistant/scripts/e2e/onboard-docker.sh
Executable file
545
docker-compose/ez-assistant/scripts/e2e/onboard-docker.sh
Executable file
@@ -0,0 +1,545 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
IMAGE_NAME="moltbot-onboard-e2e"
|
||||
|
||||
echo "Building Docker image..."
|
||||
docker build -t "$IMAGE_NAME" -f "$ROOT_DIR/scripts/e2e/Dockerfile" "$ROOT_DIR"
|
||||
|
||||
echo "Running onboarding E2E..."
|
||||
docker run --rm -t "$IMAGE_NAME" bash -lc '
|
||||
set -euo pipefail
|
||||
trap "" PIPE
|
||||
export TERM=xterm-256color
|
||||
ONBOARD_FLAGS="--flow quickstart --auth-choice skip --skip-channels --skip-skills --skip-daemon --skip-ui"
|
||||
|
||||
# Provide a minimal trash shim to avoid noisy "missing trash" logs in containers.
|
||||
export PATH="/tmp/moltbot-bin:$PATH"
|
||||
mkdir -p /tmp/moltbot-bin
|
||||
cat > /tmp/moltbot-bin/trash <<'"'"'TRASH'"'"'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
trash_dir="$HOME/.Trash"
|
||||
mkdir -p "$trash_dir"
|
||||
for target in "$@"; do
|
||||
[ -e "$target" ] || continue
|
||||
base="$(basename "$target")"
|
||||
dest="$trash_dir/$base"
|
||||
if [ -e "$dest" ]; then
|
||||
dest="$trash_dir/${base}-$(date +%s)-$$"
|
||||
fi
|
||||
mv "$target" "$dest"
|
||||
done
|
||||
TRASH
|
||||
chmod +x /tmp/moltbot-bin/trash
|
||||
|
||||
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 needle_compact
|
||||
needle_compact="$(printf "%s" "$needle" | tr -cd "[:alnum:]")"
|
||||
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); }
|
||||
if (text.length > 20000) text = text.slice(-20000);
|
||||
const stripAnsi = (value) => value.replace(/\\x1b\\[[0-9;]*[A-Za-z]/g, \"\");
|
||||
const compact = (value) => stripAnsi(value).toLowerCase().replace(/[^a-z0-9]+/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
|
||||
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() {
|
||||
node dist/index.js gateway --port 18789 --bind loopback --allow-unconfigured > /tmp/gateway-e2e.log 2>&1 &
|
||||
GATEWAY_PID="$!"
|
||||
}
|
||||
|
||||
wait_for_gateway() {
|
||||
for _ in $(seq 1 20); do
|
||||
if node --input-type=module -e "
|
||||
import net from 'node:net';
|
||||
const socket = net.createConnection({ host: '127.0.0.1', port: 18789 });
|
||||
const timeout = setTimeout(() => {
|
||||
socket.destroy();
|
||||
process.exit(1);
|
||||
}, 500);
|
||||
socket.on('connect', () => {
|
||||
clearTimeout(timeout);
|
||||
socket.end();
|
||||
process.exit(0);
|
||||
});
|
||||
socket.on('error', () => {
|
||||
clearTimeout(timeout);
|
||||
process.exit(1);
|
||||
});
|
||||
" >/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() {
|
||||
local gw_pid="$1"
|
||||
if [ -n "$gw_pid" ]; then
|
||||
kill "$gw_pid" 2>/dev/null || true
|
||||
wait "$gw_pid" || true
|
||||
fi
|
||||
}
|
||||
|
||||
run_wizard_cmd() {
|
||||
local case_name="$1"
|
||||
local home_dir="$2"
|
||||
local command="$3"
|
||||
local send_fn="$4"
|
||||
local with_gateway="${5:-false}"
|
||||
local validate_fn="${6:-}"
|
||||
|
||||
echo "== Wizard case: $case_name =="
|
||||
export HOME="$home_dir"
|
||||
mkdir -p "$HOME"
|
||||
|
||||
input_fifo="$(mktemp -u "/tmp/moltbot-onboard-${case_name}.XXXXXX")"
|
||||
mkfifo "$input_fifo"
|
||||
local log_path="/tmp/moltbot-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" &
|
||||
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 home_dir="$2"
|
||||
local send_fn="$3"
|
||||
local validate_fn="${4:-}"
|
||||
|
||||
# Default onboarding command wrapper.
|
||||
run_wizard_cmd "$case_name" "$home_dir" "node dist/index.js onboard $ONBOARD_FLAGS" "$send_fn" true "$validate_fn"
|
||||
}
|
||||
|
||||
make_home() {
|
||||
mktemp -d "/tmp/moltbot-e2e-$1.XXXXXX"
|
||||
}
|
||||
|
||||
assert_file() {
|
||||
local file_path="$1"
|
||||
if [ ! -f "$file_path" ]; then
|
||||
echo "Missing file: $file_path"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
assert_dir() {
|
||||
local dir_path="$1"
|
||||
if [ ! -d "$dir_path" ]; then
|
||||
echo "Missing dir: $dir_path"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
select_skip_hooks() {
|
||||
# Hooks multiselect: pick "Skip for now".
|
||||
wait_for_log "Enable hooks?" 60 || true
|
||||
send $'"'"' \r'"'"' 0.6
|
||||
}
|
||||
|
||||
send_local_basic() {
|
||||
# Risk acknowledgement (default is "No").
|
||||
wait_for_log "Continue?" 60
|
||||
send $'"'"'y\r'"'"' 0.6
|
||||
# Choose local gateway, accept defaults, skip channels/skills/daemon, skip UI.
|
||||
if wait_for_log "Where will the Gateway run?" 20; then
|
||||
send $'"'"'\r'"'"' 0.5
|
||||
fi
|
||||
select_skip_hooks
|
||||
}
|
||||
|
||||
send_reset_config_only() {
|
||||
# Risk acknowledgement (default is "No").
|
||||
wait_for_log "Continue?" 40 || true
|
||||
send $'"'"'y\r'"'"' 0.8
|
||||
# Select reset flow for existing config.
|
||||
wait_for_log "Config handling" 40 || true
|
||||
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 || true
|
||||
send $'"'"'\r'"'"' 0.4
|
||||
select_skip_hooks
|
||||
}
|
||||
|
||||
send_channels_flow() {
|
||||
# Configure channels via configure wizard.
|
||||
# Prompts are interactive; notes are not. Use conservative delays to stay in sync.
|
||||
# Where will the Gateway run? -> Local (default)
|
||||
send $'"'"'\r'"'"' 1.2
|
||||
# Channels mode -> Configure/link (default)
|
||||
send $'"'"'\r'"'"' 1.5
|
||||
# Select a channel -> Finished (last option; clack wraps on Up)
|
||||
send $'"'"'\e[A\r'"'"' 2.0
|
||||
# Keep stdin open until wizard exits.
|
||||
send "" 2.5
|
||||
}
|
||||
|
||||
send_skills_flow() {
|
||||
# Select skills section and skip optional installs.
|
||||
wait_for_log "Where will the Gateway run?" 60 || true
|
||||
send $'"'"'\r'"'"' 0.6
|
||||
# Configure skills now? -> No
|
||||
wait_for_log "Configure skills now?" 60 || true
|
||||
send $'"'"'n\r'"'"' 0.8
|
||||
send "" 1.0
|
||||
}
|
||||
|
||||
run_case_local_basic() {
|
||||
local home_dir
|
||||
home_dir="$(make_home local-basic)"
|
||||
export HOME="$home_dir"
|
||||
mkdir -p "$HOME"
|
||||
node dist/index.js 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="$HOME/clawd"
|
||||
config_path="$HOME/.clawdbot/moltbot.json"
|
||||
sessions_dir="$HOME/.clawdbot/agents/main/sessions"
|
||||
|
||||
assert_file "$config_path"
|
||||
assert_dir "$sessions_dir"
|
||||
for file in AGENTS.md BOOTSTRAP.md IDENTITY.md SOUL.md TOOLS.md USER.md; do
|
||||
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() {
|
||||
local home_dir
|
||||
home_dir="$(make_home remote-non-interactive)"
|
||||
export HOME="$home_dir"
|
||||
mkdir -p "$HOME"
|
||||
# Smoke test non-interactive remote config write.
|
||||
node dist/index.js onboard --non-interactive --accept-risk \
|
||||
--mode remote \
|
||||
--remote-url ws://gateway.local:18789 \
|
||||
--remote-token remote-token \
|
||||
--skip-skills \
|
||||
--skip-health
|
||||
|
||||
config_path="$HOME/.clawdbot/moltbot.json"
|
||||
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() {
|
||||
local home_dir
|
||||
home_dir="$(make_home reset-config)"
|
||||
export HOME="$home_dir"
|
||||
mkdir -p "$HOME/.clawdbot"
|
||||
# Seed a remote config to exercise reset path.
|
||||
cat > "$HOME/.clawdbot/moltbot.json" <<'"'"'JSON'"'"'
|
||||
{
|
||||
"agents": { "defaults": { "workspace": "/root/old" } },
|
||||
"gateway": {
|
||||
"mode": "remote",
|
||||
"remote": { "url": "ws://old.example:18789", "token": "old-token" }
|
||||
}
|
||||
}
|
||||
JSON
|
||||
|
||||
node dist/index.js onboard \
|
||||
--non-interactive \
|
||||
--accept-risk \
|
||||
--flow quickstart \
|
||||
--mode local \
|
||||
--reset \
|
||||
--skip-channels \
|
||||
--skip-skills \
|
||||
--skip-daemon \
|
||||
--skip-ui \
|
||||
--skip-health
|
||||
|
||||
config_path="$HOME/.clawdbot/moltbot.json"
|
||||
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() {
|
||||
local home_dir
|
||||
home_dir="$(make_home channels)"
|
||||
# Channels-only configure flow.
|
||||
run_wizard_cmd channels "$home_dir" "node dist/index.js configure --section channels" send_channels_flow
|
||||
|
||||
config_path="$HOME/.clawdbot/moltbot.json"
|
||||
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
|
||||
home_dir="$(make_home skills)"
|
||||
export HOME="$home_dir"
|
||||
mkdir -p "$HOME/.clawdbot"
|
||||
# Seed skills config to ensure it survives the wizard.
|
||||
cat > "$HOME/.clawdbot/moltbot.json" <<'"'"'JSON'"'"'
|
||||
{
|
||||
"skills": {
|
||||
"allowBundled": ["__none__"],
|
||||
"install": { "nodeManager": "bun" }
|
||||
}
|
||||
}
|
||||
JSON
|
||||
|
||||
run_wizard_cmd skills "$home_dir" "node dist/index.js configure --section skills" send_skills_flow
|
||||
|
||||
config_path="$HOME/.clawdbot/moltbot.json"
|
||||
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
|
||||
}
|
||||
|
||||
assert_log_not_contains() {
|
||||
local file_path="$1"
|
||||
local needle="$2"
|
||||
if grep -q "$needle" "$file_path"; then
|
||||
echo "Unexpected log output: $needle"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
validate_local_basic_log() {
|
||||
local log_path="$1"
|
||||
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
|
||||
'
|
||||
|
||||
echo "E2E complete."
|
||||
178
docker-compose/ez-assistant/scripts/e2e/plugins-docker.sh
Executable file
178
docker-compose/ez-assistant/scripts/e2e/plugins-docker.sh
Executable file
@@ -0,0 +1,178 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
IMAGE_NAME="moltbot-plugins-e2e"
|
||||
|
||||
echo "Building Docker image..."
|
||||
docker build -t "$IMAGE_NAME" -f "$ROOT_DIR/scripts/e2e/Dockerfile" "$ROOT_DIR"
|
||||
|
||||
echo "Running plugins Docker E2E..."
|
||||
docker run --rm -t "$IMAGE_NAME" bash -lc '
|
||||
set -euo pipefail
|
||||
|
||||
home_dir=$(mktemp -d "/tmp/moltbot-plugins-e2e.XXXXXX")
|
||||
export HOME="$home_dir"
|
||||
mkdir -p "$HOME/.clawdbot/extensions"
|
||||
|
||||
cat > "$HOME/.clawdbot/extensions/demo-plugin.js" <<'"'"'JS'"'"'
|
||||
module.exports = {
|
||||
id: "demo-plugin",
|
||||
name: "Demo Plugin",
|
||||
description: "Docker E2E demo plugin",
|
||||
register(api) {
|
||||
api.registerTool(() => null, { name: "demo_tool" });
|
||||
api.registerGatewayMethod("demo.ping", async () => ({ ok: true }));
|
||||
api.registerCli(() => {}, { commands: ["demo"] });
|
||||
api.registerService({ id: "demo-service", start: () => {} });
|
||||
},
|
||||
};
|
||||
JS
|
||||
|
||||
node dist/index.js plugins list --json > /tmp/plugins.json
|
||||
|
||||
node - <<'"'"'NODE'"'"'
|
||||
const fs = require("node:fs");
|
||||
|
||||
const data = JSON.parse(fs.readFileSync("/tmp/plugins.json", "utf8"));
|
||||
const plugin = (data.plugins || []).find((entry) => entry.id === "demo-plugin");
|
||||
if (!plugin) throw new Error("plugin not found");
|
||||
if (plugin.status !== "loaded") {
|
||||
throw new Error(`unexpected status: ${plugin.status}`);
|
||||
}
|
||||
|
||||
const assertIncludes = (list, value, label) => {
|
||||
if (!Array.isArray(list) || !list.includes(value)) {
|
||||
throw new Error(`${label} missing: ${value}`);
|
||||
}
|
||||
};
|
||||
|
||||
assertIncludes(plugin.toolNames, "demo_tool", "tool");
|
||||
assertIncludes(plugin.gatewayMethods, "demo.ping", "gateway method");
|
||||
assertIncludes(plugin.cliCommands, "demo", "cli command");
|
||||
assertIncludes(plugin.services, "demo-service", "service");
|
||||
|
||||
const diagErrors = (data.diagnostics || []).filter((diag) => diag.level === "error");
|
||||
if (diagErrors.length > 0) {
|
||||
throw new Error(`diagnostics errors: ${diagErrors.map((diag) => diag.message).join("; ")}`);
|
||||
}
|
||||
|
||||
console.log("ok");
|
||||
NODE
|
||||
|
||||
echo "Testing tgz install flow..."
|
||||
pack_dir="$(mktemp -d "/tmp/moltbot-plugin-pack.XXXXXX")"
|
||||
mkdir -p "$pack_dir/package"
|
||||
cat > "$pack_dir/package/package.json" <<'"'"'JSON'"'"'
|
||||
{
|
||||
"name": "@moltbot/demo-plugin-tgz",
|
||||
"version": "0.0.1",
|
||||
"moltbot": { "extensions": ["./index.js"] }
|
||||
}
|
||||
JSON
|
||||
cat > "$pack_dir/package/index.js" <<'"'"'JS'"'"'
|
||||
module.exports = {
|
||||
id: "demo-plugin-tgz",
|
||||
name: "Demo Plugin TGZ",
|
||||
register(api) {
|
||||
api.registerGatewayMethod("demo.tgz", async () => ({ ok: true }));
|
||||
},
|
||||
};
|
||||
JS
|
||||
tar -czf /tmp/demo-plugin-tgz.tgz -C "$pack_dir" package
|
||||
|
||||
node dist/index.js plugins install /tmp/demo-plugin-tgz.tgz
|
||||
node dist/index.js plugins list --json > /tmp/plugins2.json
|
||||
|
||||
node - <<'"'"'NODE'"'"'
|
||||
const fs = require("node:fs");
|
||||
|
||||
const data = JSON.parse(fs.readFileSync("/tmp/plugins2.json", "utf8"));
|
||||
const plugin = (data.plugins || []).find((entry) => entry.id === "demo-plugin-tgz");
|
||||
if (!plugin) throw new Error("tgz plugin not found");
|
||||
if (plugin.status !== "loaded") {
|
||||
throw new Error(`unexpected status: ${plugin.status}`);
|
||||
}
|
||||
if (!Array.isArray(plugin.gatewayMethods) || !plugin.gatewayMethods.includes("demo.tgz")) {
|
||||
throw new Error("expected gateway method demo.tgz");
|
||||
}
|
||||
console.log("ok");
|
||||
NODE
|
||||
|
||||
echo "Testing install from local folder (plugins.load.paths)..."
|
||||
dir_plugin="$(mktemp -d "/tmp/moltbot-plugin-dir.XXXXXX")"
|
||||
cat > "$dir_plugin/package.json" <<'"'"'JSON'"'"'
|
||||
{
|
||||
"name": "@moltbot/demo-plugin-dir",
|
||||
"version": "0.0.1",
|
||||
"moltbot": { "extensions": ["./index.js"] }
|
||||
}
|
||||
JSON
|
||||
cat > "$dir_plugin/index.js" <<'"'"'JS'"'"'
|
||||
module.exports = {
|
||||
id: "demo-plugin-dir",
|
||||
name: "Demo Plugin DIR",
|
||||
register(api) {
|
||||
api.registerGatewayMethod("demo.dir", async () => ({ ok: true }));
|
||||
},
|
||||
};
|
||||
JS
|
||||
|
||||
node dist/index.js plugins install "$dir_plugin"
|
||||
node dist/index.js plugins list --json > /tmp/plugins3.json
|
||||
|
||||
node - <<'"'"'NODE'"'"'
|
||||
const fs = require("node:fs");
|
||||
|
||||
const data = JSON.parse(fs.readFileSync("/tmp/plugins3.json", "utf8"));
|
||||
const plugin = (data.plugins || []).find((entry) => entry.id === "demo-plugin-dir");
|
||||
if (!plugin) throw new Error("dir plugin not found");
|
||||
if (plugin.status !== "loaded") {
|
||||
throw new Error(`unexpected status: ${plugin.status}`);
|
||||
}
|
||||
if (!Array.isArray(plugin.gatewayMethods) || !plugin.gatewayMethods.includes("demo.dir")) {
|
||||
throw new Error("expected gateway method demo.dir");
|
||||
}
|
||||
console.log("ok");
|
||||
NODE
|
||||
|
||||
echo "Testing install from npm spec (file:)..."
|
||||
file_pack_dir="$(mktemp -d "/tmp/moltbot-plugin-filepack.XXXXXX")"
|
||||
mkdir -p "$file_pack_dir/package"
|
||||
cat > "$file_pack_dir/package/package.json" <<'"'"'JSON'"'"'
|
||||
{
|
||||
"name": "@moltbot/demo-plugin-file",
|
||||
"version": "0.0.1",
|
||||
"moltbot": { "extensions": ["./index.js"] }
|
||||
}
|
||||
JSON
|
||||
cat > "$file_pack_dir/package/index.js" <<'"'"'JS'"'"'
|
||||
module.exports = {
|
||||
id: "demo-plugin-file",
|
||||
name: "Demo Plugin FILE",
|
||||
register(api) {
|
||||
api.registerGatewayMethod("demo.file", async () => ({ ok: true }));
|
||||
},
|
||||
};
|
||||
JS
|
||||
|
||||
node dist/index.js plugins install "file:$file_pack_dir/package"
|
||||
node dist/index.js plugins list --json > /tmp/plugins4.json
|
||||
|
||||
node - <<'"'"'NODE'"'"'
|
||||
const fs = require("node:fs");
|
||||
|
||||
const data = JSON.parse(fs.readFileSync("/tmp/plugins4.json", "utf8"));
|
||||
const plugin = (data.plugins || []).find((entry) => entry.id === "demo-plugin-file");
|
||||
if (!plugin) throw new Error("file plugin not found");
|
||||
if (plugin.status !== "loaded") {
|
||||
throw new Error(`unexpected status: ${plugin.status}`);
|
||||
}
|
||||
if (!Array.isArray(plugin.gatewayMethods) || !plugin.gatewayMethods.includes("demo.file")) {
|
||||
throw new Error("expected gateway method demo.file");
|
||||
}
|
||||
console.log("ok");
|
||||
NODE
|
||||
'
|
||||
|
||||
echo "OK"
|
||||
11
docker-compose/ez-assistant/scripts/e2e/qr-import-docker.sh
Executable file
11
docker-compose/ez-assistant/scripts/e2e/qr-import-docker.sh
Executable file
@@ -0,0 +1,11 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
IMAGE_NAME="${CLAWDBOT_QR_SMOKE_IMAGE:-moltbot-qr-smoke}"
|
||||
|
||||
echo "Building Docker image..."
|
||||
docker build -t "$IMAGE_NAME" -f "$ROOT_DIR/scripts/e2e/Dockerfile.qr-import" "$ROOT_DIR"
|
||||
|
||||
echo "Running qrcode-terminal import smoke..."
|
||||
docker run --rm -t "$IMAGE_NAME" node -e "import('qrcode-terminal').then((m)=>m.default.generate('qr-smoke',{small:true}))"
|
||||
Reference in New Issue
Block a user