← All posts
·playwrightflaky-testspromise-all

Fix flaky Playwright download, popup, and upload tests with Promise.all

A download test passes ten times, then goes red on the eleventh with Timeout waiting for event "download". Nobody changed the app. The test is racing itself.

This shows up on three common flows: a button that downloads a file, a "Sign in with Google" popup, a file picker behind an upload control. Each one flakes for the same reason, and the fix is the same line of structure.

Why the listener loses the race

Write a download test the way it reads in your head and you get this:

await page.getByRole('button', { name: 'Export CSV' }).click();
const download = await page.waitForEvent('download');

Click first, then wait. The trouble is timing. click() fires the download, and a fast download can resolve before the next line registers the listener. When that happens, waitForEvent('download') waits for an event that already fired, and you sit there until the 30-second timeout. On a slow CI box the order flips often enough that the failure looks random.

The popup and the file chooser have the identical shape. click() opens the popup, the popup event fires, and your waitForEvent('popup') arrives a beat too late.

Pair the listener with the action

Register the listener before the click, and await both together:

const [download] = await Promise.all([
  page.waitForEvent('download'),
  page.getByRole('button', { name: 'Export CSV' }).click(),
]);

Promise.all starts the listener first, then fires the click. The event can't slip through, because the listener is already running when it happens. The popup and the upload take the same pairing:

const [popup] = await Promise.all([
  page.waitForEvent('popup'),
  page.getByRole('button', { name: 'Continue with Google' }).click(),
]);

const [chooser] = await Promise.all([
  page.waitForEvent('filechooser'),
  page.getByText('Upload avatar').click(),
]);
await chooser.setFiles('avatar.png');

Let Hover write the pairing

You know the rule now, but you still have to remember it on every download, popup, and upload you test. Hover removes that step.

When you crystallize a session, Hover's deterministic translator already emits popup and new-tab flows as Promise.all pairings. For a download or a file upload it leaves a // hover:optimizable marker on the spot, and the optional optimization pass fills it in, taking the exact pairing from Hover's built-in worked-example seeds. You read the diff and keep it. The spec that ships is plain Playwright with the race designed out.

See how Hover structures a spec →

Try Hover on your own app.

Add Hover’s MCP to the coding agent you already run. It explores your app and crystallizes plain Playwright specs you own.

npm i -g @hover-dev/mcp && claude mcp add hover -- hover-mcpRead the quick start →