@cronn/playwright-utils 0.0.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.mts CHANGED
@@ -1,5 +1,14 @@
1
1
  import { Normalizer } from "@cronn/playwright-file-snapshots";
2
- import { APIRequestContext, Locator, Page } from "@playwright/test";
2
+ import { APIRequestContext, APIResponse, Locator, Page, Route } from "@playwright/test";
3
+ //#region src/environment.d.ts
4
+ /**
5
+ * Whether the current process runs in a CI environment, based on the `CI`
6
+ * environment variable.
7
+ *
8
+ * Evaluated once when the module is loaded.
9
+ */
10
+ declare const isCI: boolean;
11
+ //#endregion
3
12
  //#region src/api/fetch-adapter.d.ts
4
13
  type FetchAPI = typeof fetch;
5
14
  /**
@@ -12,7 +21,272 @@ type FetchAPI = typeof fetch;
12
21
  * @param requestContext - Playwright request context to send requests with
13
22
  * @returns A function with the signature of the global `fetch`
14
23
  */
15
- declare function fetchAdapter(requestContext: APIRequestContext): FetchAPI;
24
+ declare function createFetchAdapter(requestContext: APIRequestContext): FetchAPI;
25
+ //#endregion
26
+ //#region src/api/route-interceptor/filter.d.ts
27
+ /**
28
+ * HTTP Methods used by {@link RouteFilter}
29
+ */
30
+ type HttpMethod = "GET" | "PATCH" | "POST" | "PUT" | "DELETE" | "OPTIONS" | "QUERY" | "HEAD";
31
+ /**
32
+ * Filters used to intercept routes with {@link RouteInterceptor}.
33
+ *
34
+ * @see matchPath
35
+ */
36
+ interface RouteFilter {
37
+ method?: HttpMethod | Array<HttpMethod>;
38
+ url: (url: URL) => boolean;
39
+ }
40
+ /**
41
+ * Helper to create a {@link RouteFilter} that applies to the specified path.
42
+ *
43
+ * @param pattern - The regex or string matching the pathname of the URL
44
+ * @returns RouteFilter
45
+ * @example
46
+ * ```ts
47
+ * await interceptRoute(page, matchPath("/users/1"))
48
+ * .respondWith({ status: 404 })
49
+ * .during(() => {
50
+ * // ...
51
+ * });
52
+ * ```
53
+ */
54
+ declare function matchPath(pattern: RegExp | string): RouteFilter;
55
+ //#endregion
56
+ //#region src/api/route-interceptor/response-handler.d.ts
57
+ /**
58
+ * Interface used to modify responses with {@link RouteInterceptor#modifyResponse}.
59
+ *
60
+ * @see modifyJsonBody
61
+ * @see modifyTextBody
62
+ */
63
+ type ResponseHandler = (response: APIResponse) => Promise<FulfillOptions> | FulfillOptions;
64
+ /**
65
+ * Helper to create a {@link ResponseHandler} that modifies the response body, if it is a JSON object.
66
+ *
67
+ * @param modifier - The callback which modifies the response body before returning it to the client
68
+ * @returns ResponseHandler
69
+ *
70
+ * @example
71
+ * ```ts
72
+ * await interceptRoute(page, matchPath("/users/1"))
73
+ * .modifyResponse(modifyJsonBody(user => ({...user, username: "modified_username"})))
74
+ * .during(async() => {
75
+ * // ... perform action to trigger request to /users/1 ...
76
+ * });
77
+ * ```
78
+ */
79
+ declare function modifyJsonBody<T>(modifier: (body: T) => T): ResponseHandler;
80
+ /**
81
+ * Helper to create a {@link ResponseHandler} that modifies the response body as a plaintext string.
82
+ *
83
+ * @param modifier - The callback which modifies the response body before returning it to the client
84
+ * @returns ResponseHandler
85
+ *
86
+ * @example
87
+ * ```ts
88
+ * await interceptRoute(page, matchPath("/email/1/subject"))
89
+ * .modifyResponse(modifyTextBody(body => body.toLowerCase()))
90
+ * .during(async() => {
91
+ * // ... perform action to trigger request to /email/1/subject ...
92
+ * });
93
+ * ```
94
+ */
95
+ declare function modifyTextBody(modifier: (body: string) => string): ResponseHandler;
96
+ //#endregion
97
+ //#region src/api/route-interceptor/interceptor.d.ts
98
+ type Action<T> = () => Promise<T> | T;
99
+ type FulfillOptions = Parameters<Route["fulfill"]>[0];
100
+ /**
101
+ * Entrypoint to create various network interceptors using the {@link Page#route} API.
102
+ */
103
+ declare class RouteInterceptor {
104
+ readonly page: Page;
105
+ readonly filter: RouteFilter;
106
+ constructor(page: Page, filter: RouteFilter);
107
+ /**
108
+ * Abort the target route for the duration of the callback.
109
+ *
110
+ * This method returns a stub to continue chaining with.
111
+ *
112
+ * @returns ExecutableRouteInterceptor
113
+ * @see {@link Route#abort}
114
+ *
115
+ * @example
116
+ * ```ts
117
+ * interceptor.abort().during(() => button.click())
118
+ * ```
119
+ */
120
+ abort(): ExecutableRouteInterceptor;
121
+ /**
122
+ * Suspend the target route for the duration of the callback.
123
+ *
124
+ * A suspended route will not serve the response until the callback completes,
125
+ * this is useful to validate loading indicators.
126
+ *
127
+ * This method returns a stub to continue chaining with.
128
+ *
129
+ * @returns ExecutableRouteInterceptor
130
+ *
131
+ * @example
132
+ * ```ts
133
+ * interceptor.suspend().during(async () => {
134
+ * await button.click();
135
+ * await expect(loadingOverlay).toBeVisisble();
136
+ * });
137
+ * ```
138
+ */
139
+ suspend(): ExecutableRouteInterceptor;
140
+ /**
141
+ * Replace the response on the target route for the duration of the callback.
142
+ *
143
+ * The request will not hit the server at all and instead immediately return the provided response.
144
+ *
145
+ * This method returns a stub to continue chaining with.
146
+ *
147
+ * @param options - The fixed response to return for the target route
148
+ * @returns ExecutableRouteInterceptor
149
+ * @see {@link Route#fulfill}
150
+ *
151
+ * @example
152
+ * ```ts
153
+ * interceptor.respondWith({ status: 500 }).during(async () => {
154
+ * await button.click();
155
+ * await expect(alert).toBeVisisble();
156
+ * });
157
+ * ```
158
+ */
159
+ respondWith(options: FulfillOptions): ExecutableRouteInterceptor;
160
+ /**
161
+ * Modify the response on the target route for the duration of the callback.
162
+ *
163
+ * The route will first process the request to the server
164
+ * and then apply the provided callback to modify the content before returning it to the client.
165
+ *
166
+ * This method returns a stub to continue chaining with.
167
+ *
168
+ * @param modifier - Callback to modify the response of the server before returning it to the client
169
+ * @returns ExecutableRouteInterceptor
170
+ * @see {@link Route#fetch}, {@link Route#fulfill}
171
+ *
172
+ * @example
173
+ * ```ts
174
+ * interceptor.respondWith({ status: 500 }).during(async () => {
175
+ * await button.click();
176
+ * await expect(alert).toBeVisisble();
177
+ * });
178
+ * ```
179
+ */
180
+ modifyResponse(modifier: ResponseHandler): ExecutableRouteInterceptor;
181
+ }
182
+ /**
183
+ * Stub returned by {@link RouteInterceptor} used to intercept routes during a block.
184
+ *
185
+ * @example
186
+ * ```ts
187
+ * interceptor.respondWith({ status: 500 }).during(async () => {
188
+ * await button.click();
189
+ * await expect(alert).toBeVisisble();
190
+ * });
191
+ * ```
192
+ */
193
+ declare class ExecutableRouteInterceptor {
194
+ private readonly runner;
195
+ constructor(runner: <T>(action: Action<T>) => Promise<T>);
196
+ /**
197
+ * Run the given callback and intercept the stubbed route.
198
+ *
199
+ * The route interceptor is automatically removed when this method returns.
200
+ *
201
+ * @param action - The action to run with an intercepted route
202
+ * @returns The result of the action
203
+ */
204
+ during<T>(action: Action<T>): Promise<T>;
205
+ }
206
+ /**
207
+ * Intercept a route using the provided filter.
208
+ *
209
+ * The returned {@link RouteInterceptor} can be used to declare how the route should be intercepted.
210
+ *
211
+ * @param page – The page object to intercept the route on
212
+ * @param filter - The route to intercept
213
+ * @returns RouteInterceptor
214
+ * @see {@link matchPath}
215
+ *
216
+ * @example
217
+ * ```ts
218
+ * await interceptRoute(page, matchPath("/api/notifications"))
219
+ * .respondWith({ status: 500 })
220
+ * .during(async () => {
221
+ * await page.getByRole("button", {name: "View notifications"}).click();
222
+ * await expect(page.getByRole("alert")).toBeVisible();
223
+ * await expect(page.getByRole("alert")).toHaveText("Failed to load notification");
224
+ * });
225
+ * ```
226
+ */
227
+ declare function interceptRoute(page: Page, filter: RouteFilter): RouteInterceptor;
228
+ /**
229
+ * Base class to build re-usable route interceptors.
230
+ *
231
+ * This is especially useful when combined with {@link test#extend} to create a custom fixture.
232
+ *
233
+ * @example
234
+ * ```ts
235
+ * class CustomRouteInterceptorFixture extends RouteInterceptorFixture {
236
+ * public onGetUser(userId = 1): RouteInterceptor {
237
+ * return this.intercept(matchPath(`/api/users/${userId}`));
238
+ * }
239
+ * }
240
+ *
241
+ * const test = baseTest.extend<{ intercept: CustomRouteInterceptorFixture }>({
242
+ * intercept: ({ page }, use) => use(new CustomRouteInterceptorFixture(page)),
243
+ * });
244
+ *
245
+ * test("Fail to get user", async ({ intercept, page }) => {
246
+ * await page.goto("/users");
247
+ * await intercept
248
+ * .onGetUser()
249
+ * .respondWith({ status: 404 })
250
+ * .during(async () => {
251
+ * await page.getByRole("button", {name: "View profile"});
252
+ * await expect(page.getByRole("alert")).toHaveText("User not found");
253
+ * });
254
+ * });
255
+ * ```
256
+ */
257
+ declare class RouteInterceptorFixture {
258
+ private readonly page;
259
+ constructor(page: Page);
260
+ /**
261
+ * Create a {@link RouteInterceptor} for routes matching the provided filter.
262
+ *
263
+ * @param filter - The filter to intercept routes with
264
+ * @returns RouteInterceptor
265
+ *
266
+ * @example
267
+ * ```ts
268
+ * await intercept({ method: "POST", url: "/api/messages" })
269
+ * .abort()
270
+ * .during(() => sendButton.click());
271
+ * ```
272
+ */
273
+ intercept(filter: RouteFilter): RouteInterceptor;
274
+ }
275
+ //#endregion
276
+ //#region src/file.d.ts
277
+ /**
278
+ * Resolves the given path segments against the root directory of the closest
279
+ * package, i.e. the closest directory containing a `package.json`, searching
280
+ * upwards from the current working directory.
281
+ *
282
+ * Useful for referencing files independently of the directory a test run is
283
+ * started from, e.g. a subdirectory of the package.
284
+ *
285
+ * @param paths - Path segments to resolve against the package root
286
+ * @returns The absolute path to the resolved location
287
+ * @throws If no package root can be found
288
+ */
289
+ declare function resolveFromPackageRoot(...paths: Array<string>): string;
16
290
  //#endregion
