@cronn/playwright-utils 0.1.0 → 0.3.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 +266 -2
- package/dist/index.mjs +357 -1
- package/package.json +13 -12
package/dist/index.d.mts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Normalizer } from "@cronn/playwright-file-snapshots";
|
|
2
|
-
import { APIRequestContext, Locator, Page } from "@playwright/test";
|
|
2
|
+
import { APIRequestContext, APIResponse, Locator, Page, Request, Response, Route } from "@playwright/test";
|
|
3
3
|
//#region src/environment.d.ts
|
|
4
4
|
/**
|
|
5
5
|
* Whether the current process runs in a CI environment, based on the `CI`
|
|
@@ -23,6 +23,270 @@ type FetchAPI = typeof fetch;
|
|
|
23
23
|
*/
|
|
24
24
|
declare function createFetchAdapter(requestContext: APIRequestContext): FetchAPI;
|
|
25
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
|
+
* Wait until the target route was called and return the client request.
|
|
183
|
+
*
|
|
184
|
+
* @param options - Options for {@link Page#waitForRequest}
|
|
185
|
+
* @returns Response - The server response
|
|
186
|
+
*/
|
|
187
|
+
waitForRequest(options?: Parameters<Page["waitForRequest"]>[1]): Promise<Request>;
|
|
188
|
+
/**
|
|
189
|
+
* Wait until the target route was called and return the server response.
|
|
190
|
+
*
|
|
191
|
+
* @param options - Options for {@link Page#waitForResponse}
|
|
192
|
+
* @returns Response - The server response
|
|
193
|
+
*/
|
|
194
|
+
waitForResponse(options?: Parameters<Page["waitForResponse"]>[1]): Promise<Response>;
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Stub returned by {@link RouteInterceptor} used to intercept routes during a block.
|
|
198
|
+
*
|
|
199
|
+
* @example
|
|
200
|
+
* ```ts
|
|
201
|
+
* interceptor.respondWith({ status: 500 }).during(async () => {
|
|
202
|
+
* await button.click();
|
|
203
|
+
* await expect(alert).toBeVisisble();
|
|
204
|
+
* });
|
|
205
|
+
* ```
|
|
206
|
+
*/
|
|
207
|
+
declare class ExecutableRouteInterceptor {
|
|
208
|
+
private readonly runner;
|
|
209
|
+
constructor(runner: <T>(action: Action<T>) => Promise<T>);
|
|
210
|
+
/**
|
|
211
|
+
* Run the given callback and intercept the stubbed route.
|
|
212
|
+
*
|
|
213
|
+
* The route interceptor is automatically removed when this method returns.
|
|
214
|
+
*
|
|
215
|
+
* @param action - The action to run with an intercepted route
|
|
216
|
+
* @returns The result of the action
|
|
217
|
+
*/
|
|
218
|
+
during<T>(action: Action<T>): Promise<T>;
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Intercept a route using the provided filter.
|
|
222
|
+
*
|
|
223
|
+
* The returned {@link RouteInterceptor} can be used to declare how the route should be intercepted.
|
|
224
|
+
*
|
|
225
|
+
* @param page – The page object to intercept the route on
|
|
226
|
+
* @param filter - The route to intercept
|
|
227
|
+
* @returns RouteInterceptor
|
|
228
|
+
* @see {@link matchPath}
|
|
229
|
+
*
|
|
230
|
+
* @example
|
|
231
|
+
* ```ts
|
|
232
|
+
* await interceptRoute(page, matchPath("/api/notifications"))
|
|
233
|
+
* .respondWith({ status: 500 })
|
|
234
|
+
* .during(async () => {
|
|
235
|
+
* await page.getByRole("button", {name: "View notifications"}).click();
|
|
236
|
+
* await expect(page.getByRole("alert")).toBeVisible();
|
|
237
|
+
* await expect(page.getByRole("alert")).toHaveText("Failed to load notification");
|
|
238
|
+
* });
|
|
239
|
+
* ```
|
|
240
|
+
*/
|
|
241
|
+
declare function interceptRoute(page: Page, filter: RouteFilter): RouteInterceptor;
|
|
242
|
+
/**
|
|
243
|
+
* Base class to build re-usable route interceptors.
|
|
244
|
+
*
|
|
245
|
+
* This is especially useful when combined with {@link test#extend} to create a custom fixture.
|
|
246
|
+
*
|
|
247
|
+
* @example
|
|
248
|
+
* ```ts
|
|
249
|
+
* class CustomRouteInterceptorFixture extends RouteInterceptorFixture {
|
|
250
|
+
* public onGetUser(userId = 1): RouteInterceptor {
|
|
251
|
+
* return this.intercept(matchPath(`/api/users/${userId}`));
|
|
252
|
+
* }
|
|
253
|
+
* }
|
|
254
|
+
*
|
|
255
|
+
* const test = baseTest.extend<{ intercept: CustomRouteInterceptorFixture }>({
|
|
256
|
+
* intercept: ({ page }, use) => use(new CustomRouteInterceptorFixture(page)),
|
|
257
|
+
* });
|
|
258
|
+
*
|
|
259
|
+
* test("Fail to get user", async ({ intercept, page }) => {
|
|
260
|
+
* await page.goto("/users");
|
|
261
|
+
* await intercept
|
|
262
|
+
* .onGetUser()
|
|
263
|
+
* .respondWith({ status: 404 })
|
|
264
|
+
* .during(async () => {
|
|
265
|
+
* await page.getByRole("button", {name: "View profile"});
|
|
266
|
+
* await expect(page.getByRole("alert")).toHaveText("User not found");
|
|
267
|
+
* });
|
|
268
|
+
* });
|
|
269
|
+
* ```
|
|
270
|
+
*/
|
|
271
|
+
declare class RouteInterceptorFixture {
|
|
272
|
+
private readonly page;
|
|
273
|
+
constructor(page: Page);
|
|
274
|
+
/**
|
|
275
|
+
* Create a {@link RouteInterceptor} for routes matching the provided filter.
|
|
276
|
+
*
|
|
277
|
+
* @param filter - The filter to intercept routes with
|
|
278
|
+
* @returns RouteInterceptor
|
|
279
|
+
*
|
|
280
|
+
* @example
|
|
281
|
+
* ```ts
|
|
282
|
+
* await intercept({ method: "POST", url: "/api/messages" })
|
|
283
|
+
* .abort()
|
|
284
|
+
* .during(() => sendButton.click());
|
|
285
|
+
* ```
|
|
286
|
+
*/
|
|
287
|
+
intercept(filter: RouteFilter): RouteInterceptor;
|
|
288
|
+
}
|
|
289
|
+
//#endregion
|
|
26
290
|
//#region src/file.d.ts
|
|
27
291
|
/**
|
|
28
292
|
* Resolves the given path segments against the root directory of the closest
|
|
@@ -70,4 +334,4 @@ declare function maskedValueWithIndex(name: string): (index: number) => string;
|
|
|
70
334
|
*/
|
|
71
335
|
type PlaywrightTarget = Page | Locator;
|
|
72
336
|
//#endregion
|
|
73
|
-
export { type PlaywrightTarget, createFetchAdapter, isCI, maskBaseURL, maskedValue, maskedValueWithIndex, resolveFromPackageRoot };
|
|
337
|
+
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
|
@@ -54,6 +54,362 @@ function mapToRequestBody(body) {
|
|
|
54
54
|
return { data: body };
|
|
55
55
|
}
|
|
56
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
|
+
* Wait until the target route was called and return the client request.
|
|
229
|
+
*
|
|
230
|
+
* @param options - Options for {@link Page#waitForRequest}
|
|
231
|
+
* @returns Response - The server response
|
|
232
|
+
*/
|
|
233
|
+
waitForRequest(options) {
|
|
234
|
+
return this.page.waitForRequest((request) => isFiltered(this.filter, request), options);
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* Wait until the target route was called and return the server response.
|
|
238
|
+
*
|
|
239
|
+
* @param options - Options for {@link Page#waitForResponse}
|
|
240
|
+
* @returns Response - The server response
|
|
241
|
+
*/
|
|
242
|
+
waitForResponse(options) {
|
|
243
|
+
return this.page.waitForResponse((response) => isFiltered(this.filter, response.request()), options);
|
|
244
|
+
}
|
|
245
|
+
};
|
|
246
|
+
/**
|
|
247
|
+
* Stub returned by {@link RouteInterceptor} used to intercept routes during a block.
|
|
248
|
+
*
|
|
249
|
+
* @example
|
|
250
|
+
* ```ts
|
|
251
|
+
* interceptor.respondWith({ status: 500 }).during(async () => {
|
|
252
|
+
* await button.click();
|
|
253
|
+
* await expect(alert).toBeVisisble();
|
|
254
|
+
* });
|
|
255
|
+
* ```
|
|
256
|
+
*/
|
|
257
|
+
var ExecutableRouteInterceptor = class {
|
|
258
|
+
runner;
|
|
259
|
+
constructor(runner) {
|
|
260
|
+
this.runner = runner;
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* Run the given callback and intercept the stubbed route.
|
|
264
|
+
*
|
|
265
|
+
* The route interceptor is automatically removed when this method returns.
|
|
266
|
+
*
|
|
267
|
+
* @param action - The action to run with an intercepted route
|
|
268
|
+
* @returns The result of the action
|
|
269
|
+
*/
|
|
270
|
+
async during(action) {
|
|
271
|
+
return await this.runner(action);
|
|
272
|
+
}
|
|
273
|
+
};
|
|
274
|
+
/**
|
|
275
|
+
* Intercept a route using the provided filter.
|
|
276
|
+
*
|
|
277
|
+
* The returned {@link RouteInterceptor} can be used to declare how the route should be intercepted.
|
|
278
|
+
*
|
|
279
|
+
* @param page – The page object to intercept the route on
|
|
280
|
+
* @param filter - The route to intercept
|
|
281
|
+
* @returns RouteInterceptor
|
|
282
|
+
* @see {@link matchPath}
|
|
283
|
+
*
|
|
284
|
+
* @example
|
|
285
|
+
* ```ts
|
|
286
|
+
* await interceptRoute(page, matchPath("/api/notifications"))
|
|
287
|
+
* .respondWith({ status: 500 })
|
|
288
|
+
* .during(async () => {
|
|
289
|
+
* await page.getByRole("button", {name: "View notifications"}).click();
|
|
290
|
+
* await expect(page.getByRole("alert")).toBeVisible();
|
|
291
|
+
* await expect(page.getByRole("alert")).toHaveText("Failed to load notification");
|
|
292
|
+
* });
|
|
293
|
+
* ```
|
|
294
|
+
*/
|
|
295
|
+
function interceptRoute(page, filter) {
|
|
296
|
+
return new RouteInterceptor(page, filter);
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* Base class to build re-usable route interceptors.
|
|
300
|
+
*
|
|
301
|
+
* This is especially useful when combined with {@link test#extend} to create a custom fixture.
|
|
302
|
+
*
|
|
303
|
+
* @example
|
|
304
|
+
* ```ts
|
|
305
|
+
* class CustomRouteInterceptorFixture extends RouteInterceptorFixture {
|
|
306
|
+
* public onGetUser(userId = 1): RouteInterceptor {
|
|
307
|
+
* return this.intercept(matchPath(`/api/users/${userId}`));
|
|
308
|
+
* }
|
|
309
|
+
* }
|
|
310
|
+
*
|
|
311
|
+
* const test = baseTest.extend<{ intercept: CustomRouteInterceptorFixture }>({
|
|
312
|
+
* intercept: ({ page }, use) => use(new CustomRouteInterceptorFixture(page)),
|
|
313
|
+
* });
|
|
314
|
+
*
|
|
315
|
+
* test("Fail to get user", async ({ intercept, page }) => {
|
|
316
|
+
* await page.goto("/users");
|
|
317
|
+
* await intercept
|
|
318
|
+
* .onGetUser()
|
|
319
|
+
* .respondWith({ status: 404 })
|
|
320
|
+
* .during(async () => {
|
|
321
|
+
* await page.getByRole("button", {name: "View profile"});
|
|
322
|
+
* await expect(page.getByRole("alert")).toHaveText("User not found");
|
|
323
|
+
* });
|
|
324
|
+
* });
|
|
325
|
+
* ```
|
|
326
|
+
*/
|
|
327
|
+
var RouteInterceptorFixture = class {
|
|
328
|
+
page;
|
|
329
|
+
constructor(page) {
|
|
330
|
+
this.page = page;
|
|
331
|
+
}
|
|
332
|
+
/**
|
|
333
|
+
* Create a {@link RouteInterceptor} for routes matching the provided filter.
|
|
334
|
+
*
|
|
335
|
+
* @param filter - The filter to intercept routes with
|
|
336
|
+
* @returns RouteInterceptor
|
|
337
|
+
*
|
|
338
|
+
* @example
|
|
339
|
+
* ```ts
|
|
340
|
+
* await intercept({ method: "POST", url: "/api/messages" })
|
|
341
|
+
* .abort()
|
|
342
|
+
* .during(() => sendButton.click());
|
|
343
|
+
* ```
|
|
344
|
+
*/
|
|
345
|
+
intercept(filter) {
|
|
346
|
+
return interceptRoute(this.page, filter);
|
|
347
|
+
}
|
|
348
|
+
};
|
|
349
|
+
//#endregion
|
|
350
|
+
//#region src/api/route-interceptor/filter.ts
|
|
351
|
+
/**
|
|
352
|
+
* Helper to create a {@link RouteFilter} that applies to the specified path.
|
|
353
|
+
*
|
|
354
|
+
* @param pattern - The regex or string matching the pathname of the URL
|
|
355
|
+
* @returns RouteFilter
|
|
356
|
+
* @example
|
|
357
|
+
* ```ts
|
|
358
|
+
* await interceptRoute(page, matchPath("/users/1"))
|
|
359
|
+
* .respondWith({ status: 404 })
|
|
360
|
+
* .during(() => {
|
|
361
|
+
* // ...
|
|
362
|
+
* });
|
|
363
|
+
* ```
|
|
364
|
+
*/
|
|
365
|
+
function matchPath(pattern) {
|
|
366
|
+
return { url: (url) => typeof pattern === "string" ? url.pathname === pattern : url.pathname.match(pattern) !== null };
|
|
367
|
+
}
|
|
368
|
+
//#endregion
|
|
369
|
+
//#region src/api/route-interceptor/response-handler.ts
|
|
370
|
+
/**
|
|
371
|
+
* Helper to create a {@link ResponseHandler} that modifies the response body, if it is a JSON object.
|
|
372
|
+
*
|
|
373
|
+
* @param modifier - The callback which modifies the response body before returning it to the client
|
|
374
|
+
* @returns ResponseHandler
|
|
375
|
+
*
|
|
376
|
+
* @example
|
|
377
|
+
* ```ts
|
|
378
|
+
* await interceptRoute(page, matchPath("/users/1"))
|
|
379
|
+
* .modifyResponse(modifyJsonBody(user => ({...user, username: "modified_username"})))
|
|
380
|
+
* .during(async() => {
|
|
381
|
+
* // ... perform action to trigger request to /users/1 ...
|
|
382
|
+
* });
|
|
383
|
+
* ```
|
|
384
|
+
*/
|
|
385
|
+
function modifyJsonBody(modifier) {
|
|
386
|
+
return async (response) => ({
|
|
387
|
+
body: JSON.stringify(modifier(await response.json())),
|
|
388
|
+
response
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
/**
|
|
392
|
+
* Helper to create a {@link ResponseHandler} that modifies the response body as a plaintext string.
|
|
393
|
+
*
|
|
394
|
+
* @param modifier - The callback which modifies the response body before returning it to the client
|
|
395
|
+
* @returns ResponseHandler
|
|
396
|
+
*
|
|
397
|
+
* @example
|
|
398
|
+
* ```ts
|
|
399
|
+
* await interceptRoute(page, matchPath("/email/1/subject"))
|
|
400
|
+
* .modifyResponse(modifyTextBody(body => body.toLowerCase()))
|
|
401
|
+
* .during(async() => {
|
|
402
|
+
* // ... perform action to trigger request to /email/1/subject ...
|
|
403
|
+
* });
|
|
404
|
+
* ```
|
|
405
|
+
*/
|
|
406
|
+
function modifyTextBody(modifier) {
|
|
407
|
+
return async (response) => ({
|
|
408
|
+
response,
|
|
409
|
+
body: modifier(await response.text())
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
//#endregion
|
|
57
413
|
//#region src/file.ts
|
|
58
414
|
/**
|
|
59
415
|
* Resolves the given path segments against the root directory of the closest
|
|
@@ -108,4 +464,4 @@ function identity(value) {
|
|
|
108
464
|
return value;
|
|
109
465
|
}
|
|
110
466
|
//#endregion
|
|
111
|
-
export { createFetchAdapter, isCI, maskBaseURL, maskedValue, maskedValueWithIndex, resolveFromPackageRoot };
|
|
467
|
+
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.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Utilities for writing tests with Playwright",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"playwright",
|
|
@@ -33,29 +33,30 @@
|
|
|
33
33
|
},
|
|
34
34
|
"devDependencies": {
|
|
35
35
|
"@arethetypeswrong/core": "0.18.5",
|
|
36
|
-
"@changesets/cli": "3.0.
|
|
36
|
+
"@changesets/cli": "3.0.1",
|
|
37
|
+
"@cronn/element-snapshot": "0.27.0",
|
|
37
38
|
"@cronn/playwright-file-snapshots": "2.2.1",
|
|
38
39
|
"@playwright/test": "1.62.1",
|
|
39
40
|
"@types/node": "24.13.3",
|
|
40
41
|
"@typescript/native": "npm:typescript@^7.0.2",
|
|
41
|
-
"@vitest/coverage-v8": "4.1.
|
|
42
|
-
"eslint": "10.
|
|
42
|
+
"@vitest/coverage-v8": "4.1.11",
|
|
43
|
+
"eslint": "10.9.0",
|
|
43
44
|
"eslint-config-prettier": "10.1.8",
|
|
44
45
|
"eslint-plugin-check-file": "3.3.2",
|
|
45
46
|
"eslint-plugin-unused-imports": "4.4.1",
|
|
46
47
|
"knip": "6.32.2",
|
|
47
|
-
"oxc-minify": "0.
|
|
48
|
-
"oxfmt": "0.
|
|
49
|
-
"publint": "0.3.
|
|
48
|
+
"oxc-minify": "0.146.0",
|
|
49
|
+
"oxfmt": "0.64.0",
|
|
50
|
+
"publint": "0.3.24",
|
|
50
51
|
"rimraf": "6.1.3",
|
|
51
52
|
"tsdown": "0.22.14",
|
|
52
|
-
"turbo": "2.10.
|
|
53
|
+
"turbo": "2.10.11",
|
|
53
54
|
"typescript": "npm:@typescript/typescript6@^6.0.2",
|
|
54
55
|
"typescript-eslint": "8.67.0",
|
|
55
56
|
"vitepress": "2.0.0-alpha.19",
|
|
56
57
|
"vitepress-plugin-group-icons": "1.7.6",
|
|
57
|
-
"vitest": "4.1.
|
|
58
|
-
"node": "runtime:24.
|
|
58
|
+
"vitest": "4.1.11",
|
|
59
|
+
"node": "runtime:24.20.0"
|
|
59
60
|
},
|
|
60
61
|
"peerDependencies": {
|
|
61
62
|
"@cronn/playwright-file-snapshots": "^2",
|
|
@@ -64,12 +65,12 @@
|
|
|
64
65
|
"devEngines": {
|
|
65
66
|
"packageManager": {
|
|
66
67
|
"name": "pnpm",
|
|
67
|
-
"version": "11.
|
|
68
|
+
"version": "11.25.0",
|
|
68
69
|
"onFail": "download"
|
|
69
70
|
},
|
|
70
71
|
"runtime": {
|
|
71
72
|
"name": "node",
|
|
72
|
-
"version": "24.
|
|
73
|
+
"version": "24.20.0",
|
|
73
74
|
"onFail": "download"
|
|
74
75
|
}
|
|
75
76
|
},
|