feat(desktop): reusable screenshot workflow for agents (#826)

Signed-off-by: Will Pfleger <wpfleger@block.xyz>
This commit is contained in:
Will Pfleger
2026-06-03 19:33:56 +00:00
committed by GitHub
parent 9f0c22a43a
commit 06bc67fd34
4 changed files with 213 additions and 31 deletions
+37 -15
View File
@@ -224,28 +224,50 @@ See [TESTING.md](TESTING.md) for the full multi-agent E2E guide.
### Desktop Screenshots (Playwright)
The desktop app is a Tauri app that cannot render in a plain browser without the
E2E mock bridge. A standalone screenshot helper at
`desktop/tests/helpers/screenshot.mjs` wraps the same mock bridge setup the E2E
tests use (`addInitScript` + `window.__SPROUT_E2E__`) into a CLI tool.
The desktop app requires the E2E mock bridge to render — it cannot run in a plain
browser. Use `just desktop-screenshot` to capture screenshots (builds frontend,
starts preview server, runs Playwright automatically):
```bash
just desktop-build # build the frontend first
cd desktop
node tests/helpers/screenshot.mjs --name home
node tests/helpers/screenshot.mjs --name channel --route /channels/general
node tests/helpers/screenshot.mjs --name search --click open-search
node tests/helpers/screenshot.mjs --name settings --click open-settings
just desktop-screenshot --name home
just desktop-screenshot --name channel --route /channels/general
just desktop-screenshot --name search --click open-search
just desktop-screenshot --name settings --click open-settings
```
Options: `--name` (filename), `--route` (client route), `--click` (data-testid
or CSS selector), `--wait` (ms, default 2000), `--viewport` (WxH, default
1280x720), `--outdir` (default `test-results/screenshots`). Screenshots are
saved as PNGs and the path is printed to stdout.
1280x720), `--outdir` (default `test-results/screenshots`),
`--messages` (JSON file path). Output is a PNG path on stdout.
The Playwright MCP browser (`@playwright/mcp`) is also configured but cannot
drive the desktop app directly because it evaluates JS after page load — too
late for the mock bridge. Use the MCP browser for non-Tauri pages only.
Use `--messages` to inject content into a channel before capture. The JSON file
is an array of `{ channelName, content, pubkey?, kind? }` objects — all must
target the same channel (`[a-z0-9-]+`). When provided, `--route` is ignored.
```bash
just desktop-screenshot --name code-blocks --messages /tmp/msgs.json
```
```json
[
{ "channelName": "general", "content": "```typescript\nconst x = 42;\n```" },
{ "channelName": "general", "content": "plain text message" }
]
```
Available mock channels: `general`, `random`, `design`, `sales`, `engineering`,
`agents`, `watercooler`, `announcements`, `alice-tyler`, `bob-tyler`.
`scripts/post-screenshots.sh` hosts PNGs on a per-developer orphan branch
(`agent-screenshots/<github-username>`) and posts a PR comment:
```bash
./scripts/post-screenshots.sh 803 test-results/screenshots
./scripts/post-screenshots.sh 803 test-results/screenshots body.md # custom body prepended
```
Re-runs for the same PR overwrite previous images. Cleanup:
`git push origin --delete agent-screenshots/<username>`.
---
+99 -16
View File
@@ -16,9 +16,10 @@
// --wait <ms> Milliseconds to wait before capture (default: 2000)
// --viewport <WxH> Viewport dimensions (default: 1280x720)
// --outdir <path> Output directory (default: test-results/screenshots)
// --messages <path> JSON file with messages to inject before capture
import { parseArgs } from "node:util";
import { existsSync, mkdirSync } from "node:fs";
import { existsSync, mkdirSync, readFileSync } from "node:fs";
import { resolve, join } from "node:path";
import { chromium } from "@playwright/test";
@@ -30,6 +31,7 @@ const { values: args } = parseArgs({
wait: { type: "string", default: "2000" },
viewport: { type: "string", default: "1280x720" },
outdir: { type: "string", default: "test-results/screenshots" },
messages: { type: "string" },
},
strict: true,
});
@@ -110,20 +112,101 @@ await page.addInitScript(() => {
window.__SPROUT_E2E_APP_BADGE_COUNT__ = 0;
});
const url = args.route === "/" ? BASE_URL : `${BASE_URL}/#${args.route}`;
await page.goto(url);
await page.waitForTimeout(waitMs);
try {
if (args.messages) {
if (args.route !== "/") {
console.warn("warning: --route is ignored when --messages is provided");
}
if (args.click) {
const selector = args.click.startsWith("[")
? args.click
: `[data-testid="${args.click}"]`;
await page.click(selector);
await page.waitForTimeout(500);
let messages;
try {
messages = JSON.parse(readFileSync(resolve(args.messages), "utf8"));
} catch (err) {
console.error(`Failed to read messages file: ${err.message}`);
process.exitCode = 1;
throw err;
}
if (
!Array.isArray(messages) ||
messages.length === 0 ||
messages.some(
(m) =>
typeof m.channelName !== "string" || typeof m.content !== "string",
)
) {
const msg =
"messages file must be a non-empty array of { channelName: string, content: string, pubkey?: string, kind?: number }";
console.error(msg);
process.exitCode = 1;
throw new Error(msg);
}
const channels = new Set(messages.map((m) => m.channelName));
if (channels.size > 1) {
const msg =
"All messages must target the same channelName for a single screenshot";
console.error(msg);
process.exitCode = 1;
throw new Error(msg);
}
const channelName = messages[0].channelName;
if (!/^[a-z0-9-]+$/.test(channelName)) {
const msg = `Invalid channel name: ${channelName}`;
console.error(msg);
process.exitCode = 1;
throw new Error(msg);
}
await page.goto(BASE_URL);
await page.waitForSelector(`[data-testid="channel-${channelName}"]`, {
timeout: 10000,
});
await page.click(`[data-testid="channel-${channelName}"]`);
await page.waitForFunction(
(name) =>
window.__SPROUT_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({
channelName: name,
}) ?? false,
channelName,
{ timeout: 10000 },
);
for (const msg of messages) {
await page.evaluate(
(m) => {
window.__SPROUT_E2E_EMIT_MOCK_MESSAGE__?.(m);
},
{
channelName: msg.channelName,
content: msg.content,
pubkey: msg.pubkey ?? DEFAULT_MOCK_PUBKEY,
kind: msg.kind,
},
);
}
await page.waitForTimeout(waitMs);
} else {
const url = args.route === "/" ? BASE_URL : `${BASE_URL}/#${args.route}`;
await page.goto(url);
await page.waitForTimeout(waitMs);
}
if (args.click) {
const selector = args.click.startsWith("[")
? args.click
: `[data-testid="${args.click}"]`;
await page.click(selector);
await page.waitForTimeout(500);
}
const filepath = join(outdir, `${args.name}.png`);
await page.screenshot({ path: filepath });
console.log(filepath);
} finally {
await browser.close();
}
const filepath = join(outdir, `${args.name}.png`);
await page.screenshot({ path: filepath });
console.log(filepath);
await browser.close();
+13
View File
@@ -198,6 +198,19 @@ desktop-e2e-smoke:
desktop-e2e-integration: _ensure-migrations
cd {{desktop_dir}} && pnpm test:e2e:integration
# Take desktop screenshots using the mock bridge
desktop-screenshot *ARGS:
#!/usr/bin/env bash
set -euo pipefail
just desktop-build
cd {{desktop_dir}}
if ! curl -sf http://127.0.0.1:4173/ >/dev/null 2>&1; then
python3 -m http.server 4173 -d dist >/dev/null 2>&1 &
trap "kill $! 2>/dev/null || true" EXIT
for i in $(seq 1 20); do curl -sf http://127.0.0.1:4173/ >/dev/null && break; sleep 0.5; done
fi
node tests/helpers/screenshot.mjs {{ARGS}}
# Mesh-compute e2e: the CI-safe layers (relay mesh signaling invariants + Playwright UI)
mesh-e2e:
cargo test -p sprout-relay mesh_signaling
+64
View File
@@ -0,0 +1,64 @@
#!/usr/bin/env bash
set -euo pipefail
if [[ $# -lt 2 ]]; then
echo "Usage: $0 <pr-number> <png-dir> [comment-body-file]" >&2
exit 1
fi
PR="$1"
PNG_DIR="$2"
BODY_FILE="${3:-}"
if ! [[ "$PR" =~ ^[0-9]+$ ]]; then
echo "error: PR number must be a positive integer" >&2
exit 1
fi
GH_USER=$(gh api user --jq .login)
BRANCH="agent-screenshots/${GH_USER}"
REPO="block/sprout"
RAW_BASE="https://raw.githubusercontent.com/${REPO}/refs/heads/${BRANCH}"
mapfile -t PNGS < <(find "$PNG_DIR" -maxdepth 1 -name "*.png" -type f | sort)
if [[ ${#PNGS[@]} -eq 0 ]]; then
echo "error: no PNGs found in $PNG_DIR" >&2
exit 1
fi
EXISTING_ENTRIES=""
if git fetch origin "refs/heads/${BRANCH}:refs/remotes/origin/${BRANCH}" 2>/dev/null; then
EXISTING_ENTRIES=$(git ls-tree "origin/${BRANCH}" | grep -v $'\t'"\"\\{0,1\\}pr-${PR}--" || true)
fi
NEW_ENTRIES=""
IMAGE_URLS=()
for PNG in "${PNGS[@]}"; do
FILENAME=$(basename "$PNG")
BLOB=$(git hash-object -w "$PNG")
TREE_PATH="pr-${PR}--${FILENAME}"
NEW_ENTRIES+="$(printf '100644 blob %s\t%s' "$BLOB" "$TREE_PATH")"$'\n'
IMAGE_URLS+=("${RAW_BASE}/${TREE_PATH}")
done
COMBINED=$(printf '%s\n' "$EXISTING_ENTRIES" "$NEW_ENTRIES" | grep -v '^$')
TREE=$(echo "$COMBINED" | git mktree)
COMMIT=$(git commit-tree "$TREE" -m "screenshots: PR #${PR}")
git push --force-with-lease origin "${COMMIT}:refs/heads/${BRANCH}"
IMAGES_SECTION=""
for URL in "${IMAGE_URLS[@]}"; do
FILENAME=$(basename "$URL")
NAME="${FILENAME%.png}"
IMAGES_SECTION+="![${NAME}](${URL})"$'\n\n'
done
if [[ -n "$BODY_FILE" ]]; then
COMMENT_BODY="$(cat "$BODY_FILE")"$'\n\n'"${IMAGES_SECTION}"
else
COMMENT_BODY="## Screenshots"$'\n\n'"${IMAGES_SECTION}"
fi
gh pr comment "$PR" --repo "$REPO" --body "$COMMENT_BODY"
echo "Posted ${#PNGS[@]} screenshot(s) to PR #${PR}"