UI Visual Verification (screenshot proof-of-change)
Produces real, live screenshots of a running web app as evidence for a UI change — not just a code-level review. Two techniques, used together:
- CDP connection to an already-running, developer-installed browser — works with zero internet access, since it never downloads a browser.
- Programmatic-render debug-hook technique — for triggering components/states that require account data, feature flags, or flows too slow/fragile to reproduce by clicking through the UI.
Only use this when a live, running dev server for the app is available (and, if the UI depends on one, a backend/API the user has started) — confirm both are up before starting.
Prerequisites check (do this first)
- The app's dev server is running and reachable at a known local URL.
- Any backend/API/database the UI depends on is running (ask the user to confirm if unstated).
- A dedicated screenshots output folder exists for this task — create one, don't dump images into the repo's working tree or mix them with unrelated artifacts.
- A Playwright (or Puppeteer) install is available somewhere on the machine — a global
npm install is enough; you do not need it in the current project's
node_modules. Detect it rather than assuming a path: - Node:npm root -g→ check forplaywrightinside that directory. - If nothing is found and installing a full browser download isn't possible, runnpm install -g playwright(orpuppeteer-core) — installing the package does not require downloading a browser as long as you point it at an existing local browser viaexecutablePath(see Step 1).
Step 1 — Launch a real browser with a CDP debug port
Both Playwright and Puppeteer can drive an already-installed browser via
connectOverCDP/CDP connection instead of downloading their own. Launch it once per session with a
persistent user-data directory so login/session cookies survive across script invocations:
// launch-browser.js — run once, keep the process alive (async/background)
const { chromium } = require("<path-to-playwright-install>"); // see detection note above
const path = require("path");
(async () => {
const context = await chromium.launchPersistentContext(
path.join("<SCREENSHOTS_FOLDER>", "browser-profile"),
{
executablePath: "<path-to-installed-chrome-or-chromium>",
headless: false,
args: ["--remote-debugging-port=9333"]
}
);
console.log("launched, CDP available on port 9333");
})();
Common browser executable locations (pick the one that exists on the current OS):
| OS | Typical Chrome path |
|---|---|
| Windows | C:\Program Files\Google\Chrome\Application\chrome.exe |
| macOS | /Applications/Google Chrome.app/Contents/MacOS/Google Chrome |
| Linux | /usr/bin/google-chrome or /usr/bin/chromium-browser |
Run the launch script as a long-lived background/async process — do not wait for it to exit. Use a fresh profile directory the first time so the user can log in manually once; it persists on subsequent runs of the same task.
Step 2 — Reusable "step" script pattern
For every subsequent action (navigate, click, screenshot), write a small throwaway script and run
it with node. Always:
- Require the Playwright/Puppeteer module from wherever it was detected in Step 0 — a bare
require("playwright")fails if the current working directory has no local install of it. - Connect with
connectOverCDP("http://localhost:9333")(or your chosen port), then reuse the existing browser context/page — do not call.launch()again, which starts a second, disconnected browser instance. - If the context's page list is empty (browser tab was closed or never opened), create a new page rather than erroring out.
- Always exit the process explicitly on both success and failure — a script that leaves the
CDP connection open without calling
process.exit()will hang indefinitely. - When building file paths from a shell script into an embedded JS string, prefer forward slashes or a path-join utility — manual backslash interpolation across a shell layer and a JS string layer is a common source of silently mangled paths.
// pw-step.js — one action, run fresh each time
const { chromium } = require("<path-to-playwright-install>");
(async () => {
const browser = await chromium.connectOverCDP("http://localhost:9333");
const context = browser.contexts()[0];
let page = context.pages()[0];
if (!page) page = await context.newPage();
await page.setViewportSize({ width: 1400, height: 900 });
await page.goto("<APP_URL>", { waitUntil: "networkidle", timeout: 60000 });
await page.screenshot({ path: "<SCREENSHOTS_FOLDER>/01-example.png" });
process.exit(0);
})().catch((e) => {
console.error(e);
process.exit(1);
});
Step 3 — Reaching states that normal navigation can't (the debug-hook technique)
Many components worth screenshotting (limit/error dialogs, rare confirmation flows, empty/loading states) require account data or app state that doesn't naturally exist in a dev/test environment, and forcing them through the UI is slow and fragile. Instead, render them directly the same way the app does, via a temporary debug hook.
The key idea: almost every UI framework has a single chokepoint used to open a modal/dialog/ toast programmatically — a service, a context provider, a global store action. Find that chokepoint and call it directly with mock data. This is a faithful reproduction of the real rendering (the same code path the app itself uses), not a stub or a visual mock. See references/framework-adapters.md for concrete examples in Angular, React, and Vue.
High-level steps:
- Learn each target's data contract — read its source to find what props/inputs/context data it expects, and what services it depends on.
- Add a temporary hook in an always-mounted top-level component that already has access to the
dialog/modal mechanism. Expose a function on
window(e.g.window.__openTestState(key)) that looks up and invokes the right "open" call with realistic mock data per key. - Fix dependency-injection errors if a target depends on state/services only available deeper in the app's component tree — see the framework-specific notes for how to scope a temporary provider/context around just the debug call.
- Capture each target in one script — loop through target keys, calling the hook, waiting briefly for render, screenshotting, then dismissing before the next one.
- Revert the hook — non-negotiable. It must never be committed:
Wait for the dev server to rebuild once more and confirm the app compiles cleanly, so the running app matches the reverted source before you finish.git diff --stat <path-to-hooked-file> # confirm the change is only the debug hook git checkout -- <path-to-hooked-file> # revert git status --short # confirm a clean tree
Full worked example (Angular ModalService, TypeScript, handling a route-scoped dependency
injection error) is in
references/framework-adapters.md.
Step 4 — Organize and document the output
- Save screenshots with sequential, descriptive filenames (
19-download-limit-modal.png, notscreenshot1.png) into the dedicated output folder from the prerequisites step. - Write or update a
README.mdin that folder listing what each screenshot shows and, for each one, how it was captured — live UI navigation vs. the debug-hook technique — so a reviewer knows which were reproduced through normal user flows vs. direct programmatic rendering. - Do not zip, upload, or attach the folder anywhere unless the user explicitly asks.
Common pitfalls
See references/troubleshooting.md for the full table of symptoms and fixes (empty page list, mangled paths, hanging scripts, missing local module, DI/provider errors, stale modal state between captures).