From 99aa3632cd089ec4efca2a116eb881cb0b7a98ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Florian=20Spie=C3=9F?= Date: Tue, 1 Sep 2026 19:17:36 +0200 Subject: [PATCH 1/3] feat: Add new route interceptor features --- .changeset/new-cups-stop.md | 5 + .../With_aborted_request.json | 22 ++ .../Route_interceptors/With_barrier.json | 23 ++ .../With_incomplete_json_response.json | 11 + .../Route_interceptors/With_mocked_route.json | 23 ++ .../With_overwritten_response.json | 23 ++ docs/src/api/route-interceptor.md | 158 ++++++++ docs/src/index.md | 3 + package.json | 1 + pnpm-lock.yaml | 16 + src/api/route-interceptor/filter.ts | 45 +++ src/api/route-interceptor/index.ts | 370 ++++++++++++++++++ src/api/route-interceptor/response-handler.ts | 59 +++ src/index.ts | 16 + tests/lib/fixtures.ts | 3 + tests/route-interceptor.spec.ts | 218 +++++++++++ 16 files changed, 996 insertions(+) create mode 100644 .changeset/new-cups-stop.md create mode 100644 data/test/validation/route-interceptor/Route_interceptors/With_aborted_request.json create mode 100644 data/test/validation/route-interceptor/Route_interceptors/With_barrier.json create mode 100644 data/test/validation/route-interceptor/Route_interceptors/With_incomplete_json_response.json create mode 100644 data/test/validation/route-interceptor/Route_interceptors/With_mocked_route.json create mode 100644 data/test/validation/route-interceptor/Route_interceptors/With_overwritten_response.json create mode 100644 docs/src/api/route-interceptor.md create mode 100644 src/api/route-interceptor/filter.ts create mode 100644 src/api/route-interceptor/index.ts create mode 100644 src/api/route-interceptor/response-handler.ts create mode 100644 tests/lib/fixtures.ts create mode 100644 tests/route-interceptor.spec.ts diff --git a/.changeset/new-cups-stop.md b/.changeset/new-cups-stop.md new file mode 100644 index 0000000..2195403 --- /dev/null +++ b/.changeset/new-cups-stop.md @@ -0,0 +1,5 @@ +--- +"@cronn/playwright-utils": minor +--- + +Add features to intercept network requests for the duration of a callback diff --git a/data/test/validation/route-interceptor/Route_interceptors/With_aborted_request.json b/data/test/validation/route-interceptor/Route_interceptors/With_aborted_request.json new file mode 100644 index 0000000..af3d370 --- /dev/null +++ b/data/test/validation/route-interceptor/Route_interceptors/With_aborted_request.json @@ -0,0 +1,22 @@ +{ + "region": "Api Response", + "children": [ + { + "heading": "Api Response", + "level": 2 + }, + { + "label": [ + "Count:", + { + "textbox": "Count:", + "value": "1", + "readonly": true + } + ] + }, + { + "paragraph": "Status: -" + } + ] +} diff --git a/data/test/validation/route-interceptor/Route_interceptors/With_barrier.json b/data/test/validation/route-interceptor/Route_interceptors/With_barrier.json new file mode 100644 index 0000000..ba3eadf --- /dev/null +++ b/data/test/validation/route-interceptor/Route_interceptors/With_barrier.json @@ -0,0 +1,23 @@ +{ + "region": "Api Response", + "children": [ + { + "heading": "Api Response", + "level": 2 + }, + { + "label": [ + "Count:", + { + "textbox": "Count:", + "value": "4", + "readonly": true + } + ] + }, + { + "paragraph": "Status: 200" + }, + "{ \"username\": \"test_user\", \"enabled\": false, \"id\": 1 }" + ] +} diff --git a/data/test/validation/route-interceptor/Route_interceptors/With_incomplete_json_response.json b/data/test/validation/route-interceptor/Route_interceptors/With_incomplete_json_response.json new file mode 100644 index 0000000..6bc0a16 --- /dev/null +++ b/data/test/validation/route-interceptor/Route_interceptors/With_incomplete_json_response.json @@ -0,0 +1,11 @@ +{ + "alert": [ + { + "heading": "Alert", + "level": 2 + }, + { + "paragraph": "SyntaxError: Unterminated string in JSON at position 5 (line 1 column 6)" + } + ] +} diff --git a/data/test/validation/route-interceptor/Route_interceptors/With_mocked_route.json b/data/test/validation/route-interceptor/Route_interceptors/With_mocked_route.json new file mode 100644 index 0000000..6c68841 --- /dev/null +++ b/data/test/validation/route-interceptor/Route_interceptors/With_mocked_route.json @@ -0,0 +1,23 @@ +{ + "region": "Api Response", + "children": [ + { + "heading": "Api Response", + "level": 2 + }, + { + "label": [ + "Count:", + { + "textbox": "Count:", + "value": "2", + "readonly": true + } + ] + }, + { + "paragraph": "Status: 500" + }, + "{ \"error\": \"Internal Server Error\" }" + ] +} diff --git a/data/test/validation/route-interceptor/Route_interceptors/With_overwritten_response.json b/data/test/validation/route-interceptor/Route_interceptors/With_overwritten_response.json new file mode 100644 index 0000000..e1c2047 --- /dev/null +++ b/data/test/validation/route-interceptor/Route_interceptors/With_overwritten_response.json @@ -0,0 +1,23 @@ +{ + "region": "Api Response", + "children": [ + { + "heading": "Api Response", + "level": 2 + }, + { + "label": [ + "Count:", + { + "textbox": "Count:", + "value": "3", + "readonly": true + } + ] + }, + { + "paragraph": "Status: 200" + }, + "{ \"username\": \"test_user\", \"enabled\": false, \"id\": 1 }" + ] +} diff --git a/docs/src/api/route-interceptor.md b/docs/src/api/route-interceptor.md new file mode 100644 index 0000000..17a3e79 --- /dev/null +++ b/docs/src/api/route-interceptor.md @@ -0,0 +1,158 @@ +# Route Interceptor + +Playwright's [`page.route`](https://playwright.dev/docs/network#network-mocking) installs a route handler for the whole page, which has to be removed again with `page.unroute` once the test no longer needs it. +This library provides various utilities to scope these route interceptors to a function callback, to temporarily modify the behavior of a network request. + +This keeps mocking close to the assertions it belongs to and makes it possible to change the behavior of the same route multiple times within one test. + +## Usage + +```ts +import { interceptRoute, pathPattern } from "@cronn/playwright-utils"; +import { expect, test } from "@playwright/test"; + +test("shows an error when the user cannot be loaded", async ({ page }) => { + await interceptRoute(page, pathPattern("/api/users/1")) + .abort() + .during(async () => { + await page.goto("/users/1/profile"); + await expect(page.getByRole("heading", { level: 1 })).toHaveText( + "Unknown user", + ); + }); +}); +``` + +Requests which are not matched by the filter are passed on to the next matching route handler, so interceptions can be nested and combined with route handlers registered elsewhere. + +## Filtering requests + +A `RouteFilter` selects the requests to intercept. `pathPattern` creates a filter matching the path of a URL, either exactly or by regular expression. Query parameters, host and protocol are ignored: + +```ts +import { pathPattern } from "@cronn/playwright-utils"; + +pathPattern("/api/users/1"); +pathPattern(/^\/api\/users\/\d+$/); +``` + +Filters can also be written by hand, for example to restrict an interception to certain HTTP methods: + +```ts +import type { RouteFilter } from "@cronn/playwright-utils"; + +const onCreateOrUpdateUser: RouteFilter = { + method: ["POST", "PUT"], + url: (url) => url.pathname.startsWith("/api/users"), +}; +``` + +| Property | Type | Description | +| -------- | --------------------------------- | --------------------------------------------------- | +| `url` | `(url: URL) => boolean` | Decides whether the URL of a request is matched | +| `method` | `HttpMethod \| Array` | Optional. Matches requests of any method if omitted | + +## Interceptions + +All methods of a `RouteInterceptor` take the action to run while the interception is installed and return the value of that action. + +### Aborting requests + +`abort` fails matching requests, which is useful for testing how the application under test handles network errors: + +```ts +await intercept.abort().during(async () => { + await page.getByRole("button", { name: "Fetch user" }).click(); + await expect(page.getByRole("alert")).toBeVisible(); +}); +``` + +### Mocking responses + +`respondWith` answers matching requests with a mocked response, which never reaches the server. It accepts the options of [`route.fulfill`](https://playwright.dev/docs/api/class-route#route-fulfill): + +```ts +await intercept + .respondWith({ status: 500, body: `{"error": "Internal Server Error"}` }) + .during(async () => { + await page.getByRole("button", { name: "Fetch user" }).click(); + await expect(page.getByRole("alert")).toHaveText("Something went wrong"); + }); +``` + +### Modifying responses + +`modifyResponse` sends the request to the server and passes it to a modifier before it is returned to the page. + +```ts +await intercept + .modifyResponse( + // helper to easily modify the response body with a typed function + modifyJsonBody((user) => ({ + ...user, // keep original response from server + enabled: false, // only modify one field + })), + ) + .during(async () => { + await page.getByRole("button", { name: "Fetch user" }).click(); + await expect( + page.getByRole("checkbox", { name: "Enabled" }), + ).not.toBeChecked(); + }); +``` + +### Blocking requests + +`block` delays matching requests until the action has finished. This makes pending states such as loading indicators, skeletons or disabled buttons observable without relying on timing: + +```ts +await intercept.block().during(async () => { + await page.getByRole("button", { name: "Fetch user" }).click(); + // The loading indicator can be inspected without worrying about race-conditions + await expect(page.getByRole("progressbar")).toBeVisible(); +}); + +await expect(page.getByRole("progressbar")).toHaveCount(0); +``` + +The blocked requests are continued once the action returns, and `block` resolves after all of them have been answered. Assertions on the loaded state therefore belong after the action, not inside it. + +## Fixture + +`RouteInterceptorFixture` creates interceptors for a page. Extending it gives the routes of the application under test descriptive names, so tests do not have to repeat their URL patterns: + +```ts +import { + pathPattern, + type RouteInterceptor, + RouteInterceptorFixture, +} from "@cronn/playwright-utils"; +import { test as base } from "@playwright/test"; + +class AppInterceptorFixture extends RouteInterceptorFixture { + public onGetUser(userId = 1): RouteInterceptor { + return this.intercept(pathPattern(`/api/users/${userId}`)); + } +} + +export const test = base.extend<{ intercept: AppInterceptorFixture }>({ + intercept: ({ page }, use) => use(new AppInterceptorFixture(page)), +}); +``` + +The fixture is then available in every test: + +```ts +test("shows an error when the user cannot be loaded", async ({ + page, + intercept, +}) => { + await intercept + .onGetUser() + .abort() + .during(async () => { + await page.getByRole("button", { name: "Fetch user" }).click(); + await expect(page.getByRole("alert")).toBeVisible(); + }); +}); +``` diff --git a/docs/src/index.md b/docs/src/index.md index a62d3b1..b317c50 100644 --- a/docs/src/index.md +++ b/docs/src/index.md @@ -16,6 +16,9 @@ features: - title: API Testing details: Use Playwright's request context as a fetch implementation to send requests of an API client through Playwright. link: /api/fetch-adapter + - title: Network Interceptors + details: Abort, mock, modify or delay the requests of a page for the duration of a single action instead of the whole test. + link: /api/route-interceptor - 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/package.json b/package.json index 428054e..39fa3fc 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,7 @@ "devDependencies": { "@arethetypeswrong/core": "0.18.5", "@changesets/cli": "3.0.1", + "@cronn/element-snapshot": "0.27.0", "@cronn/playwright-file-snapshots": "2.2.1", "@playwright/test": "1.62.1", "@types/node": "24.13.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0ba61af..a2f4da3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -228,6 +228,9 @@ importers: '@changesets/cli': specifier: 3.0.1 version: 3.0.1 + '@cronn/element-snapshot': + specifier: 0.27.0 + version: 0.27.0(@playwright/test@1.62.1) '@cronn/playwright-file-snapshots': specifier: 2.2.1 version: 2.2.1(@playwright/test@1.62.1) @@ -400,6 +403,12 @@ packages: resolution: {integrity: sha512-y7/yvZ2TPAnR9+jnc00klvNNLkJiXFFrQA/hlLCcxA9a2A4zQIOimyFQ9XfwYKiGD1fb5GY8vbKIIgO8d5Tb2A==} engines: {node: '>= 20.12.0'} + '@cronn/element-snapshot@0.27.0': + resolution: {integrity: sha512-a3SyY/oihVXmGYzf/UTTF49dZz29rwTQyytYvm4a7wAnSqv2rO/uCHPyWfgHYahSErYsafYIKkMA01CahYXZKg==} + engines: {node: ^22 || ^24 || >=26} + peerDependencies: + '@playwright/test': ^1.55 + '@cronn/lib-file-snapshots@1.2.1': resolution: {integrity: sha512-PIwK1JzQN//7uAdQ8NH8oewp49SLjZz8Ye+M8r2Lx+LZD4ziOZU0Faj2xGBDZLfT4JFY2grHDNezA3lpZ6BA3A==} engines: {node: ^22 || ^24 || >=26} @@ -3020,6 +3029,13 @@ snapshots: fast-wrap-ansi: 0.2.2 sisteransi: 1.0.5 + '@cronn/element-snapshot@0.27.0(@playwright/test@1.62.1)': + dependencies: + '@cronn/lib-file-snapshots': 1.2.1 + '@cronn/playwright-file-snapshots': 2.2.1(@playwright/test@1.62.1) + '@playwright/test': 1.62.1 + package-directory: 8.2.0 + '@cronn/lib-file-snapshots@1.2.1': dependencies: markdown-table: 3.0.4 diff --git a/src/api/route-interceptor/filter.ts b/src/api/route-interceptor/filter.ts new file mode 100644 index 0000000..b69b3f1 --- /dev/null +++ b/src/api/route-interceptor/filter.ts @@ -0,0 +1,45 @@ +/** + * HTTP Methods used by {@link RouteFilter} + */ +export type HttpMethod = + | "GET" + | "PATCH" + | "POST" + | "PUT" + | "DELETE" + | "OPTIONS" + | "QUERY" + | "HEAD"; + +/** + * Filters used to intercept routes with {@link RouteInterceptor}. + * + * @see pathPattern + */ +export interface RouteFilter { + method?: HttpMethod | Array; + url: (url: URL) => boolean; +} + +/** + * Helper to create a {@link RouteFilter} that applies to the specified path. + * + * @param pattern - The regex or string matching the pathname of the URL + * @returns RouteFilter + * @example + * ```ts + * await interceptRoute(page, pathPattern("/users/1")) + * .respondWith({ status: 404 }) + * .during(() => { + * // ... + * }); + * ``` + */ +export function pathPattern(pattern: RegExp | string): RouteFilter { + return { + url: (url) => + typeof pattern === "string" + ? url.pathname === pattern + : url.pathname.match(pattern) !== null, + }; +} diff --git a/src/api/route-interceptor/index.ts b/src/api/route-interceptor/index.ts new file mode 100644 index 0000000..a64cda6 --- /dev/null +++ b/src/api/route-interceptor/index.ts @@ -0,0 +1,370 @@ +import type { Page, Route, Request } from "@playwright/test"; + +import { type RouteFilter } from "./filter"; +import { type ResponseHandler } from "./response-handler"; + +export type Action = () => Promise | T; + +export type FulfillOptions = Parameters[0]; + +function isFiltered(filter: RouteFilter, request: Request) { + if (!filter.url(new URL(request.url()))) { + return false; + } + + const method = filter.method; + if (method === undefined) { + return true; + } + + if (typeof method === "string") { + return method === request.method(); + } else { + return ( + method instanceof Array && + (method as Array).includes(request.method()) + ); + } +} + +async function uninstallRoute( + page: Page, + filter: RouteFilter, + handler: (route: Route, request: Request) => Promise, +) { + try { + await page.unroute(filter.url, handler); + } catch (error) { + if (!page.isClosed()) { + throw error; + } + } +} + +async function withFulfilledRoute( + page: Page, + filter: RouteFilter, + options: FulfillOptions, + action: Action, +): Promise { + async function handler(route: Route, request: Request) { + if (isFiltered(filter, request)) { + await route.fulfill(options); + } else { + await route.fallback(); + } + } + + await page.route(filter.url, handler); + try { + return await action(); + } finally { + await uninstallRoute(page, filter, handler); + } +} + +async function withAbortedRoute( + page: Page, + filter: RouteFilter, + action: Action, +): Promise { + async function handler(route: Route, request: Request) { + if (isFiltered(filter, request)) { + await route.abort(); + } else { + await route.fallback(); + } + } + + await page.route(filter.url, handler); + try { + return await action(); + } finally { + await uninstallRoute(page, filter, handler); + } +} + +async function withBlockedRoute( + page: Page, + filter: RouteFilter, + action: Action, +) { + let resolver: () => void; + const unblocked = new Promise((resolve) => { + resolver = resolve; + }); + const blockedRoutes = new Set>(); + + async function handler(route: Route, request: Request) { + if (!isFiltered(filter, request)) { + await route.fallback(); + return; + } + + const blockedRoute = unblocked.then(async () => { + await route.continue(); + }); + + blockedRoutes.add(blockedRoute); + try { + await blockedRoute; + } finally { + blockedRoutes.delete(blockedRoute); + } + } + + await page.route(filter.url, handler); + try { + return await action(); + } finally { + resolver!(); + // Let every blocked request continue before the route is uninstalled, + // otherwise Playwright handles the pending routes itself and the handler + // fails with "Route is already handled!". + await Promise.allSettled([...blockedRoutes]); + await uninstallRoute(page, filter, handler); + } +} + +async function withModifiedResponse( + page: Page, + filter: RouteFilter, + interceptor: ResponseHandler, + action: Action, +): Promise { + async function handler(route: Route, request: Request) { + if (isFiltered(filter, request)) { + const response = await route.fetch(); + await route.fulfill(await interceptor(response)); + } else { + await route.fallback(); + } + } + + await page.route(filter.url, handler); + try { + return await action(); + } finally { + await uninstallRoute(page, filter, handler); + } +} + +/** + * Entrypoint to create various network interceptors using the {@link Page#route} API. + */ +export class RouteInterceptor { + public readonly page: Page; + public readonly filter: RouteFilter; + + public constructor(page: Page, filter: RouteFilter) { + this.page = page; + this.filter = filter; + } + + /** + * Abort the target route for the duration of the callback. + * + * This method returns a stub to continue chaining with. + * + * @returns RouteInterceptorStarter + * @see {@link Route#abort} + * + * @example + * ```ts + * interceptor.abort().during(() => button.click()) + * ``` + */ + public abort(): RouteInterceptorStarter { + return new RouteInterceptorStarter((action) => + withAbortedRoute(this.page, this.filter, action), + ); + } + + /** + * Block the target route for the duration of the callback. + * + * A blocked route will not serve the response until the callback completes, + * this is useful to validate loading indicators. + * + * This method returns a stub to continue chaining with. + * + * @returns RouteInterceptorStarter + * + * @example + * ```ts + * interceptor.block().during(async () => { + * await button.click(); + * await expect(loadingOverlay).toBeVisisble(); + * }); + * ``` + */ + public block(): RouteInterceptorStarter { + return new RouteInterceptorStarter((action) => + withBlockedRoute(this.page, this.filter, action), + ); + } + + /** + * Replace the response on the target route for the duration of the callback. + * + * The request will not hit the server at all and instead immediately return the provided response. + * + * This method returns a stub to continue chaining with. + * + * @param options - The fixed response to return for the target route + * @returns RouteInterceptorStarter + * @see {@link Route#fulfill} + * + * @example + * ```ts + * interceptor.respondWith({ status: 500 }).during(async () => { + * await button.click(); + * await expect(alert).toBeVisisble(); + * }); + * ``` + */ + public respondWith(options: FulfillOptions): RouteInterceptorStarter { + return new RouteInterceptorStarter((action) => + withFulfilledRoute(this.page, this.filter, options, action), + ); + } + + /** + * Modify the response on the target route for the duration of the callback. + * + * The route will first process the request to the server + * and then apply the provided callback to modify the content before returning it to the client. + * + * This method returns a stub to continue chaining with. + * + * @param modifier - Callback to modify the response of the server before returning it to the client + * @returns RouteInterceptorStarter + * @see {@link Route#fetch}, {@link Route#fulfill} + * + * @example + * ```ts + * interceptor.respondWith({ status: 500 }).during(async () => { + * await button.click(); + * await expect(alert).toBeVisisble(); + * }); + * ``` + */ + public modifyResponse(modifier: ResponseHandler): RouteInterceptorStarter { + return new RouteInterceptorStarter((action) => + withModifiedResponse(this.page, this.filter, modifier, action), + ); + } +} + +/** + * Stub returned by {@link RouteInterceptor} used to intercept routes during a block. + * + * @example + * ```ts + * interceptor.respondWith({ status: 500 }).during(async () => { + * await button.click(); + * await expect(alert).toBeVisisble(); + * }); + * ``` + */ +export class RouteInterceptorStarter { + private readonly runner; + + public constructor(runner: (action: Action) => Promise) { + this.runner = runner; + } + + /** + * Run the given callback and intercept the stubbed route. + * + * The route interceptor is automatically removed when this method returns. + * + * @param action - The action to run with an intercepted route + * @returns The result of the action + */ + public async during(action: Action): Promise { + return await this.runner(action); + } +} + +/** + * Intercept a route using the provided filter. + * + * The returned {@link RouteInterceptor} can be used to declare how the route should be intercepted. + * + * @param page – The page object to intercept the route on + * @param filter - The route to intercept + * @returns RouteInterceptor + * @see {@link pathPattern} + * + * @example + * ```ts + * await interceptRoute(page, pathPattern("/api/notifications")) + * .respondWith({ status: 500 }) + * .during(async () => { + * await page.getByRole("button", {name: "View notifications"}).click(); + * await expect(page.getByRole("alert")).toBeVisible(); + * await expect(page.getByRole("alert")).toHaveText("Failed to load notification"); + * }); + * ``` + */ +export function interceptRoute( + page: Page, + filter: RouteFilter, +): RouteInterceptor { + return new RouteInterceptor(page, filter); +} + +/** + * Base class to build re-usable route interceptors. + * + * This is especially useful when combined with {@link test#extend} to create a custom fixture. + * + * @example + * ```ts + * class CustomRouteInterceptorFixture extends RouteInterceptorFixture { + * public onGetUser(userId = 1): RouteInterceptor { + * return this.intercept(pathPattern(`/api/users/${userId}`)); + * } + * } + * + * const test = baseTest.extend<{ intercept: CustomRouteInterceptorFixture }>({ + * intercept: ({ page }, use) => use(new CustomRouteInterceptorFixture(page)), + * }); + * + * test("Fail to get user", async ({ intercept, page }) => { + * await page.goto("/users"); + * await intercept + * .onGetUser() + * .respondWith({ status: 404 }) + * .during(async () => { + * await page.getByRole("button", {name: "View profile"}); + * await expect(page.getByRole("alert")).toHaveText("User not found"); + * }); + * }); + * ``` + */ +export class RouteInterceptorFixture { + private readonly page: Page; + + public constructor(page: Page) { + this.page = page; + } + + /** + * Create a {@link RouteInterceptor} for routes matching the provided filter. + * + * @param filter - The filter to intercept routes with + * @returns RouteInterceptor + * + * @example + * ```ts + * await intercept({ method: "POST", url: "/api/messages" }) + * .abort() + * .during(() => sendButton.click()); + * ``` + */ + public intercept(filter: RouteFilter): RouteInterceptor { + return interceptRoute(this.page, filter); + } +} diff --git a/src/api/route-interceptor/response-handler.ts b/src/api/route-interceptor/response-handler.ts new file mode 100644 index 0000000..9a4144e --- /dev/null +++ b/src/api/route-interceptor/response-handler.ts @@ -0,0 +1,59 @@ +import { type APIResponse } from "@playwright/test"; + +import { type FulfillOptions } from "./index"; + +/** + * Interface used to modify responses with {@link RouteInterceptor#modifyResponse}. + * + * @see modifyJsonBody + * @see modifyTextBody + */ +export type ResponseHandler = ( + response: APIResponse, +) => Promise | FulfillOptions; + +/** + * Helper to create a {@link ResponseHandler} that modifies the response body, if it is a JSON object. + * + * @param modifier - The callback which modifies the response body before returning it to the client + * @returns ResponseHandler + * + * @example + * ```ts + * await interceptRoute(page, pathPattern("/users/1")) + * .modifyResponse(modifyJsonBody(user => ({...user, username: "modified_username"}))) + * .during(async() => { + * // ... perform action to trigger request to /users/1 ... + * }); + * ``` + */ +export function modifyJsonBody(modifier: (body: T) => T): ResponseHandler { + return async (response) => ({ + body: JSON.stringify(modifier((await response.json()) as T)), + response, + }); +} + +/** + * Helper to create a {@link ResponseHandler} that modifies the response body as a plaintext string. + * + * @param modifier - The callback which modifies the response body before returning it to the client + * @returns ResponseHandler + * + * @example + * ```ts + * await interceptRoute(page, pathPattern("/email/1/subject")) + * .modifyResponse(modifyTextBody(body => body.toLowerCase())) + * .during(async() => { + * // ... perform action to trigger request to /email/1/subject ... + * }); + * ``` + */ +export function modifyTextBody( + modifier: (body: string) => string, +): ResponseHandler { + return async (response) => ({ + response, + body: modifier(await response.text()), + }); +} diff --git a/src/index.ts b/src/index.ts index 94100cd..597aed9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,6 +2,22 @@ export { isCI } from "./environment"; export { createFetchAdapter } from "./api/fetch-adapter"; +export { + interceptRoute, + RouteInterceptor, + RouteInterceptorFixture, +} from "./api/route-interceptor"; +export { + pathPattern, + type HttpMethod, + type RouteFilter, +} from "./api/route-interceptor/filter"; +export { + modifyJsonBody, + modifyTextBody, + type ResponseHandler, +} from "./api/route-interceptor/response-handler"; + export { resolveFromPackageRoot } from "./file"; export { maskBaseURL } from "./normalizers/mask-base-url"; diff --git a/tests/lib/fixtures.ts b/tests/lib/fixtures.ts new file mode 100644 index 0000000..d27ccf2 --- /dev/null +++ b/tests/lib/fixtures.ts @@ -0,0 +1,3 @@ +import { defineFileSnapshotMatchers } from "@cronn/playwright-file-snapshots"; + +export const expect = defineFileSnapshotMatchers(); diff --git a/tests/route-interceptor.spec.ts b/tests/route-interceptor.spec.ts new file mode 100644 index 0000000..4280303 --- /dev/null +++ b/tests/route-interceptor.spec.ts @@ -0,0 +1,218 @@ +import { semanticSnapshot } from "@cronn/element-snapshot"; +import { test } from "@playwright/test"; +import http, { type Server, type ServerResponse } from "node:http"; + +import { + pathPattern, + modifyJsonBody, + modifyTextBody, + type RouteInterceptor, + RouteInterceptorFixture, +} from "../src"; + +import { expect } from "./lib/fixtures"; + +interface ApiUserResponse { + username: string; + enabled: boolean; + id: number; +} + +function sendResponse( + response: ServerResponse, + body: T, + status = 200, +): void { + response.writeHead(status, { "content-type": "application/json" }); + response.end(JSON.stringify(body)); +} + +function createTestServer(): Server { + const server = http.createServer((request, response) => { + if (request.url === undefined) { + return; + } + + const url = request.url; + if (url === "/users/1") { + if (request.method === "GET") { + return sendResponse(response, { + username: "test_user", + enabled: true, + id: 1, + }); + } + } + + return sendResponse( + response, + { + error: "Not Found", + }, + 404, + ); + }); + + server.listen(8080, "localhost"); + + return server; +} + +let server: Server | undefined; + +test.beforeEach(() => { + server = createTestServer(); +}); + +test.afterEach(() => { + server?.close(); +}); + +class CustomRouteInterceptorFixture extends RouteInterceptorFixture { + public onGetUser(userId = 1): RouteInterceptor { + return this.intercept(pathPattern(`/users/${userId}`)); + } +} + +const customTest = test.extend<{ intercept: CustomRouteInterceptorFixture }>({ + intercept: ({ page }, use) => use(new CustomRouteInterceptorFixture(page)), +}); + +const testPage = ` + + + + + + + +
+

