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..0fdd764 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: "Capturing", + items: [{ text: "Console Messages", link: "/capturing/console" }], + }, { text: "Snapshot Testing", items: [{ text: "Normalizers", link: "/snapshots/normalizers" }], diff --git a/docs/src/capturing/console.md b/docs/src/capturing/console.md new file mode 100644 index 0000000..e4f5aa4 --- /dev/null +++ b/docs/src/capturing/console.md @@ -0,0 +1,130 @@ +# 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 = captureConsole(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. You can use the factory method to filter it to only specific levels: + +```ts +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`. + +### 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 = captureConsole( + page, + (message) => + hasLogLevel(message, "error") && message.text().startsWith("[checkout]"), +); + +// any message originating from a specific script +const analyticsMessages = captureConsole(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 captureConsole(page, "error").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 = captureConsole(page, "error"); + +// 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 = captureConsole(page, "error"); +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, `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"; +import { expect, test as base } from "@playwright/test"; + +export const test = base.extend<{ consoleErrors: ConsoleCaptor }>({ + consoleErrors: async ({ page }, use) => { + const captor = captureConsole(page, "error"); + captor.startCapture(); + + await use(captor); + + captor.stopCapture(); + 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/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/src/capturing/console-captor.ts b/src/capturing/console-captor.ts new file mode 100644 index 0000000..7474cad --- /dev/null +++ b/src/capturing/console-captor.ts @@ -0,0 +1,203 @@ +import type { ConsoleMessage, Page } from "@playwright/test"; + +/** + * Filter used to select the messages collected by a {@link ConsoleCaptor}. + */ +export type ConsoleMessageFilter = (message: ConsoleMessage) => boolean; + +/** + * The log level of a {@link ConsoleMessage}, e.g. `log`, `warning` or `error`. + */ +export 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 = captureConsole(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 consoleListener: (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 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 = captureConsole(page, (message) => + * message.location().url.endsWith("/analytics.js"), + * ); + * ``` + */ + public constructor(page: Page, filter: ConsoleMessageFilter = defaultFilter) { + this.page = page; + this.consoleListener = (event) => { + if (filter(event)) { + this.messages.push(event); + } + }; + } + + /** + * 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 = captureConsole(page, "error"); + * captor.start(); + * + * await use(captor); + * + * captor.stop(); + * expect(captor.messages.map((message) => message.text())).toEqual([]); + * }, + * }); + * ``` + */ + public startCapture(): void { + this.page.on("console", this.consoleListener); + } + + /** + * Stop collecting the console messages of the page. + * + * The already captured {@link messages} are kept. + */ + public stopCapture(): void { + this.page.off("console", this.consoleListener); + } + + /** + * 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. + * + * 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 = captureConsole(page, "error"); + * + * await captor.during(async () => { + * await page.getByRole("button", { name: "Create user" }).click(); + * await expect(page.getByRole("alert")).toBeVisible(); + * }); + * + * expect(captor.messages).toHaveLength(1); + * ``` + */ + public async during(action: () => Promise): Promise { + this.startCapture(); + + try { + return await action(); + } finally { + this.stopCapture(); + } + } +} + +/** + * 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 cf254c1..d96b523 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,6 +2,14 @@ export { isCI } from "./environment"; export { createFetchAdapter } from "./api/fetch-adapter"; +export { + captureConsole, + hasLogLevel, + ConsoleCaptor, + type ConsoleMessageLevel, + type ConsoleMessageFilter, +} from "./capturing/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..e17cc80 --- /dev/null +++ b/tests/console-captor.spec.ts @@ -0,0 +1,203 @@ +/* eslint-disable no-console -- the console calls run inside the browser page */ +import { expect, type Page, test } from "@playwright/test"; + +import { + captureConsole, + hasLogLevel, + type ConsoleCaptor, + type ConsoleMessageLevel, +} from "../src"; + +interface ConsoleEntry { + type: ConsoleMessageLevel; + 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 = captureConsole(page); + captor.startCapture(); + + await emitConsoleMessages(page); + + await expectCaptured(captor, ALL_MESSAGES); +}); + +test("captures only messages matching the filter", async ({ page }) => { + const captor = captureConsole(page, (message) => + message.text().includes("info"), + ); + captor.startCapture(); + + 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 = captureConsole(page, type); + captor.startCapture(); + + await emitConsoleMessages(page); + + await expectCaptured(captor, [{ type, text }]); + }); +} + +test("combines level and filter", async ({ page }) => { + const captor = captureConsole( + page, + (message) => hasLogLevel(message, "log") && message.text().includes("info"), + ); + captor.startCapture(); + + await emitConsoleMessages(page); + await expectCaptured(captor, []); +}); + +for (const { type, text } of ALL_MESSAGES) { + test(`provides a shorthand factory for level ${type}`, async ({ page }) => { + const captor = captureConsole(page, type); + captor.startCapture(); + + await emitConsoleMessages(page); + + await expectCaptured(captor, [{ type, text }]); + }); + + test(`applies the filter of the shorthand factory for level ${type}`, async ({ + page, + }) => { + const captor = captureConsole( + page, + (message) => + hasLogLevel(message, type) && message.text().includes("no match"), + ); + captor.startCapture(); + + await emitConsoleMessages(page); + await expectCaptured(captor, []); + }); +} + +test("stops capturing messages after stop", async ({ page }) => { + const captor = captureConsole(page, "log"); + const reference = captureConsole(page, "log"); + reference.startCapture(); + + captor.startCapture(); + await page.evaluate(() => console.log("before stop")); + await expectCaptured(captor, [{ type: "log", text: "before stop" }]); + + captor.stopCapture(); + 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 = captureConsole(page, "log"); + const reference = captureConsole(page, "log"); + reference.startCapture(); + + 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("stops capturing when the action throws", async ({ page }) => { + const captor = captureConsole(page, "log"); + const reference = captureConsole(page, "log"); + reference.startCapture(); + + await expect(() => + captor.during(() => { + throw new Error("action failed"); + }), + ).rejects.toEqual(new Error("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 = captureConsole(page, "log"); + const reference = captureConsole(page, "log"); + reference.startCapture(); + + 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" }, + ]); +});