The PR description is the home for proof-of-testing screenshots. They are hosted as raw GitHub blobs at the screenshot commit SHA. After the PR body is updated, create a follow-up revert commit that removes the screenshot files from the branch tip while keeping the original screenshot commit reachable in branch history.
-
Build a spec and capture comprehensively. Write a spec JSON (e.g. to /tmp/symphony-shots.json) listing every state worth a reviewer's eye, then run the script.
Capture every state that matters to a reviewer, not just the happy path — e.g. 01-default, 02-loading, 03-error, 04-mobile, 05-hover. Set width/height for responsive/mobile states. For states that aren't a bare URL (hover menus, opened dialogs, click-triggered loading/error UI), drive them with a shot actions list. Err on the side of complete reviewer evidence; the cleanup revert keeps the files out of the branch tip, and a missing state is the most common reviewer ask. Number names so they sort deterministically. Shots default to fullPage; set "fullPage": false only when the page is impractically tall.
Spec example (no auth):
{
"outDir": ".symphony/screenshots",
"shots": [
{ "name": "01-default", "url": "https://localhost:3000/path" },
{ "name": "04-mobile", "url": "https://localhost:3000/path", "width": 390, "height": 844 },
{ "name": "05-menu", "url": "https://localhost:3000/path", "actions": [{ "hover": "nav .menu" }, { "waitFor": ".menu-popover" }] }
]
}
For an authenticated target, add a cookie block. Only value comes from the env var named in env; every other field is passed through to Playwright so the real session cookie is replayed faithfully (provide url or domain+path; set secure/httpOnly/sameSite to match the real cookie when it matters). Never put the value in the spec, in argv, or in any echo:
"cookie": { "env": "SESSION_COOKIE", "name": "session", "domain": "localhost", "path": "/", "secure": true, "sameSite": "Lax" }
Then run it:
node .symphony/capture.mjs /tmp/symphony-shots.json
The script exits non-zero if any shot fails (navigation error, failed action, or an unexpected HTTP status — by default anything ≥ 400; opt a shot into a known status with expectStatus). It prints a JSON report per shot; also check landed: if it differs from the requested URL (redirected to a login wall or elsewhere), the cookie is stale or the account lacks access — fix that rather than embedding a wrong-page shot. PNGs land under .symphony/screenshots/ (the spec's outDir, repo-relative); the spec JSON itself may live in /tmp.
-
Stage and commit all the screenshots together:
git add .symphony/screenshots/
git commit -m "chore: temporary screenshots for PR description (will be reverted)" --no-verify
--no-verify is allowed here because this temporary evidence commit contains binary paths that lint/format hooks would reject. This is the only skill that bypasses hooks. Do not stage .symphony/capture.mjs — it's teardown plumbing, removed in the cleanup step.
-
Push. If the remote is ahead, rebase the screenshot commit onto it first (git fetch && git rebase origin/<branch>); a merge commit pollutes the screenshot evidence sequence.
git push origin "$(git branch --show-current)"
-
Build the raw URLs at the new screenshot commit SHA — one base, one URL per file.
screenshot_sha_file=".symphony/screenshot-sha"
screenshot_sha=$(git log -1 --format=%H)
mkdir -p .symphony
printf '%s\n' "$screenshot_sha" > "$screenshot_sha_file"
repo_url=$(gh repo view --json url -q .url)
for f in .symphony/screenshots/*.png; do
name=$(basename "$f")
echo "${repo_url}/raw/${screenshot_sha}/.symphony/screenshots/${name}"
done
-
Update PR body. Read the current body, append (or replace) a ## Screenshots section, write back. Never clobber existing sections. Embed every captured screenshot — one image per state — with a short caption derived from the filename so reviewers can scan them.
screenshot_sha_file=".symphony/screenshot-sha"
screenshot_sha=$(cat "$screenshot_sha_file")
repo_url=$(gh repo view --json url -q .url)
pr=$(gh pr view --json number -q .number)
body=$(gh pr view --json body -q .body)
block=$(printf '\n\n## Screenshots\n\n')
for f in .symphony/screenshots/*.png; do
name=$(basename "$f" .png)
url="${repo_url}/raw/${screenshot_sha}/.symphony/screenshots/${name}.png"
block+=$(printf '**%s**\n\n\n\n' "$name" "$name" "$url")
done
gh pr edit "$pr" --body "${body}${block}"
Use a heredoc or a built-up shell variable for the --body arg so newlines stay literal. Group related captures (e.g. mobile vs desktop) under sub-headings if it makes the PR easier to scan.
-
Verify the images render in the PR (visual confirmation by the operator, or a curl -I "$raw_url" returning 200 if running unattended). Do not proceed to step 7 without confirmation — the cleanup revert removes the screenshot files from the branch tip, so fixing bad URLs is harder after this point.
-
Revert the screenshot commit to remove the screenshot files from the branch tip while preserving the original screenshot commit in branch history:
screenshot_sha_file=".symphony/screenshot-sha"
screenshot_sha=$(cat "$screenshot_sha_file")
test "$(git log -1 --format=%H)" = "$screenshot_sha" || { git log --oneline -5; exit 1; }
git revert --no-edit "$screenshot_sha"
git push origin "$(git branch --show-current)"
Do not reset or force-push to remove the screenshot commit. If the test fails, something else changed HEAD; stop, inspect git log --oneline -5, and only continue once you know which commit contains the screenshots. If the normal push is rejected because the remote moved, fetch and rebase with care; if the screenshot commit SHA changes, rebuild the raw URLs and update the PR body before pushing.
-
Cleanup workspace artifacts: rm -rf .symphony/screenshots .symphony/capture.mjs .playwright-mcp and rm -f .symphony/screenshot-sha /tmp/symphony-shots.json (those should not appear in git status afterward). If the skill started a dev server, stop it.