feat(mantis): capture logged-in discord web evidence

This commit is contained in:
Peter Steinberger
2026-05-06 02:36:57 +01:00
parent 20163313af
commit 057d3a43c0
8 changed files with 394 additions and 10 deletions

View File

@@ -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" }
]

View File

@@ -125,9 +125,31 @@ Useful desktop smoke flags:
- `--lease-id <cbx_...>` or `OPENCLAW_MANTIS_CRABBOX_LEASE_ID` reuses a warmed desktop.
- `--browser-url <url>` changes the page opened in the visible browser.
- `--html-file <path>` 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 <remote-path>` 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 <name>` 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 <seconds>` 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

View File

@@ -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.

View File

@@ -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 {

View File

@@ -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,

View File

@@ -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 <path>", "Repository root to target when running from a neutral cwd")
.option("--output-dir <path>", "Mantis desktop browser artifact directory")
.option("--browser-url <url>", "URL to open in the visible browser")
.option(
"--browser-profile-archive-env <name>",
"Env var containing a base64 .tgz Chrome profile archive to restore before launch",
)
.option(
"--browser-profile-dir <remote-path>",
"Remote Chrome user-data-dir path to reuse for browser login state",
)
.option("--html-file <path>", "Repo-local HTML file to render in the visible browser")
.option("--crabbox-bin <path>", "Crabbox binary path")
.option("--provider <provider>", "Crabbox provider")
@@ -245,9 +256,12 @@ export function registerMantisCli(qa: Command) {
.option("--lease-id <id>", "Reuse an existing Crabbox lease")
.option("--idle-timeout <duration>", "Crabbox idle timeout")
.option("--ttl <duration>", "Crabbox maximum lease lifetime")
.option("--video-duration <seconds>", "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"),
});
});

View File

@@ -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[]) => {

View File

@@ -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" <<MANTIS_REMOTE_METADATA
{
"browserUrl": $url_json,
"browserBinary": "$browser_bin",
"display": "$DISPLAY",
"chromePid": $chrome_pid,
"browserProfileArchiveEnv": "${profileArchiveEnv}",
"browserProfileDir": "$profile",
"browserProfileRestored": $profile_restored,
"temporaryBrowserProfile": $temporary_profile,
"inputMode": $input_mode_json,
"openedUrl": $opened_url_json,
"capturedAt": "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
@@ -483,6 +529,20 @@ export async function runMantisDesktopBrowserSmoke(
const browserUrl = htmlFile
? pathToFileURL(htmlFile).toString()
: (trimToValue(opts.browserUrl) ?? DEFAULT_BROWSER_URL);
const browserProfileArchiveEnv =
trimToValue(opts.browserProfileArchiveEnv) ??
trimToValue(env.OPENCLAW_MANTIS_BROWSER_PROFILE_ARCHIVE_ENV) ??
BROWSER_PROFILE_ARCHIVE_ENV;
assertSafeEnvName(browserProfileArchiveEnv, "Mantis browser profile archive env");
const browserProfileDir =
trimToValue(opts.browserProfileDir) ?? trimToValue(env[BROWSER_PROFILE_DIR_ENV]);
if (browserProfileDir) {
assertSafeRemoteProfileDir(browserProfileDir, "Mantis browser profile dir");
}
const videoDurationSeconds = Math.max(
1,
Math.floor(opts.videoDurationSeconds ?? DEFAULT_VIDEO_DURATION_SECONDS),
);
const runner = opts.commandRunner ?? defaultCommandRunner;
const explicitLeaseId = trimToValue(opts.leaseId) ?? trimToValue(env[CRABBOX_LEASE_ID_ENV]);
const keepLease = opts.keepLease ?? isTruthyOptIn(env[CRABBOX_KEEP_ENV]);
@@ -527,7 +587,14 @@ export async function runMantisDesktopBrowserSmoke(
"--no-sync",
"--shell",
"--",
renderRemoteScript({ browserUrl, htmlBase64, remoteOutputDir }),
renderRemoteScript({
browserProfileArchiveEnv,
browserProfileDir,
browserUrl,
htmlBase64,
remoteOutputDir,
videoDurationSeconds,
}),
],
cwd: repoRoot,
env,