A Cypress `after:screenshot` handler crops the image, but the run reports the old size. Why?
answer
- The file changed; the record did not
- Cypress does not re-measure the file
- Only what comes back is merged
- A very short allowlist of keys
- Moving without reporting is the worse case
basics
~20 sBecause Cypress only updates the recorded details from what the handler returns. It reads exactly three keys - path, size and dimensions - and ignores anything else, so a handler that edits the file but returns nothing leaves the original metadata in place.
solid answer
~40 sThe event fires after the image is already on disk, so the handler is editing a real file - but the numbers Cypress reports come from the `details` it already holds, not from re-reading the file. Cypress merges back exactly three keys from whatever the handler returns: `path`, `size` and `dimensions`. Every other key is ignored, and a return value that is not an object is ignored entirely. So cropping and returning nothing, or returning `details` unchanged, leaves the run describing the pre-crop image. Fix it by measuring the result and returning `{ path, size, dimensions }`, or a promise resolving to it. This matters most when the file was **moved**: an unreported `path` leaves the recorded location pointing at nothing.
code
javascript · 26 linesconst { defineConfig } = require('cypress')
const fs = require('fs')
const path = require('path')
module.exports = defineConfig({
e2e: {
setupNodeEvents(on) {
on('after:screenshot', (details) => {
const tenant = process.env.ADMIN_CONSOLE_TENANT || 'unknown-tenant'
const renamed = path.join(
path.dirname(details.path),
`${tenant}--${path.basename(details.path)}`
)
fs.renameSync(details.path, renamed)
// only path, size and dimensions are read back
return {
path: renamed,
size: fs.statSync(renamed).size,
dimensions: details.dimensions,
}
})
},
},
})go deeper
Know that Cypress can hand a screenshot to Node code after it is saved, and that the event runs after the file is already written rather than before.
Explain the return contract: which three keys are read back, that everything else is ignored, and why a promise is allowed. Naming the allowlist is the mechanics tier here.
Diagnose the split between the file and its record, and say what downstream consumers - the run results and the onAfterScreenshot callback - do with numbers that no longer describe the image.
Decide how much post-processing of failure evidence a suite should do at all, and who is accountable when the artefacts a team debugs from were rewritten on the way out.
## When the event fires and what it is handed `after:screenshot` runs in Node **after** a screenshot has been taken and **after** the image has already been written to disk. It fires for both kinds of capture: the ones a spec asks for with `cy.screenshot()` and the ones Cypress takes by itself when a test fails. The `details` object describes the image that now exists on disk: - **`path`**, the absolute path to the file, and **`size`**, its size in bytes. - **`dimensions`**, a `{ width, height }` pair in pixels, and **`multipart`**, whether the capture was stitched together from several images. - **`takenAt`** as an ISO 8601 UTC timestamp, **`duration`** in milliseconds, and **`pixelRatio`** where it applies. - **`specName`**, plus **`name`** when `cy.screenshot()` was given a file name. - **`scaled`** and **`blackout`**, echoing what the capture was asked to do. Because the file already exists, the handler is free to move it, rename it, crop it or resize it using any Node image library the project has installed. ## The return contract is a three-key allowlist This is where the file on disk and the reported metadata drift apart. Cypress reads exactly **three keys** from the object a handler returns - `path`, `size` and `dimensions` - merges those into the screenshot details, and **ignores everything else**, including keys copied verbatim out of `details`. A returned value that is not an object at all is ignored entirely. | what the handler returns | what the run then reports | |---|---| | nothing, or a value that is not an object | the pre-edit path, size and dimensions | | `{ path }` after moving the file | the new path, but the **old** size and dimensions | | `{ path, size, dimensions }` | all three corrected | | a promise resolving to that object | awaited, then merged exactly as above | So a handler that crops an image and returns nothing leaves the run reporting the width, height and byte count of a file that no longer exists in that form. Nothing fails and nothing warns; the numbers are simply wrong from that point on. ## Why the mismatch is worth fixing - The merged values are what reach the `onAfterScreenshot` callback configured through `Cypress.Screenshot.defaults()` or through the options of `cy.screenshot()`, so a stale `dimensions` propagates into whatever per-screenshot logic the suite has built. - They are also what the run's results carry, which is what a later pipeline step or a reviewer reads when the failure is investigated. - A moved file whose new `path` was never returned is worse than stale numbers: the recorded path points at nothing, so anything that later attaches, uploads or opens the image fails on a file that is sitting safely somewhere else. ## Doing it correctly 1. Do the work - move, rename, crop or resize - and let it finish before returning. 2. Measure the result: the new absolute path, `fs.statSync(newPath).size` for the byte count, and the new width and height from whichever image library did the work. 3. Return `{ path, size, dimensions }`, or a promise resolving to it, and nothing else. An asynchronous handler has to resolve rather than merely start the work. Returning before a rename has finished means Cypress merges details that describe a file still in motion. ## Where this event stops `after:screenshot` is a **capture and post-processing** seam. It is where a screenshot of a multi-tenant admin console gets renamed to carry the tenant it belongs to, where an oversized headless capture gets scaled down before it is stored, or where an image gets moved into a per-environment folder. Cypress ships no image comparison of its own, so this is not a baseline-approval hook - it hands you a file that already exists and asks you to describe accurately what you did to it.
- Which screenshots does a Cypress `after:screenshot` handler see?Both kinds: the ones a spec requests with `cy.screenshot()` and the ones Cypress captures automatically when a test fails. The `details` object says which spec it came from through `specName`, and carries `name` when a file name was passed to `cy.screenshot()`, so a handler can branch on the two sources if it needs to.
- What happens if a Cypress `after:screenshot` handler returns a string path instead of an object?It is ignored. Cypress only reads a returned value when it is an object, and then only its `path`, `size` and `dimensions` keys. A bare string is discarded silently, so the run keeps reporting the original location - which looks exactly like the handler never ran.
saying these in an interview costs you the question
- Assumes Cypress re-reads the file after the handler runs
- Returns the whole details object and expects edits to stick
- Thinks any returned key is merged into the details
- Moves the image without reporting the new path