mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 04:40:43 +00:00
feat(mantis): capture logged-in discord web evidence
This commit is contained in:
@@ -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" }
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"),
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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[]) => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user