Api Response

+ +

Status: -

+
+    
+
+ + + + + +`; + +customTest("Route interceptors", async ({ page, intercept }) => { + const apiResponseSection = page.getByRole("region", { name: "Api Response" }); + const loadingIndicator = page.getByRole("progressbar"); + const fetchUserButton = page.getByRole("button", { name: "Fetch user" }); + const count = page.getByRole("textbox", { name: "Count" }); + + let requestCount = 0; + + async function clickFetchUser() { + await fetchUserButton.click(); + } + + async function validateResponse() { + await expect.soft(semanticSnapshot(apiResponseSection)).toMatchJsonFile(); + } + + async function validateRequestFinished() { + await expect(count).toHaveValue(`${++requestCount}`); + } + + await test.step("Open page", async () => { + await page.setContent(testPage); + await expect(count).toHaveValue("0"); + }); + + await test.step("With aborted request", async () => { + await intercept + .onGetUser() + .abort() + .during(async () => { + await clickFetchUser(); + await validateRequestFinished(); + await validateResponse(); + }); + }); + + await test.step("With mocked route", async () => { + await intercept + .onGetUser() + .respondWith({ + status: 500, + body: JSON.stringify({ error: "Internal Server Error" }), + }) + .during(async () => { + await clickFetchUser(); + await validateRequestFinished(); + await validateResponse(); + }); + }); + + await test.step("With overwritten response", async () => { + await intercept + .onGetUser() + .modifyResponse( + modifyJsonBody((body) => ({ + ...body, + enabled: false, + })), + ) + .during(async () => { + await clickFetchUser(); + await validateRequestFinished(); + await validateResponse(); + }); + }); + + await test.step("With barrier", async () => { + await intercept + .onGetUser() + .block() + .during(async () => { + await clickFetchUser(); + await page.waitForTimeout(200); + await expect(loadingIndicator).toBeVisible(); + }); + + await expect(loadingIndicator).toHaveCount(0); + await validateRequestFinished(); + await validateResponse(); + }); + + await test.step("With incomplete json response", async () => { + await intercept + .onGetUser() + .modifyResponse(modifyTextBody((body) => body.substring(0, 5))) + .during(async () => { + await clickFetchUser(); + await validateRequestFinished(); + }); + + await expect + .soft(semanticSnapshot(page.getByRole("alert"))) + .toMatchJsonFile(); + }); +}); From 7c7614e1d8784e4c1e2a7926a8481390f7491526 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Florian=20Spie=C3=9F?= Date: Wed, 2 Sep 2026 15:23:08 +0200 Subject: [PATCH 2/3] Apply review suggestions --- docs/src/api/route-interceptor.md | 50 ++++---- src/api/route-interceptor/filter.ts | 6 +- .../{index.ts => interceptor.ts} | 72 ++++++------ src/api/route-interceptor/response-handler.ts | 6 +- src/index.ts | 4 +- tests/lib/fixtures.ts | 3 - tests/route-interceptor.spec.ts | 107 +++++++++--------- 7 files changed, 119 insertions(+), 129 deletions(-) rename src/api/route-interceptor/{index.ts => interceptor.ts} (82%) delete mode 100644 tests/lib/fixtures.ts diff --git a/docs/src/api/route-interceptor.md b/docs/src/api/route-interceptor.md index 17a3e79..315c3e2 100644 --- a/docs/src/api/route-interceptor.md +++ b/docs/src/api/route-interceptor.md @@ -8,11 +8,11 @@ This keeps mocking close to the assertions it belongs to and makes it possible t ## Usage ```ts -import { interceptRoute, pathPattern } from "@cronn/playwright-utils"; +import { interceptRoute, matchPath } from "@cronn/playwright-utils"; import { expect, test } from "@playwright/test"; test("shows an error when the user cannot be loaded", async ({ page }) => { - await interceptRoute(page, pathPattern("/api/users/1")) + await interceptRoute(page, matchPath("/api/users/1")) .abort() .during(async () => { await page.goto("/users/1/profile"); @@ -27,13 +27,13 @@ Requests which are not matched by the filter are passed on to the next matching ## Filtering requests -A `RouteFilter` selects the requests to intercept. `pathPattern` creates a filter matching the path of a URL, either exactly or by regular expression. Query parameters, host and protocol are ignored: +A `RouteFilter` selects the requests to intercept. `matchPath` creates a filter matching the path of a URL, either exactly or by regular expression. Query parameters, host and protocol are ignored: ```ts -import { pathPattern } from "@cronn/playwright-utils"; +import { matchPath } from "@cronn/playwright-utils"; -pathPattern("/api/users/1"); -pathPattern(/^\/api\/users\/\d+$/); +matchPath("/api/users/1"); +matchPath(/^\/api\/users\/\d+$/); ``` Filters can also be written by hand, for example to restrict an interception to certain HTTP methods: @@ -61,10 +61,12 @@ All methods of a `RouteInterceptor` take the action to run while the interceptio `abort` fails matching requests, which is useful for testing how the application under test handles network errors: ```ts -await intercept.abort().during(async () => { - await page.getByRole("button", { name: "Fetch user" }).click(); - await expect(page.getByRole("alert")).toBeVisible(); -}); +await interceptRoute(page, matchPath("/api/users/1")) + .abort() + .during(async () => { + await page.getByRole("button", { name: "Fetch user" }).click(); + await expect(page.getByRole("alert")).toBeVisible(); + }); ``` ### Mocking responses @@ -72,8 +74,8 @@ await intercept.abort().during(async () => { `respondWith` answers matching requests with a mocked response, which never reaches the server. It accepts the options of [`route.fulfill`](https://playwright.dev/docs/api/class-route#route-fulfill): ```ts -await intercept - .respondWith({ status: 500, body: `{"error": "Internal Server Error"}` }) +await interceptRoute(page, matchPath("/api/users/1")) + .respondWith({ status: 500 }) .during(async () => { await page.getByRole("button", { name: "Fetch user" }).click(); await expect(page.getByRole("alert")).toHaveText("Something went wrong"); @@ -85,7 +87,7 @@ await intercept `modifyResponse` sends the request to the server and passes it to a modifier before it is returned to the page. ```ts -await intercept +await interceptRoute(page, matchPath("/api/users/1")) .modifyResponse( // helper to easily modify the response body with a typed function modifyJsonBody((user) => ({ @@ -101,21 +103,23 @@ await intercept }); ``` -### Blocking requests +### Suspending requests -`block` delays matching requests until the action has finished. This makes pending states such as loading indicators, skeletons or disabled buttons observable without relying on timing: +`suspend` delays matching requests until the action has finished. This makes pending states such as loading indicators, skeletons or disabled buttons observable without relying on timing: ```ts -await intercept.block().during(async () => { - await page.getByRole("button", { name: "Fetch user" }).click(); - // The loading indicator can be inspected without worrying about race-conditions - await expect(page.getByRole("progressbar")).toBeVisible(); -}); +await interceptRoute(page, matchPath("/api/users/1")) + .suspend() + .during(async () => { + await page.getByRole("button", { name: "Fetch user" }).click(); + // The loading indicator can be inspected without worrying about race-conditions + await expect(page.getByRole("progressbar")).toBeVisible(); + }); await expect(page.getByRole("progressbar")).toHaveCount(0); ``` -The blocked requests are continued once the action returns, and `block` resolves after all of them have been answered. Assertions on the loaded state therefore belong after the action, not inside it. +The suspended requests are continued once the action returns, and `suspend` resolves after all of them have been answered. Assertions on the loaded state therefore belong after the action, not inside it. ## Fixture @@ -123,7 +127,7 @@ The blocked requests are continued once the action returns, and `block` resolves ```ts import { - pathPattern, + matchPath, type RouteInterceptor, RouteInterceptorFixture, } from "@cronn/playwright-utils"; @@ -131,7 +135,7 @@ import { test as base } from "@playwright/test"; class AppInterceptorFixture extends RouteInterceptorFixture { public onGetUser(userId = 1): RouteInterceptor { - return this.intercept(pathPattern(`/api/users/${userId}`)); + return this.intercept(matchPath(`/api/users/${userId}`)); } } diff --git a/src/api/route-interceptor/filter.ts b/src/api/route-interceptor/filter.ts index b69b3f1..1740839 100644 --- a/src/api/route-interceptor/filter.ts +++ b/src/api/route-interceptor/filter.ts @@ -14,7 +14,7 @@ export type HttpMethod = /** * Filters used to intercept routes with {@link RouteInterceptor}. * - * @see pathPattern + * @see matchPath */ export interface RouteFilter { method?: HttpMethod | Array; @@ -28,14 +28,14 @@ export interface RouteFilter { * @returns RouteFilter * @example * ```ts - * await interceptRoute(page, pathPattern("/users/1")) + * await interceptRoute(page, matchPath("/users/1")) * .respondWith({ status: 404 }) * .during(() => { * // ... * }); * ``` */ -export function pathPattern(pattern: RegExp | string): RouteFilter { +export function matchPath(pattern: RegExp | string): RouteFilter { return { url: (url) => typeof pattern === "string" diff --git a/src/api/route-interceptor/index.ts b/src/api/route-interceptor/interceptor.ts similarity index 82% rename from src/api/route-interceptor/index.ts rename to src/api/route-interceptor/interceptor.ts index a64cda6..42b6236 100644 --- a/src/api/route-interceptor/index.ts +++ b/src/api/route-interceptor/interceptor.ts @@ -1,6 +1,6 @@ import type { Page, Route, Request } from "@playwright/test"; -import { type RouteFilter } from "./filter"; +import { type HttpMethod, type RouteFilter } from "./filter"; import { type ResponseHandler } from "./response-handler"; export type Action = () => Promise | T; @@ -20,10 +20,7 @@ function isFiltered(filter: RouteFilter, request: Request) { if (typeof method === "string") { return method === request.method(); } else { - return ( - method instanceof Array && - (method as Array).includes(request.method()) - ); + return method.includes(request.method() as HttpMethod); } } @@ -84,16 +81,13 @@ async function withAbortedRoute( } } -async function withBlockedRoute( +async function withSuspendedRoute( page: Page, filter: RouteFilter, action: Action, ) { - let resolver: () => void; - const unblocked = new Promise((resolve) => { - resolver = resolve; - }); - const blockedRoutes = new Set>(); + const barrier = Promise.withResolvers(); + const suspendedRoutes = new Set>(); async function handler(route: Route, request: Request) { if (!isFiltered(filter, request)) { @@ -101,15 +95,13 @@ async function withBlockedRoute( return; } - const blockedRoute = unblocked.then(async () => { - await route.continue(); - }); + const suspendedRoute = barrier.promise.then(() => route.continue()); - blockedRoutes.add(blockedRoute); + suspendedRoutes.add(suspendedRoute); try { - await blockedRoute; + await suspendedRoute; } finally { - blockedRoutes.delete(blockedRoute); + suspendedRoutes.delete(suspendedRoute); } } @@ -117,11 +109,11 @@ async function withBlockedRoute( try { return await action(); } finally { - resolver!(); - // Let every blocked request continue before the route is uninstalled, + barrier.resolve(); + // Let every suspended request continue before the route is uninstalled, // otherwise Playwright handles the pending routes itself and the handler // fails with "Route is already handled!". - await Promise.allSettled([...blockedRoutes]); + await Promise.allSettled([...suspendedRoutes]); await uninstallRoute(page, filter, handler); } } @@ -166,7 +158,7 @@ export class RouteInterceptor { * * This method returns a stub to continue chaining with. * - * @returns RouteInterceptorStarter + * @returns ExecutableRouteInterceptor * @see {@link Route#abort} * * @example @@ -174,33 +166,33 @@ export class RouteInterceptor { * interceptor.abort().during(() => button.click()) * ``` */ - public abort(): RouteInterceptorStarter { - return new RouteInterceptorStarter((action) => + public abort(): ExecutableRouteInterceptor { + return new ExecutableRouteInterceptor((action) => withAbortedRoute(this.page, this.filter, action), ); } /** - * Block the target route for the duration of the callback. + * Suspend the target route for the duration of the callback. * - * A blocked route will not serve the response until the callback completes, + * A suspended route will not serve the response until the callback completes, * this is useful to validate loading indicators. * * This method returns a stub to continue chaining with. * - * @returns RouteInterceptorStarter + * @returns ExecutableRouteInterceptor * * @example * ```ts - * interceptor.block().during(async () => { + * interceptor.suspend().during(async () => { * await button.click(); * await expect(loadingOverlay).toBeVisisble(); * }); * ``` */ - public block(): RouteInterceptorStarter { - return new RouteInterceptorStarter((action) => - withBlockedRoute(this.page, this.filter, action), + public suspend(): ExecutableRouteInterceptor { + return new ExecutableRouteInterceptor((action) => + withSuspendedRoute(this.page, this.filter, action), ); } @@ -212,7 +204,7 @@ export class RouteInterceptor { * This method returns a stub to continue chaining with. * * @param options - The fixed response to return for the target route - * @returns RouteInterceptorStarter + * @returns ExecutableRouteInterceptor * @see {@link Route#fulfill} * * @example @@ -223,8 +215,8 @@ export class RouteInterceptor { * }); * ``` */ - public respondWith(options: FulfillOptions): RouteInterceptorStarter { - return new RouteInterceptorStarter((action) => + public respondWith(options: FulfillOptions): ExecutableRouteInterceptor { + return new ExecutableRouteInterceptor((action) => withFulfilledRoute(this.page, this.filter, options, action), ); } @@ -238,7 +230,7 @@ export class RouteInterceptor { * This method returns a stub to continue chaining with. * * @param modifier - Callback to modify the response of the server before returning it to the client - * @returns RouteInterceptorStarter + * @returns ExecutableRouteInterceptor * @see {@link Route#fetch}, {@link Route#fulfill} * * @example @@ -249,8 +241,8 @@ export class RouteInterceptor { * }); * ``` */ - public modifyResponse(modifier: ResponseHandler): RouteInterceptorStarter { - return new RouteInterceptorStarter((action) => + public modifyResponse(modifier: ResponseHandler): ExecutableRouteInterceptor { + return new ExecutableRouteInterceptor((action) => withModifiedResponse(this.page, this.filter, modifier, action), ); } @@ -267,7 +259,7 @@ export class RouteInterceptor { * }); * ``` */ -export class RouteInterceptorStarter { +export class ExecutableRouteInterceptor { private readonly runner; public constructor(runner: (action: Action) => Promise) { @@ -295,11 +287,11 @@ export class RouteInterceptorStarter { * @param page – The page object to intercept the route on * @param filter - The route to intercept * @returns RouteInterceptor - * @see {@link pathPattern} + * @see {@link matchPath} * * @example * ```ts - * await interceptRoute(page, pathPattern("/api/notifications")) + * await interceptRoute(page, matchPath("/api/notifications")) * .respondWith({ status: 500 }) * .during(async () => { * await page.getByRole("button", {name: "View notifications"}).click(); @@ -324,7 +316,7 @@ export function interceptRoute( * ```ts * class CustomRouteInterceptorFixture extends RouteInterceptorFixture { * public onGetUser(userId = 1): RouteInterceptor { - * return this.intercept(pathPattern(`/api/users/${userId}`)); + * return this.intercept(matchPath(`/api/users/${userId}`)); * } * } * diff --git a/src/api/route-interceptor/response-handler.ts b/src/api/route-interceptor/response-handler.ts index 9a4144e..19ff453 100644 --- a/src/api/route-interceptor/response-handler.ts +++ b/src/api/route-interceptor/response-handler.ts @@ -1,6 +1,6 @@ import { type APIResponse } from "@playwright/test"; -import { type FulfillOptions } from "./index"; +import { type FulfillOptions } from "./interceptor"; /** * Interface used to modify responses with {@link RouteInterceptor#modifyResponse}. @@ -20,7 +20,7 @@ export type ResponseHandler = ( * * @example * ```ts - * await interceptRoute(page, pathPattern("/users/1")) + * await interceptRoute(page, matchPath("/users/1")) * .modifyResponse(modifyJsonBody(user => ({...user, username: "modified_username"}))) * .during(async() => { * // ... perform action to trigger request to /users/1 ... @@ -42,7 +42,7 @@ export function modifyJsonBody(modifier: (body: T) => T): ResponseHandler { * * @example * ```ts - * await interceptRoute(page, pathPattern("/email/1/subject")) + * await interceptRoute(page, matchPath("/email/1/subject")) * .modifyResponse(modifyTextBody(body => body.toLowerCase())) * .during(async() => { * // ... perform action to trigger request to /email/1/subject ... diff --git a/src/index.ts b/src/index.ts index 597aed9..cf254c1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,9 +6,9 @@ export { interceptRoute, RouteInterceptor, RouteInterceptorFixture, -} from "./api/route-interceptor"; +} from "./api/route-interceptor/interceptor"; export { - pathPattern, + matchPath, type HttpMethod, type RouteFilter, } from "./api/route-interceptor/filter"; diff --git a/tests/lib/fixtures.ts b/tests/lib/fixtures.ts deleted file mode 100644 index d27ccf2..0000000 --- a/tests/lib/fixtures.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { defineFileSnapshotMatchers } from "@cronn/playwright-file-snapshots"; - -export const expect = defineFileSnapshotMatchers(); diff --git a/tests/route-interceptor.spec.ts b/tests/route-interceptor.spec.ts index 4280303..37b77d1 100644 --- a/tests/route-interceptor.spec.ts +++ b/tests/route-interceptor.spec.ts @@ -3,14 +3,13 @@ import { test } from "@playwright/test"; import http, { type Server, type ServerResponse } from "node:http"; import { - pathPattern, + matchPath, modifyJsonBody, modifyTextBody, type RouteInterceptor, RouteInterceptorFixture, } from "../src"; - -import { expect } from "./lib/fixtures"; +import { expect } from "../src/test/fixtures"; interface ApiUserResponse { username: string; @@ -27,7 +26,7 @@ function sendResponse( response.end(JSON.stringify(body)); } -function createTestServer(): Server { +function createTestServer(): Promise { const server = http.createServer((request, response) => { if (request.url === undefined) { return; @@ -53,24 +52,28 @@ function createTestServer(): Server { ); }); - server.listen(8080, "localhost"); - - return server; + return new Promise((resolve) => + server.listen(0, () => resolve(server)), + ); } -let server: Server | undefined; +let server: Server; +let serverURL: string; -test.beforeEach(() => { - server = createTestServer(); +test.beforeAll(async () => { + server = await createTestServer(); + const address = server.address(); + serverURL = + typeof address === "string" ? address : `http://localhost:${address?.port}`; }); -test.afterEach(() => { +test.afterAll(() => { server?.close(); }); class CustomRouteInterceptorFixture extends RouteInterceptorFixture { public onGetUser(userId = 1): RouteInterceptor { - return this.intercept(pathPattern(`/users/${userId}`)); + return this.intercept(matchPath(`/users/${userId}`)); } } @@ -79,47 +82,39 @@ const customTest = test.extend<{ intercept: CustomRouteInterceptorFixture }>({ }); const testPage = ` - - - - - - - -
-

Api Response

- -

Status: -

-
-    
-
- - - - - -`; + + +
+

Api Response

+ +

Status: -

+
+  
+
+ + +`; customTest("Route interceptors", async ({ page, intercept }) => { const apiResponseSection = page.getByRole("region", { name: "Api Response" }); @@ -142,7 +137,9 @@ customTest("Route interceptors", async ({ page, intercept }) => { } await test.step("Open page", async () => { - await page.setContent(testPage); + await page.setContent( + testPage.replace("http://localhost:8080", serverURL!), + ); await expect(count).toHaveValue("0"); }); @@ -190,7 +187,7 @@ customTest("Route interceptors", async ({ page, intercept }) => { await test.step("With barrier", async () => { await intercept .onGetUser() - .block() + .suspend() .during(async () => { await clickFetchUser(); await page.waitForTimeout(200); From 83d03e9bad949af4fe714e65fc0b9dd599004be1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Florian=20Spie=C3=9F?= Date: Wed, 2 Sep 2026 15:47:49 +0200 Subject: [PATCH 3/3] Ignore docs/.vitepress/.temp in eslint check --- eslint.config.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/eslint.config.ts b/eslint.config.ts index 39217d9..c6a5495 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -10,6 +10,7 @@ export default defineConfig( "coverage", "docs/.vitepress/cache", "docs/.vitepress/dist", + "docs/.vitepress/.temp", "playwright-report", ]), {