From 2600bc158fd3c48f47b05cf249955e98f5d100ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Florian=20Spie=C3=9F?= Date: Fri, 4 Sep 2026 11:41:44 +0200 Subject: [PATCH 1/4] feat(console-captor): Add ConsoleCaptor to collect console logs from a page --- .changeset/lucky-moons-listen.md | 5 + docs/.vitepress/config.ts | 4 + docs/src/index.md | 3 + docs/src/logging/console-captor.md | 134 +++++++++++++++ src/api/logging/console-captor.ts | 263 +++++++++++++++++++++++++++++ src/index.ts | 2 + tests/console-captor.spec.ts | 221 ++++++++++++++++++++++++ 7 files changed, 632 insertions(+) create mode 100644 .changeset/lucky-moons-listen.md create mode 100644 docs/src/logging/console-captor.md create mode 100644 src/api/logging/console-captor.ts create mode 100644 tests/console-captor.spec.ts diff --git a/.changeset/lucky-moons-listen.md b/.changeset/lucky-moons-listen.md new file mode 100644 index 0000000..33b9194 --- /dev/null +++ b/.changeset/lucky-moons-listen.md @@ -0,0 +1,5 @@ +--- +"@cronn/playwright-utils": minor +--- + +Add `ConsoleCaptor` to collect the console messages of a page diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index ba4bb57..5543f41 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -46,6 +46,10 @@ export default defineConfig({ { text: "Fetch Adapter", link: "/api/fetch-adapter" }, ], }, + { + text: "Logging", + items: [{ text: "Console Captor", link: "/logging/console-captor" }], + }, { text: "Snapshot Testing", items: [{ text: "Normalizers", link: "/snapshots/normalizers" }], diff --git a/docs/src/index.md b/docs/src/index.md index 9582767..57fc095 100644 --- a/docs/src/index.md +++ b/docs/src/index.md @@ -19,6 +19,9 @@ features: - title: Fetch Adapter details: Use Playwright's request context as a fetch implementation to send requests of an API client through Playwright. link: /api/fetch-adapter + - title: Console Captor + details: Collect the console messages of a page, filtered by level or a custom predicate, for a single action or a whole test. + link: /logging/console-captor - title: Snapshot Testing details: Mask non-deterministic values like IDs, timestamps or base URLs in a consistent format to keep file snapshots stable. link: /snapshots/normalizers diff --git a/docs/src/logging/console-captor.md b/docs/src/logging/console-captor.md new file mode 100644 index 0000000..f8c7e35 --- /dev/null +++ b/docs/src/logging/console-captor.md @@ -0,0 +1,134 @@ +# Console Captor + +Playwright exposes the console output of a page through the [`console`](https://playwright.dev/docs/api/class-page#page-event-console) event, which requires registering a listener, collecting the messages and removing the listener again once the test no longer needs it. + +`ConsoleCaptor` bundles these steps: it collects the [`ConsoleMessage`](https://playwright.dev/docs/api/class-consolemessage) objects of a page in an array, optionally filtered by level or a custom predicate, and can limit the capturing to a single action. + +## Usage + +```ts +import { ConsoleCaptor } from "@cronn/playwright-utils"; +import { expect, test } from "@playwright/test"; + +test("logs the selected filter", async ({ page }) => { + const logs = ConsoleCaptor.log(page); + + await logs.during(async () => { + await page.goto("/users"); + await page.getByRole("button", { name: "Only enabled" }).click(); + }); + + expect(logs.messages.map((message) => message.text())).toEqual([ + "filter changed: enabled", + ]); +}); +``` + +Captured messages are available in the `messages` array, in the order in which they were reported by the page. The array is filled while the captor is running, so it can also be inspected inside the action. + +Several captors can run on the same page at the same time, each with its own filter, and every captor receives all matching messages independently. + +## Filtering messages + +By default a captor collects every console message of the page. The static factories create a captor restricted to one level: + +```ts +ConsoleCaptor.log(page); +ConsoleCaptor.info(page); +ConsoleCaptor.warning(page); +ConsoleCaptor.error(page); +``` + +`ConsoleCaptor.level` accepts any of the types reported by [`consoleMessage.type`](https://playwright.dev/docs/api/class-consolemessage#console-message-type), for example `debug`, `trace` or `table`: + +```ts +const captor = ConsoleCaptor.level(page, "debug"); +``` + +Note that `console.warn` is reported as `warning`, and that messages logged by the browser itself, such as failed requests or CSP violations, are reported as `error`. + +### Custom filters + +The constructor and all factories accept a predicate as their last argument, which receives the `ConsoleMessage` and decides whether it is captured. This is useful to ignore known noise, or to narrow the captured messages down to the ones a test is about: + +```ts +// only errors of a specific feature +const checkoutErrors = ConsoleCaptor.error(page, (message) => + message.text().startsWith("[checkout]"), +); + +// any message originating from a specific script +const analyticsMessages = new ConsoleCaptor(page, (message) => + message.location().url.endsWith("/analytics.js"), +); +``` + +A filter passed to a level factory is combined with the level, so both have to match for a message to be captured. + +## Scoping to an action + +`during` starts the capturing, runs the given action and stops the capturing again once the action has finished, even if it throws. It returns the value of the action, so it can wrap an existing step of a test: + +```ts +const userId = await ConsoleCaptor.error(page).during(async () => { + await page.getByRole("button", { name: "Create user" }).click(); + return readCreatedUserId(page); +}); +``` + +A promise returned by the action is awaited before the capturing stops, no matter when it is awaited by the test. This can be used to keep the capturing open until a request has been answered: + +```ts +const captor = ConsoleCaptor.error(page); + +// captures until the response arrives, not until `during` returns +const responsePromise = captor.during(() => page.waitForResponse("/api/users")); +await page.getByRole("button", { name: "Fetch users" }).click(); +await responsePromise; +``` + +Synchronous actions are supported as well and are not wrapped in a promise: + +```ts +const captor = ConsoleCaptor.error(page); +const users = captor.during(() => parseUsers(payload)); +``` + +::: warning +Only the value returned by the action is awaited. Asynchronous work which the action starts without returning it is not covered by the capturing: + +```ts +let responsePromise: Promise; + +captor.during(() => { + // not returned, so the captor stops before the response arrives + responsePromise = page.waitForResponse("/api/users"); +}); + +await responsePromise; +``` + +::: + +## Manual capturing + +For captures which span multiple steps, `start` and `stop` control the capturing directly. A captor registered in a fixture keeps a test free of setup and teardown, and can assert that a test produced no unexpected console errors: + +```ts +import { ConsoleCaptor } from "@cronn/playwright-utils"; +import { expect, test as base } from "@playwright/test"; + +export const test = base.extend<{ consoleErrors: ConsoleCaptor }>({ + consoleErrors: async ({ page }, use) => { + const captor = ConsoleCaptor.error(page); + captor.start(); + + await use(captor); + + captor.stop(); + expect(captor.messages.map((message) => message.text())).toEqual([]); + }, +}); +``` + +A captor can be started and stopped repeatedly; the collected messages are kept across restarts. diff --git a/src/api/logging/console-captor.ts b/src/api/logging/console-captor.ts new file mode 100644 index 0000000..d89107c --- /dev/null +++ b/src/api/logging/console-captor.ts @@ -0,0 +1,263 @@ +import type { ConsoleMessage, Page } from "@playwright/test"; + +/** + * Filter used to select the messages collected by a {@link ConsoleCaptor}. + */ +type ConsoleMessageFilter = (message: ConsoleMessage) => boolean; + +/** + * The log level of a {@link ConsoleMessage}, e.g. `log`, `warning` or `error`. + */ +type ConsoleMessageLevel = ReturnType; + +function defaultFilter(_message: ConsoleMessage) { + return true; +} + +/** + * Collects the console messages of a page. + * + * Playwright reports the console output of a page through its `console` event, + * which has to be registered and removed again by hand. A captor bundles these + * steps, optionally filters the messages by level or a custom predicate and can + * limit the capturing to a single action. + * + * Several captors can run on the same page at the same time, each with its own + * filter, and every captor receives all matching messages independently. + * + * @example + * ```ts + * const logs = ConsoleCaptor.log(page); + * + * await logs.during(async () => { + * await page.goto("/users"); + * await page.getByRole("button", { name: "Only enabled" }).click(); + * }); + * + * expect(logs.messages.map((message) => message.text())).toEqual([ + * "filter changed: enabled", + * ]); + * ``` + */ +export class ConsoleCaptor { + private readonly page: Page; + private readonly filter: ConsoleMessageFilter; + private readonly listener: (messages: ConsoleMessage) => void; + + /** + * The captured messages, in the order in which the page reported them. + * + * The array is filled while the captor is running, so it can also be + * inspected inside the action passed to {@link during}. + */ + public readonly messages: Array = []; + + /** + * Create a captor for the console messages of a page. + * + * The captor does not collect anything until it is started, either by + * {@link start} or by {@link during}. + * + * @param page - The page to capture console messages of + * @param filter - Optional. Decides which messages are captured, captures every message if omitted + * + * @example + * ```ts + * const captor = new ConsoleCaptor(page, (message) => + * message.location().url.endsWith("/analytics.js"), + * ); + * ``` + */ + public constructor(page: Page, filter: ConsoleMessageFilter = defaultFilter) { + this.page = page; + this.filter = filter; + this.listener = (event) => { + if (this.filter(event)) { + this.messages.push(event); + } + }; + } + + /** + * Create a captor for the messages of one log level. + * + * Note that `console.warn` is reported as `warning`, and that messages logged + * by the browser itself, such as failed requests or CSP violations, are + * reported as `error`. + * + * @param page - The page to capture console messages of + * @param level - The log level to capture + * @param filter - Optional. Applied in addition to the level, so both have to match + * @returns ConsoleCaptor + * + * @example + * ```ts + * const captor = ConsoleCaptor.level(page, "debug"); + * ``` + */ + public static level( + page: Page, + level: ConsoleMessageLevel, + filter: ConsoleMessageFilter = defaultFilter, + ): ConsoleCaptor { + return new ConsoleCaptor( + page, + (message) => message.type() === level && filter(message), + ); + } + + /** + * Create a captor for the messages of level `log`. + * + * @param page - The page to capture console messages of + * @param filter - Optional. Applied in addition to the level, so both have to match + * @returns ConsoleCaptor + * + * @see level + */ + public static log( + page: Page, + filter: ConsoleMessageFilter = defaultFilter, + ): ConsoleCaptor { + return ConsoleCaptor.level(page, "log", filter); + } + + /** + * Create a captor for the messages of level `info`. + * + * @param page - The page to capture console messages of + * @param filter - Optional. Applied in addition to the level, so both have to match + * @returns ConsoleCaptor + * + * @see level + */ + public static info( + page: Page, + filter: ConsoleMessageFilter = defaultFilter, + ): ConsoleCaptor { + return ConsoleCaptor.level(page, "info", filter); + } + + /** + * Create a captor for the messages of level `warning`, as reported by `console.warn`. + * + * @param page - The page to capture console messages of + * @param filter - Optional. Applied in addition to the level, so both have to match + * @returns ConsoleCaptor + * + * @see level + */ + public static warning( + page: Page, + filter: ConsoleMessageFilter = defaultFilter, + ): ConsoleCaptor { + return ConsoleCaptor.level(page, "warning", filter); + } + + /** + * Create a captor for the messages of level `error`. + * + * Besides `console.error`, this also captures the errors logged by the + * browser itself, such as failed requests or CSP violations. + * + * @param page - The page to capture console messages of + * @param filter - Optional. Applied in addition to the level, so both have to match + * @returns ConsoleCaptor + * + * @see level + * + * @example + * ```ts + * const captor = ConsoleCaptor.error(page, (message) => + * message.text().startsWith("[checkout]"), + * ); + * ``` + */ + public static error( + page: Page, + filter: ConsoleMessageFilter = defaultFilter, + ): ConsoleCaptor { + return ConsoleCaptor.level(page, "error", filter); + } + + /** + * Start collecting the console messages of the page. + * + * A captor can be started and stopped repeatedly; the collected messages are + * kept across restarts. + * + * @example + * ```ts + * export const test = baseTest.extend<{ consoleErrors: ConsoleCaptor }>({ + * consoleErrors: async ({ page }, use) => { + * const captor = ConsoleCaptor.error(page); + * captor.start(); + * + * await use(captor); + * + * captor.stop(); + * expect(captor.messages.map((message) => message.text())).toEqual([]); + * }, + * }); + * ``` + */ + public start(): void { + this.page.on("console", this.listener); + } + + /** + * Stop collecting the console messages of the page. + * + * The already captured {@link messages} are kept. + */ + public stop(): void { + this.page.off("console", this.listener); + } + + /** + * Collect the console messages reported while the callback runs. + * + * The capturing is stopped once the action has finished, even if it throws. + * A promise returned by the action is awaited before the captor stops, no + * matter when it is awaited by the caller, while synchronous actions are not + * wrapped in a promise. + * + * Only the returned value is awaited: asynchronous work which the action + * starts without returning it is not covered by the capturing. + * + * @param action - The action to capture console messages during + * @returns The value of the action + * + * @example + * ```ts + * const captor = ConsoleCaptor.error(page); + * + * await captor.during(async () => { + * await page.getByRole("button", { name: "Create user" }).click(); + * await expect(page.getByRole("alert")).toBeVisible(); + * }); + * + * expect(captor.messages).toHaveLength(1); + * ``` + */ + public during(action: () => Promise): Promise; + public during(action: () => T): T; + public during(action: () => Promise | T): Promise | T { + this.start(); + + let result: Promise | T; + try { + result = action(); + } catch (error) { + this.stop(); + throw error; + } + + if (result instanceof Promise) { + return result.finally(() => this.stop()); + } + + this.stop(); + return result; + } +} diff --git a/src/index.ts b/src/index.ts index cf254c1..bf11853 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,6 +2,8 @@ export { isCI } from "./environment"; export { createFetchAdapter } from "./api/fetch-adapter"; +export { ConsoleCaptor } from "./api/logging/console-captor"; + export { interceptRoute, RouteInterceptor, diff --git a/tests/console-captor.spec.ts b/tests/console-captor.spec.ts new file mode 100644 index 0000000..fcc6f34 --- /dev/null +++ b/tests/console-captor.spec.ts @@ -0,0 +1,221 @@ +/* eslint-disable no-console -- the console calls run inside the browser page */ +import { type ConsoleMessage, expect, type Page, test } from "@playwright/test"; + +import { ConsoleCaptor } from "../src"; + +type ConsoleMessageFilter = (message: ConsoleMessage) => boolean; + +interface ConsoleEntry { + type: string; + text: string; +} + +const ALL_MESSAGES: Array = [ + { type: "log", text: "log message" }, + { type: "info", text: "info message" }, + { type: "warning", text: "warning message" }, + { type: "error", text: "error message" }, +]; + +async function emitConsoleMessages(page: Page): Promise { + await page.evaluate(() => { + console.log("log message"); + console.info("info message"); + console.warn("warning message"); + console.error("error message"); + }); +} + +function capturedEntries(captor: ConsoleCaptor): Array { + return captor.messages.map((message) => ({ + type: message.type(), + text: message.text(), + })); +} + +async function expectCaptured( + captor: ConsoleCaptor, + expected: Array, +): Promise { + await expect.poll(() => capturedEntries(captor)).toEqual(expected); +} + +test("captures all console messages by default", async ({ page }) => { + const captor = new ConsoleCaptor(page); + captor.start(); + + await emitConsoleMessages(page); + + await expectCaptured(captor, ALL_MESSAGES); +}); + +test("captures only messages matching the filter", async ({ page }) => { + const captor = new ConsoleCaptor(page, (message) => + message.text().includes("info"), + ); + captor.start(); + + await emitConsoleMessages(page); + + await expectCaptured(captor, [{ type: "info", text: "info message" }]); +}); + +for (const { type, text } of ALL_MESSAGES) { + test(`captures messages of level ${type}`, async ({ page }) => { + const captor = ConsoleCaptor.level(page, type as "log"); + captor.start(); + + await emitConsoleMessages(page); + + await expectCaptured(captor, [{ type, text }]); + }); +} + +test("combines level and filter", async ({ page }) => { + const captor = ConsoleCaptor.level(page, "log", (message) => + message.text().includes("info"), + ); + captor.start(); + + await emitConsoleMessages(page); + await expectCaptured(captor, []); +}); + +const LEVEL_FACTORIES = { + log: (page: Page, filter?: ConsoleMessageFilter) => + ConsoleCaptor.log(page, filter), + info: (page: Page, filter?: ConsoleMessageFilter) => + ConsoleCaptor.info(page, filter), + warning: (page: Page, filter?: ConsoleMessageFilter) => + ConsoleCaptor.warning(page, filter), + error: (page: Page, filter?: ConsoleMessageFilter) => + ConsoleCaptor.error(page, filter), +}; + +for (const { type, text } of ALL_MESSAGES) { + test(`provides a shorthand factory for level ${type}`, async ({ page }) => { + const captor = LEVEL_FACTORIES[type as keyof typeof LEVEL_FACTORIES](page); + captor.start(); + + await emitConsoleMessages(page); + + await expectCaptured(captor, [{ type, text }]); + }); + + test(`applies the filter of the shorthand factory for level ${type}`, async ({ + page, + }) => { + const captor = LEVEL_FACTORIES[type as keyof typeof LEVEL_FACTORIES]( + page, + (message) => message.text().includes("no match"), + ); + captor.start(); + + await emitConsoleMessages(page); + await expectCaptured(captor, []); + }); +} + +test("stops capturing messages after stop", async ({ page }) => { + const captor = ConsoleCaptor.log(page); + const reference = ConsoleCaptor.log(page); + reference.start(); + + captor.start(); + await page.evaluate(() => console.log("before stop")); + await expectCaptured(captor, [{ type: "log", text: "before stop" }]); + + captor.stop(); + await page.evaluate(() => console.log("after stop")); + await expectCaptured(reference, [ + { type: "log", text: "before stop" }, + { type: "log", text: "after stop" }, + ]); + + expect(capturedEntries(captor)).toEqual([ + { type: "log", text: "before stop" }, + ]); +}); + +test("captures messages during an async action", async ({ page }) => { + const captor = ConsoleCaptor.log(page); + const reference = ConsoleCaptor.log(page); + reference.start(); + + const result = await captor.during(async () => { + await page.evaluate(() => console.log("during action")); + await expectCaptured(captor, [{ type: "log", text: "during action" }]); + return "result"; + }); + + expect(result).toBe("result"); + + await page.evaluate(() => console.log("after during")); + await expectCaptured(reference, [ + { type: "log", text: "during action" }, + { type: "log", text: "after during" }, + ]); + expect(capturedEntries(captor)).toEqual([ + { type: "log", text: "during action" }, + ]); +}); + +test("returns the result of a synchronous action", async ({ page }) => { + const captor = ConsoleCaptor.log(page); + const reference = ConsoleCaptor.log(page); + reference.start(); + + expect(captor.during(() => "result")).toBe("result"); + + await page.evaluate(() => console.log("after during")); + await expectCaptured(reference, [{ type: "log", text: "after during" }]); + expect(capturedEntries(captor)).toEqual([]); +}); + +test("stops capturing when the action throws", async ({ page }) => { + const captor = ConsoleCaptor.log(page); + const reference = ConsoleCaptor.log(page); + reference.start(); + + expect(() => + captor.during(() => { + throw new Error("action failed"); + }), + ).toThrow("action failed"); + + await page.evaluate(() => console.log("after during")); + await expectCaptured(reference, [{ type: "log", text: "after during" }]); + expect(capturedEntries(captor)).toEqual([]); +}); + +test("captures until a returned promise settles", async ({ page }) => { + const captor = ConsoleCaptor.log(page); + const reference = ConsoleCaptor.log(page); + reference.start(); + + const messagePromise = captor.during(() => + page.waitForEvent("console", (message) => message.text() === "trigger"), + ); + + await page.evaluate(() => { + console.log("before trigger"); + setTimeout(() => console.log("trigger"), 100); + }); + await messagePromise; + + await expectCaptured(captor, [ + { type: "log", text: "before trigger" }, + { type: "log", text: "trigger" }, + ]); + + await page.evaluate(() => console.log("after during")); + await expectCaptured(reference, [ + { type: "log", text: "before trigger" }, + { type: "log", text: "trigger" }, + { type: "log", text: "after during" }, + ]); + expect(capturedEntries(captor)).toEqual([ + { type: "log", text: "before trigger" }, + { type: "log", text: "trigger" }, + ]); +}); From 996c13e7ad757084623ab94cfd463990d14a0491 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Florian=20Spie=C3=9F?= Date: Mon, 7 Sep 2026 10:02:29 +0200 Subject: [PATCH 2/4] feat(console-captor): Add factory method and refactor API in ConsoleCaptor --- src/api/logging/console-captor.ts | 210 +++++++++++------------------- src/index.ts | 8 +- tests/console-captor.spec.ts | 98 ++++++-------- 3 files changed, 122 insertions(+), 194 deletions(-) diff --git a/src/api/logging/console-captor.ts b/src/api/logging/console-captor.ts index d89107c..7474cad 100644 --- a/src/api/logging/console-captor.ts +++ b/src/api/logging/console-captor.ts @@ -3,12 +3,12 @@ import type { ConsoleMessage, Page } from "@playwright/test"; /** * Filter used to select the messages collected by a {@link ConsoleCaptor}. */ -type ConsoleMessageFilter = (message: ConsoleMessage) => boolean; +export type ConsoleMessageFilter = (message: ConsoleMessage) => boolean; /** * The log level of a {@link ConsoleMessage}, e.g. `log`, `warning` or `error`. */ -type ConsoleMessageLevel = ReturnType; +export type ConsoleMessageLevel = ReturnType; function defaultFilter(_message: ConsoleMessage) { return true; @@ -27,7 +27,7 @@ function defaultFilter(_message: ConsoleMessage) { * * @example * ```ts - * const logs = ConsoleCaptor.log(page); + * const logs = captureConsole(page); * * await logs.during(async () => { * await page.goto("/users"); @@ -41,8 +41,7 @@ function defaultFilter(_message: ConsoleMessage) { */ export class ConsoleCaptor { private readonly page: Page; - private readonly filter: ConsoleMessageFilter; - private readonly listener: (messages: ConsoleMessage) => void; + private readonly consoleListener: (messages: ConsoleMessage) => void; /** * The captured messages, in the order in which the page reported them. @@ -56,130 +55,27 @@ export class ConsoleCaptor { * Create a captor for the console messages of a page. * * The captor does not collect anything until it is started, either by - * {@link start} or by {@link during}. + * {@link startCapture} or by {@link during}. * * @param page - The page to capture console messages of * @param filter - Optional. Decides which messages are captured, captures every message if omitted * * @example * ```ts - * const captor = new ConsoleCaptor(page, (message) => + * const captor = captureConsole(page, (message) => * message.location().url.endsWith("/analytics.js"), * ); * ``` */ public constructor(page: Page, filter: ConsoleMessageFilter = defaultFilter) { this.page = page; - this.filter = filter; - this.listener = (event) => { - if (this.filter(event)) { + this.consoleListener = (event) => { + if (filter(event)) { this.messages.push(event); } }; } - /** - * Create a captor for the messages of one log level. - * - * Note that `console.warn` is reported as `warning`, and that messages logged - * by the browser itself, such as failed requests or CSP violations, are - * reported as `error`. - * - * @param page - The page to capture console messages of - * @param level - The log level to capture - * @param filter - Optional. Applied in addition to the level, so both have to match - * @returns ConsoleCaptor - * - * @example - * ```ts - * const captor = ConsoleCaptor.level(page, "debug"); - * ``` - */ - public static level( - page: Page, - level: ConsoleMessageLevel, - filter: ConsoleMessageFilter = defaultFilter, - ): ConsoleCaptor { - return new ConsoleCaptor( - page, - (message) => message.type() === level && filter(message), - ); - } - - /** - * Create a captor for the messages of level `log`. - * - * @param page - The page to capture console messages of - * @param filter - Optional. Applied in addition to the level, so both have to match - * @returns ConsoleCaptor - * - * @see level - */ - public static log( - page: Page, - filter: ConsoleMessageFilter = defaultFilter, - ): ConsoleCaptor { - return ConsoleCaptor.level(page, "log", filter); - } - - /** - * Create a captor for the messages of level `info`. - * - * @param page - The page to capture console messages of - * @param filter - Optional. Applied in addition to the level, so both have to match - * @returns ConsoleCaptor - * - * @see level - */ - public static info( - page: Page, - filter: ConsoleMessageFilter = defaultFilter, - ): ConsoleCaptor { - return ConsoleCaptor.level(page, "info", filter); - } - - /** - * Create a captor for the messages of level `warning`, as reported by `console.warn`. - * - * @param page - The page to capture console messages of - * @param filter - Optional. Applied in addition to the level, so both have to match - * @returns ConsoleCaptor - * - * @see level - */ - public static warning( - page: Page, - filter: ConsoleMessageFilter = defaultFilter, - ): ConsoleCaptor { - return ConsoleCaptor.level(page, "warning", filter); - } - - /** - * Create a captor for the messages of level `error`. - * - * Besides `console.error`, this also captures the errors logged by the - * browser itself, such as failed requests or CSP violations. - * - * @param page - The page to capture console messages of - * @param filter - Optional. Applied in addition to the level, so both have to match - * @returns ConsoleCaptor - * - * @see level - * - * @example - * ```ts - * const captor = ConsoleCaptor.error(page, (message) => - * message.text().startsWith("[checkout]"), - * ); - * ``` - */ - public static error( - page: Page, - filter: ConsoleMessageFilter = defaultFilter, - ): ConsoleCaptor { - return ConsoleCaptor.level(page, "error", filter); - } - /** * Start collecting the console messages of the page. * @@ -190,7 +86,7 @@ export class ConsoleCaptor { * ```ts * export const test = baseTest.extend<{ consoleErrors: ConsoleCaptor }>({ * consoleErrors: async ({ page }, use) => { - * const captor = ConsoleCaptor.error(page); + * const captor = captureConsole(page, "error"); * captor.start(); * * await use(captor); @@ -201,8 +97,8 @@ export class ConsoleCaptor { * }); * ``` */ - public start(): void { - this.page.on("console", this.listener); + public startCapture(): void { + this.page.on("console", this.consoleListener); } /** @@ -210,8 +106,8 @@ export class ConsoleCaptor { * * The already captured {@link messages} are kept. */ - public stop(): void { - this.page.off("console", this.listener); + public stopCapture(): void { + this.page.off("console", this.consoleListener); } /** @@ -219,8 +115,7 @@ export class ConsoleCaptor { * * The capturing is stopped once the action has finished, even if it throws. * A promise returned by the action is awaited before the captor stops, no - * matter when it is awaited by the caller, while synchronous actions are not - * wrapped in a promise. + * matter when it is awaited by the caller. * * Only the returned value is awaited: asynchronous work which the action * starts without returning it is not covered by the capturing. @@ -230,7 +125,7 @@ export class ConsoleCaptor { * * @example * ```ts - * const captor = ConsoleCaptor.error(page); + * const captor = captureConsole(page, "error"); * * await captor.during(async () => { * await page.getByRole("button", { name: "Create user" }).click(); @@ -240,24 +135,69 @@ export class ConsoleCaptor { * expect(captor.messages).toHaveLength(1); * ``` */ - public during(action: () => Promise): Promise; - public during(action: () => T): T; - public during(action: () => Promise | T): Promise | T { - this.start(); + public async during(action: () => Promise): Promise { + this.startCapture(); - let result: Promise | T; try { - result = action(); - } catch (error) { - this.stop(); - throw error; - } - - if (result instanceof Promise) { - return result.finally(() => this.stop()); + return await action(); + } finally { + this.stopCapture(); } + } +} - this.stop(); - return result; +/** + * Factory method to create a {@link ConsoleCaptor} with the provided filter. + * + * The filter can be the log-level or a custom function on the message. + * + * @param page - The page to capture logs for + * @param levelOrFilter - The level or filter to filter out messages + * @returns ConsoleCaptor + * + * @example + * ```ts + * const errorCaptor = captureConsole(page, "error"); + * const cspCaptor = captureConsole(page, message => + * hasLogLevel(message, "warning", "error") && message.text().includes("Content Security Policy") + * ); + * ``` + */ +export function captureConsole( + page: Page, + levelOrFilter?: + | ConsoleMessageLevel + | Array + | ConsoleMessageFilter, +): ConsoleCaptor { + if (levelOrFilter === undefined) { + return new ConsoleCaptor(page); + } + if (typeof levelOrFilter === "function") { + return new ConsoleCaptor(page, levelOrFilter); } + if (Array.isArray(levelOrFilter)) { + return new ConsoleCaptor(page, (message) => + hasLogLevel(message, ...levelOrFilter), + ); + } + return new ConsoleCaptor(page, (message) => + hasLogLevel(message, levelOrFilter), + ); +} + +/** + * Tests whether the given console message has one of the specified log levels. + * + * This can be useful to define a filter with {@link captureConsole}. + * + * @param message - The console message to check + * @param level - The log level + * @returns True if the message has one of the given log levels + */ +export function hasLogLevel( + message: ConsoleMessage, + ...level: Array +): boolean { + return level.includes(message.type()); } diff --git a/src/index.ts b/src/index.ts index bf11853..b903bef 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,7 +2,13 @@ export { isCI } from "./environment"; export { createFetchAdapter } from "./api/fetch-adapter"; -export { ConsoleCaptor } from "./api/logging/console-captor"; +export { + captureConsole, + hasLogLevel, + ConsoleCaptor, + type ConsoleMessageLevel, + type ConsoleMessageFilter, +} from "./api/logging/console-captor"; export { interceptRoute, diff --git a/tests/console-captor.spec.ts b/tests/console-captor.spec.ts index fcc6f34..e17cc80 100644 --- a/tests/console-captor.spec.ts +++ b/tests/console-captor.spec.ts @@ -1,12 +1,15 @@ /* eslint-disable no-console -- the console calls run inside the browser page */ -import { type ConsoleMessage, expect, type Page, test } from "@playwright/test"; +import { expect, type Page, test } from "@playwright/test"; -import { ConsoleCaptor } from "../src"; - -type ConsoleMessageFilter = (message: ConsoleMessage) => boolean; +import { + captureConsole, + hasLogLevel, + type ConsoleCaptor, + type ConsoleMessageLevel, +} from "../src"; interface ConsoleEntry { - type: string; + type: ConsoleMessageLevel; text: string; } @@ -41,8 +44,8 @@ async function expectCaptured( } test("captures all console messages by default", async ({ page }) => { - const captor = new ConsoleCaptor(page); - captor.start(); + const captor = captureConsole(page); + captor.startCapture(); await emitConsoleMessages(page); @@ -50,10 +53,10 @@ test("captures all console messages by default", async ({ page }) => { }); test("captures only messages matching the filter", async ({ page }) => { - const captor = new ConsoleCaptor(page, (message) => + const captor = captureConsole(page, (message) => message.text().includes("info"), ); - captor.start(); + captor.startCapture(); await emitConsoleMessages(page); @@ -62,8 +65,8 @@ test("captures only messages matching the filter", async ({ page }) => { for (const { type, text } of ALL_MESSAGES) { test(`captures messages of level ${type}`, async ({ page }) => { - const captor = ConsoleCaptor.level(page, type as "log"); - captor.start(); + const captor = captureConsole(page, type); + captor.startCapture(); await emitConsoleMessages(page); @@ -72,30 +75,20 @@ for (const { type, text } of ALL_MESSAGES) { } test("combines level and filter", async ({ page }) => { - const captor = ConsoleCaptor.level(page, "log", (message) => - message.text().includes("info"), + const captor = captureConsole( + page, + (message) => hasLogLevel(message, "log") && message.text().includes("info"), ); - captor.start(); + captor.startCapture(); await emitConsoleMessages(page); await expectCaptured(captor, []); }); -const LEVEL_FACTORIES = { - log: (page: Page, filter?: ConsoleMessageFilter) => - ConsoleCaptor.log(page, filter), - info: (page: Page, filter?: ConsoleMessageFilter) => - ConsoleCaptor.info(page, filter), - warning: (page: Page, filter?: ConsoleMessageFilter) => - ConsoleCaptor.warning(page, filter), - error: (page: Page, filter?: ConsoleMessageFilter) => - ConsoleCaptor.error(page, filter), -}; - for (const { type, text } of ALL_MESSAGES) { test(`provides a shorthand factory for level ${type}`, async ({ page }) => { - const captor = LEVEL_FACTORIES[type as keyof typeof LEVEL_FACTORIES](page); - captor.start(); + const captor = captureConsole(page, type); + captor.startCapture(); await emitConsoleMessages(page); @@ -105,11 +98,12 @@ for (const { type, text } of ALL_MESSAGES) { test(`applies the filter of the shorthand factory for level ${type}`, async ({ page, }) => { - const captor = LEVEL_FACTORIES[type as keyof typeof LEVEL_FACTORIES]( + const captor = captureConsole( page, - (message) => message.text().includes("no match"), + (message) => + hasLogLevel(message, type) && message.text().includes("no match"), ); - captor.start(); + captor.startCapture(); await emitConsoleMessages(page); await expectCaptured(captor, []); @@ -117,15 +111,15 @@ for (const { type, text } of ALL_MESSAGES) { } test("stops capturing messages after stop", async ({ page }) => { - const captor = ConsoleCaptor.log(page); - const reference = ConsoleCaptor.log(page); - reference.start(); + const captor = captureConsole(page, "log"); + const reference = captureConsole(page, "log"); + reference.startCapture(); - captor.start(); + captor.startCapture(); await page.evaluate(() => console.log("before stop")); await expectCaptured(captor, [{ type: "log", text: "before stop" }]); - captor.stop(); + captor.stopCapture(); await page.evaluate(() => console.log("after stop")); await expectCaptured(reference, [ { type: "log", text: "before stop" }, @@ -138,9 +132,9 @@ test("stops capturing messages after stop", async ({ page }) => { }); test("captures messages during an async action", async ({ page }) => { - const captor = ConsoleCaptor.log(page); - const reference = ConsoleCaptor.log(page); - reference.start(); + const captor = captureConsole(page, "log"); + const reference = captureConsole(page, "log"); + reference.startCapture(); const result = await captor.during(async () => { await page.evaluate(() => console.log("during action")); @@ -160,28 +154,16 @@ test("captures messages during an async action", async ({ page }) => { ]); }); -test("returns the result of a synchronous action", async ({ page }) => { - const captor = ConsoleCaptor.log(page); - const reference = ConsoleCaptor.log(page); - reference.start(); - - expect(captor.during(() => "result")).toBe("result"); - - await page.evaluate(() => console.log("after during")); - await expectCaptured(reference, [{ type: "log", text: "after during" }]); - expect(capturedEntries(captor)).toEqual([]); -}); - test("stops capturing when the action throws", async ({ page }) => { - const captor = ConsoleCaptor.log(page); - const reference = ConsoleCaptor.log(page); - reference.start(); + const captor = captureConsole(page, "log"); + const reference = captureConsole(page, "log"); + reference.startCapture(); - expect(() => + await expect(() => captor.during(() => { throw new Error("action failed"); }), - ).toThrow("action failed"); + ).rejects.toEqual(new Error("action failed")); await page.evaluate(() => console.log("after during")); await expectCaptured(reference, [{ type: "log", text: "after during" }]); @@ -189,9 +171,9 @@ test("stops capturing when the action throws", async ({ page }) => { }); test("captures until a returned promise settles", async ({ page }) => { - const captor = ConsoleCaptor.log(page); - const reference = ConsoleCaptor.log(page); - reference.start(); + const captor = captureConsole(page, "log"); + const reference = captureConsole(page, "log"); + reference.startCapture(); const messagePromise = captor.during(() => page.waitForEvent("console", (message) => message.text() === "trigger"), From 04eceb055698a601a18dd4025e0a6f347f0344ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Florian=20Spie=C3=9F?= Date: Mon, 7 Sep 2026 10:02:49 +0200 Subject: [PATCH 3/4] feat(console-captor): Move documentation for console capturing to /capturing/console --- docs/.vitepress/config.ts | 4 +- .../console.md} | 40 +++++++++---------- 2 files changed, 20 insertions(+), 24 deletions(-) rename docs/src/{logging/console-captor.md => capturing/console.md} (76%) diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 5543f41..0fdd764 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -47,8 +47,8 @@ export default defineConfig({ ], }, { - text: "Logging", - items: [{ text: "Console Captor", link: "/logging/console-captor" }], + text: "Capturing", + items: [{ text: "Console Messages", link: "/capturing/console" }], }, { text: "Snapshot Testing", diff --git a/docs/src/logging/console-captor.md b/docs/src/capturing/console.md similarity index 76% rename from docs/src/logging/console-captor.md rename to docs/src/capturing/console.md index f8c7e35..e4f5aa4 100644 --- a/docs/src/logging/console-captor.md +++ b/docs/src/capturing/console.md @@ -11,7 +11,7 @@ import { ConsoleCaptor } from "@cronn/playwright-utils"; import { expect, test } from "@playwright/test"; test("logs the selected filter", async ({ page }) => { - const logs = ConsoleCaptor.log(page); + const logs = captureConsole(page); await logs.during(async () => { await page.goto("/users"); @@ -30,19 +30,13 @@ Several captors can run on the same page at the same time, each with its own fil ## Filtering messages -By default a captor collects every console message of the page. The static factories create a captor restricted to one level: +By default, a captor collects every console message of the page. You can use the factory method to filter it to only specific levels: ```ts -ConsoleCaptor.log(page); -ConsoleCaptor.info(page); -ConsoleCaptor.warning(page); -ConsoleCaptor.error(page); -``` - -`ConsoleCaptor.level` accepts any of the types reported by [`consoleMessage.type`](https://playwright.dev/docs/api/class-consolemessage#console-message-type), for example `debug`, `trace` or `table`: - -```ts -const captor = ConsoleCaptor.level(page, "debug"); +captureConsole(page, "log"); +captureConsole(page, "info"); +captureConsole(page, "warning"); +captureConsole(page, "error"); ``` Note that `console.warn` is reported as `warning`, and that messages logged by the browser itself, such as failed requests or CSP violations, are reported as `error`. @@ -53,12 +47,14 @@ The constructor and all factories accept a predicate as their last argument, whi ```ts // only errors of a specific feature -const checkoutErrors = ConsoleCaptor.error(page, (message) => - message.text().startsWith("[checkout]"), +const checkoutErrors = captureConsole( + page, + (message) => + hasLogLevel(message, "error") && message.text().startsWith("[checkout]"), ); // any message originating from a specific script -const analyticsMessages = new ConsoleCaptor(page, (message) => +const analyticsMessages = captureConsole(page, (message) => message.location().url.endsWith("/analytics.js"), ); ``` @@ -70,7 +66,7 @@ A filter passed to a level factory is combined with the level, so both have to m `during` starts the capturing, runs the given action and stops the capturing again once the action has finished, even if it throws. It returns the value of the action, so it can wrap an existing step of a test: ```ts -const userId = await ConsoleCaptor.error(page).during(async () => { +const userId = await captureConsole(page, "error").during(async () => { await page.getByRole("button", { name: "Create user" }).click(); return readCreatedUserId(page); }); @@ -79,7 +75,7 @@ const userId = await ConsoleCaptor.error(page).during(async () => { A promise returned by the action is awaited before the capturing stops, no matter when it is awaited by the test. This can be used to keep the capturing open until a request has been answered: ```ts -const captor = ConsoleCaptor.error(page); +const captor = captureConsole(page, "error"); // captures until the response arrives, not until `during` returns const responsePromise = captor.during(() => page.waitForResponse("/api/users")); @@ -90,7 +86,7 @@ await responsePromise; Synchronous actions are supported as well and are not wrapped in a promise: ```ts -const captor = ConsoleCaptor.error(page); +const captor = captureConsole(page, "error"); const users = captor.during(() => parseUsers(payload)); ``` @@ -112,7 +108,7 @@ await responsePromise; ## Manual capturing -For captures which span multiple steps, `start` and `stop` control the capturing directly. A captor registered in a fixture keeps a test free of setup and teardown, and can assert that a test produced no unexpected console errors: +For captures which span multiple steps, `startCapture` and `stopCapture` control the capturing directly. A captor registered in a fixture keeps a test free of setup and teardown, and can assert that a test produced no unexpected console errors: ```ts import { ConsoleCaptor } from "@cronn/playwright-utils"; @@ -120,12 +116,12 @@ import { expect, test as base } from "@playwright/test"; export const test = base.extend<{ consoleErrors: ConsoleCaptor }>({ consoleErrors: async ({ page }, use) => { - const captor = ConsoleCaptor.error(page); - captor.start(); + const captor = captureConsole(page, "error"); + captor.startCapture(); await use(captor); - captor.stop(); + captor.stopCapture(); expect(captor.messages.map((message) => message.text())).toEqual([]); }, }); From 2cf1b5269ca17bd9cd06ed553a61b2b503309446 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Florian=20Spie=C3=9F?= Date: Mon, 7 Sep 2026 10:38:17 +0200 Subject: [PATCH 4/4] feat(console-captor): Move console-captor.ts to src/capturing --- src/{api/logging => capturing}/console-captor.ts | 0 src/index.ts | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename src/{api/logging => capturing}/console-captor.ts (100%) diff --git a/src/api/logging/console-captor.ts b/src/capturing/console-captor.ts similarity index 100% rename from src/api/logging/console-captor.ts rename to src/capturing/console-captor.ts diff --git a/src/index.ts b/src/index.ts index b903bef..d96b523 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,7 +8,7 @@ export { ConsoleCaptor, type ConsoleMessageLevel, type ConsoleMessageFilter, -} from "./api/logging/console-captor"; +} from "./capturing/console-captor"; export { interceptRoute,