Skip to content

Commit 7527e16

Browse files
committed
feat(desktop): add reusable screenshot workflow for agent UI verification
Agents working on desktop UI changes had no automated way to capture and share visual results. Extends screenshot.mjs with --messages injection so agents can populate the channel timeline before capturing, adds scripts/post-screenshots.sh for hosting PNGs via a shared orphan branch on GitHub, wires up a just desktop-screenshot target that handles build and server lifecycle automatically, and updates AGENTS.md with the full workflow. Signed-off-by: Will Pfleger <wpfleger@block.xyz>
1 parent 7797ae7 commit 7527e16

4 files changed

Lines changed: 187 additions & 12 deletions

File tree

AGENTS.md

Lines changed: 48 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -230,18 +230,58 @@ E2E mock bridge. A standalone screenshot helper at
230230
tests use (`addInitScript` + `window.__SPROUT_E2E__`) into a CLI tool.
231231

232232
```bash
233-
just desktop-build # build the frontend first
234-
cd desktop
235-
node tests/helpers/screenshot.mjs --name home
236-
node tests/helpers/screenshot.mjs --name channel --route /channels/general
237-
node tests/helpers/screenshot.mjs --name search --click open-search
238-
node tests/helpers/screenshot.mjs --name settings --click open-settings
233+
just desktop-screenshot --name home
234+
just desktop-screenshot --name channel --route /channels/general
235+
just desktop-screenshot --name search --click open-search
236+
just desktop-screenshot --name settings --click open-settings
239237
```
240238

241239
Options: `--name` (filename), `--route` (client route), `--click` (data-testid
242240
or CSS selector), `--wait` (ms, default 2000), `--viewport` (WxH, default
243-
1280x720), `--outdir` (default `test-results/screenshots`). Screenshots are
244-
saved as PNGs and the path is printed to stdout.
241+
1280x720), `--outdir` (default `test-results/screenshots`),
242+
`--messages` (JSON file path). Screenshots are saved as PNGs and the path is
243+
printed to stdout. The `just desktop-screenshot` target handles building the
244+
frontend and starting the preview server automatically.
245+
246+
#### Injecting messages
247+
248+
Use `--messages` to inject content into the channel timeline before screenshotting.
249+
The JSON file contains an array of messages to inject:
250+
251+
```bash
252+
cat > /tmp/msgs.json << 'EOF'
253+
[
254+
{ "channelName": "general", "content": "```typescript\nconst x: number = 42;\n```" },
255+
{ "channelName": "general", "content": "plain text message" }
256+
]
257+
EOF
258+
just desktop-screenshot --name code-blocks --messages /tmp/msgs.json
259+
```
260+
261+
Each message requires `channelName` and `content`; optional fields are `pubkey`
262+
and `kind`. When `--messages` is provided, the script navigates to the channel
263+
from the first message (ignoring `--route`), waits for the live subscription,
264+
injects all messages, then captures. Available mock channels: `general`,
265+
`random`, `design`, `sales`, `engineering`, `agents`, `watercooler`,
266+
`announcements`, `alice-tyler`, `bob-tyler`.
267+
268+
#### Posting screenshots to a PR
269+
270+
`scripts/post-screenshots.sh` hosts PNGs on a shared `agent-screenshots` orphan
271+
branch and posts them as a PR comment:
272+
273+
```bash
274+
# Take screenshots, then post them
275+
just desktop-screenshot --name feature-demo --messages /tmp/msgs.json --outdir test-results/screenshots
276+
./scripts/post-screenshots.sh 803 test-results/screenshots
277+
278+
# Or provide a custom comment body (images are appended)
279+
./scripts/post-screenshots.sh 803 test-results/screenshots body.md
280+
```
281+
282+
The orphan branch accumulates images across PRs, namespaced as `pr-<N>/`. Re-runs
283+
for the same PR overwrite previous images. Delete the branch when no longer
284+
needed: `git push origin --delete agent-screenshots`.
245285

