Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 37 additions & 15 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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>`.

---

Expand Down
113 changes: 98 additions & 15 deletions desktop/tests/helpers/screenshot.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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,
});
Expand Down Expand Up @@ -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}"]`);

const filepath = join(outdir, `${args.name}.png`);
await page.screenshot({ path: filepath });
console.log(filepath);
await page.waitForFunction(
(name) =>
window.__SPROUT_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({
channelName: name,
}) ?? false,
channelName,
{ timeout: 10000 },
);

await browser.close();
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();
}
13 changes: 13 additions & 0 deletions justfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
64 changes: 64 additions & 0 deletions scripts/post-screenshots.sh
Original file line number Diff line number Diff line change
@@ -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://github.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}"