mirror of
https://github.com/moltbot/moltbot.git
synced 2026-05-13 15:47:28 +00:00
feat: add telegram mantis evidence builder
This commit is contained in:
13
.github/workflows/mantis-scenario.yml
vendored
13
.github/workflows/mantis-scenario.yml
vendored
@@ -12,6 +12,7 @@ on:
|
|||||||
- discord-status-reactions-tool-only
|
- discord-status-reactions-tool-only
|
||||||
- discord-thread-reply-filepath-attachment
|
- discord-thread-reply-filepath-attachment
|
||||||
- slack-desktop-smoke
|
- slack-desktop-smoke
|
||||||
|
- telegram-live
|
||||||
baseline_ref:
|
baseline_ref:
|
||||||
description: Optional baseline ref for before/after scenarios
|
description: Optional baseline ref for before/after scenarios
|
||||||
required: false
|
required: false
|
||||||
@@ -90,6 +91,18 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
gh "${args[@]}"
|
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
|
echo "Unsupported Mantis scenario: ${SCENARIO_ID}" >&2
|
||||||
exit 1
|
exit 1
|
||||||
|
|||||||
500
.github/workflows/mantis-telegram-live.yml
vendored
Normal file
500
.github/workflows/mantis-telegram-live.yml
vendored
Normal file
@@ -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 "<!-- mantis-telegram-live -->" \
|
||||||
|
"${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
|
||||||
@@ -6,6 +6,8 @@ Docs: https://docs.openclaw.ai
|
|||||||
|
|
||||||
### Changes
|
### 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.
|
- 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.
|
- 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.
|
- 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.
|
||||||
|
|||||||
@@ -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
|
this lane when you want "a Linux desktop with Slack and a claw running" instead
|
||||||
of only a bot-to-bot Slack transcript.
|
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 <name>` or created manually through VNC and kept
|
||||||
|
alive with `--keep-lease`.
|
||||||
|
|
||||||
|
Useful Telegram desktop builder flags:
|
||||||
|
|
||||||
|
- `--lease-id <cbx_...>` reruns against a VM where an operator already logged in to Telegram Desktop.
|
||||||
|
- `--telegram-profile-archive-env <name>` reads a base64 `.tgz` Telegram Desktop profile archive from that env var and restores it before launch.
|
||||||
|
- `--telegram-profile-dir <remote-path>` 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.
|
Every PR-publishing scenario writes `mantis-evidence.json` next to its report.
|
||||||
This schema is the handoff between scenario code and GitHub comments:
|
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
|
@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=<cbx_...>` when they need a specific ref or a
|
||||||
|
pre-warmed Crabbox desktop.
|
||||||
|
|
||||||
ClawSweeper command examples:
|
ClawSweeper command examples:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
|||||||
@@ -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.
|
- 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.
|
- 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 <name>` 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.
|
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)
|
### Shared Telegram credentials via Convex (v1)
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ const {
|
|||||||
runMantisDesktopBrowserSmokeCommand,
|
runMantisDesktopBrowserSmokeCommand,
|
||||||
runMantisDiscordSmokeCommand,
|
runMantisDiscordSmokeCommand,
|
||||||
runMantisSlackDesktopSmokeCommand,
|
runMantisSlackDesktopSmokeCommand,
|
||||||
|
runMantisTelegramDesktopBuilderCommand,
|
||||||
} = vi.hoisted(() => ({
|
} = vi.hoisted(() => ({
|
||||||
runQaCredentialsAddCommand: vi.fn(),
|
runQaCredentialsAddCommand: vi.fn(),
|
||||||
runQaCredentialsListCommand: vi.fn(),
|
runQaCredentialsListCommand: vi.fn(),
|
||||||
@@ -64,6 +65,7 @@ const {
|
|||||||
runMantisDesktopBrowserSmokeCommand: vi.fn(),
|
runMantisDesktopBrowserSmokeCommand: vi.fn(),
|
||||||
runMantisDiscordSmokeCommand: vi.fn(),
|
runMantisDiscordSmokeCommand: vi.fn(),
|
||||||
runMantisSlackDesktopSmokeCommand: vi.fn(),
|
runMantisSlackDesktopSmokeCommand: vi.fn(),
|
||||||
|
runMantisTelegramDesktopBuilderCommand: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const { listQaRunnerCliContributions } = vi.hoisted(() => ({
|
const { listQaRunnerCliContributions } = vi.hoisted(() => ({
|
||||||
@@ -85,6 +87,7 @@ vi.mock("./mantis/cli.runtime.js", () => ({
|
|||||||
runMantisDesktopBrowserSmokeCommand,
|
runMantisDesktopBrowserSmokeCommand,
|
||||||
runMantisDiscordSmokeCommand,
|
runMantisDiscordSmokeCommand,
|
||||||
runMantisSlackDesktopSmokeCommand,
|
runMantisSlackDesktopSmokeCommand,
|
||||||
|
runMantisTelegramDesktopBuilderCommand,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("./cli.runtime.js", () => ({
|
vi.mock("./cli.runtime.js", () => ({
|
||||||
@@ -114,6 +117,7 @@ describe("qa cli registration", () => {
|
|||||||
runMantisDesktopBrowserSmokeCommand.mockReset();
|
runMantisDesktopBrowserSmokeCommand.mockReset();
|
||||||
runMantisDiscordSmokeCommand.mockReset();
|
runMantisDiscordSmokeCommand.mockReset();
|
||||||
runMantisSlackDesktopSmokeCommand.mockReset();
|
runMantisSlackDesktopSmokeCommand.mockReset();
|
||||||
|
runMantisTelegramDesktopBuilderCommand.mockReset();
|
||||||
listQaRunnerCliContributions
|
listQaRunnerCliContributions
|
||||||
.mockReset()
|
.mockReset()
|
||||||
.mockReturnValue([createAvailableQaRunnerContribution()]);
|
.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 () => {
|
it("routes coverage report flags into the qa runtime command", async () => {
|
||||||
await program.parseAsync([
|
await program.parseAsync([
|
||||||
"node",
|
"node",
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ import {
|
|||||||
runMantisSlackDesktopSmoke,
|
runMantisSlackDesktopSmoke,
|
||||||
type MantisSlackDesktopSmokeOptions,
|
type MantisSlackDesktopSmokeOptions,
|
||||||
} from "./slack-desktop-smoke.runtime.js";
|
} from "./slack-desktop-smoke.runtime.js";
|
||||||
|
import {
|
||||||
|
runMantisTelegramDesktopBuilder,
|
||||||
|
type MantisTelegramDesktopBuilderOptions,
|
||||||
|
} from "./telegram-desktop-builder.runtime.js";
|
||||||
import {
|
import {
|
||||||
runMantisVisualDriver,
|
runMantisVisualDriver,
|
||||||
runMantisVisualTask,
|
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) {
|
export async function runMantisVisualDriverCommand(opts: MantisVisualDriverOptions) {
|
||||||
const result = await runMantisVisualDriver(opts);
|
const result = await runMantisVisualDriver(opts);
|
||||||
process.stdout.write(`Mantis visual driver result: ${result.status}\n`);
|
process.stdout.write(`Mantis visual driver result: ${result.status}\n`);
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ import type {
|
|||||||
MantisSlackDesktopHydrateMode,
|
MantisSlackDesktopHydrateMode,
|
||||||
MantisSlackDesktopSmokeOptions,
|
MantisSlackDesktopSmokeOptions,
|
||||||
} from "./slack-desktop-smoke.runtime.js";
|
} from "./slack-desktop-smoke.runtime.js";
|
||||||
|
import type {
|
||||||
|
MantisTelegramDesktopBuilderOptions,
|
||||||
|
MantisTelegramDesktopHydrateMode,
|
||||||
|
} from "./telegram-desktop-builder.runtime.js";
|
||||||
import type {
|
import type {
|
||||||
MantisVisualDriverOptions,
|
MantisVisualDriverOptions,
|
||||||
MantisVisualTaskOptions,
|
MantisVisualTaskOptions,
|
||||||
@@ -39,6 +43,11 @@ async function runSlackDesktopSmoke(opts: MantisSlackDesktopSmokeOptions) {
|
|||||||
await runtime.runMantisSlackDesktopSmokeCommand(opts);
|
await runtime.runMantisSlackDesktopSmokeCommand(opts);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function runTelegramDesktopBuilder(opts: MantisTelegramDesktopBuilderOptions) {
|
||||||
|
const runtime = await loadMantisCliRuntime();
|
||||||
|
await runtime.runMantisTelegramDesktopBuilderCommand(opts);
|
||||||
|
}
|
||||||
|
|
||||||
async function runVisualDriver(opts: MantisVisualDriverOptions) {
|
async function runVisualDriver(opts: MantisVisualDriverOptions) {
|
||||||
const runtime = await loadMantisCliRuntime();
|
const runtime = await loadMantisCliRuntime();
|
||||||
await runtime.runMantisVisualDriverCommand(opts);
|
await runtime.runMantisVisualDriverCommand(opts);
|
||||||
@@ -118,6 +127,25 @@ type MantisSlackDesktopSmokeCommanderOptions = {
|
|||||||
ttl?: string;
|
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 = {
|
type MantisVisualTaskCommanderOptions = {
|
||||||
browserUrl?: string;
|
browserUrl?: string;
|
||||||
class?: 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 <path>", "Repository root to target when running from a neutral cwd")
|
||||||
|
.option("--output-dir <path>", "Mantis Telegram desktop builder artifact directory")
|
||||||
|
.option("--crabbox-bin <path>", "Crabbox binary path")
|
||||||
|
.option("--provider <provider>", "Crabbox provider")
|
||||||
|
.option("--machine-class <class>", "Crabbox machine class")
|
||||||
|
.option("--class <class>", "Alias for --machine-class")
|
||||||
|
.option("--lease-id <id>", "Reuse an existing Crabbox lease")
|
||||||
|
.option("--idle-timeout <duration>", "Crabbox idle timeout")
|
||||||
|
.option("--ttl <duration>", "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 <source>", "Credential source for Telegram setup: env or convex")
|
||||||
|
.option("--credential-role <role>", "Credential role for convex auth")
|
||||||
|
.option("--hydrate-mode <mode>", "Remote hydrate mode: source or prehydrated")
|
||||||
|
.option(
|
||||||
|
"--telegram-profile-archive-env <name>",
|
||||||
|
"Env var containing a base64 .tgz Telegram Desktop profile archive",
|
||||||
|
)
|
||||||
|
.option(
|
||||||
|
"--telegram-profile-dir <remote-path>",
|
||||||
|
"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
|
mantis
|
||||||
.command("visual-task")
|
.command("visual-task")
|
||||||
.description(
|
.description(
|
||||||
|
|||||||
@@ -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",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
887
extensions/qa-lab/src/mantis/telegram-desktop-builder.runtime.ts
Normal file
887
extensions/qa-lab/src/mantis/telegram-desktop-builder.runtime.ts
Normal file
@@ -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<typeof acquireQaCredentialLease<TelegramGatewayCredentialPayload>>
|
||||||
|
>;
|
||||||
|
type TelegramGatewayCredentialHeartbeat = ReturnType<typeof startQaCredentialLeaseHeartbeat>;
|
||||||
|
|
||||||
|
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<T>(name: string, run: () => Promise<T>): Promise<T> {
|
||||||
|
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<string, unknown>;
|
||||||
|
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<TelegramGatewayCredentialPayload>({
|
||||||
|
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<TelegramDesktopRemoteMetadata | undefined> {
|
||||||
|
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<string, unknown>;
|
||||||
|
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" </dev/null >"$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" <<MANTIS_TELEGRAM_PATCH
|
||||||
|
{
|
||||||
|
gateway: {
|
||||||
|
port: 38974,
|
||||||
|
auth: { mode: "none" },
|
||||||
|
},
|
||||||
|
channels: {
|
||||||
|
telegram: {
|
||||||
|
enabled: true,
|
||||||
|
botToken: { source: "env", provider: "default", id: "TELEGRAM_BOT_TOKEN" },
|
||||||
|
dmPolicy: "disabled",
|
||||||
|
groupPolicy: "allowlist",
|
||||||
|
groups: {
|
||||||
|
"$telegram_group_id": {
|
||||||
|
enabled: true,
|
||||||
|
groupPolicy: "open",
|
||||||
|
requireMention: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
MANTIS_TELEGRAM_PATCH
|
||||||
|
pnpm openclaw config patch --file "$out/telegram.patch.json5" --dry-run
|
||||||
|
pnpm openclaw config patch --file "$out/telegram.patch.json5"
|
||||||
|
node --input-type=module >"$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 </dev/null >"$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" <<MANTIS_REMOTE_METADATA
|
||||||
|
{
|
||||||
|
"display": "$DISPLAY",
|
||||||
|
"telegramDesktopBinary": "$telegram_bin",
|
||||||
|
"telegramDesktopPid": "$telegram_pid",
|
||||||
|
"telegramProfileDir": "$telegram_profile_dir",
|
||||||
|
"telegramProfileRestored": $telegram_profile_restored,
|
||||||
|
"gatewaySetup": $setup_gateway,
|
||||||
|
"gatewayAlive": $(if [ "$setup_gateway" = "1" ] && [ -f "$out/openclaw-gateway.pid" ] && kill -0 "$(cat "$out/openclaw-gateway.pid")" >/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<MantisTelegramDesktopBuilderResult> {
|
||||||
|
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)}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
405
scripts/mantis/build-telegram-evidence.mjs
Normal file
405
scripts/mantis/build-telegram-evidence.mjs
Normal file
@@ -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 "<li>No scenarios recorded.</li>";
|
||||||
|
}
|
||||||
|
return scenarios
|
||||||
|
.map((scenario) => {
|
||||||
|
const statusClass = scenario.status === "pass" ? "pass" : "fail";
|
||||||
|
const rtt = typeof scenario.rttMs === "number" ? `, ${Math.round(scenario.rttMs)}ms RTT` : "";
|
||||||
|
return `<li><span class="status ${statusClass}">${escapeHtml(scenario.status ?? "unknown")}</span> <strong>${escapeHtml(scenario.title ?? scenario.id)}</strong><span class="muted"> ${escapeHtml(scenario.id ?? "")}${rtt}</span><p>${escapeHtml(scenario.details ?? "")}</p></li>`;
|
||||||
|
})
|
||||||
|
.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderObservedMessages(observedMessages) {
|
||||||
|
if (!Array.isArray(observedMessages) || observedMessages.length === 0) {
|
||||||
|
return '<p class="empty">No observed Telegram messages were recorded.</p>';
|
||||||
|
}
|
||||||
|
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 [
|
||||||
|
`<article class="message ${sender}">`,
|
||||||
|
` <div class="meta"><span>#${index + 1}</span><span>${escapeHtml(sender)}</span>${scenario ? `<span>${escapeHtml(scenario)}</span>` : ""}</div>`,
|
||||||
|
` <pre>${escapeHtml(text)}</pre>`,
|
||||||
|
buttons.length > 0
|
||||||
|
? ` <div class="buttons">${buttons.map((button) => `<span>${escapeHtml(button)}</span>`).join("")}</div>`
|
||||||
|
: "",
|
||||||
|
"</article>",
|
||||||
|
]
|
||||||
|
.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 `<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Mantis Telegram Live Evidence</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
color-scheme: light;
|
||||||
|
--bg: #f5f7fb;
|
||||||
|
--fg: #1c2430;
|
||||||
|
--muted: #657083;
|
||||||
|
--line: #d9e0ea;
|
||||||
|
--panel: #ffffff;
|
||||||
|
--pass: #0f8b4c;
|
||||||
|
--fail: #b42318;
|
||||||
|
--bot: #e7f0ff;
|
||||||
|
--user: #eef8ef;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--fg);
|
||||||
|
font: 15px/1.45 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
|
}
|
||||||
|
main {
|
||||||
|
width: min(1120px, calc(100vw - 40px));
|
||||||
|
margin: 24px auto 40px;
|
||||||
|
}
|
||||||
|
header, section {
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 18px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
h1, h2 {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
h1 { font-size: 28px; }
|
||||||
|
h2 { font-size: 18px; }
|
||||||
|
.summary {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.pill {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
.status {
|
||||||
|
display: inline-block;
|
||||||
|
min-width: 44px;
|
||||||
|
text-align: center;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 12px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.status.pass { background: var(--pass); }
|
||||||
|
.status.fail { background: var(--fail); }
|
||||||
|
.muted { color: var(--muted); }
|
||||||
|
ul { padding-left: 20px; }
|
||||||
|
li { margin: 10px 0; }
|
||||||
|
li p { margin: 4px 0 0; color: var(--muted); }
|
||||||
|
.messages {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.message {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
.message.bot { background: var(--bot); }
|
||||||
|
.message.user { background: var(--user); }
|
||||||
|
.meta {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
pre {
|
||||||
|
margin: 0;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
font: 14px/1.45 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
}
|
||||||
|
.buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
.buttons span {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 3px 7px;
|
||||||
|
background: rgba(255, 255, 255, 0.75);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.empty { color: var(--muted); }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<header>
|
||||||
|
<h1>Mantis Telegram Live Evidence</h1>
|
||||||
|
<div class="summary">
|
||||||
|
<span class="pill">status: ${pass ? "pass" : "fail"}</span>
|
||||||
|
<span class="pill">total: ${escapeHtml(counts.total ?? 0)}</span>
|
||||||
|
<span class="pill">passed: ${escapeHtml(counts.passed ?? 0)}</span>
|
||||||
|
<span class="pill">failed: ${escapeHtml(counts.failed ?? 0)}</span>
|
||||||
|
<span class="pill">credentials: ${escapeHtml(summary.credentials?.source ?? "unknown")}</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<section>
|
||||||
|
<h2>Scenarios</h2>
|
||||||
|
<ul>
|
||||||
|
${renderScenarioList(summary)}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<h2>Observed Telegram Messages</h2>
|
||||||
|
<div class="messages">
|
||||||
|
${renderObservedMessages(observedMessages)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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/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/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/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/mantis/publish-pr-evidence.mjs", ["test/scripts/mantis-publish-pr-evidence.test.ts"]],
|
||||||
[
|
[
|
||||||
"scripts/run-vitest.mjs",
|
"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-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",
|
||||||
["test/scripts/plugin-prerelease-test-plan.test.ts"],
|
["test/scripts/plugin-prerelease-test-plan.test.ts"],
|
||||||
|
|||||||
136
test/scripts/mantis-build-telegram-evidence.test.ts
Normal file
136
test/scripts/mantis-build-telegram-evidence.test.ts
Normal file
@@ -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: "<redacted>",
|
||||||
|
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: "<status ok>",
|
||||||
|
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: "<hello>",
|
||||||
|
inlineButtons: ["Approve"],
|
||||||
|
mediaKinds: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(html).toContain("Mantis Telegram Live Evidence");
|
||||||
|
expect(html).toContain("<hello>");
|
||||||
|
expect(html).toContain("status: pass");
|
||||||
|
expect(html).not.toContain("<hello>");
|
||||||
|
});
|
||||||
|
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user