17
291
  //#region src/normalizers/mask-base-url.d.ts
18
292
  /**
@@ -46,5 +320,4 @@ declare function maskedValueWithIndex(name: string): (index: number) => string;
46
320
  */
47
321
  type PlaywrightTarget = Page | Locator;
48
322
  //#endregion
49
- export { type PlaywrightTarget, fetchAdapter, maskBaseURL, maskedValue, maskedValueWithIndex };
50
- //# sourceMappingURL=index.d.mts.map
323
+ export { type HttpMethod, type PlaywrightTarget, type ResponseHandler, type RouteFilter, RouteInterceptor, RouteInterceptorFixture, createFetchAdapter, interceptRoute, isCI, maskBaseURL, maskedValue, maskedValueWithIndex, matchPath, modifyJsonBody, modifyTextBody, resolveFromPackageRoot };
package/dist/index.mjs CHANGED
@@ -1,4 +1,15 @@
1
+ import path from "node:path";
2
+ import { packageDirectorySync } from "package-directory";
1
3
  import { maskString, stringNormalizer } from "@cronn/playwright-file-snapshots";
4
+ //#region src/environment.ts
5
+ /**
6
+ * Whether the current process runs in a CI environment, based on the `CI`
7
+ * environment variable.
8
+ *
9
+ * Evaluated once when the module is loaded.
10
+ */
11
+ const isCI = process.env.CI === "true";
12
+ //#endregion
2
13
  //#region src/api/fetch-adapter.ts