246286
The Playwright MCP browser (`@playwright/mcp`) is also configured but cannot
247287
drive the desktop app directly because it evaluates JS after page load — too

desktop/tests/helpers/screenshot.mjs

Lines changed: 64 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,10 @@
1616
// --wait <ms> Milliseconds to wait before capture (default: 2000)
1717
// --viewport <WxH> Viewport dimensions (default: 1280x720)
1818
// --outdir <path> Output directory (default: test-results/screenshots)
19+
// --messages <path> JSON file with messages to inject before capture
1920

2021
import { parseArgs } from "node:util";
21-
import { existsSync, mkdirSync } from "node:fs";
22+
import { existsSync, mkdirSync, readFileSync } from "node:fs";
2223
import { resolve, join } from "node:path";
2324
import { chromium } from "@playwright/test";
2425

@@ -30,6 +31,7 @@ const { values: args } = parseArgs({
3031
wait: { type: "string", default: "2000" },
3132
viewport: { type: "string", default: "1280x720" },
3233
outdir: { type: "string", default: "test-results/screenshots" },
34+
messages: { type: "string" },
3335
},
3436
strict: true,
3537
});
@@ -110,9 +112,67 @@ await page.addInitScript(() => {
110112
window.__SPROUT_E2E_APP_BADGE_COUNT__ = 0;
111113
});
112114

113-
const url = args.route === "/" ? BASE_URL : `${BASE_URL}/#${args.route}`;
114-
await page.goto(url);
115-
await page.waitForTimeout(waitMs);
115+
if (args.messages) {
116+
let messages;
117+
try {
118+
messages = JSON.parse(readFileSync(resolve(args.messages), "utf8"));
119+
} catch (err) {
120+
console.error(`Failed to read messages file: ${err.message}`);
121+
await browser.close();
122+
process.exit(1);
123+
}
124+
125+
if (
126+
!Array.isArray(messages) ||
127+
messages.length === 0 ||
128+
messages.some(
129+
(m) => typeof m.channelName !== "string" || typeof m.content !== "string",
130+
)
131+
) {
132+
console.error(
133+
"messages file must be a non-empty array of { channelName: string, content: string, pubkey?: string, kind?: number }",
134+
);
135+
await browser.close();
136+
process.exit(1);
137+
}
138+
139+
const channelName = messages[0].channelName;
140+
141+
await page.goto(BASE_URL);
142+
await page.waitForSelector(`[data-testid="channel-${channelName}"]`, {
143+
timeout: 10000,
144+
});
145+
await page.click(`[data-testid="channel-${channelName}"]`);
146+
147+
await page.waitForFunction(
148+
(name) =>
149+
window.__SPROUT_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({
150+
channelName: name,
151+
}) ?? false,
152+
channelName,
153+
{ timeout: 10000 },
154+
);
155+
156+
for (const msg of messages) {
157+
await page.evaluate(
158+
(m) => {
159+
window.__SPROUT_E2E_EMIT_MOCK_MESSAGE__?.(m);
160+
},
161+
{
162+
channelName: msg.channelName,
163+
content: msg.content,
164+
pubkey: msg.pubkey ?? DEFAULT_MOCK_PUBKEY,
165+
kind: msg.kind,
166+
},
167+
);
168+
}
169+
170+
await page.waitForTimeout(waitMs);
171+
} else {
172+
const url = args.route === "/" ? BASE_URL : `${BASE_URL}/#${args.route}`;
173+
await page.goto(url);
174+
await page.waitForTimeout(waitMs);
175+
}
116176

