Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/lucky-moons-listen.md
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
4 changes: 4 additions & 0 deletions docs/.vitepress/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" }],
Expand Down
130 changes: 130 additions & 0 deletions docs/src/capturing/console.md
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.
3 changes: 3 additions & 0 deletions docs/src/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
203 changes: 203 additions & 0 deletions src/capturing/console-captor.ts
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 {
Comment thread
foxable marked this conversation as resolved.
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());
}
8 changes: 8 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading