Capturing UI screenshots
Produce reviewable PNG evidence from a deterministic local app state. Build, serve, check, and capture sequentially; mock every external dependency; inspect the images; then remove capture-only code and stop the service.
The invariant
- Use the portal URL only for human review. Playwright runs inside the orb and
must use the service's private loopback URL from
amp orb service status. - Navigate only after installing mock auth and API routes.
- Capture viewport screenshots. Do not use
fullPage: changing page dimensions can alter layout and stale overlay positioning. - Every assertion waits for a specific state: visible, enabled, exact text, or final opacity. A roughly 500 ms delay is allowed only after those waits to let a known overlay animation and paint settle.
- Run the build, service checks, Playwright work, and cleanup sequentially. Never launch parallel captures.
1. Build deterministic E2E state
Read the repository's own scripts and E2E setup first. Use its existing E2E-mode build and mock-auth contract rather than inventing production credentials. Run the build by itself and wait for success before starting the preview, for example:
pnpm build --mode e2e
Adapt the command to the repository. The requirements are an optimized preview build, E2E/mock-auth mode enabled at build time when the app requires it, and no concurrent build, server, test, or capture jobs.
If Chromium is missing for the installed Playwright revision, install exactly that revision through the project dependency before capturing:
npx playwright install chromium
Do not pin a separate Playwright version.
2. Start and prove the supervised preview
Start a supervised orb service that consumes Amp's assigned $PORT. Keep the
variable expansion inside the service command:
amp orb service start ui-capture --portal --command 'pnpm preview --host 0.0.0.0 --port "$PORT" --strictPort'
amp orb service status ui-capture
amp orb service logs ui-capture
Use the repository's package manager and preview script. Read the assigned port from service status, then require an HTTP 200 over loopback before Playwright starts:
curl -fsS -o /dev/null -w '%{http_code}\n' http://127.0.0.1:<assigned-port>/
The result must be 200. Diagnose status and logs before retrying. Share the
portal URL with a human reviewer, but do not make internal Playwright traverse
the portal proxy.
3. Write one disposable Playwright capture
Prefer a one-shot script over a new test suite. Put it in a temporary,
gitignored location or create a clearly temporary script in the checkout and
delete it after capture. Keep screenshots under .amp/in/artifacts/.
Mock authentication before the first navigation with page.addInitScript. Mock
network APIs with specific routes and route.fulfill; an auth route must match
only /auth/me, not invites or another auth subroute. Use reserved
example.com names in reusable examples:
import { chromium } from "playwright";
const baseURL = process.env.CAPTURE_BASE_URL;
if (!baseURL) throw new Error("CAPTURE_BASE_URL is required");
const browser = await chromium.launch();
try {
const context = await browser.newContext({
viewport: { width: 1440, height: 1000 },
});
const page = await context.newPage();
await page.addInitScript(() => {
localStorage.setItem(
"example-auth",
JSON.stringify({ user: { id: "example-user", email: "you@example.com" } }),
);
});
await page.route(
/^https:\/\/api\.example\.com\/auth\/me(?:\?.*)?$/,
(route) => route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ id: "example-user", email: "you@example.com" }),
}),
);
await page.route(
/^https:\/\/api\.example\.com\/settings\/options(?:\?.*)?$/,
(route) => route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ options: ["Default", "Final option"] }),
}),
);
// Add the app-specific capture sequence from §4 here.
} finally {
await browser.close();
}
Adapt storage keys, response shapes, and endpoint origins to the app. Keep route
regexes anchored and specific. A broad pattern such as **/auth/** can swallow
invite subroutes and create misleading UI state.
If captures run inside an existing Playwright suite, preserve serial execution:
export default defineConfig({
fullyParallel: false,
workers: 1,
});
Also pass --workers=1 when the command permits it. Do not add retries to hide
unstable state.
4. Capture Radix Select and AlertDialog reliably
Use a helper whose first act is a fresh navigation. The stable sequence is:
page.goto()the target route and wait for its final app-ready element.- Locate the combobox by role and accessible name, scroll it into view, require it visible and enabled, then click it.
- Wait for the final option by role and exact name to be visible. If the app exposes transition styles, also wait for the Select content to be opaque.
- Wait about 500 ms for the known Radix animation and final paint, then take a viewport screenshot of the open dropdown.
- Fresh-navigate to the route again. Re-find every locator; never reuse an element handle or locator state from before navigation.
- Reopen the Select, wait for the exact option, and click it.
- Click
Apply, wait for the AlertDialog and its exact warning text to be visible, then wait about 500 ms and capture the viewport dialog screenshot.
An adaptable helper looks like this:
async function openFinalOption(page) {
await page.goto(`${baseURL}/settings`, { waitUntil: "domcontentloaded" });
await page.getByRole("heading", { name: "Example settings", exact: true })
.waitFor({ state: "visible" });
const trigger = page.getByRole("combobox", { name: "Example option" });
await trigger.scrollIntoViewIfNeeded();
await trigger.waitFor({ state: "visible" });
if (!(await trigger.isEnabled())) throw new Error("Select trigger is disabled");
await trigger.click();
const option = page.getByRole("option", { name: "Final option", exact: true });
await option.waitFor({ state: "visible" });
await page.waitForTimeout(500); // Radix overlay animation and final paint only.
return option;
}
await openFinalOption(page);
await page.screenshot({ path: ".amp/in/artifacts/select-open.png" });
const option = await openFinalOption(page); // Fresh page state and fresh locators.
await option.click();
await page.getByRole("button", { name: "Apply", exact: true }).click();
const dialog = page.getByRole("alertdialog");
await dialog.waitFor({ state: "visible" });
await dialog.getByText("This change affects current sessions.", { exact: true })
.waitFor({ state: "visible" });
await page.waitForTimeout(500); // Dialog animation and final paint only.
await page.screenshot({ path: ".amp/in/artifacts/confirm-dialog.png" });
Do not keep retrying a detached portal locator. Detachment means the browsing context or overlay was replaced; repeating the same click indefinitely cannot repair it.
5. Use fallbacks in this order
- Fresh page and reopen: navigate again, reacquire locators, reopen the Select, and continue from known state.
- Existing passing E2E flow: inject screenshot calls into an already-passing single-worker E2E test rather than rebuilding its setup.
- Focused keyboard navigation: focus the Select trigger, use Arrow keys and Enter, and still wait for the exact option/dialog state.
- Reduced motion plus final state: emulate reduced motion, then wait for the final overlay to be visible and opaque before a narrowly justified settle.
If those fail, stop and inspect the app state, service logs, route specificity, and screenshot. Do not respond with unbounded locator retries or parallel workers.
6. Verify and clean up
- Use
view_mediaon every PNG. Verify the intended overlay is open, labels are legible, no loading/error state is present, and no sensitive data appears. - If an image is wrong, change the state preparation or wait and recapture sequentially; do not crop away evidence of a bad state.
- Delete the disposable capture script and any Playwright reports, traces,
videos, or test-output directories. Keep requested review PNGs only under
.amp/in/artifacts/and never stage them in a product commit. - Inspect
git statusand ensure capture scripts, reports, browser artifacts, and screenshots are absent from the product diff. - Close the browser in
finally, then stop the supervised service:
amp orb service stop ui-capture
Check service status after stopping it. Cleanup is mandatory: abandoned browsers, preview servers, workers, and retries consume orb resources and can make later work unreliable.
Review checklist
- E2E/mock-auth build completed before service startup.
- Supervised preview consumed
$PORT; status, logs, and HTTP 200 checked. - Internal Playwright used loopback; portal reserved for human review.
- Mock auth installed before navigation; API routes anchored and specific.
- Captures ran sequentially with one worker and explicit state waits.
- Select capture used fresh navigation, scroll, open, exact option, settle.
- Dialog capture fresh-navigated/reopened before select, Apply, exact warning.
- PNGs are viewport captures under
.amp/in/artifacts/and were inspected. - Temporary code and reports removed; browser and service stopped.