MidstreamDocs
Marking scenes

Choosing good scenes

Where to put scene calls, how to name them, and why a slug you never change is worth the thought up front.

Two decisions decide whether a scene catalog stays useful: where you put the call, and what you name it. The name is the one that is expensive to get wrong, so start there.

The slug is the scene

A scene has no other identity. Every capture of it — one per commit that runs CI — is tied together by the slug, and that thread is what gives you a history to compare against and a stable link to send someone.

Change the slug and you have not renamed a scene. You have made a new one, with no history, and left the old one behind pointing at a call that no longer exists. Anyone who opens the old scene gets a deploy that runs your test, never reaches a call with that slug, and fails.

So treat a slug the way you would treat a public URL. If you want a friendlier label, that is what name is for — change it as often as you like:

await midstreamScene(page, "cart-one-item", {
  name: "Cart — one item, ready to check out",
});

If you genuinely need to rename, do it as a swap: add the new call, delete the old one, and expect the old scene to stop collecting captures. It stays in the catalog with the history it already has.

Writing a slug

A slug ends up inside a hostname, because that is how an instance is served. That constrains it more than a plain identifier:

  • Lowercase letters, numbers, and hyphens only. No spaces, underscores, dots, or accented characters — none of those survive being turned into a hostname label.

  • Short. The leftmost label of an instance hostname holds your workspace slug, the scene slug, and a short instance id, and the whole label cannot exceed 63 characters. Your workspace slug and the instance id are kept whole; the scene slug is the part that gets trimmed to fit. Twenty to thirty characters is comfortable.

  • Distinct early, not late. Two slugs in one project that agree on their first 63 characters cannot be told apart once truncated, so when a commit registers both, the second is rejected with a conflict. Long shared prefixes are the way this bites:

    checkout-payment-step-with-saved-card-and-a-promo-code-applied
    checkout-payment-step-with-saved-card-and-no-promo-code-applied

    Both of those are fine on their own and collide together. Put the difference at the front instead: checkout-promo-applied, checkout-no-promo.

  • Unique across your test files. Reusing a slug in two tests makes the local debugger ambiguous — it will refuse to guess which one you meant — and the two calls fight over one scene.

What not to put in a slug: branch names, ticket numbers, dates, commit hashes, or a person's name. All of those change, and the point of a slug is that it does not. The branch and commit are recorded on each capture already, so the same slug on two branches is exactly what you want: it is what lets a reviewer see your version of the moment next to the one on the main branch.

Good slugs read like the moment they mark:

signup-confirmation
cart-empty
cart-one-item
invoice-overdue
settings-notifications

Where to put the call

Mark the moment the change takes effect. The interesting scene is the invoice that is overdue, not the login screen you passed through to reach it. A reviewer who lands on a login form has to do the work you were trying to save them.

Put it after the app has settled. The snapshot and screenshot are taken the moment the call runs, so assert your way to the state first. await the element you care about, then mark the scene, and you will not capture a spinner.

await expect(page.getByRole("heading", { name: "Overdue" })).toBeVisible();

await midstreamScene(page, "invoice-overdue");

A test that leans on earlier tests belongs in test.describe.serial. A deploy replays your scene by running its test out of the file. If the test reads state an earlier test created — an account, a draft order, an id held in a module variable — that state is not there, and the deploy fails before it reaches the scene. test.describe.serial is the fix, and Midstream reads it: when the scene sits in a serial block, the deploy runs the whole block in order and stops at your scene. A test that leans on a sibling outside a serial block gets no such promise — Playwright may run those in any order, or at the same time — so it will not replay.

test.describe.serial("Checkout", () => {
  test("adds an item to the cart", async ({ page }) => { /* … */ });

  test("applies a promo code", async ({ page }) => {
    await midstreamScene(page, "checkout-promo-applied");
  });
});

Nothing destructive after the call. During a deploy, the test keeps running after the scene is captured, against the same running app the reviewer is about to open. A cleanup step that deletes the record you just marked will delete it out from under them. Put teardown in an afterEach hook, or mark the scene last.

One scene per moment worth reviewing. The test is a rough guide: if you would show two different screens to two different people and ask them different questions, those are two scenes, even in one test. If the second call would show the same thing with one field filled in, it is not worth its own entry.

Be deliberate about volume. Each scene call in CI takes a screenshot and an accessibility snapshot and makes one API call. That is cheap individually, and a hundred of them per commit is still a hundred. More to the point, a catalog of a hundred scenes nobody chose carefully is a catalog nobody reads.

Organizing with folders

folderPath files a scene in a folder, one array element per level:

await midstreamScene(page, "cart-one-item", {
  folderPath: ["storefront", "checkout"],
});

Folders are created the first time a capture names them. Nothing to set up beforehand.

Two things that work well as a folder structure: your product's areas (storefront, admin, billing), or the shape of your test directory if that already mirrors the product. What works badly is a structure that encodes who wrote something or when — the same problem as putting it in a slug.

Changing folderPath moves the scene the next time CI runs, and unlike the slug that is a safe change. The folder is where a scene is filed; the slug is what it is.