mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat(desktop): reusable screenshot workflow for agents (#826)
Signed-off-by: Will Pfleger <wpfleger@block.xyz>
This commit is contained in:
@@ -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>`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
Executable
+64
@@ -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+=""$'\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}"
|
||||
Reference in New Issue
Block a user