3
14
  /**
4
15
  * Creates a `fetch`-compatible function backed by a Playwright
@@ -10,7 +21,7 @@ import { maskString, stringNormalizer } from "@cronn/playwright-file-snapshots";
10
21
  * @param requestContext - Playwright request context to send requests with
11
22
  * @returns A function with the signature of the global `fetch`
12
23
  */
13
- function fetchAdapter(requestContext) {
24
+ function createFetchAdapter(requestContext) {
14
25
  return async (input, init) => {
15
26
  const response = await requestContext.fetch(mapToUrl(input), await mapToOptions(input instanceof Request ? input : null, init));
16
27
  const responseBody = await response.body();
@@ -43,6 +54,363 @@ function mapToRequestBody(body) {
43
54
  return { data: body };
44
55
  }
45
56
  //#endregion
57
+ //#region src/api/route-interceptor/interceptor.ts
58
+ function isFiltered(filter, request) {
59
+ if (!filter.url(new URL(request.url()))) return false;
60
+ const method = filter.method;
61
+ if (method === void 0) return true;
62
+ if (typeof method === "string") return method === request.method();
63
+ else return method.includes(request.method());
64
+ }
65
+ async function uninstallRoute(page, filter, handler) {
66
+ try {
67
+ await page.unroute(filter.url, handler);
68
+ } catch (error) {
69
+ if (!page.isClosed()) throw error;
70
+ }
71
+ }
72
+ async function withFulfilledRoute(page, filter, options, action) {
73
+ async function handler(route, request) {
74
+ if (isFiltered(filter, request)) await route.fulfill(options);
75
+ else await route.fallback();
76
+ }
77
+ await page.route(filter.url, handler);
78
+ try {
79
+ return await action();
80
+ } finally {
81
+ await uninstallRoute(page, filter, handler);
82
+ }
83
+ }
84
+ async function withAbortedRoute(page, filter, action) {
85
+ async function handler(route, request) {
86
+ if (isFiltered(filter, request)) await route.abort();
87
+ else await route.fallback();
88
+ }
89
+ await page.route(filter.url, handler);
90
+ try {
91
+ return await action();
92
+ } finally {
93
+ await uninstallRoute(page, filter, handler);
94
+ }
95
+ }
96
+ async function withSuspendedRoute(page, filter, action) {
97
+ const barrier = Promise.withResolvers();
98
+ const suspendedRoutes = /* @__PURE__ */ new Set();
99
+ async function handler(route, request) {
100
+ if (!isFiltered(filter, request)) {
101
+ await route.fallback();
102
+ return;
103
+ }
104
+ const suspendedRoute = barrier.promise.then(() => route.continue());
105
+ suspendedRoutes.add(suspendedRoute);
106
+ try {
107
+ await suspendedRoute;
108
+ } finally {
109
+ suspendedRoutes.delete(suspendedRoute);
110
+ }
111
+ }
112
+ await page.route(filter.url, handler);
113
+ try {
114
+ return await action();
115
+ } finally {
116
+ barrier.resolve();
117
+ await Promise.allSettled([...suspendedRoutes]);
118
+ await uninstallRoute(page, filter, handler);
119
+ }
120
+ }
121
+ async function withModifiedResponse(page, filter, interceptor, action) {
122
+ async function handler(route, request) {
123
+ if (isFiltered(filter, request)) {
124
+ const response = await route.fetch();
125
+ await route.fulfill(await interceptor(response));
126
+ } else await route.fallback();
127
+ }
128
+ await page.route(filter.url, handler);
129
+ try {
130
+ return await action();
131
+ } finally {
132
+ await uninstallRoute(page, filter, handler);
133
+ }
134
+ }
135
+ /**
136
+ * Entrypoint to create various network interceptors using the {@link Page#route} API.
137
+ */
138
+ var RouteInterceptor = class {
139
+ page;
140
+ filter;
141
+ constructor(page, filter) {
142
+ this.page = page;
143
+ this.filter = filter;
144
+ }
145
+ /**
146
+ * Abort the target route for the duration of the callback.
147
+ *
148
+ * This method returns a stub to continue chaining with.
149
+ *
150
+ * @returns ExecutableRouteInterceptor
151
+ * @see {@link Route#abort}
152
+ *
153
+ * @example
154
+ * ```ts
155
+ * interceptor.abort().during(() => button.click())
156
+ * ```
157
+ */
158
+ abort() {
159
+ return new ExecutableRouteInterceptor((action) => withAbortedRoute(this.page, this.filter, action));
160
+ }
161
+ /**
162
+ * Suspend the target route for the duration of the callback.
163
+ *
164
+ * A suspended route will not serve the response until the callback completes,
165
+ * this is useful to validate loading indicators.
166
+ *
167
+ * This method returns a stub to continue chaining with.
168
+ *
169
+ * @returns ExecutableRouteInterceptor
170
+ *
171
+ * @example
172
+ * ```ts
173
+ * interceptor.suspend().during(async () => {
174
+ * await button.click();
175
+ * await expect(loadingOverlay).toBeVisisble();
176
+ * });
177
+ * ```
178
+ */
179
+ suspend() {
180
+ return new ExecutableRouteInterceptor((action) => withSuspendedRoute(this.page, this.filter, action));
181
+ }
182
+ /**
183
+ * Replace the response on the target route for the duration of the callback.
184
+ *
185
+ * The request will not hit the server at all and instead immediately return the provided response.
186
+ *
187
+ * This method returns a stub to continue chaining with.
188
+ *
189
+ * @param options - The fixed response to return for the target route
190
+ * @returns ExecutableRouteInterceptor
191
+ * @see {@link Route#fulfill}
192
+ *
193
+ * @example
194
+ * ```ts
195
+ * interceptor.respondWith({ status: 500 }).during(async () => {
196
+ * await button.click();
197
+ * await expect(alert).toBeVisisble();
198
+ * });
199
+ * ```
200
+ */
201
+ respondWith(options) {
202
+ return new ExecutableRouteInterceptor((action) => withFulfilledRoute(this.page, this.filter, options, action));
203
+ }
204
+ /**
205
+ * Modify the response on the target route for the duration of the callback.
206
+ *
207
+ * The route will first process the request to the server
208
+ * and then apply the provided callback to modify the content before returning it to the client.
209
+ *
210
+ * This method returns a stub to continue chaining with.
211
+ *
212
+ * @param modifier - Callback to modify the response of the server before returning it to the client
213
+ * @returns ExecutableRouteInterceptor
214
+ * @see {@link Route#fetch}, {@link Route#fulfill}
215
+ *
216
+ * @example
217
+ * ```ts
218
+ * interceptor.respondWith({ status: 500 }).during(async () => {
219
+ * await button.click();
220
+ * await expect(alert).toBeVisisble();
221
+ * });
222
+ * ```
223
+ */
224
+ modifyResponse(modifier) {
225
+ return new ExecutableRouteInterceptor((action) => withModifiedResponse(this.page, this.filter, modifier, action));
226
+ }
227
+ };
228
+ /**
229
+ * Stub returned by {@link RouteInterceptor} used to intercept routes during a block.
230
+ *
231
+ * @example
232
+ * ```ts
233
+ * interceptor.respondWith({ status: 500 }).during(async () => {
234
+ * await button.click();
235
+ * await expect(alert).toBeVisisble();
236
+ * });
237
+ * ```
238
+ */
239
+ var ExecutableRouteInterceptor = class {
240
+ runner;
241
+ constructor(runner) {
242
+ this.runner = runner;
243
+ }
244
+ /**
245
+ * Run the given callback and intercept the stubbed route.
246
+ *
247
+ * The route interceptor is automatically removed when this method returns.
248
+ *
249
+ * @param action - The action to run with an intercepted route
250
+ * @returns The result of the action
251
+ */
252
+ async during(action) {
253
+ return await this.runner(action);
254
+ }
255
+ };
256
+ /**
257
+ * Intercept a route using the provided filter.
258
+ *
259
+ * The returned {@link RouteInterceptor} can be used to declare how the route should be intercepted.
260
+ *
261
+ * @param page – The page object to intercept the route on
262
+ * @param filter - The route to intercept
263
+ * @returns RouteInterceptor
264
+ * @see {@link matchPath}
265
+ *
266
+ * @example
267
+ * ```ts
268
+ * await interceptRoute(page, matchPath("/api/notifications"))
269
+ * .respondWith({ status: 500 })
270
+ * .during(async () => {
271
+ * await page.getByRole("button", {name: "View notifications"}).click();
272
+ * await expect(page.getByRole("alert")).toBeVisible();
273
+ * await expect(page.getByRole("alert")).toHaveText("Failed to load notification");
274
+ * });
275
+ * ```
276
+ */
277
+ function interceptRoute(page, filter) {
278
+ return new RouteInterceptor(page, filter);
279
+ }
280
+ /**
281
+ * Base class to build re-usable route interceptors.
282
+ *
283
+ * This is especially useful when combined with {@link test#extend} to create a custom fixture.
284
+ *
285
+ * @example
286
+ * ```ts
287
+ * class CustomRouteInterceptorFixture extends RouteInterceptorFixture {
288
+ * public onGetUser(userId = 1): RouteInterceptor {
289
+ * return this.intercept(matchPath(`/api/users/${userId}`));
290
+ * }
291
+ * }
292
+ *
293
+ * const test = baseTest.extend<{ intercept: CustomRouteInterceptorFixture }>({
294
+ * intercept: ({ page }, use) => use(new CustomRouteInterceptorFixture(page)),
295
+ * });
296
+ *
297
+ * test("Fail to get user", async ({ intercept, page }) => {
298
+ * await page.goto("/users");
299
+ * await intercept
300
+ * .onGetUser()
301
+ * .respondWith({ status: 404 })
302
+ * .during(async () => {
303
+ * await page.getByRole("button", {name: "View profile"});
304
+ * await expect(page.getByRole("alert")).toHaveText("User not found");
305
+ * });
306
+ * });
307
+ * ```
308
+ */
309
+ var RouteInterceptorFixture = class {
310
+ page;
311
+ constructor(page) {
312
+ this.page = page;
313
+ }
314
+ /**
315
+ * Create a {@link RouteInterceptor} for routes matching the provided filter.
316
+ *
317
+ * @param filter - The filter to intercept routes with
318
+ * @returns RouteInterceptor
319
+ *
320
+ * @example
321
+ * ```ts
322
+ * await intercept({ method: "POST", url: "/api/messages" })
323
+ * .abort()
324
+ * .during(() => sendButton.click());
325
+ * ```
326
+ */
327
+ intercept(filter) {
328
+ return interceptRoute(this.page, filter);
329
+ }
330
+ };
331
+ //#endregion
332
+ //#region src/api/route-interceptor/filter.ts
333
+ /**
334
+ * Helper to create a {@link RouteFilter} that applies to the specified path.
335
+ *
336
+ * @param pattern - The regex or string matching the pathname of the URL
337
+ * @returns RouteFilter
338
+ * @example
339
+ * ```ts
340
+ * await interceptRoute(page, matchPath("/users/1"))
341
+ * .respondWith({ status: 404 })
342
+ * .during(() => {
343
+ * // ...
344
+ * });
345
+ * ```
346
+ */
347
+ function matchPath(pattern) {
348
+ return { url: (url) => typeof pattern === "string" ? url.pathname === pattern : url.pathname.match(pattern) !== null };
349
+ }
350
+ //#endregion
351
+ //#region src/api/route-interceptor/response-handler.ts
352
+ /**
353
+ * Helper to create a {@link ResponseHandler} that modifies the response body, if it is a JSON object.
354
+ *
355
+ * @param modifier - The callback which modifies the response body before returning it to the client
356
+ * @returns ResponseHandler
357
+ *
358
+ * @example
359
+ * ```ts
360
+ * await interceptRoute(page, matchPath("/users/1"))
361
+ * .modifyResponse(modifyJsonBody(user => ({...user, username: "modified_username"})))
362
+ * .during(async() => {
363
+ * // ... perform action to trigger request to /users/1 ...
364
+ * });
365
+ * ```
366
+ */
367
+ function modifyJsonBody(modifier) {
368
+ return async (response) => ({
369
+ body: JSON.stringify(modifier(await response.json())),
370
+ response
371
+ });
372
+ }
373
+ /**
374
+ * Helper to create a {@link ResponseHandler} that modifies the response body as a plaintext string.
375
+ *
376
+ * @param modifier - The callback which modifies the response body before returning it to the client
377
+ * @returns ResponseHandler
378
+ *
379
+ * @example
380
+ * ```ts
381
+ * await interceptRoute(page, matchPath("/email/1/subject"))
382
+ * .modifyResponse(modifyTextBody(body => body.toLowerCase()))
383
+ * .during(async() => {
384
+ * // ... perform action to trigger request to /email/1/subject ...
385
+ * });
386
+ * ```
387
+ */
388
+ function modifyTextBody(modifier) {
389
+ return async (response) => ({
390
+ response,
391
+ body: modifier(await response.text())
392
+ });
393
+ }
394
+ //#endregion
395
+ //#region src/file.ts
396
+ /**
397
+ * Resolves the given path segments against the root directory of the closest
398
+ * package, i.e. the closest directory containing a `package.json`, searching
399
+ * upwards from the current working directory.
400
+ *
401
+ * Useful for referencing files independently of the directory a test run is
402
+ * started from, e.g. a subdirectory of the package.
403
+ *
404
+ * @param paths - Path segments to resolve against the package root
405
+ * @returns The absolute path to the resolved location
406
+ * @throws If no package root can be found
407
+ */
408
+ function resolveFromPackageRoot(...paths) {
409
+ const packageDir = packageDirectorySync();
410
+ if (packageDir === void 0) throw new Error("Unable to resolve package directory");
411
+ return path.resolve(packageDir, ...paths);
412
+ }
413
+ //#endregion
46
414
  //#region src/normalizers/masked-value.ts
47
415
  /**
48
416
  * Formats a name as a masked placeholder, e.g. `maskedValue("base url")`
@@ -78,6 +446,4 @@ function identity(value) {
78
446
  return value;
79
447
  }
80
448
  //#endregion
81
- export { fetchAdapter, maskBaseURL, maskedValue, maskedValueWithIndex };
82
-
83
- //# sourceMappingURL=index.mjs.map
449
+ export { RouteInterceptor, RouteInterceptorFixture, createFetchAdapter, interceptRoute, isCI, maskBaseURL, maskedValue, maskedValueWithIndex, matchPath, modifyJsonBody, modifyTextBody, resolveFromPackageRoot };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cronn/playwright-utils",
3
- "version": "0.0.1",
3
+ "version": "0.2.0",
4
4
  "description": "Utilities for writing tests with Playwright",
5
5
  "keywords": [
6
6
  "playwright",
@@ -28,54 +28,35 @@
28
28
  "default": "./dist/index.mjs"
29
29
  }
30
30
  },
31
- "scripts": {
32
- "build": "tsdown",
33
- "clean": "rimraf .turbo dist coverage playwright-report test-results docs/.vitepress/cache docs/.vitepress/dist && pnpm run pnpm:clean",
34
- "compile": "tsc",
35
- "docs:build": "vitepress build docs",
36
- "docs:dev": "vitepress dev docs",
37
- "docs:preview": "vitepress preview docs",
38
- "format:check": "oxfmt --check",
39
- "format:fix": "oxfmt",
40
- "knip:check": "knip",
41
- "knip:fix": "knip --fix",
42
- "lint:check": "eslint . --max-warnings=0",
43
- "lint:fix": "eslint . --max-warnings=0 --fix",
44
- "playwright:install": "playwright install chromium",
45
- "pnpm:check": "pnpm dedupe --check",
46
- "pnpm:clean": "pnpm pm clean && pnpm store prune",
47
- "pnpm:fix": "pnpm dedupe",
48
- "prepublishOnly": "pnpm turbo run ci",
49
- "release": "pnpm turbo run build && pnpm changeset publish",
50
- "test:integration": "playwright test",
51
- "test:unit": "vitest run",
52
- "test:unit:coverage": "vitest run --coverage",
53
- "version": "pnpm changeset version && pnpm turbo run format:fix"
31
+ "dependencies": {
32
+ "package-directory": "8.2.0"
54
33
  },
55
34
  "devDependencies": {
56
35
  "@arethetypeswrong/core": "0.18.5",
57
- "@changesets/cli": "3.0.0",
36
+ "@changesets/cli": "3.0.1",
37
+ "@cronn/element-snapshot": "0.27.0",
58
38
  "@cronn/playwright-file-snapshots": "2.2.1",
59
39
  "@playwright/test": "1.62.1",
60
40
  "@types/node": "24.13.3",
61
41
  "@typescript/native": "npm:typescript@^7.0.2",
62
- "@vitest/coverage-v8": "4.1.10",
63
- "eslint": "10.8.1",
42
+ "@vitest/coverage-v8": "4.1.11",
43
+ "eslint": "10.9.0",
64
44
  "eslint-config-prettier": "10.1.8",
65
45
  "eslint-plugin-check-file": "3.3.2",
66
46
  "eslint-plugin-unused-imports": "4.4.1",
67
47
  "knip": "6.32.2",
68
- "oxc-minify": "0.144.0",
69
- "oxfmt": "0.63.0",
70
- "publint": "0.3.23",
48
+ "oxc-minify": "0.146.0",
49
+ "oxfmt": "0.64.0",
50
+ "publint": "0.3.24",
71
51
  "rimraf": "6.1.3",
72
52
  "tsdown": "0.22.14",
73
- "turbo": "2.10.10",
53
+ "turbo": "2.10.11",
74
54
  "typescript": "npm:@typescript/typescript6@^6.0.2",
75
55
  "typescript-eslint": "8.67.0",
76
56
  "vitepress": "2.0.0-alpha.19",
77
57
  "vitepress-plugin-group-icons": "1.7.6",
78
- "vitest": "4.1.10"
58
+ "vitest": "4.1.11",
59
+ "node": "runtime:24.20.0"
79
60
  },
80
61
  "peerDependencies": {
81
62
  "@cronn/playwright-file-snapshots": "^2",
@@ -84,16 +65,39 @@
84
65
  "devEngines": {
85
66
  "packageManager": {
86
67
  "name": "pnpm",
87
- "version": "11.22.0",
88
- "onFail": "ignore"
68
+ "version": "11.25.0",
69
+ "onFail": "download"
89
70
  },
90
71
  "runtime": {
91
72
  "name": "node",
92
- "version": "24.19.0",
93
- "onFail": "ignore"
73
+ "version": "24.20.0",
74
+ "onFail": "download"
94
75
  }
95
76
  },
96
77
  "engines": {
97
78
  "node": "^22 || ^24 || >=26"
79
+ },
80
+ "scripts": {
81
+ "build": "tsdown",
82
+ "clean": "rimraf .turbo dist coverage playwright-report test-results docs/.vitepress/cache docs/.vitepress/dist && pnpm run pnpm:clean",
83
+ "compile": "tsc",
84
+ "docs:build": "vitepress build docs",
85
+ "docs:dev": "vitepress dev docs",
86
+ "docs:preview": "vitepress preview docs",
87
+ "format:check": "oxfmt --check",
88
+ "format:fix": "oxfmt",
89
+ "knip:check": "knip",
90
+ "knip:fix": "knip --fix",
91
+ "lint:check": "eslint . --max-warnings=0",
92
+ "lint:fix": "eslint . --max-warnings=0 --fix",
93
+ "playwright:install": "playwright install chromium",
94
+ "pnpm:check": "pnpm dedupe --check",
95
+ "pnpm:clean": "pnpm pm clean && pnpm store prune",
96
+ "pnpm:fix": "pnpm dedupe",
97
+ "release": "pnpm turbo run build && pnpm changeset publish",
98
+ "test:integration": "playwright test",
99
+ "test:unit": "vitest run",
100
+ "test:unit:coverage": "vitest run --coverage",
101
+ "version": "pnpm changeset version && pnpm turbo run format:fix"
98
102
  }
99
- }
103
+ }
@@ -1 +0,0 @@
1
- {"version":3,"file":"index.d.mts","names":[],"sources":["../src/api/fetch-adapter.ts","../src/normalizers/mask-base-url.ts","../src/normalizers/masked-value.ts","../src/types/playwright.ts"],"mappings":";;;KAEY,kBAAkB;;;;;;;;;;;iBAgBd,aAAa,gBAAgB,oBAAoB;;;;;;;;;iBCPjD,YAAY,QAC1B,8BACC,WAAW;;;;;;;;;iBCPE,YAAY;;;;;;;iBAWZ,qBAAqB,gBAAgB;;;;;;;KCXzC,mBAAmB,OAAO"}
@@ -1 +0,0 @@
1
- {"version":3,"file":"index.mjs","names":[],"sources":["../src/api/fetch-adapter.ts","../src/normalizers/masked-value.ts","../src/normalizers/mask-base-url.ts"],"sourcesContent":["import type { APIRequestContext } from \"@playwright/test\";\n\nexport type FetchAPI = typeof fetch;\n\ntype PlaywrightFetchOptions = NonNullable<\n Parameters<APIRequestContext[\"fetch\"]>[1]\n>;\n\n/**\n * Creates a `fetch`-compatible function backed by a Playwright\n * {@link APIRequestContext}.\n *\n * Useful for passing Playwright's request context to API clients that expect\n * the standard Fetch API.\n *\n * @param requestContext - Playwright request context to send requests with\n * @returns A function with the signature of the global `fetch`\n */\nexport function fetchAdapter(requestContext: APIRequestContext): FetchAPI {\n return async (input, init): Promise<Response> => {\n const response = await requestContext.fetch(\n mapToUrl(input),\n await mapToOptions(input instanceof Request ? input : null, init),\n );\n\n const responseBody = await response.body();\n return new Response(Buffer.from(responseBody), {\n status: response.status(),\n headers: response.headers(),\n });\n };\n}\n\nfunction mapToUrl(input: RequestInfo | URL): string {\n if (typeof input === \"string\") {\n return input;\n }\n\n if (input instanceof Request) {\n return input.url;\n }\n\n return input.toString();\n}\n\nasync function mapToOptions(\n request: Request | null,\n init?: RequestInit,\n): Promise<PlaywrightFetchOptions> {\n const requestBody =\n request?.body !== null ? await request?.bytes() : undefined;\n return {\n method: request?.method ?? init?.method,\n headers: mapToRequestHeaders(request?.headers ?? init?.headers),\n ...mapToRequestBody(\n requestBody !== undefined ? Buffer.from(requestBody) : init?.body,\n ),\n };\n}\n\nfunction mapToRequestHeaders(\n headers: HeadersInit | undefined,\n): PlaywrightFetchOptions[\"headers\"] {\n if (Array.isArray(headers)) {\n return Object.fromEntries(headers);\n }\n\n if (headers instanceof Headers) {\n return Object.fromEntries(headers.entries());\n }\n\n return headers;\n}\n\nfunction mapToRequestBody(\n body: BodyInit | null | undefined,\n): Pick<PlaywrightFetchOptions, \"data\" | \"multipart\"> {\n if (body instanceof FormData) {\n return {\n multipart: body,\n };\n }\n\n return {\n data: body,\n };\n}\n","/**\n * Formats a name as a masked placeholder, e.g. `maskedValue(\"base url\")`\n * returns `[BASE_URL]`.\n *\n * @param name - Name of the masked value\n */\nexport function maskedValue(name: string): string {\n const normalizedName = name.toUpperCase().replace(/[ -.:,;]/g, \"_\");\n return `[${normalizedName}]`;\n}\n\n/**\n * Creates a factory for indexed masked placeholders, e.g. `[USER_1]`.\n *\n * @param name - Name of the masked value\n * @returns A function mapping an index to a masked placeholder\n */\nexport function maskedValueWithIndex(name: string): (index: number) => string {\n return (index: number) => maskedValue(`${name}_${index}`);\n}\n","import type { Normalizer } from \"@cronn/playwright-file-snapshots\";\nimport { maskString, stringNormalizer } from \"@cronn/playwright-file-snapshots\";\n\nimport { maskedValue } from \"./masked-value\";\n\n/**\n * Creates a normalizer that replaces occurrences of the base URL with\n * `[BASE_URL]`, keeping file snapshots stable across environments.\n *\n * @param baseURL - Base URL to mask; if `undefined`, values are left unchanged\n */\nexport function maskBaseURL<TValue>(\n baseURL: string | undefined,\n): Normalizer<TValue> {\n if (baseURL === undefined) {\n return identity<TValue>;\n }\n\n return stringNormalizer(maskString(baseURL, maskedValue(\"BASE_URL\")));\n}\n\nfunction identity<TValue>(value: TValue): TValue {\n return value;\n}\n"],"mappings":";;;;;;;;;;;;AAkBA,SAAgB,aAAa,gBAA6C;CACxE,OAAO,OAAO,OAAO,SAA4B;EAC/C,MAAM,WAAW,MAAM,eAAe,MACpC,SAAS,KAAK,GACd,MAAM,aAAa,iBAAiB,UAAU,QAAQ,MAAM,IAAI,CAClE;EAEA,MAAM,eAAe,MAAM,SAAS,KAAK;EACzC,OAAO,IAAI,SAAS,OAAO,KAAK,YAAY,GAAG;GAC7C,QAAQ,SAAS,OAAO;GACxB,SAAS,SAAS,QAAQ;EAC5B,CAAC;CACH;AACF;AAEA,SAAS,SAAS,OAAkC;CAClD,IAAI,OAAO,UAAU,UACnB,OAAO;CAGT,IAAI,iBAAiB,SACnB,OAAO,MAAM;CAGf,OAAO,MAAM,SAAS;AACxB;AAEA,eAAe,aACb,SACA,MACiC;CACjC,MAAM,cACJ,SAAS,SAAS,OAAO,MAAM,SAAS,MAAM,IAAI,KAAA;CACpD,OAAO;EACL,QAAQ,SAAS,UAAU,MAAM;EACjC,SAAS,oBAAoB,SAAS,WAAW,MAAM,OAAO;EAC9D,GAAG,iBACD,gBAAgB,KAAA,IAAY,OAAO,KAAK,WAAW,IAAI,MAAM,IAC/D;CACF;AACF;AAEA,SAAS,oBACP,SACmC;CACnC,IAAI,MAAM,QAAQ,OAAO,GACvB,OAAO,OAAO,YAAY,OAAO;CAGnC,IAAI,mBAAmB,SACrB,OAAO,OAAO,YAAY,QAAQ,QAAQ,CAAC;CAG7C,OAAO;AACT;AAEA,SAAS,iBACP,MACoD;CACpD,IAAI,gBAAgB,UAClB,OAAO,EACL,WAAW,KACb;CAGF,OAAO,EACL,MAAM,KACR;AACF;;;;;;;;;AChFA,SAAgB,YAAY,MAAsB;CAEhD,OAAO,IADgB,KAAK,YAAY,CAAC,CAAC,QAAQ,aAAa,GACvC,EAAE;AAC5B;;;;;;;AAQA,SAAgB,qBAAqB,MAAyC;CAC5E,QAAQ,UAAkB,YAAY,GAAG,KAAK,GAAG,OAAO;AAC1D;;;;;;;;;ACRA,SAAgB,YACd,SACoB;CACpB,IAAI,YAAY,KAAA,GACd,OAAO;CAGT,OAAO,iBAAiB,WAAW,SAAS,YAAY,UAAU,CAAC,CAAC;AACtE;AAEA,SAAS,SAAiB,OAAuB;CAC/C,OAAO;AACT"}