Page events

Subscribe to dialogs, console messages, errors, downloads, and new tabs.

The browser is constantly emitting "things". If you watch closely, you'll find a console.log here, an uncaught exception there, a dialog popping up, a file starting to download, or a target="_blank" link opening a new tab. Playwright exposes these as events on the Page class. Subscribing lets your tests assert on side effects the DOM doesn't show.

You've already met two events, request and response, in the network lesson. This lesson covers the rest.

Two ways to listen

You can listen to every page event in two ways. Pick one based on whether you care about all occurrences or the next one.

Continuous
page.on()
every event

Stays subscribed for the lifetime of the page. Reach for it when you care about all occurrences.

// fires every time, until you remove it
page.on("console", (msg) => {
  console.log(msg.text());
});
Remove it with page.off(), page.removeListener(), or page.removeAllListeners().
One-shot
page.waitForEvent()
next event

Returns a Promise that resolves with the next match. Reach for it when you care about the next one.

// resolves with the next matching event
const downloadPromise =
  page.waitForEvent("download");
await page.getByRole("link", {
  name: "Export CSV",
}).click();
const download = await downloadPromise;
Resolves once, then it's done.
Warning

Register the listener before the action that triggers the event. waitForEvent() must be set up before the click, and page.on() only catches events fired after on() runs.

The console event

Fires for every console.log, console.warn, console.error, etc. on the page. The handler receives a ConsoleMessage with .text(), .type() ("log", "warning", "error", …), and .args().

Use page.on('console') if you want to catch every message during a test run and don't care about a specific one.

const messages = [];
page.on("console", (msg) => messages.push(msg.text()));

await page.goto("/");
// ...
// ...
// ...

// Assert that there were no `console.log` messages at the end of the test
expect(messages).toEqual([]);

This approach works great if you want to make sure that no leftover console.log messages make it to production. .text() is enough to find out if someone or something is spamming the JS console.

The pageerror event

Fires when an uncaught exception bubbles up to the window object. pageerror is the most useful "your app broke and the test should know" hook in Playwright land.

You can collect errors the same way you collected console.log messages above. Collect page errors, let your test do whatever it needs to do, and check for errors at the end of the test.

const errors = [];
page.on("pageerror", (error) => errors.push(error));

await page.goto("/");
await page.getByRole("button", { name: "Add to cart" }).click();
// ...
// ...
// ...

// Assert that there were no errors at the end of the test
expect(errors).toEqual([]);

A common pattern is to register these handlers in a beforeEach (or a custom fixture) so every test fails on unexpected runtime errors without having to opt in.

Inline exercise

Catch the runtime errorSelect this container via test id: pageerror-exercise

The button below throws an uncaught Error from its onClick handler.

  1. Navigate to this lesson page.
  2. Subscribe with page.on("pageerror") and push every error into an array.
  3. Click the button (it lives inside this exercise, so scope your locator with the data-testid).
  4. Assert exactly one error was captured and that its message matches what you see below.
▸ Show solution
const errors = [];
page.on("pageerror", (error) => errors.push(error));

await page.goto("/lessons/writing-tests/10-page-events");
await page
  .getByTestId("pageerror-exercise")
  .getByRole("button", { name: /Trigger an error/ })
  .click();

expect(errors).toHaveLength(1);
expect(errors[0].message).toBe("Boom! Something went wrong.");

The dialog event

Fires for alert(), confirm(), prompt(), and beforeunload. The Dialog object tells you the type and message, and lets you respond.

Remember to register your page.on event handler early because it must be in place when the dialog appears.

page.on("dialog", async (dialog) => {
  console.log(dialog.type(), dialog.message()); // "confirm", "Delete this item?"
  if (dialog.type() === "prompt") {
    await dialog.accept("Playwright");
  } else {
    await dialog.dismiss();
  }
});

await page.goto('data:text/html,<script>alert("hi")</script>');
Warning

If no dialog listener is registered, Playwright auto-dismisses the dialog and the action that triggered it (e.g. a click) hangs until it times out.

Always register a handler for pages that can open dialogs even if all you do is dialog.dismiss().

Inline exercise

