page.on()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());
});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.
Every page event has two access patterns. Pick based on whether you care about all occurrences or the next one.
page.on()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());
});page.waitForEvent()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;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.
Catch the runtime errorSelect this container via test id: pageerror-exercise
The button below throws an uncaught Error from its onClick handler.
page.on("pageerror") and push every error into an array.data-testid).message matches what you see below.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>');
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().
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.
page.on("dialog") and call dialog.accept("Stefan") —
the argument is what gets returned from the page's prompt().data-testid)."Hi, Stefan!".No name yet…
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.
More events live on Page — crash, close, domcontentloaded, load,
worker, websocket, frameattached, … See the full list in the Playwright
API reference.
Fail the test on unexpected console output
?log=true to any page URL and the Logger component prints
four messages to the console.page.on("console") and collect every message into an
array.?log=true and watch it pass.Catch the Bluesky share popup
/product/the-multi-managed-snowboard).bsky.app/intent/compose in a
new tab.page.waitForEvent("popup") before the click to grab the new
page.bsky.app.