From 378da8b9d3df18d8823554f92f933d9ca712a940 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 10 May 2026 06:39:43 +0100 Subject: [PATCH] feat: add telegram mantis evidence builder --- .github/workflows/mantis-scenario.yml | 13 + .github/workflows/mantis-telegram-live.yml | 500 ++++++++++ CHANGELOG.md | 2 + docs/concepts/mantis.md | 51 + docs/help/testing.md | 20 + extensions/qa-lab/src/cli.test.ts | 60 ++ extensions/qa-lab/src/mantis/cli.runtime.ts | 21 + extensions/qa-lab/src/mantis/cli.ts | 76 ++ .../telegram-desktop-builder.runtime.test.ts | 254 +++++ .../telegram-desktop-builder.runtime.ts | 887 ++++++++++++++++++ scripts/mantis/build-telegram-evidence.mjs | 405 ++++++++ scripts/test-projects.test-support.mjs | 8 + .../mantis-build-telegram-evidence.test.ts | 136 +++ 13 files changed, 2433 insertions(+) create mode 100644 .github/workflows/mantis-telegram-live.yml create mode 100644 extensions/qa-lab/src/mantis/telegram-desktop-builder.runtime.test.ts create mode 100644 extensions/qa-lab/src/mantis/telegram-desktop-builder.runtime.ts create mode 100644 scripts/mantis/build-telegram-evidence.mjs create mode 100644 test/scripts/mantis-build-telegram-evidence.test.ts diff --git a/.github/workflows/mantis-scenario.yml b/.github/workflows/mantis-scenario.yml index ad58d804f7b..c053a25adb0 100644 --- a/.github/workflows/mantis-scenario.yml +++ b/.github/workflows/mantis-scenario.yml @@ -12,6 +12,7 @@ on: - discord-status-reactions-tool-only - discord-thread-reply-filepath-attachment - slack-desktop-smoke + - telegram-live baseline_ref: description: Optional baseline ref for before/after scenarios required: false @@ -90,6 +91,18 @@ jobs: fi gh "${args[@]}" ;; + telegram-live) + args=( + workflow run mantis-telegram-live.yml + --repo "$GITHUB_REPOSITORY" + --ref main + -f "candidate_ref=${CANDIDATE_REF}" + ) + if [[ -n "${PR_NUMBER:-}" ]]; then + args+=(-f "pr_number=${PR_NUMBER}") + fi + gh "${args[@]}" + ;; *) echo "Unsupported Mantis scenario: ${SCENARIO_ID}" >&2 exit 1 diff --git a/.github/workflows/mantis-telegram-live.yml b/.github/workflows/mantis-telegram-live.yml new file mode 100644 index 00000000000..7ef7c993b5e --- /dev/null +++ b/.github/workflows/mantis-telegram-live.yml @@ -0,0 +1,500 @@ +name: Mantis Telegram Live + +on: + issue_comment: + types: [created] + workflow_dispatch: + inputs: + candidate_ref: + description: Ref, tag, or SHA to verify with Telegram live QA + required: true + default: main + type: string + pr_number: + description: Optional PR number to receive the QA evidence comment + required: false + type: string + scenario: + description: Optional comma-separated Telegram scenario ids + required: false + default: telegram-status-command + type: string + crabbox_provider: + description: Crabbox provider for the desktop transcript capture + required: false + default: aws + type: choice + options: + - aws + - hetzner + crabbox_lease_id: + description: Optional existing Crabbox desktop/browser lease id or slug to reuse + required: false + type: string + +permissions: + contents: write + issues: write + pull-requests: write + +concurrency: + group: mantis-telegram-live-${{ github.event.issue.number || inputs.pr_number || inputs.candidate_ref || github.run_id }}-${{ github.run_attempt }} + cancel-in-progress: false + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" + NODE_VERSION: "24.x" + PNPM_VERSION: "10.33.0" + OPENCLAW_BUILD_PRIVATE_QA: "1" + OPENCLAW_ENABLE_PRIVATE_QA_CLI: "1" + CRABBOX_REF: main + +jobs: + authorize_actor: + name: Authorize workflow actor + if: >- + ${{ + github.event_name == 'workflow_dispatch' || + ( + github.event_name == 'issue_comment' && + github.event.issue.pull_request && + ( + contains(github.event.comment.body, '@Mantis') || + contains(github.event.comment.body, '@mantis') || + contains(github.event.comment.body, '/mantis') + ) + ) + }} + runs-on: ubuntu-24.04 + steps: + - name: Require maintainer-level repository access + uses: actions/github-script@v8 + with: + script: | + const allowed = new Set(["admin", "maintain", "write"]); + const { owner, repo } = context.repo; + const { data } = await github.rest.repos.getCollaboratorPermissionLevel({ + owner, + repo, + username: context.actor, + }); + const permission = data.permission; + core.info(`Actor ${context.actor} permission: ${permission}`); + if (!allowed.has(permission)) { + core.setFailed( + `Workflow requires write/maintain/admin access. Actor "${context.actor}" has "${permission}".`, + ); + } + + resolve_request: + name: Resolve Mantis request + needs: authorize_actor + runs-on: ubuntu-24.04 + outputs: + candidate_ref: ${{ steps.resolve.outputs.candidate_ref }} + crabbox_provider: ${{ steps.resolve.outputs.crabbox_provider }} + lease_id: ${{ steps.resolve.outputs.lease_id }} + pr_number: ${{ steps.resolve.outputs.pr_number }} + request_source: ${{ steps.resolve.outputs.request_source }} + scenario: ${{ steps.resolve.outputs.scenario }} + should_run: ${{ steps.resolve.outputs.should_run }} + steps: + - name: Resolve refs and target PR + id: resolve + uses: actions/github-script@v8 + with: + script: | + const eventName = context.eventName; + + function setOutput(name, value) { + core.setOutput(name, value ?? ""); + core.info(`${name}=${value ?? ""}`); + } + + if (eventName === "workflow_dispatch") { + const inputs = context.payload.inputs ?? {}; + setOutput("should_run", "true"); + setOutput("candidate_ref", inputs.candidate_ref || "main"); + setOutput("pr_number", inputs.pr_number || ""); + setOutput("scenario", inputs.scenario || "telegram-status-command"); + setOutput("crabbox_provider", inputs.crabbox_provider || "aws"); + setOutput("lease_id", inputs.crabbox_lease_id || ""); + setOutput("request_source", "workflow_dispatch"); + return; + } + + if (eventName !== "issue_comment") { + core.setFailed(`Unsupported event: ${eventName}`); + return; + } + + const issue = context.payload.issue; + const body = context.payload.comment?.body ?? ""; + if (!issue?.pull_request) { + core.setFailed("Mantis issue_comment trigger requires a pull request comment."); + return; + } + + const normalized = body.toLowerCase(); + const requested = + (normalized.includes("@mantis") || normalized.includes("/mantis")) && + normalized.includes("telegram"); + if (!requested) { + core.notice("Comment mentioned Mantis but did not request Telegram live QA."); + setOutput("should_run", "false"); + setOutput("candidate_ref", ""); + setOutput("pr_number", ""); + setOutput("scenario", ""); + setOutput("crabbox_provider", ""); + setOutput("lease_id", ""); + setOutput("request_source", "unsupported_issue_comment"); + return; + } + + const { owner, repo } = context.repo; + const { data: pr } = await github.rest.pulls.get({ + owner, + repo, + pull_number: issue.number, + }); + const candidateMatch = body.match(/(?:candidate|head)[\s:=]+([^\s`]+)/i); + const scenarioMatch = body.match(/(?:scenario|scenarios)[\s:=]+([^\s`]+)/i); + const providerMatch = body.match(/(?:provider|crabbox_provider)[\s:=]+([^\s`]+)/i); + const leaseMatch = body.match(/(?:lease|lease_id|crabbox_lease_id)[\s:=]+([^\s`]+)/i); + const rawCandidate = candidateMatch?.[1]; + const candidate = + rawCandidate && !["head", "pr", "pr-head"].includes(rawCandidate.toLowerCase()) + ? rawCandidate + : pr.head.sha; + const provider = providerMatch?.[1] || "aws"; + if (!["aws", "hetzner"].includes(provider)) { + core.setFailed(`Unsupported Crabbox provider for Mantis Telegram: ${provider}`); + return; + } + + setOutput("should_run", "true"); + setOutput("candidate_ref", candidate); + setOutput("pr_number", String(issue.number)); + setOutput("scenario", scenarioMatch?.[1] || "telegram-status-command"); + setOutput("crabbox_provider", provider); + setOutput("lease_id", leaseMatch?.[1] || ""); + setOutput("request_source", "issue_comment"); + + await github.rest.reactions.createForIssueComment({ + owner, + repo, + comment_id: context.payload.comment.id, + content: "eyes", + }).catch((error) => core.warning(`Could not add eyes reaction: ${error.message}`)); + + validate_ref: + name: Validate candidate ref + needs: resolve_request + if: ${{ needs.resolve_request.outputs.should_run == 'true' }} + runs-on: ubuntu-24.04 + outputs: + candidate_revision: ${{ steps.validate.outputs.candidate_revision }} + steps: + - name: Checkout harness ref + uses: actions/checkout@v6 + with: + persist-credentials: false + fetch-depth: 0 + + - name: Validate ref is trusted + id: validate + env: + GH_TOKEN: ${{ github.token }} + CANDIDATE_REF: ${{ needs.resolve_request.outputs.candidate_ref }} + shell: bash + run: | + set -euo pipefail + + git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main + + revision="$(git rev-parse "${CANDIDATE_REF}^{commit}")" + reason="" + if git merge-base --is-ancestor "$revision" refs/remotes/origin/main; then + reason="main-ancestor" + elif git tag --points-at "$revision" | grep -Eq '^v'; then + reason="release-tag" + else + pr_head_count="$( + gh api \ + -H "Accept: application/vnd.github+json" \ + "repos/${GITHUB_REPOSITORY}/commits/${revision}/pulls" \ + --jq '[.[] | select(.state == "open" and .head.repo.full_name == "'"${GITHUB_REPOSITORY}"'" and .head.sha == "'"${revision}"'")] | length' + )" + if [[ "$pr_head_count" != "0" ]]; then + reason="open-pr-head" + fi + fi + + if [[ -z "$reason" ]]; then + echo "Candidate ref '${CANDIDATE_REF}' resolved to ${revision}, which is not trusted for this secret-bearing Mantis run." >&2 + exit 1 + fi + + echo "candidate_revision=${revision}" >> "$GITHUB_OUTPUT" + { + echo "candidate: \`${CANDIDATE_REF}\`" + echo "candidate SHA: \`${revision}\`" + echo "candidate trust reason: \`${reason}\`" + } >> "$GITHUB_STEP_SUMMARY" + + run_telegram_live: + name: Run Telegram live QA with Crabbox evidence + needs: [resolve_request, validate_ref] + if: ${{ needs.resolve_request.outputs.should_run == 'true' }} + runs-on: ubuntu-24.04 + timeout-minutes: 180 + environment: qa-live-shared + outputs: + comparison_status: ${{ steps.run_mantis.outputs.comparison_status }} + output_dir: ${{ steps.run_mantis.outputs.output_dir }} + steps: + - name: Checkout harness ref + uses: actions/checkout@v6 + with: + persist-credentials: false + fetch-depth: 0 + + - name: Setup Node environment + uses: ./.github/actions/setup-node-env + with: + node-version: ${{ env.NODE_VERSION }} + pnpm-version: ${{ env.PNPM_VERSION }} + install-bun: "true" + + - name: Build Mantis harness + run: pnpm build + + - name: Cache Mantis candidate pnpm store + uses: actions/cache@v4 + with: + path: | + ~/.local/share/pnpm/store + ~/.cache/pnpm + key: mantis-telegram-pnpm-${{ runner.os }}-${{ env.NODE_VERSION }}-${{ hashFiles('pnpm-lock.yaml') }} + restore-keys: | + mantis-telegram-pnpm-${{ runner.os }}-${{ env.NODE_VERSION }}- + + - name: Setup Go for Crabbox CLI + uses: actions/setup-go@v6 + with: + go-version: "1.26.x" + cache: false + + - name: Install Crabbox CLI + shell: bash + run: | + set -euo pipefail + install_dir="${RUNNER_TEMP}/crabbox" + mkdir -p "$install_dir/src" "$HOME/.local/bin" + git init "$install_dir/src" + git -C "$install_dir/src" remote add origin https://github.com/openclaw/crabbox.git + git -C "$install_dir/src" fetch --depth 1 origin "$CRABBOX_REF" + git -C "$install_dir/src" checkout --detach FETCH_HEAD + go build -C "$install_dir/src" -o "$HOME/.local/bin/crabbox" ./cmd/crabbox + echo "$HOME/.local/bin" >> "$GITHUB_PATH" + "$HOME/.local/bin/crabbox" --version + "$HOME/.local/bin/crabbox" warmup --help > "$install_dir/warmup-help.txt" 2>&1 + grep -q -- "-desktop" "$install_dir/warmup-help.txt" + "$HOME/.local/bin/crabbox" media preview --help >/dev/null + + - name: Prepare candidate worktree + env: + CANDIDATE_SHA: ${{ needs.validate_ref.outputs.candidate_revision }} + shell: bash + run: | + set -euo pipefail + worktree_root=".artifacts/qa-e2e/mantis/telegram-live-worktrees" + mkdir -p "$worktree_root" + git worktree add --detach "$worktree_root/candidate" "$CANDIDATE_SHA" + pnpm --dir "$worktree_root/candidate" install --frozen-lockfile --prefer-offline + pnpm --dir "$worktree_root/candidate" build + + - name: Run Telegram live scenario and capture desktop evidence + id: run_mantis + env: + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + OPENCLAW_QA_CONVEX_SITE_URL: ${{ secrets.OPENCLAW_QA_CONVEX_SITE_URL }} + OPENCLAW_QA_CONVEX_SECRET_CI: ${{ secrets.OPENCLAW_QA_CONVEX_SECRET_CI }} + OPENCLAW_QA_REDACT_PUBLIC_METADATA: "1" + OPENCLAW_QA_TELEGRAM_CAPTURE_CONTENT: "1" + CRABBOX_COORDINATOR: ${{ secrets.CRABBOX_COORDINATOR }} + CRABBOX_COORDINATOR_TOKEN: ${{ secrets.CRABBOX_COORDINATOR_TOKEN }} + OPENCLAW_QA_MANTIS_CRABBOX_COORDINATOR: ${{ secrets.OPENCLAW_QA_MANTIS_CRABBOX_COORDINATOR }} + OPENCLAW_QA_MANTIS_CRABBOX_COORDINATOR_TOKEN: ${{ secrets.OPENCLAW_QA_MANTIS_CRABBOX_COORDINATOR_TOKEN }} + CRABBOX_ACCESS_CLIENT_ID: ${{ secrets.CRABBOX_ACCESS_CLIENT_ID }} + CRABBOX_ACCESS_CLIENT_SECRET: ${{ secrets.CRABBOX_ACCESS_CLIENT_SECRET }} + CRABBOX_LEASE_ID: ${{ needs.resolve_request.outputs.lease_id }} + CRABBOX_PROVIDER: ${{ needs.resolve_request.outputs.crabbox_provider }} + SCENARIO_INPUT: ${{ needs.resolve_request.outputs.scenario }} + CANDIDATE_SHA: ${{ needs.validate_ref.outputs.candidate_revision }} + shell: bash + run: | + set -euo pipefail + + require_var() { + local key="$1" + if [[ -z "${!key:-}" ]]; then + echo "Missing required ${key}." >&2 + exit 1 + fi + } + + CRABBOX_COORDINATOR="${CRABBOX_COORDINATOR:-${OPENCLAW_QA_MANTIS_CRABBOX_COORDINATOR:-}}" + CRABBOX_COORDINATOR_TOKEN="${CRABBOX_COORDINATOR_TOKEN:-${OPENCLAW_QA_MANTIS_CRABBOX_COORDINATOR_TOKEN:-}}" + export CRABBOX_COORDINATOR CRABBOX_COORDINATOR_TOKEN + + require_var OPENAI_API_KEY + require_var OPENCLAW_QA_CONVEX_SITE_URL + require_var OPENCLAW_QA_CONVEX_SECRET_CI + require_var CRABBOX_COORDINATOR_TOKEN + + candidate_repo="$(pwd)/.artifacts/qa-e2e/mantis/telegram-live-worktrees/candidate" + output_rel=".artifacts/qa-e2e/mantis/telegram-live" + root="$candidate_repo/$output_rel" + echo "output_dir=${root}" >> "$GITHUB_OUTPUT" + model="${OPENCLAW_CI_OPENAI_MODEL:-openai/gpt-5.4}" + + scenario_args=() + if [[ -n "${SCENARIO_INPUT// }" ]]; then + IFS=',' read -r -a raw_scenarios <<<"${SCENARIO_INPUT}" + for raw in "${raw_scenarios[@]}"; do + scenario="$(printf '%s' "${raw}" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')" + if [[ -n "${scenario}" ]]; then + scenario_args+=(--scenario "${scenario}") + fi + done + fi + + set +e + pnpm --dir "$candidate_repo" openclaw qa telegram \ + --repo-root "$candidate_repo" \ + --output-dir "$output_rel" \ + --provider-mode live-frontier \ + --model "$model" \ + --alt-model "$model" \ + --fast \ + --credential-source convex \ + --credential-role ci \ + --allow-failures \ + "${scenario_args[@]}" + telegram_exit=$? + set -e + + if [[ ! -f "$root/telegram-qa-summary.json" ]]; then + echo "Telegram live QA did not produce a summary." >&2 + exit "$telegram_exit" + fi + echo "telegram_exit=${telegram_exit}" >> "$GITHUB_OUTPUT" + + node "${GITHUB_WORKSPACE}/scripts/mantis/build-telegram-evidence.mjs" \ + --output-dir "$root" \ + --candidate-ref "$CANDIDATE_SHA" \ + --candidate-sha "$CANDIDATE_SHA" \ + --scenario-label "${SCENARIO_INPUT:-telegram-live}" + + comparison_status="$(jq -r 'if .comparison.pass then "pass" else "fail" end' "$root/mantis-evidence.json")" + echo "comparison_status=${comparison_status}" >> "$GITHUB_OUTPUT" + + desktop_args=() + if [[ -n "${CRABBOX_LEASE_ID:-}" ]]; then + desktop_args+=(--lease-id "$CRABBOX_LEASE_ID") + fi + pnpm --dir "$candidate_repo" openclaw qa mantis desktop-browser-smoke \ + --repo-root "$candidate_repo" \ + --html-file "$output_rel/telegram-live-transcript.html" \ + --output-dir "$output_rel/desktop-browser" \ + --provider "$CRABBOX_PROVIDER" \ + --class standard \ + --idle-timeout 45m \ + --ttl 120m \ + --video-duration 18 \ + "${desktop_args[@]}" + + cp "$root/desktop-browser/desktop-browser-smoke.png" "$root/telegram-live-desktop.png" + if [[ -f "$root/desktop-browser/desktop-browser-smoke.mp4" ]]; then + cp "$root/desktop-browser/desktop-browser-smoke.mp4" "$root/telegram-live.mp4" + fi + + if [[ -f "$root/telegram-live.mp4" ]]; then + if ! command -v ffmpeg >/dev/null 2>&1 || ! command -v ffprobe >/dev/null 2>&1; then + sudo apt-get update -y >/tmp/mantis-telegram-ffmpeg-apt.log 2>&1 || true + sudo DEBIAN_FRONTEND=noninteractive apt-get install -y ffmpeg >>/tmp/mantis-telegram-ffmpeg-apt.log 2>&1 || true + fi + if ! crabbox media preview \ + --input "$root/telegram-live.mp4" \ + --output "$root/telegram-live-preview.gif" \ + --trimmed-video-output "$root/telegram-live-change.mp4" \ + --json > "$root/telegram-live-preview.json"; then + rm -f "$root/telegram-live-preview.gif" + rm -f "$root/telegram-live-change.mp4" + rm -f "$root/telegram-live-preview.json" + echo "::warning::Could not generate Telegram motion-trimmed desktop preview." + fi + fi + + cat "$root/telegram-qa-report.md" >> "$GITHUB_STEP_SUMMARY" + + - name: Upload Mantis Telegram artifacts + id: upload_artifact + if: ${{ always() && steps.run_mantis.outputs.output_dir != '' }} + uses: actions/upload-artifact@v4 + with: + name: mantis-telegram-live-${{ github.run_id }}-${{ github.run_attempt }} + path: ${{ steps.run_mantis.outputs.output_dir }} + retention-days: 14 + if-no-files-found: warn + + - name: Create Mantis GitHub App token + id: mantis_app_token + if: ${{ always() && needs.resolve_request.outputs.pr_number != '' }} + uses: actions/create-github-app-token@v3 + with: + app-id: ${{ secrets.MANTIS_GITHUB_APP_ID }} + private-key: ${{ secrets.MANTIS_GITHUB_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: ${{ github.event.repository.name }} + permission-contents: write + permission-issues: write + permission-pull-requests: write + + - name: Comment PR with inline QA evidence + if: ${{ always() && needs.resolve_request.outputs.pr_number != '' && steps.run_mantis.outputs.output_dir != '' }} + env: + GH_TOKEN: ${{ steps.mantis_app_token.outputs.token }} + TARGET_PR: ${{ needs.resolve_request.outputs.pr_number }} + ARTIFACT_URL: ${{ steps.upload_artifact.outputs.artifact-url }} + REQUEST_SOURCE: ${{ needs.resolve_request.outputs.request_source }} + shell: bash + run: | + set -euo pipefail + root="${{ steps.run_mantis.outputs.output_dir }}" + if [[ ! -f "$root/mantis-evidence.json" ]]; then + echo "No Mantis evidence manifest found; skipping PR evidence comment." + exit 0 + fi + artifact_url_args=() + if [[ -n "${ARTIFACT_URL:-}" ]]; then + artifact_url_args=(--artifact-url "$ARTIFACT_URL") + fi + node scripts/mantis/publish-pr-evidence.mjs \ + --manifest "$root/mantis-evidence.json" \ + --target-pr "$TARGET_PR" \ + --artifact-root "mantis/telegram-live/pr-${TARGET_PR}/run-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" \ + --marker "" \ + "${artifact_url_args[@]}" \ + --run-url "https://github.com/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" \ + --request-source "$REQUEST_SOURCE" + + - name: Fail when Mantis Telegram failed + if: ${{ always() && steps.run_mantis.outputs.output_dir != '' && (steps.run_mantis.outputs.comparison_status != 'pass' || steps.run_mantis.outputs.telegram_exit != '0') }} + env: + COMPARISON_STATUS: ${{ steps.run_mantis.outputs.comparison_status }} + TELEGRAM_EXIT: ${{ steps.run_mantis.outputs.telegram_exit }} + run: | + echo "Mantis Telegram live failed: comparison=${COMPARISON_STATUS:-unset} telegram_exit=${TELEGRAM_EXIT:-unset}." >&2 + exit 1 diff --git a/CHANGELOG.md b/CHANGELOG.md index 962a3f9188f..d4fe43da34e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ Docs: https://docs.openclaw.ai ### Changes +- QA/Mantis: add Telegram live PR evidence automation with Convex-leased credentials, Crabbox transcript capture, motion GIF previews, and inline PR comments. +- QA/Mantis: add a Telegram desktop scenario builder that leases Crabbox, installs native Telegram Desktop, configures an OpenClaw Telegram gateway with leased bot credentials, and records VNC screenshot/video artifacts. - Discord/voice: add realtime voice diagnostics for speaker turns, playback resets, barge-in detection, and audio cutoff analysis. - Talk: add `talk.realtime.instructions` so operators can append realtime voice style instructions while preserving OpenClaw's built-in agent-consult guidance. (#79081) Thanks @VACInc. - Discord/voice: default test and source installs to the pure-JS `opusscript` decoder by ignoring optional native `@discordjs/opus` builds, avoiding slow native addon compiles outside dedicated voice-performance lanes. diff --git a/docs/concepts/mantis.md b/docs/concepts/mantis.md index e87ae37f189..bd2156d2996 100644 --- a/docs/concepts/mantis.md +++ b/docs/concepts/mantis.md @@ -239,6 +239,44 @@ operators can switch to Hetzner when AWS capacity is slow or unavailable. Use this lane when you want "a Linux desktop with Slack and a claw running" instead of only a bot-to-bot Slack transcript. +`Mantis Telegram Live` wraps the existing Telegram live QA lane in the same PR +evidence pipeline. It checks out the trusted candidate ref in a separate +worktree, runs `pnpm openclaw qa telegram --credential-source convex +--credential-role ci`, writes a `mantis-evidence.json` manifest from the +Telegram QA summary and observed-message artifact, renders the redacted +transcript HTML through a Crabbox desktop browser, generates a motion-trimmed GIF +with `crabbox media preview`, and posts the inline PR evidence comment when a PR +number is available. This lane is transcript-visual rather than logged-in +Telegram Web proof: the Telegram Bot API gives stable live message evidence, but +Telegram Web login state is not required for normal Mantis automation. + +For human-in-the-loop Telegram desktop setup, use the scenario builder: + +```bash +pnpm openclaw qa mantis telegram-desktop-builder \ + --credential-source convex \ + --credential-role maintainer \ + --keep-lease +``` + +The builder leases or reuses a Crabbox desktop, installs the native Linux +Telegram Desktop binary, optionally restores a user-session archive, configures +OpenClaw with the leased Telegram SUT bot token, starts `openclaw gateway run` +on port `38974`, posts a driver-bot readiness message to the leased private +group, then captures a screenshot and MP4 from the visible VNC desktop. A bot +token never logs Telegram Desktop in; it only configures OpenClaw. The desktop +viewer is a separate Telegram user session restored from +`--telegram-profile-archive-env ` or created manually through VNC and kept +alive with `--keep-lease`. + +Useful Telegram desktop builder flags: + +- `--lease-id ` reruns against a VM where an operator already logged in to Telegram Desktop. +- `--telegram-profile-archive-env ` reads a base64 `.tgz` Telegram Desktop profile archive from that env var and restores it before launch. +- `--telegram-profile-dir ` controls the remote Telegram Desktop profile directory. The default is `$HOME/.local/share/TelegramDesktop`. +- `--no-gateway-setup` installs and opens Telegram Desktop without configuring OpenClaw. +- `--credential-source convex --credential-role ci` uses the shared credential broker instead of direct Telegram env tokens. + Every PR-publishing scenario writes `mantis-evidence.json` next to its report. This schema is the handoff between scenario code and GitHub comments: @@ -306,6 +344,19 @@ ref: @Mantis discord status reactions baseline=origin/main candidate=HEAD ``` +Telegram live QA can also be triggered from a PR comment: + +```text +@Mantis telegram +@Mantis telegram scenario=telegram-status-command +@Mantis telegram scenarios=telegram-status-command,telegram-mentioned-message-reply +``` + +By default it uses the current PR head SHA as the candidate and runs +`telegram-status-command`. Maintainers can override `candidate=...`, +`provider=aws|hetzner`, and `lease=` when they need a specific ref or a +pre-warmed Crabbox desktop. + ClawSweeper command examples: ```text diff --git a/docs/help/testing.md b/docs/help/testing.md index 3b6692f5691..b03ffb15c15 100644 --- a/docs/help/testing.md +++ b/docs/help/testing.md @@ -326,6 +326,26 @@ gh workflow run package-acceptance.yml --ref main \ - For stable bot-to-bot observation, enable Bot-to-Bot Communication Mode in `@BotFather` for both bots and ensure the driver bot can observe group bot traffic. - Writes a Telegram QA report, summary, and observed-messages artifact under `.artifacts/qa-e2e/...`. Replying scenarios include RTT from driver send request to observed SUT reply. +`Mantis Telegram Live` is the PR-evidence wrapper around this lane. It runs the +candidate ref with Convex-leased Telegram credentials, renders the redacted +observed-message transcript in a Crabbox desktop browser, records MP4 evidence, +generates a motion-trimmed GIF, uploads the artifact bundle, and posts inline PR +evidence through the Mantis GitHub App when `pr_number` is set. Maintainers can +start it from the Actions UI through `Mantis Scenario` (`scenario_id: +telegram-live`) or directly from a pull request comment: + +```text +@Mantis telegram +@Mantis telegram scenario=telegram-status-command +@Mantis telegram scenarios=telegram-status-command,telegram-mentioned-message-reply +``` + +- `pnpm openclaw qa mantis telegram-desktop-builder` + - Leases or reuses a Crabbox Linux desktop, installs native Telegram Desktop, configures OpenClaw with a leased Telegram SUT bot token, starts the gateway, and records screenshot/MP4 evidence from the visible VNC desktop. + - Defaults to `--credential-source convex` so workflows only need the Convex broker secret. Use `--credential-source env` with the same `OPENCLAW_QA_TELEGRAM_*` variables as `pnpm openclaw qa telegram`. + - Telegram Desktop still needs a user login/profile. The bot token configures OpenClaw only. Use `--telegram-profile-archive-env ` for a base64 `.tgz` profile archive, or use `--keep-lease` and log in manually through VNC once. + - Writes `mantis-telegram-desktop-builder-report.md`, `mantis-telegram-desktop-builder-summary.json`, `telegram-desktop-builder.png`, and `telegram-desktop-builder.mp4` under the output directory. + Live transport lanes share one standard contract so new transports do not drift; the per-lane coverage matrix lives in [QA overview → Live transport coverage](/concepts/qa-e2e-automation#live-transport-coverage). `qa-channel` is the broad synthetic suite and is not part of that matrix. ### Shared Telegram credentials via Convex (v1) diff --git a/extensions/qa-lab/src/cli.test.ts b/extensions/qa-lab/src/cli.test.ts index a28dafee5f0..a5a606d0a0c 100644 --- a/extensions/qa-lab/src/cli.test.ts +++ b/extensions/qa-lab/src/cli.test.ts @@ -52,6 +52,7 @@ const { runMantisDesktopBrowserSmokeCommand, runMantisDiscordSmokeCommand, runMantisSlackDesktopSmokeCommand, + runMantisTelegramDesktopBuilderCommand, } = vi.hoisted(() => ({ runQaCredentialsAddCommand: vi.fn(), runQaCredentialsListCommand: vi.fn(), @@ -64,6 +65,7 @@ const { runMantisDesktopBrowserSmokeCommand: vi.fn(), runMantisDiscordSmokeCommand: vi.fn(), runMantisSlackDesktopSmokeCommand: vi.fn(), + runMantisTelegramDesktopBuilderCommand: vi.fn(), })); const { listQaRunnerCliContributions } = vi.hoisted(() => ({ @@ -85,6 +87,7 @@ vi.mock("./mantis/cli.runtime.js", () => ({ runMantisDesktopBrowserSmokeCommand, runMantisDiscordSmokeCommand, runMantisSlackDesktopSmokeCommand, + runMantisTelegramDesktopBuilderCommand, })); vi.mock("./cli.runtime.js", () => ({ @@ -114,6 +117,7 @@ describe("qa cli registration", () => { runMantisDesktopBrowserSmokeCommand.mockReset(); runMantisDiscordSmokeCommand.mockReset(); runMantisSlackDesktopSmokeCommand.mockReset(); + runMantisTelegramDesktopBuilderCommand.mockReset(); listQaRunnerCliContributions .mockReset() .mockReturnValue([createAvailableQaRunnerContribution()]); @@ -353,6 +357,62 @@ describe("qa cli registration", () => { }); }); + it("routes mantis Telegram desktop builder flags into the mantis runtime command", async () => { + await program.parseAsync([ + "node", + "openclaw", + "qa", + "mantis", + "telegram-desktop-builder", + "--repo-root", + "/tmp/openclaw-repo", + "--output-dir", + ".artifacts/qa-e2e/mantis/telegram-desktop", + "--crabbox-bin", + "/tmp/crabbox", + "--provider", + "hetzner", + "--machine-class", + "beast", + "--lease-id", + "cbx_123abc", + "--idle-timeout", + "45m", + "--ttl", + "120m", + "--credential-source", + "convex", + "--credential-role", + "ci", + "--hydrate-mode", + "prehydrated", + "--telegram-profile-archive-env", + "TELEGRAM_PROFILE_TGZ_B64", + "--telegram-profile-dir", + "/home/crabbox/.local/share/TelegramDesktop", + "--no-gateway-setup", + "--keep-lease", + ]); + + expect(runMantisTelegramDesktopBuilderCommand).toHaveBeenCalledWith({ + crabboxBin: "/tmp/crabbox", + credentialRole: "ci", + credentialSource: "convex", + gatewaySetup: false, + hydrateMode: "prehydrated", + idleTimeout: "45m", + keepLease: true, + leaseId: "cbx_123abc", + machineClass: "beast", + outputDir: ".artifacts/qa-e2e/mantis/telegram-desktop", + provider: "hetzner", + repoRoot: "/tmp/openclaw-repo", + telegramProfileArchiveEnv: "TELEGRAM_PROFILE_TGZ_B64", + telegramProfileDir: "/home/crabbox/.local/share/TelegramDesktop", + ttl: "120m", + }); + }); + it("routes coverage report flags into the qa runtime command", async () => { await program.parseAsync([ "node", diff --git a/extensions/qa-lab/src/mantis/cli.runtime.ts b/extensions/qa-lab/src/mantis/cli.runtime.ts index d2643f33e69..7ad6bec994b 100644 --- a/extensions/qa-lab/src/mantis/cli.runtime.ts +++ b/extensions/qa-lab/src/mantis/cli.runtime.ts @@ -8,6 +8,10 @@ import { runMantisSlackDesktopSmoke, type MantisSlackDesktopSmokeOptions, } from "./slack-desktop-smoke.runtime.js"; +import { + runMantisTelegramDesktopBuilder, + type MantisTelegramDesktopBuilderOptions, +} from "./telegram-desktop-builder.runtime.js"; import { runMantisVisualDriver, runMantisVisualTask, @@ -63,6 +67,23 @@ export async function runMantisSlackDesktopSmokeCommand(opts: MantisSlackDesktop } } +export async function runMantisTelegramDesktopBuilderCommand( + opts: MantisTelegramDesktopBuilderOptions, +) { + const result = await runMantisTelegramDesktopBuilder(opts); + process.stdout.write(`Mantis Telegram desktop builder report: ${result.reportPath}\n`); + process.stdout.write(`Mantis Telegram desktop builder summary: ${result.summaryPath}\n`); + if (result.screenshotPath) { + process.stdout.write(`Mantis Telegram desktop builder screenshot: ${result.screenshotPath}\n`); + } + if (result.videoPath) { + process.stdout.write(`Mantis Telegram desktop builder video: ${result.videoPath}\n`); + } + if (result.status === "fail") { + process.exitCode = 1; + } +} + export async function runMantisVisualDriverCommand(opts: MantisVisualDriverOptions) { const result = await runMantisVisualDriver(opts); process.stdout.write(`Mantis visual driver result: ${result.status}\n`); diff --git a/extensions/qa-lab/src/mantis/cli.ts b/extensions/qa-lab/src/mantis/cli.ts index e5e7b801eac..70197172ec2 100644 --- a/extensions/qa-lab/src/mantis/cli.ts +++ b/extensions/qa-lab/src/mantis/cli.ts @@ -7,6 +7,10 @@ import type { MantisSlackDesktopHydrateMode, MantisSlackDesktopSmokeOptions, } from "./slack-desktop-smoke.runtime.js"; +import type { + MantisTelegramDesktopBuilderOptions, + MantisTelegramDesktopHydrateMode, +} from "./telegram-desktop-builder.runtime.js"; import type { MantisVisualDriverOptions, MantisVisualTaskOptions, @@ -39,6 +43,11 @@ async function runSlackDesktopSmoke(opts: MantisSlackDesktopSmokeOptions) { await runtime.runMantisSlackDesktopSmokeCommand(opts); } +async function runTelegramDesktopBuilder(opts: MantisTelegramDesktopBuilderOptions) { + const runtime = await loadMantisCliRuntime(); + await runtime.runMantisTelegramDesktopBuilderCommand(opts); +} + async function runVisualDriver(opts: MantisVisualDriverOptions) { const runtime = await loadMantisCliRuntime(); await runtime.runMantisVisualDriverCommand(opts); @@ -118,6 +127,25 @@ type MantisSlackDesktopSmokeCommanderOptions = { ttl?: string; }; +type MantisTelegramDesktopBuilderCommanderOptions = { + class?: string; + crabboxBin?: string; + credentialRole?: string; + credentialSource?: string; + gatewaySetup?: boolean; + hydrateMode?: MantisTelegramDesktopHydrateMode; + idleTimeout?: string; + keepLease?: boolean; + leaseId?: string; + machineClass?: string; + outputDir?: string; + provider?: string; + repoRoot?: string; + telegramProfileArchiveEnv?: string; + telegramProfileDir?: string; + ttl?: string; +}; + type MantisVisualTaskCommanderOptions = { browserUrl?: string; class?: string; @@ -334,6 +362,54 @@ export function registerMantisCli(qa: Command) { }); }); + mantis + .command("telegram-desktop-builder") + .description( + "Lease or reuse a Crabbox VNC desktop, install Telegram Desktop, configure OpenClaw Telegram with a bot token, and capture screenshot/video artifacts", + ) + .option("--repo-root ", "Repository root to target when running from a neutral cwd") + .option("--output-dir ", "Mantis Telegram desktop builder artifact directory") + .option("--crabbox-bin ", "Crabbox binary path") + .option("--provider ", "Crabbox provider") + .option("--machine-class ", "Crabbox machine class") + .option("--class ", "Alias for --machine-class") + .option("--lease-id ", "Reuse an existing Crabbox lease") + .option("--idle-timeout ", "Crabbox idle timeout") + .option("--ttl ", "Crabbox maximum lease lifetime") + .option("--keep-lease", "Keep a lease created by this run after a passing builder run") + .option("--no-keep-lease", "Stop a lease created by this run after a passing builder run") + .option("--no-gateway-setup", "Install Telegram Desktop only; do not configure OpenClaw") + .option("--credential-source ", "Credential source for Telegram setup: env or convex") + .option("--credential-role ", "Credential role for convex auth") + .option("--hydrate-mode ", "Remote hydrate mode: source or prehydrated") + .option( + "--telegram-profile-archive-env ", + "Env var containing a base64 .tgz Telegram Desktop profile archive", + ) + .option( + "--telegram-profile-dir ", + "Remote Telegram Desktop profile dir restored before app launch", + ) + .action(async (opts: MantisTelegramDesktopBuilderCommanderOptions) => { + await runTelegramDesktopBuilder({ + crabboxBin: opts.crabboxBin, + credentialRole: opts.credentialRole, + credentialSource: opts.credentialSource, + gatewaySetup: opts.gatewaySetup, + hydrateMode: opts.hydrateMode, + idleTimeout: opts.idleTimeout, + keepLease: opts.keepLease, + leaseId: opts.leaseId, + machineClass: opts.machineClass ?? opts.class, + outputDir: opts.outputDir, + provider: opts.provider, + repoRoot: opts.repoRoot, + telegramProfileArchiveEnv: opts.telegramProfileArchiveEnv, + telegramProfileDir: opts.telegramProfileDir, + ttl: opts.ttl, + }); + }); + mantis .command("visual-task") .description( diff --git a/extensions/qa-lab/src/mantis/telegram-desktop-builder.runtime.test.ts b/extensions/qa-lab/src/mantis/telegram-desktop-builder.runtime.test.ts new file mode 100644 index 00000000000..037af1066d5 --- /dev/null +++ b/extensions/qa-lab/src/mantis/telegram-desktop-builder.runtime.test.ts @@ -0,0 +1,254 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { runMantisTelegramDesktopBuilder } from "./telegram-desktop-builder.runtime.js"; + +function describeFetchInput(input: RequestInfo | URL) { + if (typeof input === "string") { + return input; + } + if (input instanceof URL) { + return input.href; + } + return input.url; +} + +describe("mantis Telegram desktop builder runtime", () => { + let repoRoot: string; + + beforeEach(async () => { + repoRoot = await fs.mkdtemp(path.join(os.tmpdir(), "mantis-telegram-desktop-builder-")); + }); + + afterEach(async () => { + vi.unstubAllGlobals(); + await fs.rm(repoRoot, { force: true, recursive: true }); + }); + + it("leases a desktop box, installs Telegram Desktop, configures OpenClaw, and keeps the gateway lease", async () => { + const commands: { args: readonly string[]; command: string; env?: NodeJS.ProcessEnv }[] = []; + const runner = vi.fn( + async (command: string, args: readonly string[], options: { env?: NodeJS.ProcessEnv }) => { + commands.push({ command, args, env: options.env }); + if (command === "/tmp/crabbox" && args[0] === "warmup") { + return { stdout: "ready lease cbx_a123\n", stderr: "" }; + } + if (command === "/tmp/crabbox" && args[0] === "inspect") { + return { + stdout: `${JSON.stringify({ + host: "203.0.113.20", + id: "cbx_a123", + provider: "hetzner", + slug: "telegram-builder", + sshKey: "/tmp/key", + sshPort: "2222", + sshUser: "crabbox", + state: "active", + })}\n`, + stderr: "", + }; + } + if (command === "rsync") { + const outputDir = args.at(-1); + expect(outputDir).toBeTypeOf("string"); + await fs.mkdir(outputDir as string, { recursive: true }); + await fs.writeFile(path.join(outputDir as string, "telegram-desktop-builder.png"), "png"); + await fs.writeFile(path.join(outputDir as string, "telegram-desktop-builder.mp4"), "mp4"); + await fs.writeFile( + path.join(outputDir as string, "remote-metadata.json"), + `${JSON.stringify({ gatewayAlive: true, hydrateMode: "source", qaExitCode: 0 })}\n`, + ); + await fs.writeFile( + path.join(outputDir as string, "telegram-desktop-builder-command.log"), + "qa\n", + ); + await fs.writeFile(path.join(outputDir as string, "telegram-desktop.log"), "tdesktop\n"); + await fs.writeFile(path.join(outputDir as string, "ffmpeg.log"), "ffmpeg\n"); + } + return { stdout: "", stderr: "" }; + }, + ); + + const result = await runMantisTelegramDesktopBuilder({ + commandRunner: runner, + crabboxBin: "/tmp/crabbox", + credentialSource: "env", + env: { + OPENAI_API_KEY: "openai-runtime-key", + OPENCLAW_QA_TELEGRAM_DRIVER_BOT_TOKEN: "driver-token", + OPENCLAW_QA_TELEGRAM_GROUP_ID: "-1001234567890", + OPENCLAW_QA_TELEGRAM_SUT_BOT_TOKEN: "sut-token", + PATH: process.env.PATH, + TELEGRAM_PROFILE_TGZ_B64: "profile-archive", + }, + now: () => new Date("2026-05-05T12:00:00.000Z"), + outputDir: ".artifacts/qa-e2e/mantis/telegram-desktop-test", + repoRoot, + telegramProfileArchiveEnv: "TELEGRAM_PROFILE_TGZ_B64", + telegramProfileDir: "/home/crabbox/.local/share/TelegramDesktop", + }); + + expect(result.status).toBe("pass"); + expect(commands.map((entry) => [entry.command, entry.args[0]])).toEqual([ + ["/tmp/crabbox", "warmup"], + ["/tmp/crabbox", "inspect"], + ["/tmp/crabbox", "run"], + ["rsync", "-az"], + ]); + const runCommand = commands.find( + (entry) => entry.command === "/tmp/crabbox" && entry.args[0] === "run", + ); + expect(runCommand?.env).toMatchObject({ + OPENCLAW_LIVE_OPENAI_KEY: "openai-runtime-key", + OPENCLAW_MANTIS_TELEGRAM_DESKTOP_PROFILE_TGZ_B64: "profile-archive", + OPENCLAW_MANTIS_TELEGRAM_DRIVER_BOT_TOKEN: "driver-token", + OPENCLAW_MANTIS_TELEGRAM_GROUP_ID: "-1001234567890", + OPENCLAW_MANTIS_TELEGRAM_SUT_BOT_TOKEN: "sut-token", + }); + const remoteScript = runCommand?.args.at(-1); + expect(remoteScript).toContain("https://telegram.org/dl/desktop/linux"); + expect(remoteScript).toContain('-workdir "$telegram_profile_dir"'); + expect(remoteScript).toContain("OPENCLAW_MANTIS_TELEGRAM_DESKTOP_PROFILE_TGZ_B64"); + expect(remoteScript).toContain( + 'botToken: { source: "env", provider: "default", id: "TELEGRAM_BOT_TOKEN" }', + ); + expect(remoteScript).not.toContain("groupAllowFrom"); + expect(remoteScript).not.toContain("allowFrom:"); + expect(remoteScript).toContain("openclaw gateway run"); + expect(remoteScript).toContain("telegram-ready-message.json"); + expect(remoteScript).toContain("telegram-desktop-builder.mp4"); + expect( + commands.some((entry) => entry.command === "/tmp/crabbox" && entry.args[0] === "stop"), + ).toBe(false); + await expect(fs.readFile(result.screenshotPath ?? "", "utf8")).resolves.toBe("png"); + await expect(fs.readFile(result.videoPath ?? "", "utf8")).resolves.toBe("mp4"); + const summary = JSON.parse(await fs.readFile(result.summaryPath, "utf8")) as { + crabbox: { id: string; vncCommand: string }; + gatewaySetup: boolean; + hydrateMode: string; + status: string; + telegramDesktop: { profileArchiveEnv?: string; profileDir: string }; + }; + expect(summary).toMatchObject({ + crabbox: { + id: "cbx_a123", + vncCommand: "/tmp/crabbox vnc --provider hetzner --id cbx_a123 --open", + }, + gatewaySetup: true, + hydrateMode: "source", + status: "pass", + telegramDesktop: { + profileArchiveEnv: "TELEGRAM_PROFILE_TGZ_B64", + profileDir: "/home/crabbox/.local/share/TelegramDesktop", + }, + }); + }); + + it("leases Convex Telegram credentials and maps them into the VM env", async () => { + const commands: { args: readonly string[]; command: string; env?: NodeJS.ProcessEnv }[] = []; + const events: string[] = []; + const fetchMock = vi.fn(async (input: RequestInfo | URL) => { + const url = describeFetchInput(input); + if (url.endsWith("/acquire")) { + events.push("acquire"); + return new Response( + JSON.stringify({ + credentialId: "cred-telegram", + heartbeatIntervalMs: 600_000, + leaseToken: "lease-telegram", + leaseTtlMs: 900_000, + payload: { + driverToken: "driver-leased", + groupId: "-100222333444", + sutToken: "sut-leased", + }, + status: "ok", + }), + { status: 200 }, + ); + } + if (url.endsWith("/release")) { + events.push("release"); + return new Response(JSON.stringify({ status: "ok" }), { status: 200 }); + } + if (url.endsWith("/heartbeat")) { + events.push("heartbeat"); + return new Response(JSON.stringify({ status: "ok" }), { status: 200 }); + } + throw new Error(`unexpected fetch: ${url}`); + }); + vi.stubGlobal("fetch", fetchMock); + + const runner = vi.fn( + async (command: string, args: readonly string[], options: { env?: NodeJS.ProcessEnv }) => { + commands.push({ command, args, env: options.env }); + if (command === "/tmp/crabbox" && args[0] === "warmup") { + return { stdout: "ready lease cbx_c0ffee\n", stderr: "" }; + } + if (command === "/tmp/crabbox" && args[0] === "inspect") { + return { + stdout: `${JSON.stringify({ + host: "203.0.113.20", + id: "cbx_c0ffee", + provider: "hetzner", + sshKey: "/tmp/key", + sshPort: "2222", + sshUser: "crabbox", + state: "active", + })}\n`, + stderr: "", + }; + } + if (command === "rsync") { + const outputDir = args.at(-1); + await fs.mkdir(outputDir as string, { recursive: true }); + await fs.writeFile(path.join(outputDir as string, "telegram-desktop-builder.png"), "png"); + await fs.writeFile( + path.join(outputDir as string, "remote-metadata.json"), + `${JSON.stringify({ gatewayAlive: true, qaExitCode: 0 })}\n`, + ); + } + return { stdout: "", stderr: "" }; + }, + ); + + const result = await runMantisTelegramDesktopBuilder({ + commandRunner: runner, + crabboxBin: "/tmp/crabbox", + credentialRole: "ci", + credentialSource: "convex", + env: { + CI: "1", + OPENCLAW_QA_CONVEX_SECRET_CI: "convex-secret", + OPENCLAW_QA_CONVEX_SITE_URL: "https://example.convex.site", + PATH: process.env.PATH, + }, + keepLease: false, + now: () => new Date("2026-05-05T12:30:00.000Z"), + outputDir: ".artifacts/qa-e2e/mantis/telegram-desktop-convex", + repoRoot, + }); + + expect(result.status).toBe("pass"); + expect(events).toEqual(expect.arrayContaining(["acquire", "release"])); + const runCommand = commands.find( + (entry) => entry.command === "/tmp/crabbox" && entry.args[0] === "run", + ); + expect(runCommand?.env).toMatchObject({ + OPENCLAW_MANTIS_TELEGRAM_DRIVER_BOT_TOKEN: "driver-leased", + OPENCLAW_MANTIS_TELEGRAM_GROUP_ID: "-100222333444", + OPENCLAW_MANTIS_TELEGRAM_SUT_BOT_TOKEN: "sut-leased", + OPENCLAW_QA_TELEGRAM_DRIVER_BOT_TOKEN: "driver-leased", + OPENCLAW_QA_TELEGRAM_GROUP_ID: "-100222333444", + OPENCLAW_QA_TELEGRAM_SUT_BOT_TOKEN: "sut-leased", + }); + expect( + commands.some((entry) => entry.command === "/tmp/crabbox" && entry.args[0] === "stop"), + ).toBe(true); + expect(fetchMock.mock.calls.map(([url]) => describeFetchInput(url))).toEqual([ + "https://example.convex.site/qa-credentials/v1/acquire", + "https://example.convex.site/qa-credentials/v1/release", + ]); + }); +}); diff --git a/extensions/qa-lab/src/mantis/telegram-desktop-builder.runtime.ts b/extensions/qa-lab/src/mantis/telegram-desktop-builder.runtime.ts new file mode 100644 index 00000000000..2f5e10103c7 --- /dev/null +++ b/extensions/qa-lab/src/mantis/telegram-desktop-builder.runtime.ts @@ -0,0 +1,887 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; +import { pathExists } from "openclaw/plugin-sdk/security-runtime"; +import { ensureRepoBoundDirectory, resolveRepoRelativeOutputDir } from "../cli-paths.js"; +import { + acquireQaCredentialLease, + startQaCredentialLeaseHeartbeat, +} from "../live-transports/shared/credential-lease.runtime.js"; +import { + type CommandRunner, + type CrabboxInspect, + defaultCommandRunner, + inspectCrabbox, + resolveCrabboxBin, + runCommand, + shellQuote, + sshCommand, + stopCrabbox, + warmupCrabbox, +} from "./crabbox-runtime.js"; + +export type MantisTelegramDesktopBuilderOptions = { + commandRunner?: CommandRunner; + crabboxBin?: string; + credentialRole?: string; + credentialSource?: string; + env?: NodeJS.ProcessEnv; + gatewaySetup?: boolean; + hydrateMode?: MantisTelegramDesktopHydrateMode; + idleTimeout?: string; + keepLease?: boolean; + leaseId?: string; + machineClass?: string; + now?: () => Date; + outputDir?: string; + provider?: string; + repoRoot?: string; + telegramProfileArchiveEnv?: string; + telegramProfileDir?: string; + ttl?: string; +}; + +export type MantisTelegramDesktopHydrateMode = "prehydrated" | "source"; + +export type MantisTelegramDesktopBuilderResult = { + outputDir: string; + reportPath: string; + screenshotPath?: string; + status: "pass" | "fail"; + summaryPath: string; + videoPath?: string; +}; + +type TelegramGatewayCredentialPayload = { + driverToken: string; + groupId: string; + sutToken: string; +}; + +type TelegramGatewayCredentialLease = Awaited< + ReturnType> +>; +type TelegramGatewayCredentialHeartbeat = ReturnType; + +type MantisTelegramDesktopBuilderSummary = { + artifacts: { + reportPath: string; + screenshotPath?: string; + summaryPath: string; + videoPath?: string; + }; + crabbox: { + bin: string; + createdLease: boolean; + id: string; + provider: string; + slug?: string; + state?: string; + vncCommand: string; + }; + error?: string; + finishedAt: string; + gatewaySetup: boolean; + hydrateMode: MantisTelegramDesktopHydrateMode; + outputDir: string; + remoteOutputDir: string; + startedAt: string; + status: "pass" | "fail"; + telegramDesktop: { + profileArchiveEnv?: string; + profileDir: string; + }; + timings: MantisPhaseTimings; +}; + +type MantisPhaseTiming = { + durationMs: number; + finishedAt: string; + name: string; + startedAt: string; + status: "accepted" | "fail" | "pass"; +}; + +type MantisPhaseTimings = { + phases: MantisPhaseTiming[]; + totalMs: number; +}; + +type TelegramDesktopRemoteMetadata = { + gatewayAlive?: boolean; + gatewayPid?: string; + hydrateMode?: string; + qaExitCode?: number; + telegramDesktopPid?: string; + telegramProfileRestored?: boolean; +}; + +const DEFAULT_PROVIDER = "hetzner"; +const DEFAULT_CLASS = "beast"; +const DEFAULT_IDLE_TIMEOUT = "90m"; +const DEFAULT_TTL = "180m"; +const DEFAULT_CREDENTIAL_SOURCE = "convex"; +const DEFAULT_CREDENTIAL_ROLE = "maintainer"; +const DEFAULT_HYDRATE_MODE: MantisTelegramDesktopHydrateMode = "source"; +const DEFAULT_TELEGRAM_PROFILE_DIR = "$HOME/.local/share/TelegramDesktop"; +const CRABBOX_BIN_ENV = "OPENCLAW_MANTIS_CRABBOX_BIN"; +const CRABBOX_PROVIDER_ENV = "OPENCLAW_MANTIS_CRABBOX_PROVIDER"; +const CRABBOX_CLASS_ENV = "OPENCLAW_MANTIS_CRABBOX_CLASS"; +const CRABBOX_LEASE_ID_ENV = "OPENCLAW_MANTIS_CRABBOX_LEASE_ID"; +const CRABBOX_KEEP_ENV = "OPENCLAW_MANTIS_KEEP_VM"; +const CRABBOX_IDLE_TIMEOUT_ENV = "OPENCLAW_MANTIS_CRABBOX_IDLE_TIMEOUT"; +const CRABBOX_TTL_ENV = "OPENCLAW_MANTIS_CRABBOX_TTL"; +const HYDRATE_MODE_ENV = "OPENCLAW_MANTIS_HYDRATE_MODE"; +const TELEGRAM_PROFILE_ARCHIVE_ENV = "OPENCLAW_MANTIS_TELEGRAM_DESKTOP_PROFILE_TGZ_B64"; +const TELEGRAM_PROFILE_ARCHIVE_ENV_NAME_ENV = + "OPENCLAW_MANTIS_TELEGRAM_DESKTOP_PROFILE_ARCHIVE_ENV"; +const TELEGRAM_PROFILE_DIR_ENV = "OPENCLAW_MANTIS_TELEGRAM_DESKTOP_PROFILE_DIR"; + +function trimToValue(value: string | undefined) { + const trimmed = value?.trim(); + return trimmed && trimmed.length > 0 ? trimmed : undefined; +} + +function isTruthyOptIn(value: string | undefined) { + const normalized = value?.trim().toLowerCase(); + return normalized === "1" || normalized === "true" || normalized === "yes"; +} + +function normalizeHydrateMode( + value: string | undefined, +): MantisTelegramDesktopHydrateMode | undefined { + const normalized = trimToValue(value)?.toLowerCase(); + if (!normalized) { + return undefined; + } + if (normalized === "source" || normalized === "prehydrated") { + return normalized; + } + throw new Error(`Unsupported Mantis Telegram desktop hydrate mode: ${value}`); +} + +function createPhaseTimer(startedAt: Date) { + const phases: MantisPhaseTiming[] = []; + const origin = startedAt.getTime(); + function recordPhase(name: string, phaseStarted: Date, status: MantisPhaseTiming["status"]) { + const phaseFinished = new Date(); + phases.push({ + durationMs: phaseFinished.getTime() - phaseStarted.getTime(), + finishedAt: phaseFinished.toISOString(), + name, + startedAt: phaseStarted.toISOString(), + status, + }); + } + async function timePhase(name: string, run: () => Promise): Promise { + const phaseStarted = new Date(); + try { + const result = await run(); + recordPhase(name, phaseStarted, "pass"); + return result; + } catch (error) { + recordPhase(name, phaseStarted, "fail"); + throw error; + } + } + function snapshot(now = new Date()): MantisPhaseTimings { + return { + phases: [...phases], + totalMs: now.getTime() - origin, + }; + } + function updatePhaseStatus(name: string, status: MantisPhaseTiming["status"]) { + const phase = phases.findLast((entry) => entry.name === name); + if (phase) { + phase.status = status; + } + } + return { recordPhase, snapshot, timePhase, updatePhaseStatus }; +} + +function defaultOutputDir(repoRoot: string, startedAt: Date) { + const stamp = startedAt.toISOString().replace(/[:.]/gu, "-"); + return path.join(repoRoot, ".artifacts", "qa-e2e", "mantis", `telegram-desktop-${stamp}`); +} + +function buildCrabboxEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + const next = { ...env }; + if (!trimToValue(next.OPENCLAW_LIVE_OPENAI_KEY) && trimToValue(next.OPENAI_API_KEY)) { + next.OPENCLAW_LIVE_OPENAI_KEY = next.OPENAI_API_KEY; + } + if (!trimToValue(next.OPENCLAW_MANTIS_TELEGRAM_GROUP_ID)) { + next.OPENCLAW_MANTIS_TELEGRAM_GROUP_ID = trimToValue(next.OPENCLAW_QA_TELEGRAM_GROUP_ID); + } + if (!trimToValue(next.OPENCLAW_MANTIS_TELEGRAM_DRIVER_BOT_TOKEN)) { + next.OPENCLAW_MANTIS_TELEGRAM_DRIVER_BOT_TOKEN = trimToValue( + next.OPENCLAW_QA_TELEGRAM_DRIVER_BOT_TOKEN, + ); + } + if (!trimToValue(next.OPENCLAW_MANTIS_TELEGRAM_SUT_BOT_TOKEN)) { + next.OPENCLAW_MANTIS_TELEGRAM_SUT_BOT_TOKEN = trimToValue( + next.OPENCLAW_QA_TELEGRAM_SUT_BOT_TOKEN, + ); + } + return next; +} + +function resolveTelegramGatewayEnvPayload( + env: NodeJS.ProcessEnv, +): TelegramGatewayCredentialPayload { + const groupId = trimToValue(env.OPENCLAW_QA_TELEGRAM_GROUP_ID); + const driverToken = trimToValue(env.OPENCLAW_QA_TELEGRAM_DRIVER_BOT_TOKEN); + const sutToken = trimToValue(env.OPENCLAW_QA_TELEGRAM_SUT_BOT_TOKEN); + if (!groupId || !driverToken || !sutToken) { + throw new Error( + "Telegram desktop builder requires OPENCLAW_QA_TELEGRAM_GROUP_ID, OPENCLAW_QA_TELEGRAM_DRIVER_BOT_TOKEN, and OPENCLAW_QA_TELEGRAM_SUT_BOT_TOKEN when using --credential-source env.", + ); + } + return { driverToken, groupId, sutToken }; +} + +function parseTelegramGatewayCredentialPayload(payload: unknown): TelegramGatewayCredentialPayload { + if (!payload || typeof payload !== "object") { + throw new Error("Telegram credential payload must be an object."); + } + const candidate = payload as Record; + const groupId = + typeof candidate.groupId === "string" ? trimToValue(candidate.groupId) : undefined; + const driverToken = + typeof candidate.driverToken === "string" ? trimToValue(candidate.driverToken) : undefined; + const sutToken = + typeof candidate.sutToken === "string" ? trimToValue(candidate.sutToken) : undefined; + if (!groupId || !/^-?\d+$/u.test(groupId) || !driverToken || !sutToken) { + throw new Error( + "Telegram credential payload must include numeric groupId, driverToken, and sutToken.", + ); + } + return { driverToken, groupId, sutToken }; +} + +async function prepareGatewayCredentialEnv(params: { + credentialRole: string; + credentialSource: string; + env: NodeJS.ProcessEnv; + gatewaySetup: boolean; +}) { + if (!params.gatewaySetup) { + return {}; + } + if ( + trimToValue(params.env.OPENCLAW_MANTIS_TELEGRAM_GROUP_ID) && + trimToValue(params.env.OPENCLAW_MANTIS_TELEGRAM_DRIVER_BOT_TOKEN) && + trimToValue(params.env.OPENCLAW_MANTIS_TELEGRAM_SUT_BOT_TOKEN) + ) { + return {}; + } + const credentialLease = await acquireQaCredentialLease({ + env: params.env, + kind: "telegram", + source: params.credentialSource, + role: params.credentialRole, + resolveEnvPayload: () => resolveTelegramGatewayEnvPayload(params.env), + parsePayload: parseTelegramGatewayCredentialPayload, + }); + const leaseHeartbeat = startQaCredentialLeaseHeartbeat(credentialLease); + const payload = credentialLease.payload; + params.env.OPENCLAW_MANTIS_TELEGRAM_GROUP_ID = payload.groupId; + params.env.OPENCLAW_MANTIS_TELEGRAM_DRIVER_BOT_TOKEN = payload.driverToken; + params.env.OPENCLAW_MANTIS_TELEGRAM_SUT_BOT_TOKEN = payload.sutToken; + params.env.OPENCLAW_QA_TELEGRAM_GROUP_ID = + trimToValue(params.env.OPENCLAW_QA_TELEGRAM_GROUP_ID) ?? payload.groupId; + params.env.OPENCLAW_QA_TELEGRAM_DRIVER_BOT_TOKEN = + trimToValue(params.env.OPENCLAW_QA_TELEGRAM_DRIVER_BOT_TOKEN) ?? payload.driverToken; + params.env.OPENCLAW_QA_TELEGRAM_SUT_BOT_TOKEN = + trimToValue(params.env.OPENCLAW_QA_TELEGRAM_SUT_BOT_TOKEN) ?? payload.sutToken; + return { + credentialLease, + leaseHeartbeat, + }; +} + +function resolveProfileArchive(params: { env: NodeJS.ProcessEnv; explicitEnvName?: string }): { + archiveValue?: string; + envName?: string; +} { + const envName = + trimToValue(params.explicitEnvName) ?? + trimToValue(params.env[TELEGRAM_PROFILE_ARCHIVE_ENV_NAME_ENV]) ?? + TELEGRAM_PROFILE_ARCHIVE_ENV; + return { + archiveValue: trimToValue(params.env[envName]), + envName, + }; +} + +async function readRemoteMetadata( + outputDir: string, +): Promise { + const metadataPath = path.join(outputDir, "remote-metadata.json"); + if (!(await pathExists(metadataPath))) { + return undefined; + } + try { + const parsed = JSON.parse(await fs.readFile(metadataPath, "utf8")) as unknown; + if (!parsed || typeof parsed !== "object") { + return undefined; + } + const candidate = parsed as Record; + return { + gatewayAlive: + typeof candidate.gatewayAlive === "boolean" ? candidate.gatewayAlive : undefined, + gatewayPid: typeof candidate.gatewayPid === "string" ? candidate.gatewayPid : undefined, + hydrateMode: typeof candidate.hydrateMode === "string" ? candidate.hydrateMode : undefined, + qaExitCode: typeof candidate.qaExitCode === "number" ? candidate.qaExitCode : undefined, + telegramDesktopPid: + typeof candidate.telegramDesktopPid === "string" ? candidate.telegramDesktopPid : undefined, + telegramProfileRestored: + typeof candidate.telegramProfileRestored === "boolean" + ? candidate.telegramProfileRestored + : undefined, + }; + } catch { + return undefined; + } +} + +function renderRemoteScript(params: { + credentialRole: string; + credentialSource: string; + hydrateMode: MantisTelegramDesktopHydrateMode; + remoteOutputDir: string; + setupGateway: boolean; + telegramProfileDir: string; +}) { + const shellOutputDir = shellQuote(params.remoteOutputDir); + const credentialSource = shellQuote(params.credentialSource); + const credentialRole = shellQuote(params.credentialRole); + const hydrateMode = shellQuote(params.hydrateMode); + const setupGateway = params.setupGateway ? "1" : "0"; + const telegramProfileDir = shellQuote(params.telegramProfileDir); + return `set -euo pipefail +out=${shellOutputDir} +credential_source=${credentialSource} +credential_role=${credentialRole} +hydrate_mode=${hydrateMode} +setup_gateway=${setupGateway} +telegram_profile_dir=${telegramProfileDir} +rm -rf "$out" +mkdir -p "$out" +export DISPLAY="\${DISPLAY:-:99}" +if [ -n "\${OPENCLAW_LIVE_OPENAI_KEY:-}" ] && [ -z "\${OPENAI_API_KEY:-}" ]; then + export OPENAI_API_KEY="$OPENCLAW_LIVE_OPENAI_KEY" +fi +if ! command -v node >/dev/null 2>&1; then + sudo apt-get update -y >"$out/node-apt.log" 2>&1 + curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash - >>"$out/node-apt.log" 2>&1 + sudo DEBIAN_FRONTEND=noninteractive apt-get install -y nodejs >>"$out/node-apt.log" 2>&1 +fi +if ! command -v scrot >/dev/null 2>&1 || ! command -v curl >/dev/null 2>&1 || ! command -v xz >/dev/null 2>&1; then + sudo apt-get update -y >"$out/apt.log" 2>&1 + sudo DEBIAN_FRONTEND=noninteractive apt-get install -y curl xz-utils scrot libxcb-cursor0 libxkbcommon-x11-0 libxcb-xinerama0 >>"$out/apt.log" 2>&1 +fi +if ! command -v ffmpeg >/dev/null 2>&1; then + sudo apt-get update -y >>"$out/apt.log" 2>&1 || true + sudo DEBIAN_FRONTEND=noninteractive apt-get install -y ffmpeg >>"$out/apt.log" 2>&1 || true +fi +telegram_root="$HOME/.local/share/openclaw-mantis/telegram-desktop-bin" +telegram_bin="$telegram_root/Telegram/Telegram" +if [ ! -x "$telegram_bin" ]; then + mkdir -p "$telegram_root" + curl -fsSL https://telegram.org/dl/desktop/linux -o "$out/telegram-desktop.tar.xz" + tar -xJf "$out/telegram-desktop.tar.xz" -C "$telegram_root" +fi +if [ -z "$telegram_profile_dir" ] || [ "$telegram_profile_dir" = "\\$HOME/.local/share/TelegramDesktop" ]; then + telegram_profile_dir="$HOME/.local/share/TelegramDesktop" +fi +mkdir -p "$telegram_profile_dir" +telegram_profile_restored=false +if [ -n "\${OPENCLAW_MANTIS_TELEGRAM_DESKTOP_PROFILE_TGZ_B64:-}" ]; then + printf '%s' "$OPENCLAW_MANTIS_TELEGRAM_DESKTOP_PROFILE_TGZ_B64" | base64 -d >"$out/telegram-profile.tgz" + tar -xzf "$out/telegram-profile.tgz" -C "$telegram_profile_dir" + telegram_profile_restored=true +fi +video_pid="" +if command -v ffmpeg >/dev/null 2>&1; then + display_input="$DISPLAY" + case "$display_input" in + *.*) ;; + *) display_input="$display_input.0" ;; + esac + ffmpeg -hide_banner -loglevel error -y -f x11grab -framerate 15 -i "$display_input" -t 45 -pix_fmt yuv420p "$out/telegram-desktop-builder.mp4" >"$out/ffmpeg.log" 2>&1 & + video_pid=$! +else + echo "ffmpeg missing; video artifact skipped" >"$out/ffmpeg.log" +fi +nohup "$telegram_bin" -workdir "$telegram_profile_dir" "$out/telegram-desktop.log" 2>&1 & +telegram_pid="$!" +sleep 6 +qa_status=0 +{ + set -e + echo "remote pwd: $(pwd)" + sudo corepack enable || sudo npm install -g pnpm@10.33.2 + if [ "$hydrate_mode" = "source" ]; then + if ! command -v make >/dev/null 2>&1 || ! command -v python3 >/dev/null 2>&1; then + sudo apt-get update -y >>"$out/apt.log" 2>&1 || true + sudo DEBIAN_FRONTEND=noninteractive apt-get install -y build-essential python3 >>"$out/apt.log" 2>&1 || true + fi + if [ -d /var/cache/crabbox ]; then + export PNPM_STORE_DIR="\${PNPM_STORE_DIR:-/var/cache/crabbox/pnpm}" + mkdir -p "$PNPM_STORE_DIR" >/dev/null 2>&1 || true + pnpm config set store-dir "$PNPM_STORE_DIR" >/dev/null 2>&1 || true + fi + pnpm install --frozen-lockfile --prefer-offline + pnpm build + elif [ "$hydrate_mode" = "prehydrated" ]; then + test -d node_modules || { + echo "hydrate-mode=prehydrated requires node_modules in the remote workspace." >&2 + exit 3 + } + test -d dist || { + echo "hydrate-mode=prehydrated requires a built dist/ directory in the remote workspace." >&2 + exit 3 + } + else + echo "Unsupported hydrate mode: $hydrate_mode" >&2 + exit 3 + fi + if [ "$setup_gateway" = "1" ]; then + export TELEGRAM_BOT_TOKEN="\${OPENCLAW_MANTIS_TELEGRAM_SUT_BOT_TOKEN:-\${TELEGRAM_BOT_TOKEN:-}}" + telegram_group_id="\${OPENCLAW_MANTIS_TELEGRAM_GROUP_ID:-}" + driver_token="\${OPENCLAW_MANTIS_TELEGRAM_DRIVER_BOT_TOKEN:-}" + if [ -z "$TELEGRAM_BOT_TOKEN" ] || [ -z "$telegram_group_id" ] || [ -z "$driver_token" ]; then + echo "Gateway setup requires OPENCLAW_MANTIS_TELEGRAM_GROUP_ID, OPENCLAW_MANTIS_TELEGRAM_DRIVER_BOT_TOKEN, and OPENCLAW_MANTIS_TELEGRAM_SUT_BOT_TOKEN." >&2 + exit 2 + fi + driver_user_id="$(node --input-type=module >"$out/telegram-driver-getme.json" 2>"$out/telegram-driver-getme.err" <<'MANTIS_TELEGRAM_GETME' +const token = process.env.OPENCLAW_MANTIS_TELEGRAM_DRIVER_BOT_TOKEN; +const response = await fetch(\`https://api.telegram.org/bot\${token}/getMe\`); +const body = await response.json(); +process.stdout.write(JSON.stringify({ ok: body.ok, id: body.result?.id, username: body.result?.username })); +if (!body.ok || !body.result?.id) process.exit(1); +MANTIS_TELEGRAM_GETME +node --input-type=module -e 'import fs from "node:fs"; const value = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); process.stdout.write(String(value.id || ""));' "$out/telegram-driver-getme.json")" + export OPENCLAW_HOME="$HOME/.openclaw-mantis/telegram-openclaw" + mkdir -p "$OPENCLAW_HOME" + cat >"$out/telegram.patch.json5" <"$out/telegram-ready-message.json" 2>"$out/telegram-ready-message.err" <<'MANTIS_TELEGRAM_READY' +const token = process.env.OPENCLAW_MANTIS_TELEGRAM_DRIVER_BOT_TOKEN; +const chatId = process.env.OPENCLAW_MANTIS_TELEGRAM_GROUP_ID; +const text = \`Mantis Telegram desktop builder ready: \${new Date().toISOString()}\`; +const response = await fetch(\`https://api.telegram.org/bot\${token}/sendMessage\`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ chat_id: chatId, text, disable_notification: true }), +}); +const body = await response.json(); +process.stdout.write(JSON.stringify({ ok: body.ok, message_id: body.result?.message_id })); +if (!body.ok) process.exit(1); +MANTIS_TELEGRAM_READY + nohup pnpm openclaw gateway run --dev --allow-unconfigured --port 38974 --cli-backend-logs "$out/openclaw-gateway.log" 2>&1 & + gateway_pid="$!" + echo "$gateway_pid" >"$out/openclaw-gateway.pid" + sleep 12 + if ! kill -0 "$gateway_pid" >/dev/null 2>&1; then + echo "OpenClaw gateway exited during startup." >&2 + wait "$gateway_pid" || true + exit 1 + fi + disown "$gateway_pid" >/dev/null 2>&1 || true + fi +} >"$out/telegram-desktop-builder-command.log" 2>&1 || qa_status=$? +sleep 5 +scrot "$out/telegram-desktop-builder.png" || true +if [ -n "$video_pid" ]; then + wait "$video_pid" || true +fi +cat >"$out/remote-metadata.json" </dev/null 2>&1; then echo true; else echo false; fi), + "gatewayPid": "$(if [ -f "$out/openclaw-gateway.pid" ]; then cat "$out/openclaw-gateway.pid"; fi)", + "gatewayPort": 38974, + "qaExitCode": $qa_status, + "credentialSource": "$credential_source", + "credentialRole": "$credential_role", + "hydrateMode": "$hydrate_mode", + "capturedAt": "$(date -u +%Y-%m-%dT%H:%M:%SZ)" +} +MANTIS_REMOTE_METADATA +test -s "$out/telegram-desktop-builder.png" +exit "$qa_status" +`; +} + +function renderReport(summary: MantisTelegramDesktopBuilderSummary) { + const lines = [ + "# Mantis Telegram Desktop Builder", + "", + `Status: ${summary.status}`, + `Output: ${summary.outputDir}`, + `Started: ${summary.startedAt}`, + `Finished: ${summary.finishedAt}`, + "", + "## Crabbox", + "", + `- Provider: ${summary.crabbox.provider}`, + `- Lease: ${summary.crabbox.id}${summary.crabbox.slug ? ` (${summary.crabbox.slug})` : ""}`, + `- Created by run: ${summary.crabbox.createdLease}`, + `- State: ${summary.crabbox.state ?? "unknown"}`, + `- VNC: \`${summary.crabbox.vncCommand}\``, + `- Hydrate mode: ${summary.hydrateMode}`, + `- Gateway setup: ${summary.gatewaySetup ? "yes" : "no"}`, + "", + "## Telegram Desktop", + "", + `- Profile dir: \`${summary.telegramDesktop.profileDir}\``, + summary.telegramDesktop.profileArchiveEnv + ? `- Profile archive env: \`${summary.telegramDesktop.profileArchiveEnv}\`` + : undefined, + "", + "## Timings", + "", + `- Total: ${Math.round(summary.timings.totalMs / 100) / 10}s`, + ...summary.timings.phases.map( + (phase) => `- ${phase.name}: ${Math.round(phase.durationMs / 100) / 10}s (${phase.status})`, + ), + "", + "## Artifacts", + "", + summary.artifacts.screenshotPath + ? `- Screenshot: \`${path.basename(summary.artifacts.screenshotPath)}\`` + : "- Screenshot: missing", + summary.artifacts.videoPath + ? `- Video: \`${path.basename(summary.artifacts.videoPath)}\`` + : "- Video: missing", + "- Remote metadata: `remote-metadata.json`", + "- Remote command log: `telegram-desktop-builder-command.log`", + "- Telegram Desktop log: `telegram-desktop.log`", + "- OpenClaw gateway log: `openclaw-gateway.log`", + summary.error ? `- Error: ${summary.error}` : undefined, + "", + ].filter((line) => line !== undefined); + return `${lines.join("\n")}\n`; +} + +async function copyRemoteArtifacts(params: { + cwd: string; + env: NodeJS.ProcessEnv; + inspect: CrabboxInspect; + outputDir: string; + remoteOutputDir: string; + runner: CommandRunner; +}) { + const { host, sshArgs, sshUser } = sshCommand({ inspect: params.inspect }); + await runCommand({ + command: "rsync", + args: [ + "-az", + "-e", + sshArgs, + `${sshUser}@${host}:${params.remoteOutputDir}/`, + `${params.outputDir}/`, + ], + cwd: params.cwd, + env: params.env, + runner: params.runner, + }); +} + +export async function runMantisTelegramDesktopBuilder( + opts: MantisTelegramDesktopBuilderOptions = {}, +): Promise { + const env = buildCrabboxEnv(opts.env ?? process.env); + const startedAt = (opts.now ?? (() => new Date()))(); + const timer = createPhaseTimer(startedAt); + const repoRoot = path.resolve(opts.repoRoot ?? process.cwd()); + const outputDir = await ensureRepoBoundDirectory( + repoRoot, + resolveRepoRelativeOutputDir(repoRoot, opts.outputDir) ?? defaultOutputDir(repoRoot, startedAt), + "Mantis Telegram desktop builder output directory", + { mode: 0o755 }, + ); + const summaryPath = path.join(outputDir, "mantis-telegram-desktop-builder-summary.json"); + const reportPath = path.join(outputDir, "mantis-telegram-desktop-builder-report.md"); + const crabboxBin = await resolveCrabboxBin({ + env, + envName: CRABBOX_BIN_ENV, + explicit: opts.crabboxBin, + repoRoot, + }); + const provider = + trimToValue(opts.provider) ?? trimToValue(env[CRABBOX_PROVIDER_ENV]) ?? DEFAULT_PROVIDER; + const machineClass = + trimToValue(opts.machineClass) ?? trimToValue(env[CRABBOX_CLASS_ENV]) ?? DEFAULT_CLASS; + const idleTimeout = + trimToValue(opts.idleTimeout) ?? + trimToValue(env[CRABBOX_IDLE_TIMEOUT_ENV]) ?? + DEFAULT_IDLE_TIMEOUT; + const ttl = trimToValue(opts.ttl) ?? trimToValue(env[CRABBOX_TTL_ENV]) ?? DEFAULT_TTL; + const credentialSource = trimToValue(opts.credentialSource) ?? DEFAULT_CREDENTIAL_SOURCE; + const credentialRole = trimToValue(opts.credentialRole) ?? DEFAULT_CREDENTIAL_ROLE; + const hydrateMode = + normalizeHydrateMode(opts.hydrateMode) ?? + normalizeHydrateMode(env[HYDRATE_MODE_ENV]) ?? + DEFAULT_HYDRATE_MODE; + const gatewaySetup = opts.gatewaySetup ?? true; + const profileArchive = resolveProfileArchive({ + env, + explicitEnvName: opts.telegramProfileArchiveEnv, + }); + if (profileArchive.archiveValue) { + env[TELEGRAM_PROFILE_ARCHIVE_ENV] = profileArchive.archiveValue; + } + const telegramProfileDir = + trimToValue(opts.telegramProfileDir) ?? + trimToValue(env[TELEGRAM_PROFILE_DIR_ENV]) ?? + DEFAULT_TELEGRAM_PROFILE_DIR; + env[TELEGRAM_PROFILE_DIR_ENV] = telegramProfileDir; + const runner = opts.commandRunner ?? defaultCommandRunner; + const explicitLeaseId = trimToValue(opts.leaseId) ?? trimToValue(env[CRABBOX_LEASE_ID_ENV]); + const keepLease = opts.keepLease ?? (gatewaySetup || isTruthyOptIn(env[CRABBOX_KEEP_ENV])); + const createdLease = explicitLeaseId === undefined; + const remoteOutputDir = `/tmp/openclaw-mantis-telegram-desktop-${startedAt + .toISOString() + .replace(/[^0-9A-Za-z]/gu, "-")}`; + let credentialLease: TelegramGatewayCredentialLease | undefined; + let leaseHeartbeat: TelegramGatewayCredentialHeartbeat | undefined; + let leaseId = explicitLeaseId; + let summary: MantisTelegramDesktopBuilderSummary | undefined; + let screenshotPath: string | undefined; + let videoPath: string | undefined; + + try { + leaseId = + leaseId ?? + (await timer.timePhase("crabbox.warmup", () => + warmupCrabbox({ + crabboxBin, + cwd: repoRoot, + env, + idleTimeout, + machineClass, + provider, + runner, + ttl, + }), + )); + if (!leaseId) { + throw new Error("Crabbox lease id was not resolved."); + } + const resolvedLeaseId = leaseId; + const inspected = await timer.timePhase("crabbox.inspect", () => + inspectCrabbox({ + crabboxBin, + cwd: repoRoot, + env, + leaseId: resolvedLeaseId, + provider, + runner, + }), + ); + const preparedCredentialEnv = await timer.timePhase("credentials.prepare", () => + prepareGatewayCredentialEnv({ + credentialRole, + credentialSource, + env, + gatewaySetup, + }), + ); + credentialLease = preparedCredentialEnv.credentialLease; + leaseHeartbeat = preparedCredentialEnv.leaseHeartbeat; + let remoteRunError: unknown; + const remoteRunStartedAt = new Date(); + await runCommand({ + command: crabboxBin, + args: [ + "run", + "--provider", + provider, + "--id", + resolvedLeaseId, + "--desktop", + "--shell", + "--", + renderRemoteScript({ + credentialRole, + credentialSource, + hydrateMode, + remoteOutputDir, + setupGateway: gatewaySetup, + telegramProfileDir, + }), + ], + cwd: repoRoot, + env, + runner, + stdio: "inherit", + }).then( + () => { + timer.recordPhase("crabbox.remote_run", remoteRunStartedAt, "pass"); + }, + (error: unknown) => { + timer.recordPhase("crabbox.remote_run", remoteRunStartedAt, "fail"); + remoteRunError = error; + return { stdout: "", stderr: "" }; + }, + ); + leaseHeartbeat?.throwIfFailed(); + await timer.timePhase("artifacts.copy", () => + copyRemoteArtifacts({ + cwd: repoRoot, + env, + inspect: inspected, + outputDir, + remoteOutputDir, + runner, + }), + ); + screenshotPath = path.join(outputDir, "telegram-desktop-builder.png"); + videoPath = path.join(outputDir, "telegram-desktop-builder.mp4"); + if (!(await pathExists(videoPath))) { + videoPath = undefined; + } + const remoteMetadata = await readRemoteMetadata(outputDir); + if (!(await pathExists(screenshotPath))) { + throw new Error("Telegram desktop screenshot was not copied back from Crabbox."); + } + const gatewaySetupCompleted = + gatewaySetup && remoteMetadata?.qaExitCode === 0 && remoteMetadata.gatewayAlive === true; + if (remoteRunError && gatewaySetupCompleted) { + timer.updatePhaseStatus("crabbox.remote_run", "accepted"); + } + if (remoteRunError && !gatewaySetupCompleted) { + throw remoteRunError; + } + if (gatewaySetup && !gatewaySetupCompleted) { + throw new Error("Telegram desktop builder did not report a live OpenClaw gateway."); + } + summary = { + artifacts: { + reportPath, + screenshotPath, + summaryPath, + videoPath, + }, + crabbox: { + bin: crabboxBin, + createdLease, + id: resolvedLeaseId, + provider, + slug: inspected.slug, + state: inspected.state, + vncCommand: `${crabboxBin} vnc --provider ${provider} --id ${resolvedLeaseId} --open`, + }, + finishedAt: new Date().toISOString(), + gatewaySetup, + hydrateMode: normalizeHydrateMode(remoteMetadata?.hydrateMode) ?? hydrateMode, + outputDir, + remoteOutputDir, + startedAt: startedAt.toISOString(), + status: "pass", + telegramDesktop: { + profileArchiveEnv: profileArchive.archiveValue ? profileArchive.envName : undefined, + profileDir: telegramProfileDir, + }, + timings: timer.snapshot(), + }; + return { + outputDir, + reportPath, + screenshotPath, + status: "pass", + summaryPath, + videoPath, + }; + } catch (error) { + summary = { + artifacts: { + reportPath, + screenshotPath, + summaryPath, + videoPath, + }, + crabbox: { + bin: crabboxBin, + createdLease, + id: leaseId ?? "unallocated", + provider, + vncCommand: leaseId + ? `${crabboxBin} vnc --provider ${provider} --id ${leaseId} --open` + : "unallocated", + }, + error: formatErrorMessage(error), + finishedAt: new Date().toISOString(), + gatewaySetup, + hydrateMode, + outputDir, + remoteOutputDir, + startedAt: startedAt.toISOString(), + status: "fail", + telegramDesktop: { + profileArchiveEnv: profileArchive.archiveValue ? profileArchive.envName : undefined, + profileDir: telegramProfileDir, + }, + timings: timer.snapshot(), + }; + await fs.writeFile(path.join(outputDir, "error.txt"), `${summary.error}\n`, "utf8"); + return { + outputDir, + reportPath, + screenshotPath, + status: "fail", + summaryPath, + videoPath, + }; + } finally { + if (summary) { + summary.finishedAt = new Date().toISOString(); + summary.timings = timer.snapshot(); + await fs.writeFile(summaryPath, `${JSON.stringify(summary, null, 2)}\n`, "utf8"); + await fs.writeFile(reportPath, renderReport(summary), "utf8"); + } + if (createdLease && leaseId && !keepLease) { + await stopCrabbox({ crabboxBin, cwd: repoRoot, env, leaseId, provider, runner }); + } + if (leaseHeartbeat) { + await leaseHeartbeat.stop().catch((error: unknown) => { + console.warn(`Telegram credential heartbeat cleanup failed: ${formatErrorMessage(error)}`); + }); + } + if (credentialLease) { + await credentialLease.release().catch((error: unknown) => { + console.warn(`Telegram credential release failed: ${formatErrorMessage(error)}`); + }); + } + } +} diff --git a/scripts/mantis/build-telegram-evidence.mjs b/scripts/mantis/build-telegram-evidence.mjs new file mode 100644 index 00000000000..b8b1144d299 --- /dev/null +++ b/scripts/mantis/build-telegram-evidence.mjs @@ -0,0 +1,405 @@ +#!/usr/bin/env node +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +function parseArgs(argv) { + const args = {}; + for (let index = 0; index < argv.length; index += 1) { + const key = argv[index]; + if (!key.startsWith("--")) { + throw new Error(`Unexpected argument: ${key}`); + } + const name = key.slice(2).replaceAll("-", "_"); + const value = argv[index + 1]; + if (!value || value.startsWith("--")) { + throw new Error(`Missing value for ${key}`); + } + args[name] = value; + index += 1; + } + return args; +} + +function readJson(filePath) { + return JSON.parse(readFileSync(filePath, "utf8")); +} + +function escapeHtml(value) { + return String(value ?? "") + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +function formatMessageText(message) { + const text = typeof message.text === "string" ? message.text : ""; + const caption = typeof message.caption === "string" ? message.caption : ""; + const content = text || caption || ""; + if (content.trim()) { + return content; + } + const mediaKinds = Array.isArray(message.mediaKinds) ? message.mediaKinds : []; + return mediaKinds.length > 0 ? `[${mediaKinds.join(", ")}]` : "[no text]"; +} + +function renderScenarioList(summary) { + const scenarios = Array.isArray(summary.scenarios) ? summary.scenarios : []; + if (scenarios.length === 0) { + return "
  • No scenarios recorded.
  • "; + } + return scenarios + .map((scenario) => { + const statusClass = scenario.status === "pass" ? "pass" : "fail"; + const rtt = typeof scenario.rttMs === "number" ? `, ${Math.round(scenario.rttMs)}ms RTT` : ""; + return `
  • ${escapeHtml(scenario.status ?? "unknown")} ${escapeHtml(scenario.title ?? scenario.id)} ${escapeHtml(scenario.id ?? "")}${rtt}

    ${escapeHtml(scenario.details ?? "")}

  • `; + }) + .join("\n"); +} + +function renderObservedMessages(observedMessages) { + if (!Array.isArray(observedMessages) || observedMessages.length === 0) { + return '

    No observed Telegram messages were recorded.

    '; + } + return observedMessages + .map((message, index) => { + const sender = message.senderIsBot ? "bot" : "user"; + const scenario = message.scenarioTitle ?? message.scenarioId ?? ""; + const text = formatMessageText(message); + const buttons = Array.isArray(message.inlineButtons) + ? message.inlineButtons + : typeof message.inlineButtonCount === "number" && message.inlineButtonCount > 0 + ? [`${message.inlineButtonCount} inline button(s)`] + : []; + return [ + `
    `, + `
    #${index + 1}${escapeHtml(sender)}${scenario ? `${escapeHtml(scenario)}` : ""}
    `, + `
    ${escapeHtml(text)}
    `, + buttons.length > 0 + ? `
    ${buttons.map((button) => `${escapeHtml(button)}`).join("")}
    ` + : "", + "
    ", + ] + .filter(Boolean) + .join("\n"); + }) + .join("\n"); +} + +export function renderTelegramEvidenceHtml({ observedMessages, summary }) { + const counts = summary.counts ?? {}; + const pass = counts.failed === 0 && Number(counts.total ?? 0) > 0; + return ` + + + + + Mantis Telegram Live Evidence + + + +
    +
    +

    Mantis Telegram Live Evidence

    +
    + status: ${pass ? "pass" : "fail"} + total: ${escapeHtml(counts.total ?? 0)} + passed: ${escapeHtml(counts.passed ?? 0)} + failed: ${escapeHtml(counts.failed ?? 0)} + credentials: ${escapeHtml(summary.credentials?.source ?? "unknown")} +
    +
    +
    +

    Scenarios

    +
      + ${renderScenarioList(summary)} +
    +
    +
    +

    Observed Telegram Messages

    +
    + ${renderObservedMessages(observedMessages)} +
    +
    +
    + + +`; +} + +export function buildTelegramEvidenceManifest({ + candidateRef, + candidateSha, + scenarioLabel, + summary, +}) { + const counts = summary.counts ?? {}; + const pass = counts.failed === 0 && Number(counts.total ?? 0) > 0; + const scenarioNames = Array.isArray(summary.scenarios) + ? summary.scenarios.map((scenario) => scenario.id).filter(Boolean) + : []; + const scenario = scenarioLabel || scenarioNames.join(",") || "telegram-live"; + const status = pass ? "pass" : "fail"; + const artifacts = [ + { + kind: "desktopScreenshot", + lane: "candidate", + label: "Telegram live transcript", + path: "telegram-live-desktop.png", + targetPath: "telegram-live-desktop.png", + alt: "Rendered Telegram live transcript in a Crabbox desktop browser", + width: 720, + inline: true, + required: false, + }, + { + kind: "motionPreview", + lane: "candidate", + label: "Telegram motion preview", + path: "telegram-live-preview.gif", + targetPath: "telegram-live-preview.gif", + alt: "Animated Telegram live transcript capture", + width: 720, + inline: true, + required: false, + }, + { + kind: "motionClip", + lane: "candidate", + label: "Telegram change MP4", + path: "telegram-live-change.mp4", + targetPath: "telegram-live-change.mp4", + required: false, + }, + { + kind: "fullVideo", + lane: "candidate", + label: "Telegram desktop MP4", + path: "telegram-live.mp4", + targetPath: "telegram-live.mp4", + required: false, + }, + { + kind: "metadata", + lane: "run", + label: "Telegram QA summary", + path: "telegram-qa-summary.json", + targetPath: "summary.json", + }, + { + kind: "metadata", + lane: "run", + label: "Telegram observed messages", + path: "telegram-qa-observed-messages.json", + targetPath: "observed-messages.json", + }, + { + kind: "metadata", + lane: "run", + label: "Telegram transcript HTML", + path: "telegram-live-transcript.html", + targetPath: "telegram-live-transcript.html", + }, + { + kind: "metadata", + lane: "run", + label: "Telegram preview metadata", + path: "telegram-live-preview.json", + targetPath: "telegram-live-preview.json", + required: false, + }, + { + kind: "metadata", + lane: "run", + label: "Telegram QA error", + path: "error.txt", + targetPath: "error.txt", + required: false, + }, + { + kind: "report", + lane: "run", + label: "Telegram QA report", + path: "telegram-qa-report.md", + targetPath: "report.md", + }, + ]; + return { + schemaVersion: 1, + id: "telegram-live", + title: "Mantis Telegram Live QA", + summary: + "Mantis ran the Telegram live QA lane with Convex-leased credentials, rendered a redacted transcript in a Crabbox desktop browser, and captured screenshot/video evidence for PR review.", + scenario, + comparison: { + candidate: { + ...(candidateSha ? { sha: candidateSha } : {}), + ...(candidateRef ? { ref: candidateRef } : {}), + expected: "Telegram live QA scenarios pass", + status, + fixed: pass, + }, + pass, + }, + artifacts, + }; +} + +export function writeTelegramEvidence(rawArgs = process.argv.slice(2)) { + const args = parseArgs(rawArgs); + if (!args.output_dir) { + throw new Error("Missing --output-dir."); + } + const outputDir = path.resolve(args.output_dir); + mkdirSync(outputDir, { recursive: true }); + const summaryPath = path.join(outputDir, "telegram-qa-summary.json"); + const observedPath = path.join(outputDir, "telegram-qa-observed-messages.json"); + const reportPath = path.join(outputDir, "telegram-qa-report.md"); + if (!existsSync(summaryPath)) { + throw new Error(`Missing Telegram QA summary: ${summaryPath}`); + } + if (!existsSync(observedPath)) { + throw new Error(`Missing Telegram observed messages: ${observedPath}`); + } + if (!existsSync(reportPath)) { + writeFileSync(reportPath, "# Mantis Telegram Live QA\n\nTelegram QA report was unavailable.\n"); + } + + const summary = readJson(summaryPath); + const observedMessages = readJson(observedPath); + const transcriptHtml = renderTelegramEvidenceHtml({ observedMessages, summary }); + writeFileSync(path.join(outputDir, "telegram-live-transcript.html"), transcriptHtml, "utf8"); + const manifest = buildTelegramEvidenceManifest({ + candidateRef: args.candidate_ref, + candidateSha: args.candidate_sha, + scenarioLabel: args.scenario_label, + summary, + }); + writeFileSync( + path.join(outputDir, "mantis-evidence.json"), + `${JSON.stringify(manifest, null, 2)}\n`, + "utf8", + ); + return { + manifest, + manifestPath: path.join(outputDir, "mantis-evidence.json"), + transcriptPath: path.join(outputDir, "telegram-live-transcript.html"), + }; +} + +const executedPath = process.argv[1] ? path.resolve(process.argv[1]) : ""; +if (executedPath === fileURLToPath(import.meta.url)) { + try { + writeTelegramEvidence(); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); + } +} diff --git a/scripts/test-projects.test-support.mjs b/scripts/test-projects.test-support.mjs index 37de11e7a32..7ed7796bc02 100644 --- a/scripts/test-projects.test-support.mjs +++ b/scripts/test-projects.test-support.mjs @@ -331,6 +331,10 @@ const TOOLING_SOURCE_TEST_TARGETS = new Map([ ["scripts/lib/live-docker-stage.sh", ["test/scripts/live-docker-stage.test.ts"]], ["scripts/lib/openclaw-test-state.mjs", ["test/scripts/openclaw-test-state.test.ts"]], ["scripts/lib/vitest-local-scheduling.mjs", ["test/scripts/vitest-local-scheduling.test.ts"]], + [ + "scripts/mantis/build-telegram-evidence.mjs", + ["test/scripts/mantis-build-telegram-evidence.test.ts"], + ], ["scripts/mantis/publish-pr-evidence.mjs", ["test/scripts/mantis-publish-pr-evidence.test.ts"]], [ "scripts/run-vitest.mjs", @@ -388,6 +392,10 @@ const TOOLING_TEST_TARGETS = new Map([ "test/scripts/mantis-publish-pr-evidence.test.ts", ["test/scripts/mantis-publish-pr-evidence.test.ts"], ], + [ + "test/scripts/mantis-build-telegram-evidence.test.ts", + ["test/scripts/mantis-build-telegram-evidence.test.ts"], + ], [ "test/scripts/plugin-prerelease-test-plan.test.ts", ["test/scripts/plugin-prerelease-test-plan.test.ts"], diff --git a/test/scripts/mantis-build-telegram-evidence.test.ts b/test/scripts/mantis-build-telegram-evidence.test.ts new file mode 100644 index 00000000000..37602f9bd8c --- /dev/null +++ b/test/scripts/mantis-build-telegram-evidence.test.ts @@ -0,0 +1,136 @@ +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + buildTelegramEvidenceManifest, + renderTelegramEvidenceHtml, + writeTelegramEvidence, +} from "../../scripts/mantis/build-telegram-evidence.mjs"; +import { loadEvidenceManifest } from "../../scripts/mantis/publish-pr-evidence.mjs"; + +const tempDirs: string[] = []; + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +function makeTelegramOutput() { + const dir = mkdtempSync(path.join(tmpdir(), "mantis-telegram-evidence-test-")); + tempDirs.push(dir); + mkdirSync(dir, { recursive: true }); + writeFileSync( + path.join(dir, "telegram-qa-summary.json"), + JSON.stringify({ + credentials: { source: "convex", kind: "telegram", role: "ci" }, + groupId: "", + startedAt: "2026-05-10T00:00:00.000Z", + finishedAt: "2026-05-10T00:00:05.000Z", + cleanupIssues: [], + counts: { total: 1, passed: 1, failed: 0 }, + scenarios: [ + { + id: "telegram-status-command", + title: "Telegram status command reply", + status: "pass", + details: "Observed expected status response.", + rttMs: 1234, + }, + ], + }), + ); + writeFileSync( + path.join(dir, "telegram-qa-observed-messages.json"), + JSON.stringify([ + { + scenarioId: "telegram-status-command", + scenarioTitle: "Telegram status command reply", + senderIsBot: true, + text: "", + inlineButtons: ["Open"], + mediaKinds: [], + }, + ]), + ); + writeFileSync(path.join(dir, "telegram-qa-report.md"), "# Telegram QA\n\npass\n"); + return dir; +} + +describe("scripts/mantis/build-telegram-evidence", () => { + it("renders redacted Telegram observed messages as a transcript HTML page", () => { + const html = renderTelegramEvidenceHtml({ + summary: { + credentials: { source: "convex" }, + counts: { total: 1, passed: 1, failed: 0 }, + scenarios: [ + { + id: "telegram-status-command", + title: "Telegram status command reply", + status: "pass", + details: "ok", + }, + ], + }, + observedMessages: [ + { + senderIsBot: true, + scenarioId: "telegram-status-command", + text: "", + inlineButtons: ["Approve"], + mediaKinds: [], + }, + ], + }); + + expect(html).toContain("Mantis Telegram Live Evidence"); + expect(html).toContain("<hello>"); + expect(html).toContain("status: pass"); + expect(html).not.toContain(""); + }); + + it("writes a Mantis manifest with optional Crabbox GIF and video artifacts", () => { + const dir = makeTelegramOutput(); + const result = writeTelegramEvidence([ + "--output-dir", + dir, + "--candidate-ref", + "refs/pull/1/head", + "--candidate-sha", + "abc123", + "--scenario-label", + "telegram-status-command", + ]); + + expect(readFileSync(result.transcriptPath, "utf8")).toContain("Telegram status command reply"); + const manifest = loadEvidenceManifest(result.manifestPath); + expect(manifest.comparison.pass).toBe(true); + expect(manifest.comparison.candidate.sha).toBe("abc123"); + expect(manifest.artifacts.map((artifact) => artifact.targetPath)).toEqual([ + "summary.json", + "observed-messages.json", + "telegram-live-transcript.html", + "report.md", + "mantis-evidence.json", + ]); + expect(result.manifest.artifacts.some((artifact) => artifact.kind === "motionPreview")).toBe( + true, + ); + }); + + it("marks the comparison failed when any Telegram scenario fails", () => { + const manifest = buildTelegramEvidenceManifest({ + candidateRef: "main", + candidateSha: "abc123", + scenarioLabel: "telegram-live", + summary: { + counts: { total: 2, passed: 1, failed: 1 }, + scenarios: [], + }, + }); + + expect(manifest.comparison.pass).toBe(false); + expect(manifest.comparison.candidate.status).toBe("fail"); + }); +});