Answer a prompt() with a nameSelect this container via test id: prompt-exercise

The button below opens window.prompt("What's your name?"). The text underneath flips from No name yet… to Hi, <name>! once you answer.

  1. Navigate to this lesson page.
  2. Register page.on("dialog") and call dialog.accept("Stefan") to answer the prompt.
  3. Click the Tell me your name button (scope your locator with the data-testid).
  4. Assert the visible greeting reads "Hi, Stefan!".

No name yet…

▸ Show solution
page.on("dialog", async (dialog) => {
  expect(dialog.type()).toBe("prompt");
  expect(dialog.message()).toBe("What's your name?");
  await dialog.accept("Stefan");
});

await page.goto("/lessons/writing-tests/10-page-events");
const exercise = page.getByTestId("prompt-exercise");
await exercise.getByRole("button", { name: "Tell me your name" }).click();

await expect(exercise.getByTestId("prompt-greeting")).toHaveText("Hi, Stefan!");

dialog.accept(value) is what makes a prompt() return a string. Pass nothing and the page sees "". Call dismiss() and it sees null.

The download event

Fires when the browser starts a download. Use waitForEvent here because the download is triggered by a specific click.

const downloadPromise = page.waitForEvent("download");
await page.getByRole("link", { name: "Download invoice" }).click();
const download = await downloadPromise;

await download.saveAs(`./downloads/${download.suggestedFilename()}`);

Download also exposes .path() (the temp file Playwright wrote) and .failure() for inspecting failed downloads.

Inline exercise

Download and inspect an invoiceSelect this container via test id: download-exercise

The link below downloads a small text invoice instead of navigating to it.

  1. Navigate to this lesson page.
  2. Set up page.waitForEvent("download") before the click, and don't await it yet.
  3. Click the Download invoice link (scope your locator with the data-testid).
  4. Await the download and assert that its suggested filename is invoice-0042.txt.
  5. Bonus: read the downloaded file via download.path() and assert that it contains Total: $42.00.
▸ Show solution
import { readFile } from "node:fs/promises";

// ...

await page.goto("/lessons/writing-tests/10-page-events");

const downloadPromise = page.waitForEvent("download");
await page
  .getByTestId("download-exercise")
  .getByRole("link", { name: "Download invoice" })
  .click();
const download = await downloadPromise;

expect(download.suggestedFilename()).toBe("invoice-0042.txt");

const content = await readFile(await download.path(), "utf-8");
expect(content).toContain("Total: $42.00");

download.path() resolves once the download has finished, so there's no need to wait for anything else before reading the file. Playwright deletes downloaded files when the browser context closes. Call download.saveAs() if you want to keep one around.

The popup event

Fires when the page opens a new tab. It could be triggered by clicking a target="_blank" link, window.open(), or a server-issued redirect that lands in a new window. The handler receives a fresh Page you can drive like any other.

If a specific click opens the tab, use waitForEvent to catch this one-time action.

const popupPromise = page.waitForEvent("popup");
await page.getByRole("link", { name: "Open in new tab" }).click();
const popup = await popupPromise;

await popup.waitForLoadState();
await expect(popup).toHaveTitle(/Docs/);

To catch new pages opened from any page in the browser context (e.g. a deep link that opens a tab from JS you didn't trigger), use browserContext.on("page") instead.

Note

There are plenty more events available on Page: crash, close, domcontentloaded, load, worker, websocket, frameattached, etc.

See the full list in the Playwright API reference.


Hands on

Practice listening to page events

Exercise 1 of 2

Fail the test on unexpected console output

  1. Append ?log=true to any page URL and the Logger component prints four messages to the console.
  2. Subscribe to page.on("console") and collect every message into an array.
  3. Navigate around the site and assert the array is empty (the test should fail, and that's the point).
  4. Then drop the ?log=true and watch it pass.
Exercise 2 of 2

Catch the Bluesky share popup

  1. Open any product page (e.g. /product/the-multi-managed-snowboard).
  2. The Share on Bluesky ↗ link opens bsky.app/intent/compose in a new tab.
  3. Use page.waitForEvent("popup") before the click to grab the new page.
  4. After clicking, assert the popup's URL matches bsky.app.