mirror of
https://github.com/block/buzz.git
synced 2026-10-27 20:41:57 +00:00
feat(desktop): reusable screenshot workflow for agents (#826)
Signed-off-by: Will Pfleger <wpfleger@block.xyz>
This commit is contained in:
52
AGENTS.md
52
AGENTS.md
@@ -224,28 +224,50 @@ See [TESTING.md](TESTING.md) for the full multi-agent E2E guide.
|
|||||||
|
|
||||||
### Desktop Screenshots (Playwright)
|
### Desktop Screenshots (Playwright)
|
||||||
|
|
||||||
The desktop app is a Tauri app that cannot render in a plain browser without the
|
The desktop app requires the E2E mock bridge to render — it cannot run in a plain
|
||||||
E2E mock bridge. A standalone screenshot helper at
|
browser. Use `just desktop-screenshot` to capture screenshots (builds frontend,
|
||||||
`desktop/tests/helpers/screenshot.mjs` wraps the same mock bridge setup the E2E
|
starts preview server, runs Playwright automatically):
|
||||||
tests use (`addInitScript` + `window.__SPROUT_E2E__`) into a CLI tool.
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
just desktop-build # build the frontend first
|
just desktop-screenshot --name home
|
||||||
cd desktop
|
just desktop-screenshot --name channel --route /channels/general
|
||||||
node tests/helpers/screenshot.mjs --name home
|
just desktop-screenshot --name search --click open-search
|
||||||
node tests/helpers/screenshot.mjs --name channel --route /channels/general
|
just desktop-screenshot --name settings --click open-settings
|
||||||
node tests/helpers/screenshot.mjs --name search --click open-search
|
|
||||||
node tests/helpers/screenshot.mjs --name settings --click open-settings
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Options: `--name` (filename), `--route` (client route), `--click` (data-testid
|
Options: `--name` (filename), `--route` (client route), `--click` (data-testid
|
||||||
or CSS selector), `--wait` (ms, default 2000), `--viewport` (WxH, default
|
or CSS selector), `--wait` (ms, default 2000), `--viewport` (WxH, default
|
||||||
1280x720), `--outdir` (default `test-results/screenshots`). Screenshots are
|
1280x720), `--outdir` (default `test-results/screenshots`),
|
||||||
saved as PNGs and the path is printed to stdout.
|
`--messages` (JSON file path). Output is a PNG path on stdout.
|
||||||
|
|
||||||
The Playwright MCP browser (`@playwright/mcp`) is also configured but cannot
|
Use `--messages` to inject content into a channel before capture. The JSON file
|
||||||
drive the desktop app directly because it evaluates JS after page load — too
|
is an array of `{ channelName, content, pubkey?, kind? }` objects — all must
|
||||||
late for the mock bridge. Use the MCP browser for non-Tauri pages only.
|
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)
|
// --wait <ms> Milliseconds to wait before capture (default: 2000)
|
||||||
// --viewport <WxH> Viewport dimensions (default: 1280x720)
|
// --viewport <WxH> Viewport dimensions (default: 1280x720)
|
||||||
// --outdir <path> Output directory (default: test-results/screenshots)
|
// --outdir <path> Output directory (default: test-results/screenshots)
|
||||||
|
// --messages <path> JSON file with messages to inject before capture
|
||||||
|
|
||||||
import { parseArgs } from "node:util";
|
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 { resolve, join } from "node:path";
|
||||||
import { chromium } from "@playwright/test";
|
import { chromium } from "@playwright/test";
|
||||||
|
|
||||||
@@ -30,6 +31,7 @@ const { values: args } = parseArgs({
|
|||||||
wait: { type: "string", default: "2000" },
|
wait: { type: "string", default: "2000" },
|
||||||
viewport: { type: "string", default: "1280x720" },
|
viewport: { type: "string", default: "1280x720" },
|
||||||
outdir: { type: "string", default: "test-results/screenshots" },
|
outdir: { type: "string", default: "test-results/screenshots" },
|
||||||
|
messages: { type: "string" },
|
||||||
},
|
},
|
||||||
strict: true,
|
strict: true,
|
||||||
});
|
});
|
||||||
@@ -110,20 +112,101 @@ await page.addInitScript(() => {
|
|||||||
window.__SPROUT_E2E_APP_BADGE_COUNT__ = 0;
|
window.__SPROUT_E2E_APP_BADGE_COUNT__ = 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
const url = args.route === "/" ? BASE_URL : `${BASE_URL}/#${args.route}`;
|
try {
|
||||||
await page.goto(url);
|
if (args.messages) {
|
||||||
await page.waitForTimeout(waitMs);
|
if (args.route !== "/") {
|
||||||
|
console.warn("warning: --route is ignored when --messages is provided");
|
||||||
|
}
|
||||||
|
|
||||||
if (args.click) {
|
let messages;
|
||||||
const selector = args.click.startsWith("[")
|
try {
|
||||||
? args.click
|
messages = JSON.parse(readFileSync(resolve(args.messages), "utf8"));
|
||||||
: `[data-testid="${args.click}"]`;
|
} catch (err) {
|
||||||
await page.click(selector);
|
console.error(`Failed to read messages file: ${err.message}`);
|
||||||
await page.waitForTimeout(500);
|
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
justfile
13
justfile
@@ -198,6 +198,19 @@ desktop-e2e-smoke:
|
|||||||
desktop-e2e-integration: _ensure-migrations
|
desktop-e2e-integration: _ensure-migrations
|
||||||
cd {{desktop_dir}} && pnpm test:e2e:integration
|
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-compute e2e: the CI-safe layers (relay mesh signaling invariants + Playwright UI)
|
||||||
mesh-e2e:
|
mesh-e2e:
|
||||||
cargo test -p sprout-relay mesh_signaling
|
cargo test -p sprout-relay mesh_signaling
|
||||||
|
|||||||
64
scripts/post-screenshots.sh
Executable file
64
scripts/post-screenshots.sh
Executable 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+=""$'\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