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..315c3e2 --- /dev/null +++ b/docs/src/api/route-interceptor.md @@ -0,0 +1,162 @@ +# 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, 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, matchPath("/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. `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 { matchPath } from "@cronn/playwright-utils"; + +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: + +```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 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 + +`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 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"); + }); +``` + +### Modifying responses + +`modifyResponse` sends the request to the server and passes it to a modifier before it is returned to the page. + +```ts +await interceptRoute(page, matchPath("/api/users/1")) + .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(); + }); +``` + +### Suspending requests + +`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 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 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 + +`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 { + matchPath, + 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(matchPath(`/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/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", ]), { 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..1740839 --- /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 matchPath + */ +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, matchPath("/users/1")) + * .respondWith({ status: 404 }) + * .during(() => { + * // ... + * }); + * ``` + */ +export function matchPath(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/interceptor.ts b/src/api/route-interceptor/interceptor.ts new file mode 100644 index 0000000..42b6236 --- /dev/null +++ b/src/api/route-interceptor/interceptor.ts @@ -0,0 +1,362 @@ +import type { Page, Route, Request } from "@playwright/test"; + +import { type HttpMethod, 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.includes(request.method() as HttpMethod); + } +} + +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 withSuspendedRoute( + page: Page, + filter: RouteFilter, + action: Action, +) { + const barrier = Promise.withResolvers(); + const suspendedRoutes = new Set>(); + + async function handler(route: Route, request: Request) { + if (!isFiltered(filter, request)) { + await route.fallback(); + return; + } + + const suspendedRoute = barrier.promise.then(() => route.continue()); + + suspendedRoutes.add(suspendedRoute); + try { + await suspendedRoute; + } finally { + suspendedRoutes.delete(suspendedRoute); + } + } + + await page.route(filter.url, handler); + try { + return await action(); + } finally { + 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([...suspendedRoutes]); + 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 ExecutableRouteInterceptor + * @see {@link Route#abort} + * + * @example + * ```ts + * interceptor.abort().during(() => button.click()) + * ``` + */ + public abort(): ExecutableRouteInterceptor { + return new ExecutableRouteInterceptor((action) => + withAbortedRoute(this.page, this.filter, action), + ); + } + + /** + * Suspend the target route for the duration of the callback. + * + * 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 ExecutableRouteInterceptor + * + * @example + * ```ts + * interceptor.suspend().during(async () => { + * await button.click(); + * await expect(loadingOverlay).toBeVisisble(); + * }); + * ``` + */ + public suspend(): ExecutableRouteInterceptor { + return new ExecutableRouteInterceptor((action) => + withSuspendedRoute(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 ExecutableRouteInterceptor + * @see {@link Route#fulfill} + * + * @example + * ```ts + * interceptor.respondWith({ status: 500 }).during(async () => { + * await button.click(); + * await expect(alert).toBeVisisble(); + * }); + * ``` + */ + public respondWith(options: FulfillOptions): ExecutableRouteInterceptor { + return new ExecutableRouteInterceptor((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 ExecutableRouteInterceptor + * @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): ExecutableRouteInterceptor { + return new ExecutableRouteInterceptor((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 ExecutableRouteInterceptor { + 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 matchPath} + * + * @example + * ```ts + * await interceptRoute(page, matchPath("/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(matchPath(`/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..19ff453 --- /dev/null +++ b/src/api/route-interceptor/response-handler.ts @@ -0,0 +1,59 @@ +import { type APIResponse } from "@playwright/test"; + +import { type FulfillOptions } from "./interceptor"; + +/** + * 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, matchPath("/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, matchPath("/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..cf254c1 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/interceptor"; +export { + matchPath, + 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/route-interceptor.spec.ts b/tests/route-interceptor.spec.ts new file mode 100644 index 0000000..37b77d1 --- /dev/null +++ b/tests/route-interceptor.spec.ts @@ -0,0 +1,215 @@ +import { semanticSnapshot } from "@cronn/element-snapshot"; +import { test } from "@playwright/test"; +import http, { type Server, type ServerResponse } from "node:http"; + +import { + matchPath, + modifyJsonBody, + modifyTextBody, + type RouteInterceptor, + RouteInterceptorFixture, +} from "../src"; +import { expect } from "../src/test/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(): Promise { + 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, + ); + }); + + return new Promise((resolve) => + server.listen(0, () => resolve(server)), + ); +} + +let server: Server; +let serverURL: string; + +test.beforeAll(async () => { + server = await createTestServer(); + const address = server.address(); + serverURL = + typeof address === "string" ? address : `http://localhost:${address?.port}`; +}); + +test.afterAll(() => { + server?.close(); +}); + +class CustomRouteInterceptorFixture extends RouteInterceptorFixture { + public onGetUser(userId = 1): RouteInterceptor { + return this.intercept(matchPath(`/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.replace("http://localhost:8080", serverURL!), + ); + 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() + .suspend() + .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(); + }); +});