From 057d3a43c049602326a475ff197f9f3593cdab75 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 6 May 2026 02:36:57 +0100 Subject: [PATCH] feat(mantis): capture logged-in discord web evidence --- .../mantis-discord-thread-attachment.yml | 120 +++++++++++++++++- docs/concepts/mantis.md | 22 ++++ docs/concepts/qa-e2e-automation.md | 5 + .../discord/discord-live.runtime.test.ts | 10 ++ .../discord/discord-live.runtime.ts | 73 ++++++++++- extensions/qa-lab/src/mantis/cli.ts | 15 +++ .../desktop-browser-smoke.runtime.test.ts | 84 ++++++++++++ .../mantis/desktop-browser-smoke.runtime.ts | 75 ++++++++++- 8 files changed, 394 insertions(+), 10 deletions(-) diff --git a/.github/workflows/mantis-discord-thread-attachment.yml b/.github/workflows/mantis-discord-thread-attachment.yml index 0245023a2e6..f38f9013ba0 100644 --- a/.github/workflows/mantis-discord-thread-attachment.yml +++ b/.github/workflows/mantis-discord-thread-attachment.yml @@ -245,6 +245,24 @@ jobs: - name: Build Mantis harness run: pnpm build + - 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" "$HOME/.local/bin" + git clone --depth 1 https://github.com/openclaw/crabbox.git "$install_dir/src" + 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 2>&1 | grep -q -- "-desktop" + - name: Prepare baseline and candidate worktrees shell: bash env: @@ -307,6 +325,14 @@ jobs: OPENCLAW_QA_CONVEX_SECRET_CI: ${{ secrets.OPENCLAW_QA_CONVEX_SECRET_CI }} OPENCLAW_QA_REDACT_PUBLIC_METADATA: "1" OPENCLAW_QA_DISCORD_CAPTURE_CONTENT: "1" + MANTIS_DISCORD_VIEWER_CHROME_PROFILE_TGZ_B64: ${{ secrets.MANTIS_DISCORD_VIEWER_CHROME_PROFILE_TGZ_B64 }} + MANTIS_DISCORD_VIEWER_CHROME_PROFILE_DIR: ${{ vars.MANTIS_DISCORD_VIEWER_CHROME_PROFILE_DIR }} + 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 }} CANDIDATE_SHA: ${{ needs.validate_candidate.outputs.candidate_revision }} BASELINE_LABEL: ${{ needs.resolve_request.outputs.baseline_ref }} run: | @@ -331,7 +357,14 @@ jobs: local lane="$1" local repo_root="${GITHUB_WORKSPACE}/${worktree_root}/${lane}" local output_dir=".artifacts/qa-e2e/mantis/discord-thread-attachment/${lane}" - pnpm --dir "$repo_root" openclaw qa discord \ + local lane_env=() + if [[ "$lane" == "candidate" ]]; then + lane_env=( + OPENCLAW_QA_DISCORD_CAPTURE_UI_METADATA=1 + OPENCLAW_QA_DISCORD_KEEP_THREADS=1 + ) + fi + env "${lane_env[@]}" pnpm --dir "$repo_root" openclaw qa discord \ --repo-root "$repo_root" \ --output-dir "$output_dir" \ --provider-mode mock-openai \ @@ -347,6 +380,73 @@ jobs: run_lane baseline run_lane candidate + capture_candidate_discord_web() { + if [[ -z "${MANTIS_DISCORD_VIEWER_CHROME_PROFILE_TGZ_B64:-}" && -z "${MANTIS_DISCORD_VIEWER_CHROME_PROFILE_DIR:-}" ]]; then + echo "::notice::No Mantis Discord viewer browser profile is configured; skipping logged-in Discord Web video." + return 0 + 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 + if [[ -z "${CRABBOX_COORDINATOR_TOKEN:-}" ]]; then + echo "::warning::Crabbox coordinator token missing; skipping logged-in Discord Web video." + return 0 + fi + + local ui_json="$root/candidate/discord-thread-reply-filepath-attachment-ui.json" + if [[ ! -f "$ui_json" ]]; then + echo "::warning::Candidate Discord UI metadata is missing; skipping logged-in Discord Web video." + return 0 + fi + local discord_url + discord_url="$(jq -r '.discordWebUrl // empty' "$ui_json")" + if [[ -z "$discord_url" ]]; then + echo "::warning::Candidate Discord UI URL is empty; skipping logged-in Discord Web video." + return 0 + fi + + local desktop_dir="$root/candidate/discord-web" + local profile_args=() + if [[ -n "${MANTIS_DISCORD_VIEWER_CHROME_PROFILE_TGZ_B64:-}" ]]; then + profile_args+=(--browser-profile-archive-env MANTIS_DISCORD_VIEWER_CHROME_PROFILE_TGZ_B64) + fi + if [[ -n "${MANTIS_DISCORD_VIEWER_CHROME_PROFILE_DIR:-}" ]]; then + profile_args+=(--browser-profile-dir "$MANTIS_DISCORD_VIEWER_CHROME_PROFILE_DIR") + fi + pnpm openclaw qa mantis desktop-browser-smoke \ + --browser-url "$discord_url" \ + "${profile_args[@]}" \ + --video-duration 24 \ + --output-dir "$desktop_dir" \ + --provider hetzner \ + --class standard \ + --idle-timeout 30m \ + --ttl 90m + + cp "$desktop_dir/desktop-browser-smoke.png" "$root/candidate/discord-thread-reply-filepath-attachment-discord-web.png" + if [[ -f "$desktop_dir/desktop-browser-smoke.mp4" ]]; then + cp "$desktop_dir/desktop-browser-smoke.mp4" "$root/candidate/discord-thread-reply-filepath-attachment-discord-web.mp4" + fi + + if [[ -f "$root/candidate/discord-thread-reply-filepath-attachment-discord-web.mp4" ]]; then + if ! command -v ffmpeg >/dev/null 2>&1 || ! command -v ffprobe >/dev/null 2>&1; then + sudo apt-get update && sudo apt-get install -y ffmpeg || true + fi + crabbox media preview \ + --input "$root/candidate/discord-thread-reply-filepath-attachment-discord-web.mp4" \ + --output "$root/candidate/discord-thread-reply-filepath-attachment-discord-web-preview.gif" \ + --trimmed-video-output "$root/candidate/discord-thread-reply-filepath-attachment-discord-web-change.mp4" \ + --json > "$root/candidate/discord-thread-reply-filepath-attachment-discord-web-preview.json" || { + rm -f "$root/candidate/discord-thread-reply-filepath-attachment-discord-web-preview.gif" + rm -f "$root/candidate/discord-thread-reply-filepath-attachment-discord-web-change.mp4" + rm -f "$root/candidate/discord-thread-reply-filepath-attachment-discord-web-preview.json" + echo "::warning::Could not generate logged-in Discord Web motion preview; keeping screenshot/full MP4." + } + fi + } + + capture_candidate_discord_web + baseline_status="$(jq -r '.scenarios[] | select(.id == "discord-thread-reply-filepath-attachment") | .status' "$root/baseline/discord-qa-summary.json")" candidate_status="$(jq -r '.scenarios[] | select(.id == "discord-thread-reply-filepath-attachment") | .status' "$root/candidate/discord-qa-summary.json")" comparison_status="fail" @@ -380,6 +480,18 @@ jobs: echo "- Result: \`${comparison_status}\`" echo "- Baseline screenshot: \`baseline/discord-thread-reply-filepath-attachment-attachment.png\`" echo "- Candidate screenshot: \`candidate/discord-thread-reply-filepath-attachment-attachment.png\`" + if [[ -f "$root/candidate/discord-thread-reply-filepath-attachment-discord-web.png" ]]; then + echo "- Candidate logged-in Discord Web screenshot: \`candidate/discord-thread-reply-filepath-attachment-discord-web.png\`" + fi + if [[ -f "$root/candidate/discord-thread-reply-filepath-attachment-discord-web-preview.gif" ]]; then + echo "- Candidate logged-in Discord Web preview: \`candidate/discord-thread-reply-filepath-attachment-discord-web-preview.gif\`" + fi + if [[ -f "$root/candidate/discord-thread-reply-filepath-attachment-discord-web-change.mp4" ]]; then + echo "- Candidate logged-in Discord Web change clip: \`candidate/discord-thread-reply-filepath-attachment-discord-web-change.mp4\`" + fi + if [[ -f "$root/candidate/discord-thread-reply-filepath-attachment-discord-web.mp4" ]]; then + echo "- Candidate logged-in Discord Web video: \`candidate/discord-thread-reply-filepath-attachment-discord-web.mp4\`" + fi } > "$root/mantis-report.md" jq -n \ @@ -402,6 +514,12 @@ jobs: artifacts: [ { kind: "timeline", lane: "baseline", label: "Baseline missing filePath attachment", path: "baseline/discord-thread-reply-filepath-attachment-attachment.png", targetPath: "baseline.png", alt: "Baseline Discord thread reply without filePath attachment", width: 420 }, { kind: "timeline", lane: "candidate", label: "Candidate includes filePath attachment", path: "candidate/discord-thread-reply-filepath-attachment-attachment.png", targetPath: "candidate.png", alt: "Candidate Discord thread reply with filePath attachment", width: 420 }, + { kind: "desktopScreenshot", lane: "candidate", label: "Candidate logged-in Discord Web", path: "candidate/discord-thread-reply-filepath-attachment-discord-web.png", targetPath: "candidate-discord-web.png", alt: "Logged-in Discord Web showing the candidate thread attachment", width: 560, required: false, inline: true }, + { kind: "motionPreview", lane: "candidate", label: "Candidate logged-in Discord Web motion", path: "candidate/discord-thread-reply-filepath-attachment-discord-web-preview.gif", targetPath: "candidate-discord-web-preview.gif", alt: "Animated logged-in Discord Web proof for the candidate thread attachment", width: 560, required: false, inline: true }, + { kind: "motionClip", lane: "candidate", label: "Candidate logged-in Discord Web change MP4", path: "candidate/discord-thread-reply-filepath-attachment-discord-web-change.mp4", targetPath: "candidate-discord-web-change.mp4", required: false }, + { kind: "fullVideo", lane: "candidate", label: "Candidate logged-in Discord Web MP4", path: "candidate/discord-thread-reply-filepath-attachment-discord-web.mp4", targetPath: "candidate-discord-web.mp4", required: false }, + { kind: "metadata", lane: "candidate", label: "Candidate logged-in Discord Web preview metadata", path: "candidate/discord-thread-reply-filepath-attachment-discord-web-preview.json", targetPath: "candidate-discord-web-preview.json", required: false }, + { kind: "metadata", lane: "candidate", label: "Candidate Discord UI metadata", path: "candidate/discord-thread-reply-filepath-attachment-ui.json", targetPath: "candidate-discord-ui.json", required: false }, { kind: "metadata", lane: "run", label: "Comparison JSON", path: "comparison.json", targetPath: "comparison.json" }, { kind: "report", lane: "run", label: "Mantis report", path: "mantis-report.md", targetPath: "mantis-report.md" } ] diff --git a/docs/concepts/mantis.md b/docs/concepts/mantis.md index a247c834b36..0f8682f084f 100644 --- a/docs/concepts/mantis.md +++ b/docs/concepts/mantis.md @@ -125,9 +125,31 @@ Useful desktop smoke flags: - `--lease-id ` or `OPENCLAW_MANTIS_CRABBOX_LEASE_ID` reuses a warmed desktop. - `--browser-url ` changes the page opened in the visible browser. - `--html-file ` renders a repo-local HTML artifact in the visible browser. Mantis uses this to capture the generated Discord status-reaction timeline through a real Crabbox desktop. +- `--browser-profile-dir ` reuses a remote Chrome user-data-dir so a persistent Mantis desktop can stay logged in between runs. Use this for the long-lived Discord Web viewer profile. +- `--browser-profile-archive-env ` restores a base64 `.tgz` Chrome user-data-dir archive from the named environment variable before launching the browser. Use this for logged-in witnesses such as Discord Web. The default env var is `OPENCLAW_MANTIS_BROWSER_PROFILE_TGZ_B64`. +- `--video-duration ` controls the MP4 capture length. Use a longer duration for slow logged-in web apps that need time to settle. - `--keep-lease` or `OPENCLAW_MANTIS_KEEP_VM=1` keeps a newly created passing lease open for VNC inspection. Failed runs keep the lease by default when one was created so an operator can reconnect. - `--class`, `--idle-timeout`, and `--ttl` tune machine size and lease lifetime. +For Discord Web evidence, Mantis uses a dedicated viewer account instead of a +bot token. The live Discord API scenario remains the oracle: it creates the real +thread, sends the SUT `thread-reply`, and checks the attachment through Discord +REST. When `OPENCLAW_QA_DISCORD_CAPTURE_UI_METADATA=1` is set, the scenario also +writes a Discord Web URL artifact. When `OPENCLAW_QA_DISCORD_KEEP_THREADS=1` is +set, it leaves that thread available long enough for a logged-in browser to open +and record it. + +The GitHub workflow opens the candidate thread URL in Discord Web, captures a +screenshot, records an MP4, and generates a trimmed GIF preview when Crabbox +media tooling is available. Prefer a persistent viewer profile path configured +through `MANTIS_DISCORD_VIEWER_CHROME_PROFILE_DIR`, because full Chrome profile +archives can outgrow GitHub's secret-size limit. For small/bootstrap profiles, +the workflow can also restore a base64 `.tgz` archive from +`MANTIS_DISCORD_VIEWER_CHROME_PROFILE_TGZ_B64`. If neither profile source is +configured, the workflow still publishes the deterministic baseline/candidate +attachment screenshots and logs a notice that the logged-in Discord Web witness +was skipped. + The first full desktop transport primitive is the Slack desktop smoke: ```bash diff --git a/docs/concepts/qa-e2e-automation.md b/docs/concepts/qa-e2e-automation.md index af2e3cf6f82..82c90328321 100644 --- a/docs/concepts/qa-e2e-automation.md +++ b/docs/concepts/qa-e2e-automation.md @@ -119,6 +119,11 @@ timeline, or `--scenario discord-thread-reply-filepath-attachment` to create a real Discord thread and verify that `message.thread-reply` preserves a `filePath` attachment. These scenarios stay out of the default live Discord lane because they are before/after repro probes rather than broad smoke coverage. +The thread-attachment Mantis workflow can also add a logged-in Discord Web +witness video when `MANTIS_DISCORD_VIEWER_CHROME_PROFILE_DIR` or +`MANTIS_DISCORD_VIEWER_CHROME_PROFILE_TGZ_B64` is configured in the QA +environment. That viewer profile is only for visual capture; the pass/fail +decision still comes from the Discord REST oracle. CI uses the same command surface in `.github/workflows/qa-live-transports-convex.yml`. Scheduled and default manual runs execute the fast Matrix profile with live frontier credentials, `--fast`, and `OPENCLAW_QA_MATRIX_NO_REPLY_WINDOW_MS=3000`. Manual `matrix_profile=all` fans out into the five profile shards so the exhaustive catalog can run in parallel while keeping one artifact directory per shard. diff --git a/extensions/qa-lab/src/live-transports/discord/discord-live.runtime.test.ts b/extensions/qa-lab/src/live-transports/discord/discord-live.runtime.test.ts index 6da7d90ddc9..f68fd9945a8 100644 --- a/extensions/qa-lab/src/live-transports/discord/discord-live.runtime.test.ts +++ b/extensions/qa-lab/src/live-transports/discord/discord-live.runtime.test.ts @@ -343,6 +343,16 @@ describe("discord live qa runtime", () => { expect(html).toContain("mantis-thread-report.md"); }); + it("builds Discord Web message URLs for logged-in Mantis capture", () => { + expect( + __testing.buildDiscordWebMessageUrl({ + guildId: "111111111111111111", + messageId: "333333333333333333", + threadId: "222222222222222222", + }), + ).toBe("https://discord.com/channels/111111111111111111/222222222222222222/333333333333333333"); + }); + it("waits for the Discord account to become connected, not just running", async () => { vi.useFakeTimers(); try { diff --git a/extensions/qa-lab/src/live-transports/discord/discord-live.runtime.ts b/extensions/qa-lab/src/live-transports/discord/discord-live.runtime.ts index 4f5b216c7f0..727c0f7e6a3 100644 --- a/extensions/qa-lab/src/live-transports/discord/discord-live.runtime.ts +++ b/extensions/qa-lab/src/live-transports/discord/discord-live.runtime.ts @@ -215,10 +215,14 @@ type DiscordStatusReactionTimeline = { type DiscordThreadReplyAttachmentEvidence = { attachmentFilenames: string[]; + channelId?: string; + discordWebUrl?: string; expectedAttachmentFilename: string; + guildId?: string; htmlPath?: string; messageContent?: string; messageId?: string; + parentMessageId?: string; scenarioId: DiscordQaScenarioId; scenarioTitle: string; screenshotPath?: string; @@ -229,6 +233,8 @@ type DiscordThreadReplyAttachmentEvidence = { }; const DISCORD_QA_CAPTURE_CONTENT_ENV = "OPENCLAW_QA_DISCORD_CAPTURE_CONTENT"; +const DISCORD_QA_CAPTURE_UI_METADATA_ENV = "OPENCLAW_QA_DISCORD_CAPTURE_UI_METADATA"; +const DISCORD_QA_KEEP_THREADS_ENV = "OPENCLAW_QA_DISCORD_KEEP_THREADS"; const QA_REDACT_PUBLIC_METADATA_ENV = "OPENCLAW_QA_REDACT_PUBLIC_METADATA"; const DISCORD_QA_ENV_KEYS = [ "OPENCLAW_QA_DISCORD_GUILD_ID", @@ -772,6 +778,9 @@ async function writeDiscordThreadReplyAttachmentEvidence(params: { outputDir: string; }) { const htmlPath = path.join(params.outputDir, `${params.evidence.scenarioId}-attachment.html`); + const uiPath = params.evidence.discordWebUrl + ? path.join(params.outputDir, `${params.evidence.scenarioId}-ui.json`) + : undefined; const screenshotPath = path.join( params.outputDir, `${params.evidence.scenarioId}-attachment.png`, @@ -785,8 +794,33 @@ async function writeDiscordThreadReplyAttachmentEvidence(params: { threadName: params.evidence.threadName, }); await fs.writeFile(htmlPath, html, { encoding: "utf8", mode: 0o600 }); + if (uiPath) { + await fs.writeFile( + uiPath, + `${JSON.stringify( + { + attachmentFilenames: params.evidence.attachmentFilenames, + channelId: params.evidence.channelId, + discordWebUrl: params.evidence.discordWebUrl, + expectedAttachmentFilename: params.evidence.expectedAttachmentFilename, + guildId: params.evidence.guildId, + messageContent: params.evidence.messageContent, + messageId: params.evidence.messageId, + parentMessageId: params.evidence.parentMessageId, + scenarioId: params.evidence.scenarioId, + scenarioTitle: params.evidence.scenarioTitle, + status: params.evidence.status, + threadId: params.evidence.threadId, + threadName: params.evidence.threadName, + }, + null, + 2, + )}\n`, + { encoding: "utf8", mode: 0o600 }, + ); + } const screenshot = await writeHtmlScreenshot({ htmlPath, screenshotPath }); - return { htmlPath, ...screenshot }; + return { htmlPath, ...(uiPath ? { uiPath } : {}), ...screenshot }; } async function observeStatusReactionTimeline(params: { @@ -847,6 +881,16 @@ function compareDiscordSnowflakes(a: string, b: string) { return left < right ? -1 : left > right ? 1 : 0; } +function buildDiscordWebMessageUrl(params: { + guildId: string; + messageId?: string; + threadId: string; +}) { + return `https://discord.com/channels/${params.guildId}/${params.threadId}${ + params.messageId ? `/${params.messageId}` : "" + }`; +} + function normalizeDiscordObservedMessage(message: DiscordMessage): DiscordObservedMessage | null { if (!message.author?.id) { return null; @@ -944,6 +988,8 @@ async function runDiscordThreadReplyFilePathAttachmentScenario(params: { sutAccountId: string; sutBotId: string; }) { + const captureUiMetadata = isTruthyOptIn(process.env[DISCORD_QA_CAPTURE_UI_METADATA_ENV]); + const keepThread = isTruthyOptIn(process.env[DISCORD_QA_KEEP_THREADS_ENV]); const threadName = `mantis-thread-filepath-${randomUUID().slice(0, 8)}`; const parent = await sendChannelMessage( params.runtimeEnv.driverBotToken, @@ -1003,8 +1049,21 @@ async function runDiscordThreadReplyFilePathAttachmentScenario(params: { const status = attachmentFilenames.includes(params.scenarioRun.expectedAttachmentFilename) ? "pass" : "fail"; + const discordWebUrl = buildDiscordWebMessageUrl({ + guildId: params.runtimeEnv.guildId, + messageId: reply?.id, + threadId: thread.id, + }); const evidence: DiscordThreadReplyAttachmentEvidence = { attachmentFilenames, + ...(captureUiMetadata + ? { + channelId: params.runtimeEnv.channelId, + discordWebUrl, + guildId: params.runtimeEnv.guildId, + parentMessageId: parent.id, + } + : {}), expectedAttachmentFilename: params.scenarioRun.expectedAttachmentFilename, messageContent: reply?.content, messageId: reply?.id, @@ -1032,13 +1091,16 @@ async function runDiscordThreadReplyFilePathAttachmentScenario(params: { attachmentSource: attachmentPath, html: artifactEvidence.htmlPath, ...(artifactEvidence.screenshotPath ? { screenshot: artifactEvidence.screenshotPath } : {}), + ...(artifactEvidence.uiPath ? { ui: artifactEvidence.uiPath } : {}), }, } satisfies DiscordQaScenarioResult; } finally { - await archiveDiscordThread({ - token: params.runtimeEnv.driverBotToken, - threadId: thread.id, - }).catch(() => {}); + if (!keepThread) { + await archiveDiscordThread({ + token: params.runtimeEnv.driverBotToken, + threadId: thread.id, + }).catch(() => {}); + } } } @@ -1630,6 +1692,7 @@ export const __testing = { assertDiscordScenarioReply, assertDiscordApplicationCommandsRegistered, buildDiscordQaConfig, + buildDiscordWebMessageUrl, buildObservedMessagesArtifact, findScenario, getCurrentDiscordUser, diff --git a/extensions/qa-lab/src/mantis/cli.ts b/extensions/qa-lab/src/mantis/cli.ts index 27bf7528f1e..e5e7b801eac 100644 --- a/extensions/qa-lab/src/mantis/cli.ts +++ b/extensions/qa-lab/src/mantis/cli.ts @@ -77,6 +77,8 @@ type MantisBeforeAfterCommanderOptions = { }; type MantisDesktopBrowserSmokeCommanderOptions = { + browserProfileArchiveEnv?: string; + browserProfileDir?: string; browserUrl?: string; class?: string; crabboxBin?: string; @@ -89,6 +91,7 @@ type MantisDesktopBrowserSmokeCommanderOptions = { provider?: string; repoRoot?: string; ttl?: string; + videoDuration?: string; }; type MantisSlackDesktopSmokeCommanderOptions = { @@ -237,6 +240,14 @@ export function registerMantisCli(qa: Command) { .option("--repo-root ", "Repository root to target when running from a neutral cwd") .option("--output-dir ", "Mantis desktop browser artifact directory") .option("--browser-url ", "URL to open in the visible browser") + .option( + "--browser-profile-archive-env ", + "Env var containing a base64 .tgz Chrome profile archive to restore before launch", + ) + .option( + "--browser-profile-dir ", + "Remote Chrome user-data-dir path to reuse for browser login state", + ) .option("--html-file ", "Repo-local HTML file to render in the visible browser") .option("--crabbox-bin ", "Crabbox binary path") .option("--provider ", "Crabbox provider") @@ -245,9 +256,12 @@ export function registerMantisCli(qa: Command) { .option("--lease-id ", "Reuse an existing Crabbox lease") .option("--idle-timeout ", "Crabbox idle timeout") .option("--ttl ", "Crabbox maximum lease lifetime") + .option("--video-duration ", "Visible desktop recording duration in seconds") .option("--keep-lease", "Keep a lease created by this run after a passing smoke") .action(async (opts: MantisDesktopBrowserSmokeCommanderOptions) => { await runDesktopBrowserSmoke({ + browserProfileArchiveEnv: opts.browserProfileArchiveEnv, + browserProfileDir: opts.browserProfileDir, browserUrl: opts.browserUrl, crabboxBin: opts.crabboxBin, htmlFile: opts.htmlFile, @@ -259,6 +273,7 @@ export function registerMantisCli(qa: Command) { provider: opts.provider, repoRoot: opts.repoRoot, ttl: opts.ttl, + videoDurationSeconds: parseOptionalInteger(opts.videoDuration, "--video-duration"), }); }); diff --git a/extensions/qa-lab/src/mantis/desktop-browser-smoke.runtime.test.ts b/extensions/qa-lab/src/mantis/desktop-browser-smoke.runtime.test.ts index 3f9b5b252e4..2256ba383f4 100644 --- a/extensions/qa-lab/src/mantis/desktop-browser-smoke.runtime.test.ts +++ b/extensions/qa-lab/src/mantis/desktop-browser-smoke.runtime.test.ts @@ -94,6 +94,10 @@ describe("mantis desktop browser smoke runtime", () => { expect(remoteScript).toContain("${BROWSER:-}"); expect(remoteScript).toContain("${CHROME_BIN:-}"); expect(remoteScript).toContain("chromium-browser"); + expect(remoteScript).toContain("${OPENCLAW_MANTIS_BROWSER_PROFILE_TGZ_B64:-}"); + expect(remoteScript).toContain('"browserProfileRestored": $profile_restored'); + expect(remoteScript).toContain('"temporaryBrowserProfile": $temporary_profile'); + expect(remoteScript).toContain("-t 10"); expect(remoteScript).toContain("base64 -d"); expect(remoteScript).toContain("ffmpeg"); expect(remoteScript).toContain('sudo apt-get update -y >>"$out/apt.log" 2>&1 || true'); @@ -135,6 +139,86 @@ describe("mantis desktop browser smoke runtime", () => { expect(runner).not.toHaveBeenCalled(); }); + it("restores a named browser profile archive env and honors the video duration", async () => { + const commands: { args: readonly string[]; command: string }[] = []; + const runner = vi.fn(async (command: string, args: readonly string[]) => { + commands.push({ command, args }); + if (command === "/tmp/crabbox" && args[0] === "inspect") { + return { + stdout: `${JSON.stringify({ + host: "203.0.113.10", + id: "cbx_existing", + provider: "hetzner", + sshKey: "/tmp/key", + sshUser: "crabbox", + })}\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, "desktop-browser-smoke.png"), "png"); + await fs.writeFile(path.join(outputDir as string, "desktop-browser-smoke.mp4"), "mp4"); + } + return { stdout: "", stderr: "" }; + }); + + await expect( + runMantisDesktopBrowserSmoke({ + browserProfileArchiveEnv: "MANTIS_DISCORD_VIEWER_CHROME_PROFILE_TGZ_B64", + browserProfileDir: "$HOME/.config/openclaw-mantis/discord-viewer-chrome-profile", + commandRunner: runner, + crabboxBin: "/tmp/crabbox", + leaseId: "cbx_existing", + outputDir: ".artifacts/qa-e2e/mantis/desktop-browser-profile", + repoRoot, + videoDurationSeconds: 24, + }), + ).resolves.toMatchObject({ status: "pass" }); + + const remoteScript = commands + .find((entry) => entry.command === "/tmp/crabbox" && entry.args[0] === "run") + ?.args.at(-1); + expect(remoteScript).toContain("${MANTIS_DISCORD_VIEWER_CHROME_PROFILE_TGZ_B64:-}"); + expect(remoteScript).toContain( + "profile='$HOME/.config/openclaw-mantis/discord-viewer-chrome-profile'", + ); + expect(remoteScript).toContain("temporary_profile=false"); + expect(remoteScript).toContain('tar -xzf "$profile_archive" -C "$profile"'); + expect(remoteScript).toContain("-t 24"); + }); + + it("rejects unsafe browser profile archive env names", async () => { + const runner = vi.fn(async () => ({ stdout: "", stderr: "" })); + + await expect( + runMantisDesktopBrowserSmoke({ + browserProfileArchiveEnv: "BAD-NAME", + commandRunner: runner, + crabboxBin: "/tmp/crabbox", + outputDir: ".artifacts/qa-e2e/mantis/desktop-browser-profile", + repoRoot, + }), + ).rejects.toThrow("Mantis browser profile archive env must be an environment variable name"); + expect(runner).not.toHaveBeenCalled(); + }); + + it("rejects relative browser profile dirs", async () => { + const runner = vi.fn(async () => ({ stdout: "", stderr: "" })); + + await expect( + runMantisDesktopBrowserSmoke({ + browserProfileDir: "relative-profile", + commandRunner: runner, + crabboxBin: "/tmp/crabbox", + outputDir: ".artifacts/qa-e2e/mantis/desktop-browser-profile", + repoRoot, + }), + ).rejects.toThrow("Mantis browser profile dir must be an absolute path"); + expect(runner).not.toHaveBeenCalled(); + }); + it("accepts Blacksmith Testbox lease ids from Crabbox warmup", async () => { const commands: { args: readonly string[]; command: string }[] = []; const runner = vi.fn(async (command: string, args: readonly string[]) => { diff --git a/extensions/qa-lab/src/mantis/desktop-browser-smoke.runtime.ts b/extensions/qa-lab/src/mantis/desktop-browser-smoke.runtime.ts index 4b2054a396b..4a67982146d 100644 --- a/extensions/qa-lab/src/mantis/desktop-browser-smoke.runtime.ts +++ b/extensions/qa-lab/src/mantis/desktop-browser-smoke.runtime.ts @@ -7,6 +7,8 @@ import { pathExists } from "openclaw/plugin-sdk/security-runtime"; import { ensureRepoBoundDirectory, resolveRepoRelativeOutputDir } from "../cli-paths.js"; export type MantisDesktopBrowserSmokeOptions = { + browserProfileArchiveEnv?: string; + browserProfileDir?: string; browserUrl?: string; commandRunner?: CommandRunner; crabboxBin?: string; @@ -21,6 +23,7 @@ export type MantisDesktopBrowserSmokeOptions = { provider?: string; repoRoot?: string; ttl?: string; + videoDurationSeconds?: number; }; export type MantisDesktopBrowserSmokeResult = { @@ -93,6 +96,9 @@ 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 BROWSER_PROFILE_ARCHIVE_ENV = "OPENCLAW_MANTIS_BROWSER_PROFILE_TGZ_B64"; +const BROWSER_PROFILE_DIR_ENV = "OPENCLAW_MANTIS_BROWSER_PROFILE_DIR"; +const DEFAULT_VIDEO_DURATION_SECONDS = 10; function trimToValue(value: string | undefined) { const trimmed = value?.trim(); @@ -171,6 +177,21 @@ function shellQuote(value: string) { return `'${value.replaceAll("'", "'\\''")}'`; } +function assertSafeEnvName(value: string, label: string) { + if (!/^[A-Za-z_][A-Za-z0-9_]*$/u.test(value)) { + throw new Error(`${label} must be an environment variable name.`); + } +} + +function assertSafeRemoteProfileDir(value: string, label: string) { + if (!value.startsWith("/") && !value.startsWith("$HOME/") && !value.startsWith("~/")) { + throw new Error(`${label} must be an absolute path, ~/ path, or $HOME path.`); + } + if (value.includes("\n") || value.includes("\r") || value.includes("\0")) { + throw new Error(`${label} must not contain control characters.`); + } +} + function resolveRepoBoundFile(repoRoot: string, filePath: string, label: string) { const resolved = path.resolve(repoRoot, filePath); const relative = path.relative(repoRoot, resolved); @@ -182,13 +203,22 @@ function resolveRepoBoundFile(repoRoot: string, filePath: string, label: string) function renderRemoteScript(params: { browserUrl: string; + browserProfileArchiveEnv: string; + browserProfileDir?: string; htmlBase64?: string; remoteOutputDir: string; + videoDurationSeconds: number; }) { const shellUrl = shellQuote(params.browserUrl); const shellUrlJson = shellQuote(JSON.stringify(params.browserUrl)); const htmlBase64 = shellQuote(params.htmlBase64 ?? ""); const shellOutputDir = shellQuote(params.remoteOutputDir); + const videoDurationSeconds = Math.max(1, Math.floor(params.videoDurationSeconds)); + const profileArchiveEnv = params.browserProfileArchiveEnv; + const profileDir = shellQuote( + params.browserProfileDir ?? `${params.remoteOutputDir}/chrome-profile`, + ); + const temporaryProfile = params.browserProfileDir ? "false" : "true"; const inputModeJson = shellQuote(JSON.stringify(params.htmlBase64 ? "html-file" : "url")); const openedUrlJson = shellQuote( JSON.stringify( @@ -213,8 +243,18 @@ if ! command -v scrot >/dev/null 2>&1; then sudo apt-get update -y >"$out/apt.log" 2>&1 sudo DEBIAN_FRONTEND=noninteractive apt-get install -y scrot >>"$out/apt.log" 2>&1 fi -profile="$out/chrome-profile" +profile=${profileDir} +temporary_profile=${temporaryProfile} mkdir -p "$profile" +profile_restored=false +profile_archive_b64="\${${profileArchiveEnv}:-}" +if [ -n "$profile_archive_b64" ]; then + profile_archive="$profile/openclaw-mantis-browser-profile.tgz" + printf '%s' "$profile_archive_b64" | base64 -d >"$profile_archive" + tar -xzf "$profile_archive" -C "$profile" + rm -f "$profile_archive" + profile_restored=true +fi browser_bin="" for candidate in "\${BROWSER:-}" "\${CHROME_BIN:-}" google-chrome chromium chromium-browser; do if [ -n "$candidate" ] && command -v "$candidate" >/dev/null 2>&1; then @@ -239,7 +279,7 @@ if command -v ffmpeg >/dev/null 2>&1; then *.*) ;; *) display_input="$display_input.0" ;; esac - ffmpeg -hide_banner -loglevel error -y -f x11grab -framerate 15 -i "$display_input" -t 10 -pix_fmt yuv420p "$out/desktop-browser-smoke.mp4" >"$out/ffmpeg.log" 2>&1 & + ffmpeg -hide_banner -loglevel error -y -f x11grab -framerate 15 -i "$display_input" -t ${videoDurationSeconds} -pix_fmt yuv420p "$out/desktop-browser-smoke.mp4" >"$out/ffmpeg.log" 2>&1 & video_pid=$! else echo "ffmpeg missing; video artifact skipped" >"$out/ffmpeg.log" @@ -266,13 +306,19 @@ fi cleanup trap - EXIT sleep 1 -rm -rf "$profile" || true +if [ "$temporary_profile" = "true" ]; then + rm -rf "$profile" || true +fi cat >"$out/remote-metadata.json" <