A Playwright HAR replay leaves the weather dashboard's forecast panel empty in CI though it passes locally — how do you diagnose it?
answer
- Two shapes: no match, or empty match
- Compare the exact requested URL
- Check the bodies actually shipped
- Relative paths follow the working directory
- Fallback tells you which half
basics
~20 sEstablish first whether the archive answered the request at all. Then compare the URL the app asks for against the recorded one, and check that the archive's response bodies actually travelled into CI rather than staying in uncommitted attachment files.
solid answer
~40 sSplit the failure in two: either the request was not matched, or it was matched and returned nothing useful. A miss usually means the lookup key differs — a different `baseURL`, a cache-busting query parameter, or a relative HAR path that resolved against a different working directory in CI, since `routeFromHAR()` resolves relative paths against the process's cwd. An empty match usually means the bodies are missing: with the default `'attach'` content handling, payloads live in files beside the archive, so committing only the `.har` gives you entries with nothing behind them; recording with `content: 'omit'` has the same effect. Temporarily switching to `notFound: 'fallback'` tells you which half you are in — if the panel fills, the request was escaping to the live API locally and simply is not in the archive.
code
typescript · 12 linesimport path from 'node:path';
import { test, expect } from '@playwright/test';
test('forecast panel renders from the archive', async ({ page }) => {
await page.routeFromHAR(path.join(__dirname, 'har', 'weather.har.zip'), {
url: '**/api/forecast**',
notFound: 'abort',
});
await page.goto('/dashboard');
await expect(page.getByTestId('forecast-high')).toHaveText('21');
});go deeper
Know the first two things to check: whether the archive file is really present where the test looks, and whether the URL the app requests matches one recorded in the file.
Explain how a miss and an empty match differ, and name the mechanics behind each: notFound aborting, relative paths resolving against the working directory, and bodies stored as attachments.
Drive the diagnosis in order, from proving which request the app makes to confirming the payloads shipped, and make the local run resemble CI so the gap cannot hide behind network access.
Set conventions that remove the failure class: self-contained archives, paths resolved from the spec folder, and CI runners without egress so an unrecorded request cannot silently succeed.
## Split the failure in two before you touch anything There are only two shapes of this bug, and every fix depends on which one you have: 1. **The request was never answered from the archive.** No entry matched, so with the default `notFound: 'abort'` the request died and the panel rendered its error or empty state. 2. **The request was answered, but with nothing useful.** An entry matched and replay served an empty or truncated body, so the panel rendered with no data. The cheapest discriminator is to flip `notFound` to `'fallback'` for one local run with network access. If the panel fills in, you were in case 1 and the request had been quietly reaching the real forecast API on your machine all along — which is exactly why it passed locally and failed in CI. ## Causes, symptoms and fixes | symptom | likely cause | fix | |---|---|---| | passes locally, aborts in CI | archive path resolved against a different working directory | build the path with `path.join(__dirname, ...)` | | entries exist, bodies empty | `'attach'` content left in sibling files that were never committed | record to a `.zip` path, or re-record with `updateContent: 'embed'` | | nothing matches at all | different `baseURL` between local run and CI project | record and replay against the same origin, or scope with `url` | | one endpoint misses | cache-busting `?t=` or a rotating key in the query string | re-record, and narrow `url` so only stable endpoints are replayed | | archive has no payloads | recorded with `content: 'omit'` | re-record with the default content handling | | endpoint absent entirely | `recordHar.urlFilter` excluded it during capture | widen the filter and re-record | ## A diagnosis order that converges 1. **Prove which request the app makes.** Log it or read it off a trace of the CI run, then compare that URL, character for character, with the URLs in the archive. `jq '.log.entries[].request.url'` over the `.har` is enough. 2. **Check the file arrived.** List the archive path from inside the CI job. A relative path is resolved against the current working directory of the run, and CI often starts the process somewhere other than the package folder. 3. **Check the payloads arrived.** If the archive is a plain `.har` recorded with the default attachment behaviour, its bodies are separate files; confirm they are in the repository and in the checkout, not just on the machine that recorded them. 4. **Check the scope options.** A `url` pattern that matched locally can miss in CI if the origin differs, and a request outside `url` is never served from the archive at all. 5. **Only then suspect the app.** If the request matched and the recorded body is right, the bug is in rendering, not in replay. ## Why "works on my machine" is the signature failure A local developer run usually has network access, so any request the archive misses is answered by the real forecast API and nobody notices the gap. CI typically has different DNS, different credentials, sometimes no egress at all, and a different working directory. The recording is the only thing that changed shape between the two environments, so the archive is where to look first, not the application code. - Make the local run resemble CI: run once with `notFound: 'abort'` and no access to the live API. - Keep archives self-contained — a `.zip` path bundles the HAR and its attachments into one file that cannot be half-committed. - Prefer a narrow `url` scope so the archive answers only the endpoints you deliberately recorded, and everything else keeps working through the normal server. ## Hardening so it does not recur - Resolve archive paths from `__dirname`, never from the process working directory. - Assert something that only real data can satisfy, so an empty replay fails on an assertion instead of rendering a plausible empty state. - Record with a narrowed `urlFilter` so the archive is small enough that a human reads the diff when it is refreshed. - Re-record with `update: true` through one shared helper, so every archive in the suite is produced the same way and none of them depends on a developer's ad-hoc flags.
- Why can committing only the .har file break replay in CI?Because the default content handling writes response bodies as separate files beside the archive rather than inside it. The HAR alone then carries entries whose payloads are missing in the checkout, so requests match but return nothing. Recording to a `.zip` path keeps everything in one committed file.
- How do you prove the archive rather than the application is at fault?Run the same spec locally with `notFound: 'abort'` and no route to the live API. If the panel is empty there too, the archive is incomplete. If it fills in only when the network is reachable, the request was escaping replay, and the URLs the two environments request need comparing.
- What makes an empty replay pass silently instead of failing?Assertions that a blank page also satisfies — checking a container is visible, or that no error banner appeared. Assert on values that only real recorded data produces, such as a specific temperature or city name, so a missing body fails the test at the assertion.
saying these in an interview costs you the question
- Blames a flaky third-party API that replay never calls
- Assumes the HAR path resolves relative to the spec file
- Thinks committing the .har alone always carries the bodies
- Ignores query-string differences between recording and CI
- Adds a wait instead of checking whether the archive matched
- Rewrites the assertion until the empty state passes