Page events

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

The browser is constantly emitting things — a console.log here, an uncaught exception there, a dialog popping up, a file starting to download, a target="_blank" link spawning a new tab. Playwright surfaces 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 of them: request and response in the network lesson. This lesson is the canonical tour.

Two ways to listen

Every page event has two access patterns. Pick 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 call
// page.off() or the page closes
page.on("console", (msg) => {
  console.log(msg.text());
});
Stops on page.off() or when the page closes.
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; page.on() only catches events fired after on() runs. This is the same gotcha as page.route().

page.on('console')

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

Use page.on here — you want to catch every message during the run, not a specific one.

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

await page.goto("/");

expect(messages).toEqual([]);

A clean console is what we all aim for, right?

In this case, .text() is enough for most check if someone spamming the JS console. Reach for .args() only when you need structured values out of console.log({ foo: "bar" }).

page.on('pageerror')

Fires when an uncaught exception bubbles to window. The single most useful "your app broke and the test should know" hook.

Same shape as console: page.on so any error during the test gets caught.

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

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

expect(errors).toEqual([]);

A common pattern is to register this 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 — 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.");

Scoping the click via getByTestId("pageerror-exercise") keeps the locator stable even if a future lesson edit adds another button on the page.

page.on('dialog')

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

Use page.on because the handler must be in place when the dialog appears — Playwright won't pause and wait for you to attach one.

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") — the argument is what gets returned from the page's 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.

page.on('download')

Fires when the browser starts a download. Use waitForEvent here — the download is triggered by a specific click, so you wait for that exact event.

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.

page.on('popup')

Fires when the page opens a new tab — 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.

Same as downloads: a specific click opens the tab, so waitForEvent is the natural fit.

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

More events live on Pagecrash, close, domcontentloaded, load, worker, websocket, frameattached, … 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 the site and assert the array is empty (the test should fail — 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.