117177
if (args.click) {
118178
const selector = args.click.startsWith("[")

justfile

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,19 @@ desktop-e2e-smoke:
198198
desktop-e2e-integration: _ensure-migrations
199199
cd {{desktop_dir}} && pnpm test:e2e:integration
200200

201+
# Take desktop screenshots using the mock bridge
202+
desktop-screenshot *ARGS:
203+
#!/usr/bin/env bash
204+
set -euo pipefail
205+
cd {{desktop_dir}}
206+
[[ -d dist ]] || { cd ..; just desktop-build; cd {{desktop_dir}}; }
207+
if ! curl -sf http://127.0.0.1:4173/ >/dev/null 2>&1; then
208+
python3 -m http.server 4173 -d dist >/dev/null 2>&1 &
209+
trap "kill $! 2>/dev/null || true" EXIT
210+
for i in $(seq 1 20); do curl -sf http://127.0.0.1:4173/ >/dev/null && break; sleep 0.5; done
211+
fi
212+
node tests/helpers/screenshot.mjs {{ARGS}}
213+
201214
# Mesh-compute e2e: the CI-safe layers (relay mesh signaling invariants + Playwright UI)
202215
mesh-e2e:
203216
cargo test -p sprout-relay mesh_signaling

scripts/post-screenshots.sh

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
#!/usr/bin/env bash
2+
set -euo pipefail
3+
4+
if [[ $# -lt 2 ]]; then
5+
echo "Usage: $0 <pr-number> <png-dir> [comment-body-file]" >&2
6+
exit 1
7+
fi
8+
9+
PR="$1"
10+
PNG_DIR="$2"
11+
BODY_FILE="${3:-}"
12+
13+
BRANCH="agent-screenshots"
14+
REPO="block/sprout"
15+
RAW_BASE="https://raw.githubusercontent.com/${REPO}/refs/heads/${BRANCH}"
16+
17+
mapfile -t PNGS < <(find "$PNG_DIR" -maxdepth 1 -name "*.png" -type f | sort)
18+
if [[ ${#PNGS[@]} -eq 0 ]]; then
19+
echo "error: no PNGs found in $PNG_DIR" >&2
20+
exit 1
21+
fi
22+
23+
EXISTING_ENTRIES=""
24+
if git fetch origin "refs/heads/${BRANCH}:refs/remotes/origin/${BRANCH}" 2>/dev/null; then
25+
EXISTING_ENTRIES=$(git ls-tree "origin/${BRANCH}" | grep -v " pr-${PR}/" || true)
26+
fi
27+
28+
NEW_ENTRIES=""
29+
IMAGE_URLS=()
30+
for PNG in "${PNGS[@]}"; do
31+
FILENAME=$(basename "$PNG")
32+
BLOB=$(git hash-object -w "$PNG")
33+
TREE_PATH="pr-${PR}/${FILENAME}"
34+
NEW_ENTRIES+="100644 blob ${BLOB} ${TREE_PATH}"$'\n'
35+
IMAGE_URLS+=("${RAW_BASE}/${TREE_PATH}")
36+
done
37+
38+
COMBINED=$(printf '%s\n' "$EXISTING_ENTRIES" "$NEW_ENTRIES" | grep -v '^$')
39+
TREE=$(echo "$COMBINED" | git mktree)
40+
41+
COMMIT=$(git commit-tree "$TREE" -m "screenshots: PR #${PR}")
42+
git push --force origin "${COMMIT}:refs/heads/${BRANCH}"
43+
44+
if [[ -n "$BODY_FILE" ]]; then
45+
COMMENT_BODY=$(cat "$BODY_FILE")
46+
COMMENT_BODY+=$'\n\n'
47+
for URL in "${IMAGE_URLS[@]}"; do
48+
FILENAME=$(basename "$URL")
49+
NAME="${FILENAME%.png}"
50+
COMMENT_BODY+="![${NAME}](${URL})"$'\n\n'
51+
done
52+
else
53+
COMMENT_BODY="## Screenshots"$'\n\n'
54+
for URL in "${IMAGE_URLS[@]}"; do
55+
FILENAME=$(basename "$URL")
56+
NAME="${FILENAME%.png}"
57+
COMMENT_BODY+="![${NAME}](${URL})"$'\n\n'
58+
done
59+
fi
60+
61+
gh pr comment "$PR" --repo "$REPO" --body "$COMMENT_BODY"
62+
echo "Posted ${#PNGS[@]} screenshot(s) to PR #${PR}"

0 commit comments

Comments
 (0)