An agent built a new web flow for an app. The flow lets a user fix an item that is on hold, save the fix to a marketplace API, and schedule an automatic re-add of the item later. Before any of it was merged, a reviewer wanted to see the real screens, logged in as a real account, with real data on them. The rule was firm: nothing could be written. Every button in the flow writes something. This is how the agent got the screens anyway.

Key facts

  • Nine screens of a save-and-schedule flow came out of a real account with no change to production.
  • The rows the flow needs were staged in a local, uncommitted shim, not created as real data.
  • The app's API calls that write were answered with fake “OK” bodies shaped like the real ones.
  • The database's write channel was blocked as a backstop; reads stayed live.
  • The trap: the real record's state sent the UI down a different branch than the mocks assumed.

Why reviewers need real screens before merge

A reviewer approves what they see. Mockups and component previews show the layout the author had in mind, filled with tidy sample data. They hide the things that break in practice: a long item name that wraps badly, a price with odd decimals, an empty list, a label that reads fine in English sample text and wrong next to real values. A real account shows all of that.

After merge it is too late to be cheap. The flow is live, users press the buttons, and every fix is a new change on top. Before merge, a screen that looks wrong is a comment on a pull request. So the reviewer asked for real screens first, and the agent had to find a way to press every button without any button doing anything.

The four steps

  1. Stage the rows the flow needs in a local, uncommitted shim. The flow starts from an item on hold. The real account had none at that moment, and creating one would be a write. So a small local patch, never committed, makes the app see a held item when it loads its list. Everything else on the page is the real account's data.
  2. Drive the real UI with Playwright and click the real buttons. No hand-drawn screens and no direct calls to the code under the page. The browser opens the app, signs in, and clicks through the flow like a user, taking a screenshot after each step.
  3. Intercept the app's API POSTs and answer them with fake “OK” bodies. Playwright's page.route() catches the save and the schedule requests before they leave the browser. Each one gets a fulfilled response with the same fields the real API returns, so the app moves to its success screen as it would for real.
  4. Abort the database's write channel. The app also talks to its database straight from the browser. Requests to the write endpoint are aborted, so nothing can persist even if a path was missed. Reads stay live, so the screens show the account's real data.

Nine screens came out this way, from the held item to the scheduled re-add, and production did not change.

The Playwright part

A minimal version in Node. The paths are examples; use the patterns your app really calls.

await page.route('**/api/items/*/save', (route) =>
  route.fulfill({
    status: 200,
    contentType: 'application/json',
    body: JSON.stringify({ ok: true, id: 'staged-1', status: 'saved' }),
  }));

// backstop: no database write may leave the browser
await page.route('**/Firestore/Write/**', (route) => route.abort());

await page.goto(APP_URL);
await page.getByRole('button', { name: 'Save' }).click();
await page.screenshot({ path: 'shots/03-saved.png', fullPage: true });

The first route is the part that makes the screens look right. The second is the part that keeps the account safe. They do different jobs. A fake response only covers the requests you thought of. The abort covers the ones you did not, and turns a missed write into a failed request you can see in the log instead of a real change you find later.

The trap: the record's own state picks the branch

The re-add step has two versions. If the item's shipment is still open, the flow offers to add the item back to that same batch. If the shipment is already confirmed, it cannot take new items, so the flow offers to add the item to a new batch instead.

The mocks assumed the first case. The staged screens showed “add it back to this batch”. But the real shipment on that account was already confirmed. Had the flow run for real, the user would have seen “add it to a new batch”. The screens were real pixels from the real UI, and they still showed a branch the real account would not take. Nothing looked wrong. The fake responses were consistent, the staged row was plausible, and the app did what its code said.

The fix was in two places. The shim now sets the shipment state on purpose instead of inheriting whatever the real record had. And the screenshot caption says which state was staged.

How to catch it

  • Read the real record first. Before staging anything, fetch the records the flow depends on, read-only, and note every field that picks a branch: status, dates, counts, flags.
  • Print which branch the UI will take. Have the script log one line per decision point, such as “shipment confirmed: yes, so re-add goes to a new batch”. If the staged state differs from the real one, the log shows it before any screenshot exists.
  • Label every staged screen. Put the staged state in the caption, for example “staged: batch not yet confirmed”. A reviewer then knows which parts are real and which were set up.

Three rules

  • Block writes at the network layer as a backstop, not only by fake responses. Fakes cover the paths you know. An abort on the write channel covers the rest.
  • Stage state explicitly and say so on the screen. Never let a staged row inherit branch-picking state by accident. Set it, and write it in the caption.
  • A screenshot of a staged branch is not proof of the real one. It proves the UI can render that branch. Whether a real user reaches it depends on the record, so check the record.

Common questions

Why not use a staging account instead?

Use one if it has the same data, permissions and settings as the real account. Often it does not, and the point of the review is to see the screens a real user will see. Reads from the real account are what make the screens honest.

Why block database writes if every API call is already faked?

Because you only fake the calls you know about. A client library that writes straight to the database, a retry, or a second button you forgot can still persist something. The abort turns a missed path into a failed request instead of a real change.