diff --git a/.agents/skills/telegram-crabbox-e2e-proof/SKILL.md b/.agents/skills/telegram-crabbox-e2e-proof/SKILL.md index cca044b4381..62a483ab2d7 100644 --- a/.agents/skills/telegram-crabbox-e2e-proof/SKILL.md +++ b/.agents/skills/telegram-crabbox-e2e-proof/SKILL.md @@ -51,6 +51,29 @@ proof needs something else. ## While Testing +For visual proof, first send or identify a bottom marker message, then open the +group/topic directly by message id: + +```bash +pnpm qa:telegram-user:crabbox -- view \ + --session .artifacts/qa-e2e/telegram-user-crabbox/pr-review/session.json \ + --message-id +``` + +This uses Telegram Desktop directly with `tg://privatepost`, not `xdg-open`. +It also resizes Telegram to `650x1000` at the tested desktop position so +Telegram switches to single-chat mode with no left chat list or right info +pane. Do not press Escape after this; Escape can close the selected chat. + +Bottom behavior matters: + +- deep-linking to the newest message keeps Telegram pinned to the bottom, so + later messages appear live in the recording +- deep-linking to an older message does not auto-scroll to new arrivals; link + again to the newest/final marker instead of clicking the down-arrow +- `650px` is the largest tested clean width; `660px` switches Telegram back to + split/sidebar layout + Send as the real Telegram user: ```bash @@ -100,13 +123,17 @@ Always finish or explicitly keep the box: ```bash pnpm qa:telegram-user:crabbox -- finish \ - --session .artifacts/qa-e2e/telegram-user-crabbox/pr-review/session.json + --session .artifacts/qa-e2e/telegram-user-crabbox/pr-review/session.json \ + --preview-crop telegram-window ``` `finish` stops recording, creates motion-trimmed MP4/GIF artifacts, captures a final screenshot and logs, releases the Convex credential, stops the local SUT, -and stops the Crabbox lease. Pass `--keep-box` only when a human needs to -continue VNC debugging after the credential is released. +and stops the Crabbox lease. `--preview-crop telegram-window` also creates a +fixed-geometry GIF from the tested Telegram proof window for clean side-by-side +PR tables; the full desktop video/GIF remains in the artifact directory. Pass +`--keep-box` only when a human needs to continue VNC debugging after the +credential is released. After any failure or interruption, verify cleanup: @@ -129,19 +156,21 @@ pnpm qa:telegram-user:crabbox -- publish \ --summary 'Telegram real-user Crabbox session motion GIF' ``` -This copies only `telegram-user-crabbox-session-motion.gif` into a temporary -publish bundle and comments that GIF. Use `--full-artifacts` only when the PR -needs logs or JSON output. Never publish credential payloads, local env files, -TDLib databases, Telegram Desktop profiles, or raw session archives. +This copies only the useful GIF into a temporary publish bundle and comments +that GIF. If `finish --preview-crop telegram-window` produced a cropped GIF, +publish uses that; otherwise it uses `telegram-user-crabbox-session-motion.gif`. +Use `--full-artifacts` only when the PR needs logs or JSON output. Never publish +credential payloads, local env files, TDLib databases, Telegram Desktop +profiles, or raw session archives. For before/after proof, run one session on `main` and one on the PR head, then publish only the intended GIFs from a clean bundle: ```bash mkdir -p .artifacts/qa-e2e/telegram-user-crabbox/pr-123/comparison -cp /telegram-user-crabbox-session-motion.gif \ +cp /telegram-user-crabbox-session-motion-telegram-window.gif \ .artifacts/qa-e2e/telegram-user-crabbox/pr-123/comparison/main-before.gif -cp /telegram-user-crabbox-session-motion.gif \ +cp /telegram-user-crabbox-session-motion-telegram-window.gif \ .artifacts/qa-e2e/telegram-user-crabbox/pr-123/comparison/pr-after.gif crabbox artifacts publish \ --repo openclaw/openclaw \ diff --git a/scripts/e2e/telegram-user-crabbox-proof.ts b/scripts/e2e/telegram-user-crabbox-proof.ts index 69de5c56566..c34b8a11210 100644 --- a/scripts/e2e/telegram-user-crabbox-proof.ts +++ b/scripts/e2e/telegram-user-crabbox-proof.ts @@ -13,6 +13,8 @@ type CommandResult = { type JsonObject = Record; +type PreviewCrop = "telegram-window"; + type CrabboxInspect = { host?: string; id?: string; @@ -25,7 +27,16 @@ type CrabboxInspect = { type Options = { crabboxClass: string; - command: "finish" | "probe" | "publish" | "run" | "screenshot" | "send" | "start" | "status"; + command: + | "finish" + | "probe" + | "publish" + | "run" + | "screenshot" + | "send" + | "start" + | "status" + | "view"; crabboxBin: string; desktopChatTitle: string; dryRun: boolean; @@ -38,7 +49,10 @@ type Options = { mockResponseText: string; mockPort: number; outputDir: string; + messageId?: string; + previewCrop?: PreviewCrop; previewFps: number; + previewCropWidth: number; previewWidth: number; provider: string; publishFullArtifacts: boolean; @@ -121,6 +135,13 @@ const DEFAULT_USER_DRIVER = `${DEFAULT_SKILL_DIR}/scripts/user-driver.py`; const DEFAULT_OUTPUT_ROOT = ".artifacts/qa-e2e/telegram-user-crabbox"; const REMOTE_ROOT = "/tmp/openclaw-telegram-user-crabbox"; const CREDENTIAL_SCRIPT = fileURLToPath(new URL("./telegram-user-credential.ts", import.meta.url)); +const TELEGRAM_PROOF_VIEW = { + cropWidth: 520, + height: 1000, + width: 650, + x: 635, + y: 40, +}; function usageText() { return [ @@ -129,6 +150,7 @@ function usageText() { " node --import tsx scripts/e2e/telegram-user-crabbox-proof.ts start [--tdlib-url ]", " node --import tsx scripts/e2e/telegram-user-crabbox-proof.ts send --session --text ", " node --import tsx scripts/e2e/telegram-user-crabbox-proof.ts run --session -- ", + " node --import tsx scripts/e2e/telegram-user-crabbox-proof.ts view --session --message-id ", " node --import tsx scripts/e2e/telegram-user-crabbox-proof.ts screenshot --session ", " node --import tsx scripts/e2e/telegram-user-crabbox-proof.ts status --session ", " node --import tsx scripts/e2e/telegram-user-crabbox-proof.ts finish --session ", @@ -141,6 +163,9 @@ function usageText() { " --keep-box Leave the Crabbox lease running for VNC debugging.", " --mock-response-file Text returned by the mock model.", " --output-dir Artifact directory under the repo.", + " --message-id Telegram message id for proof-view deep link.", + " --preview-crop telegram-window Create a side-by-side friendly Telegram-window GIF.", + " --preview-crop-width Cropped preview GIF width. Default: 520.", " --preview-fps Motion GIF frames per second. Default: 24.", " --preview-width Motion GIF width. Default: 1920.", " --pr Pull request number for publish.", @@ -194,6 +219,7 @@ function parseArgs(argv: string[]): Options { "send", "start", "status", + "view", ]); const command = commands.has(argv[0] ?? "") ? (argv.shift() as Options["command"]) : "probe"; const stamp = new Date().toISOString().replace(/[:.]/gu, "-"); @@ -211,6 +237,7 @@ function parseArgs(argv: string[]): Options { mockResponseText: "OPENCLAW_E2E_OK", mockPort: 19_882, outputDir: path.join(DEFAULT_OUTPUT_ROOT, stamp), + previewCropWidth: TELEGRAM_PROOF_VIEW.cropWidth, previewFps: 24, previewWidth: 1920, provider: process.env.OPENCLAW_TELEGRAM_USER_CRABBOX_PROVIDER?.trim() || "aws", @@ -269,8 +296,18 @@ function parseArgs(argv: string[]): Options { opts.mockPort = parsePositiveInteger(readValue(), "--mock-port"); } else if (arg === "--mock-response-file") { opts.mockResponseText = fs.readFileSync(resolveRepoPath(process.cwd(), readValue()), "utf8"); + } else if (arg === "--message-id") { + opts.messageId = String(parsePositiveInteger(readValue(), "--message-id")); } else if (arg === "--output-dir") { opts.outputDir = readValue(); + } else if (arg === "--preview-crop") { + const value = readValue(); + if (value !== "telegram-window") { + throw new Error("--preview-crop must be telegram-window."); + } + opts.previewCrop = value; + } else if (arg === "--preview-crop-width") { + opts.previewCropWidth = parsePositiveInteger(readValue(), "--preview-crop-width"); } else if (arg === "--preview-fps") { opts.previewFps = parsePositiveInteger(readValue(), "--preview-fps"); } else if (arg === "--preview-width") { @@ -318,11 +355,14 @@ function parseArgs(argv: string[]): Options { throw new Error("run requires a remote command after --."); } if ( - ["finish", "publish", "run", "screenshot", "send", "status"].includes(command) && + ["finish", "publish", "run", "screenshot", "send", "status", "view"].includes(command) && !opts.sessionFile ) { throw new Error(`${command} requires --session.`); } + if (command === "view" && !opts.messageId) { + throw new Error("view requires --message-id."); + } if (command === "publish" && !opts.publishPr) { throw new Error("publish requires --pr."); } @@ -865,6 +905,63 @@ async function createMotionPreview(params: { return JSON.parse(preview.stdout) as JsonObject; } +function previewCrop(opts: Options) { + return opts.previewCrop === "telegram-window" + ? { ...TELEGRAM_PROOF_VIEW, cropWidth: opts.previewCropWidth } + : undefined; +} + +async function createCroppedMotionPreview(params: { + crop: typeof TELEGRAM_PROOF_VIEW; + croppedGifPath: string; + croppedVideoPath: string; + opts: Options; + root: string; + videoPath: string; +}) { + const crop = `crop=${params.crop.width}:${params.crop.height}:${params.crop.x}:${params.crop.y}`; + const scale = `scale=${params.crop.cropWidth}:-2:flags=lanczos`; + await runCommand({ + command: "ffmpeg", + args: [ + "-y", + "-hide_banner", + "-loglevel", + "warning", + "-i", + params.videoPath, + "-vf", + `${crop},${scale}`, + "-pix_fmt", + "yuv420p", + params.croppedVideoPath, + ], + cwd: params.root, + stdio: "inherit", + }); + await runCommand({ + command: "ffmpeg", + args: [ + "-y", + "-hide_banner", + "-loglevel", + "warning", + "-i", + params.videoPath, + "-filter_complex", + `${crop},fps=${params.opts.previewFps},${scale},split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse`, + params.croppedGifPath, + ], + cwd: params.root, + stdio: "inherit", + }); + return { + crop, + fps: params.opts.previewFps, + outputWidth: params.crop.cropWidth, + }; +} + async function inspectCrabbox(opts: Options, root: string, leaseId: string) { const result = await runCommand({ command: opts.crabboxBin, @@ -1201,6 +1298,8 @@ function summarizeProbe(probePath: string) { } function writeReport(params: { + croppedMotionGifPath?: string; + croppedMotionVideoPath?: string; motionGifPath?: string; motionVideoPath?: string; outputDir: string; @@ -1224,11 +1323,19 @@ function writeReport(params: { params.motionGifPath ? `Motion GIF: ${path.basename(params.motionGifPath)}` : "Motion GIF: missing", + params.croppedMotionVideoPath + ? `Cropped motion video: ${path.basename(params.croppedMotionVideoPath)}` + : undefined, + params.croppedMotionGifPath + ? `Cropped motion GIF: ${path.basename(params.croppedMotionGifPath)}` + : undefined, params.screenshotPath ? `Screenshot: ${path.basename(params.screenshotPath)}` : "Screenshot: missing", "", - ].join("\n"), + ] + .filter((line) => line !== undefined) + .join("\n"), ); return reportPath; } @@ -1313,7 +1420,7 @@ video="$root/session.mp4" log="$root/ffmpeg.log" pid_file="$root/ffmpeg.pid" rm -f "$video" "$log" "$pid_file" -size="$(xdpyinfo | awk '/dimensions:/ {print $2; exit}')" +size="$(xdpyinfo | awk '/dimensions:/ {size=$2} END {if (!size) exit 1; print size}')" nohup ffmpeg -y -hide_banner -loglevel warning -f x11grab -framerate ${opts.recordFps} -video_size "$size" -i "$DISPLAY" -pix_fmt yuv420p "$video" >"$log" 2>&1 & echo $! >"$pid_file"`; await sshRun(root, inspect, command); @@ -1438,8 +1545,9 @@ async function startSession(root: string, opts: Options, outputDir: string) { webvnc: `${opts.crabboxBin} webvnc --provider ${opts.provider} --target ${opts.target} --id ${leaseId} --open`, commands: { send: `pnpm qa:telegram-user:crabbox -- send --session ${path.relative(root, pathname)} --text '/status'`, + view: `pnpm qa:telegram-user:crabbox -- view --session ${path.relative(root, pathname)} --message-id `, run: `pnpm qa:telegram-user:crabbox -- run --session ${path.relative(root, pathname)} -- bash -lc 'source ${REMOTE_ROOT}/env.sh && python3 ${REMOTE_ROOT}/user-driver.py transcript --limit 20 --json'`, - finish: `pnpm qa:telegram-user:crabbox -- finish --session ${path.relative(root, pathname)}`, + finish: `pnpm qa:telegram-user:crabbox -- finish --session ${path.relative(root, pathname)} --preview-crop telegram-window`, }, }; } catch (error) { @@ -1533,6 +1641,58 @@ async function statusSession(root: string, opts: Options, outputDir: string) { }; } +function telegramPrivatePostLink(groupId: string, messageId: string) { + if (!/^-100\d+$/u.test(groupId)) { + throw new Error(`Telegram privatepost links require a -100 group id, got ${groupId}.`); + } + return `tg://privatepost?channel=${groupId.slice(4)}&post=${messageId}`; +} + +function renderProofViewCommand(link: string) { + return `set -euo pipefail +export DISPLAY="\${DISPLAY:-:99}" +root=${REMOTE_ROOT} +win="$(wmctrl -lxG | awk 'tolower($0) ~ /telegramdesktop/ {print $1; exit}')" +if [ -z "$win" ]; then + echo "Telegram Desktop window not found." >&2 + exit 1 +fi +wmctrl -ir "$win" -b remove,maximized_vert,maximized_horz,fullscreen +wmctrl -ir "$win" -e 0,${TELEGRAM_PROOF_VIEW.x},${TELEGRAM_PROOF_VIEW.y},${TELEGRAM_PROOF_VIEW.width},${TELEGRAM_PROOF_VIEW.height} +telegram="$root/Telegram/Telegram" +test -x "$telegram" +set +e +timeout 5 "$telegram" -workdir "$root/desktop" ${shellQuote(link)} +status="$?" +set -e +if [ "$status" -ne 0 ] && [ "$status" -ne 124 ]; then + exit "$status" +fi +sleep 1 +wmctrl -lxG | awk 'tolower($0) ~ /telegramdesktop/'`; +} + +async function viewSession(root: string, opts: Options, outputDir: string) { + const { session } = readSession(root, opts, outputDir); + const messageId = opts.messageId; + if (!messageId) { + throw new Error("view requires --message-id."); + } + const link = telegramPrivatePostLink(session.credential.groupId, messageId); + const result = await sshRun(root, session.crabbox.inspect, renderProofViewCommand(link)); + const logPath = path.join( + session.outputDir, + `proof-view-${new Date().toISOString().replace(/[:.]/gu, "-")}.log`, + ); + fs.writeFileSync(logPath, `${result.stdout}${result.stderr}`); + return { + geometry: TELEGRAM_PROOF_VIEW, + link, + log: path.relative(root, logPath), + status: "pass", + }; +} + async function finishSession(root: string, opts: Options, outputDir: string) { const { path: pathname, session } = readSession(root, opts, outputDir); const summary: JsonObject = { @@ -1545,10 +1705,19 @@ async function finishSession(root: string, opts: Options, outputDir: string) { const videoPath = path.join(session.outputDir, "telegram-user-crabbox-session.mp4"); const motionVideoPath = path.join(session.outputDir, "telegram-user-crabbox-session-motion.mp4"); const motionGifPath = path.join(session.outputDir, "telegram-user-crabbox-session-motion.gif"); + const croppedMotionVideoPath = path.join( + session.outputDir, + "telegram-user-crabbox-session-motion-telegram-window.mp4", + ); + const croppedMotionGifPath = path.join( + session.outputDir, + "telegram-user-crabbox-session-motion-telegram-window.gif", + ); const screenshotPath = path.join(session.outputDir, "telegram-user-crabbox-session.png"); const desktopLogPath = path.join(session.outputDir, "telegram-desktop.log"); const statusPath = path.join(session.outputDir, "status.json"); const ffmpegLogPath = path.join(session.outputDir, "ffmpeg.log"); + const crop = previewCrop(opts); try { await stopRemoteRecording(root, session.crabbox.inspect, session); await scpFromRemote(root, session.crabbox.inspect, session.recorder.remoteVideo, videoPath); @@ -1574,6 +1743,16 @@ async function finishSession(root: string, opts: Options, outputDir: string) { root, videoPath, }); + if (crop) { + summary.croppedMediaPreview = await createCroppedMotionPreview({ + crop, + croppedGifPath: croppedMotionGifPath, + croppedVideoPath: croppedMotionVideoPath, + opts, + root, + videoPath: motionVideoPath, + }); + } await runCommand({ command: opts.crabboxBin, args: [ @@ -1594,6 +1773,12 @@ async function finishSession(root: string, opts: Options, outputDir: string) { desktopLog: path.relative(root, desktopLogPath), ffmpegLog: path.relative(root, ffmpegLogPath), previewGif: path.relative(root, motionGifPath), + ...(crop + ? { + previewGifCropped: path.relative(root, croppedMotionGifPath), + trimmedVideoCropped: path.relative(root, croppedMotionVideoPath), + } + : {}), screenshot: path.relative(root, screenshotPath), status: path.relative(root, statusPath), trimmedVideo: path.relative(root, motionVideoPath), @@ -1619,6 +1804,8 @@ async function finishSession(root: string, opts: Options, outputDir: string) { const summaryPath = path.join(session.outputDir, "telegram-user-crabbox-session-summary.json"); fs.writeFileSync(summaryPath, `${JSON.stringify(summary, null, 2)}\n`); const reportPath = writeReport({ + croppedMotionGifPath: crop ? croppedMotionGifPath : undefined, + croppedMotionVideoPath: crop ? croppedMotionVideoPath : undefined, motionGifPath, motionVideoPath, outputDir: session.outputDir, @@ -1639,11 +1826,16 @@ async function finishSession(root: string, opts: Options, outputDir: string) { async function publishSessionArtifacts(root: string, opts: Options, outputDir: string) { const { session } = readSession(root, opts, outputDir); const motionGifPath = path.join(session.outputDir, "telegram-user-crabbox-session-motion.gif"); + const croppedMotionGifPath = path.join( + session.outputDir, + "telegram-user-crabbox-session-motion-telegram-window.gif", + ); + const publishGifPath = fs.existsSync(croppedMotionGifPath) ? croppedMotionGifPath : motionGifPath; const publishDir = opts.publishFullArtifacts ? session.outputDir : path.join(session.outputDir, "publish-gif-only"); if (!opts.publishFullArtifacts) { - if (!fs.existsSync(motionGifPath)) { + if (!fs.existsSync(publishGifPath)) { throw new Error( `Missing motion GIF. Run finish first: ${path.relative(root, motionGifPath)}`, ); @@ -1651,7 +1843,7 @@ async function publishSessionArtifacts(root: string, opts: Options, outputDir: s fs.rmSync(publishDir, { force: true, recursive: true }); fs.mkdirSync(publishDir, { recursive: true }); fs.copyFileSync( - motionGifPath, + publishGifPath, path.join(publishDir, "telegram-user-crabbox-session-motion.gif"), ); } @@ -1679,7 +1871,11 @@ async function publishSessionArtifacts(root: string, opts: Options, outputDir: s stdio: "inherit", }); return { - artifactMode: opts.publishFullArtifacts ? "full" : "gif-only", + artifactMode: opts.publishFullArtifacts + ? "full" + : publishGifPath === croppedMotionGifPath + ? "gif-only-cropped" + : "gif-only", publishDir: path.relative(root, publishDir), status: "pass", }; @@ -1712,6 +1908,10 @@ async function main() { console.log(JSON.stringify(await statusSession(root, opts, outputDir), null, 2)); return; } + if (opts.command === "view") { + console.log(JSON.stringify(await viewSession(root, opts, outputDir), null, 2)); + return; + } if (opts.command === "finish") { await finishSession(root, opts, outputDir); return;