-
Notifications
You must be signed in to change notification settings - Fork 1
feat(console-captor): Add ConsoleCaptor to collect console logs #20
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
2600bc1
feat(console-captor): Add ConsoleCaptor to collect console logs from …
florian-cronn 996c13e
feat(console-captor): Add factory method and refactor API in ConsoleC…
florian-cronn 04eceb0
feat(console-captor): Move documentation for console capturing to /ca…
florian-cronn 2cf1b52
feat(console-captor): Move console-captor.ts to src/capturing
florian-cronn File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| "@cronn/playwright-utils": minor | ||
| --- | ||
|
|
||
| Add `ConsoleCaptor` to collect the console messages of a page |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<Response>; | ||
|
|
||
| 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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<ConsoleMessage["type"]>; | ||
|
|
||
| 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<ConsoleMessage> = []; | ||
|
|
||
| /** | ||
| * 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<T>(action: () => Promise<T>): Promise<T> { | ||
| 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<ConsoleMessageLevel> | ||
| | 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<ConsoleMessageLevel> | ||
| ): boolean { | ||
| return level.includes(message.type()); | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.