feat(qa): telegram soak test on digitalocean + fix bun -e (#2547)

- soak.sh: SOAK_CLOUD env var makes cloud configurable (default: sprite)
- qa.sh: load TELEGRAM_BOT_TOKEN, TELEGRAM_TEST_CHAT_ID, SOAK_CLOUD from
  /etc/spawn-qa-auth.env in soak mode
- qa.yml: add weekly Monday 3am UTC scheduled soak trigger
- fix: bun eval → bun -e across soak.sh, key-request.sh, github-auth.sh
  (bun eval is not a valid subcommand in bun 1.3.9)
- fix: export _TOKEN via env prefix so process.env._TOKEN works in bun -e
- docs: update shell-scripts.md rule to say bun -e (not bun eval)

Verified: 3/4 Telegram tests pass in smoke test on DigitalOcean (120s wait)
getMe ✓ sendMessage ✓ getWebhookInfo ✓; cron test needs full 55-min window.

Co-authored-by: spawn-qa-bot <qa@openrouter.ai>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
A 2026-03-12 16:45:18 -07:00 committed by GitHub
parent 2b83a8106d
commit 6081c0a17f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 50 additions and 19 deletions

View file

@ -25,10 +25,10 @@ macOS ships bash 3.2. All scripts MUST work on it:
## Use Bun + TypeScript for Inline Scripting — NEVER python/python3
When shell scripts need JSON processing, HTTP calls, crypto, or any non-trivial logic:
- **ALWAYS** use `bun eval '...'` or write a temp `.ts` file and `bun run` it
- **ALWAYS** use `bun -e '...'` or write a temp `.ts` file and `bun run` it
- **NEVER** use `python3 -c` or `python -c` for inline scripting — python is not a project dependency
- Prefer `jq` for simple JSON extraction; fall back to `bun eval` when jq is unavailable
- Pass data to bun via environment variables (e.g., `_DATA="${var}" bun eval "..."`) or temp files — never interpolate untrusted values into JS strings
- Prefer `jq` for simple JSON extraction; fall back to `bun -e` when jq is unavailable
- Pass data to bun via environment variables (e.g., `_DATA="${var}" bun -e "..."`) or temp files — never interpolate untrusted values into JS strings
- For complex operations (SigV4 signing, API calls with retries), write a heredoc `.ts` file and `bun run` it
## ESM Only — NEVER use require() or CommonJS

View file

@ -226,6 +226,29 @@ if [[ "${RUN_MODE}" == "e2e" ]]; then
fi
fi
# --- Load Telegram credentials for soak mode ---
if [[ "${RUN_MODE}" == "soak" ]]; then
if [[ -f /etc/spawn-qa-auth.env ]]; then
while IFS='=' read -r _tkey _tval || [[ -n "${_tkey}" ]]; do
_tkey="${_tkey#"${_tkey%%[! ]*}"}"
_tkey="${_tkey%"${_tkey##*[! ]}"}"
[[ -z "${_tkey}" || "${_tkey}" == \#* ]] && continue
case "${_tkey}" in
TELEGRAM_BOT_TOKEN|TELEGRAM_TEST_CHAT_ID|SOAK_CLOUD)
export "${_tkey}=${_tval}"
;;
esac
done < /etc/spawn-qa-auth.env
if [[ -n "${TELEGRAM_BOT_TOKEN:-}" ]] && [[ -n "${TELEGRAM_TEST_CHAT_ID:-}" ]]; then
log "Telegram credentials loaded for soak test (cloud: ${SOAK_CLOUD:-sprite})"
else
log "WARNING: TELEGRAM_BOT_TOKEN or TELEGRAM_TEST_CHAT_ID missing from /etc/spawn-qa-auth.env — soak test will fail"
fi
else
log "WARNING: /etc/spawn-qa-auth.env not found — soak test requires TELEGRAM_BOT_TOKEN and TELEGRAM_TEST_CHAT_ID"
fi
fi
# Launch Claude Code with mode-specific prompt
# Enable agent teams (required for team-based workflows)
export CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1

View file

@ -1,7 +1,8 @@
name: QA
on:
schedule:
- cron: '0 */4 * * *'
- cron: '0 */4 * * *' # Every 4 hours — quality sweep
- cron: '0 3 * * 1' # Every Monday 3am UTC — Telegram soak test (OpenClaw on DigitalOcean)
workflow_dispatch:
inputs:
reason:
@ -24,7 +25,11 @@ jobs:
SPRITE_URL: ${{ secrets.QA_SPRITE_URL }}
TRIGGER_SECRET: ${{ secrets.QA_TRIGGER_SECRET }}
run: |
REASON="${{ github.event.inputs.reason || 'schedule' }}"
if [ "${{ github.event_name }}" = "schedule" ] && [ "${{ github.event.schedule }}" = "0 3 * * 1" ]; then
REASON="soak"
else
REASON="${{ github.event.inputs.reason || 'schedule' }}"
fi
curl -sS --fail-with-body -X POST \
"${SPRITE_URL}/trigger?reason=${REASON}" \
-H "Authorization: Bearer ${TRIGGER_SECRET}"

View file

@ -19,6 +19,7 @@ set -eo pipefail
# ---------------------------------------------------------------------------
SOAK_WAIT_SECONDS="${SOAK_WAIT_SECONDS:-3600}"
SOAK_CRON_DELAY_SECONDS="${SOAK_CRON_DELAY_SECONDS:-3300}"
SOAK_CLOUD="${SOAK_CLOUD:-sprite}"
SOAK_HEARTBEAT_INTERVAL=300 # 5 minutes
SOAK_GATEWAY_PORT=18789
TELEGRAM_API_BASE="https://api.telegram.org"
@ -146,14 +147,15 @@ soak_inject_telegram_config() {
log_step "Patching ~/.openclaw/openclaw.json with Telegram bot token..."
# Use bun eval on the remote to JSON-patch the config file
# Use bun -e on the remote to JSON-patch the config file.
# _TOKEN is passed via env var prefix so process.env._TOKEN is available in bun.
cloud_exec "${app}" "source ~/.spawnrc 2>/dev/null; \
export PATH=\$HOME/.npm-global/bin:\$HOME/.bun/bin:\$HOME/.local/bin:\$PATH; \
_TOKEN=\$(printf '%s' '${encoded_token}' | base64 -d); \
bun eval ' \
_TOKEN=\${_TOKEN} bun -e ' \
import { mkdirSync, readFileSync, writeFileSync } from \"node:fs\"; \
import { dirname } from \"node:path\"; \
const configPath = process.env.HOME + \"/.openclaw/openclaw.json\"; \
const configPath = (process.env.HOME ?? \"\") + \"/.openclaw/openclaw.json\"; \
let config = {}; \
try { config = JSON.parse(readFileSync(configPath, \"utf-8\")); } catch {} \
if (!config.channels) config.channels = {}; \
@ -162,7 +164,7 @@ soak_inject_telegram_config() {
mkdirSync(dirname(configPath), { recursive: true }); \
writeFileSync(configPath, JSON.stringify(config, null, 2)); \
console.log(\"Telegram config injected\"); \
'" >/dev/null 2>&1
'" 2>&1
if [ $? -ne 0 ]; then
log_err "Failed to inject Telegram config"
@ -477,7 +479,7 @@ soak_run_telegram_tests() {
# ---------------------------------------------------------------------------
# run_soak_test [LOG_DIR]
#
# Orchestrator: validate env → load sprite driver → provision openclaw →
# Orchestrator: validate env → load cloud driver (SOAK_CLOUD) → provision openclaw →
# verify → inject telegram config → schedule openclaw cron reminder →
# soak wait → run tests (including openclaw cron verification) → teardown.
# ---------------------------------------------------------------------------
@ -488,6 +490,7 @@ run_soak_test() {
fi
log_header "Spawn Soak Test: OpenClaw + Telegram (with cron reminder)"
log_info "Cloud: ${SOAK_CLOUD}"
log_info "Soak wait: ${SOAK_WAIT_SECONDS}s"
log_info "Cron delay: ${SOAK_CRON_DELAY_SECONDS}s"
@ -497,8 +500,8 @@ run_soak_test() {
return 1
fi
# Load sprite cloud driver
load_cloud_driver "sprite"
# Load cloud driver (configurable via SOAK_CLOUD, default: sprite)
load_cloud_driver "${SOAK_CLOUD}"
# Validate cloud environment
if ! require_env; then

View file

@ -136,11 +136,11 @@ _fetch_gh_latest_version() {
}
local latest_version=""
# Prefer jq for safe JSON parsing; fall back to bun eval (never python)
# Prefer jq for safe JSON parsing; fall back to bun -e (never python)
if command -v jq &>/dev/null; then
latest_version=$(printf '%s' "${api_response}" | jq -r '.tag_name // empty' 2>/dev/null) || true
elif command -v bun &>/dev/null; then
latest_version=$(_GH_API_RESPONSE="${api_response}" bun eval "
latest_version=$(_GH_API_RESPONSE="${api_response}" bun -e "
const data = JSON.parse(process.env._GH_API_RESPONSE || '{}');
const tag = typeof data.tag_name === 'string' ? data.tag_name : '';
process.stdout.write(tag);

View file

@ -29,7 +29,7 @@ _check_cli_auth_clouds() {
if command -v jq &>/dev/null; then
cli_clouds=$(jq -r '.clouds | to_entries[] | select(.value.auth != null) | select(.value.auth | test("\\b(login|configure|setup)\\b"; "i")) | "\(.key)|\(.value.auth)"' "${manifest_path}" 2>/dev/null)
else
cli_clouds=$(_MANIFEST="${manifest_path}" bun eval "
cli_clouds=$(_MANIFEST="${manifest_path}" bun -e "
import fs from 'fs';
const m = JSON.parse(fs.readFileSync(process.env._MANIFEST, 'utf8'));
for (const [key, cloud] of Object.entries(m.clouds || {})) {
@ -58,7 +58,7 @@ for (const [key, cloud] of Object.entries(m.clouds || {})) {
if command -v jq &>/dev/null; then
project=$(jq -r '.GCP_PROJECT // .project // "" | select(. != null)' "${gcp_config}" 2>/dev/null)
else
project=$(_FILE="${gcp_config}" bun eval "
project=$(_FILE="${gcp_config}" bun -e "
import fs from 'fs';
const d = JSON.parse(fs.readFileSync(process.env._FILE, 'utf8'));
process.stdout.write(d.GCP_PROJECT || d.project || '');
@ -95,7 +95,7 @@ _parse_cloud_auths() {
if command -v jq &>/dev/null; then
jq -r '.clouds | to_entries[] | select(.value.auth != null and .value.auth != "") | select(.value.key_request != false) | select(.value.auth | test("\\b(login|configure|setup)\\b"; "i") | not) | "\(.key)|\(.value.auth)"' "${manifest_path}" 2>/dev/null
else
_MANIFEST="${manifest_path}" bun eval "
_MANIFEST="${manifest_path}" bun -e "
import fs from 'fs';
const m = JSON.parse(fs.readFileSync(process.env._MANIFEST, 'utf8'));
for (const [key, cloud] of Object.entries(m.clouds || {})) {
@ -134,7 +134,7 @@ _try_load_env_var() {
if command -v jq &>/dev/null; then
val=$(jq -r --arg v "${var_name}" '(.[$v] // .api_key // .token) // "" | select(. != null)' "${config_file}" 2>/dev/null)
else
val=$(_FILE="${config_file}" _VAR="${var_name}" bun eval "
val=$(_FILE="${config_file}" _VAR="${var_name}" bun -e "
import fs from 'fs';
const d = JSON.parse(fs.readFileSync(process.env._FILE, 'utf8'));
process.stdout.write(d[process.env._VAR] || d.api_key || d.token || '');
@ -268,7 +268,7 @@ request_missing_cloud_keys() {
if command -v jq &>/dev/null; then
providers_json=$(printf '%s\n' ${MISSING_KEY_PROVIDERS} | jq -Rn '[inputs | select(. != "")]' 2>/dev/null) || return 0
elif command -v bun &>/dev/null; then
providers_json=$(_PROVIDERS="${MISSING_KEY_PROVIDERS}" bun eval "
providers_json=$(_PROVIDERS="${MISSING_KEY_PROVIDERS}" bun -e "
const providers = process.env._PROVIDERS.trim().split(/\s+/).filter(Boolean);
process.stdout.write(JSON.stringify(providers));
" 2>/dev/null) || return 0