@open-wa/driver-playwright 5.0.0-alpha.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/.turbo/turbo-build.log +17 -0
- package/.turbo/turbo-lint.log +6 -0
- package/.turbo/turbo-test.log +14 -0
- package/CHANGELOG.md +21 -0
- package/LICENSE.md +67 -0
- package/README.md +19 -0
- package/dist/index.cjs +446 -0
- package/dist/index.d.cts +118 -0
- package/dist/index.d.cts.map +1 -0
- package/package.json +36 -0
- package/src/PlaywrightBrowser.ts +47 -0
- package/src/PlaywrightDriver.ts +114 -0
- package/src/PlaywrightElementHandle.ts +32 -0
- package/src/PlaywrightPage.ts +412 -0
- package/src/__tests__/PlaywrightPage.test.ts +144 -0
- package/src/index.ts +5 -0
- package/tsconfig.json +10 -0
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
import {
|
|
2
|
+
IPage,
|
|
3
|
+
IElementHandle,
|
|
4
|
+
DriverCapabilities,
|
|
5
|
+
type DisposableHandle,
|
|
6
|
+
type IConsoleMessage,
|
|
7
|
+
type IFrame,
|
|
8
|
+
type IRequest,
|
|
9
|
+
type IRequestContinueOverrides,
|
|
10
|
+
type IRequestResponse,
|
|
11
|
+
type NavigationWaitUntil,
|
|
12
|
+
type PageEventHandler,
|
|
13
|
+
type WaitForFunctionOptions,
|
|
14
|
+
} from '@open-wa/driver-interface';
|
|
15
|
+
import type { ConsoleMessage, Frame, Page, Request, CDPSession } from 'playwright';
|
|
16
|
+
import { PlaywrightElementHandle } from './PlaywrightElementHandle';
|
|
17
|
+
import { PlaywrightBrowserType } from './PlaywrightDriver';
|
|
18
|
+
|
|
19
|
+
type ListenerWrapper = (...args: any[]) => void | Promise<void>;
|
|
20
|
+
|
|
21
|
+
const PLAYWRIGHT_REQUEST_ROUTE_ERROR = 'Playwright request event payload cannot abort, continue, or respond without a bound route';
|
|
22
|
+
|
|
23
|
+
class PlaywrightDisposableHandle implements DisposableHandle {
|
|
24
|
+
constructor(private readonly disposer: () => Promise<void> | void) {}
|
|
25
|
+
|
|
26
|
+
dispose(): Promise<void> | void {
|
|
27
|
+
return this.disposer();
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
class PlaywrightFrame implements IFrame {
|
|
32
|
+
constructor(private readonly frame: Frame, private readonly isMain: boolean) {}
|
|
33
|
+
|
|
34
|
+
url(): string {
|
|
35
|
+
return this.frame.url();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
name(): string {
|
|
39
|
+
return this.frame.name();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
isMainFrame(): boolean {
|
|
43
|
+
return this.isMain;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
parentFrame(): IFrame | null {
|
|
47
|
+
const parent = this.frame.parentFrame();
|
|
48
|
+
return parent ? new PlaywrightFrame(parent, false) : null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async content(): Promise<string> {
|
|
52
|
+
return this.frame.content();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
unwrap(): Frame {
|
|
56
|
+
return this.frame;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
class PlaywrightRequest implements IRequest {
|
|
61
|
+
constructor(private readonly request: Request, private readonly page: Page) {}
|
|
62
|
+
|
|
63
|
+
url(): string {
|
|
64
|
+
return this.request.url();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
method(): string {
|
|
68
|
+
return this.request.method();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
headers(): Record<string, string> {
|
|
72
|
+
return this.request.headers();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
resourceType(): string | null {
|
|
76
|
+
return this.request.resourceType?.() ?? null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
isNavigationRequest(): boolean {
|
|
80
|
+
return this.request.isNavigationRequest();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
frame(): IFrame | null {
|
|
84
|
+
const frame = this.request.frame();
|
|
85
|
+
return frame ? new PlaywrightFrame(frame, frame === this.page.mainFrame()) : null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async abort(): Promise<void> {
|
|
89
|
+
throw new Error(PLAYWRIGHT_REQUEST_ROUTE_ERROR);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async continue(_overrides?: IRequestContinueOverrides): Promise<void> {
|
|
93
|
+
throw new Error(PLAYWRIGHT_REQUEST_ROUTE_ERROR);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async respond(_response: IRequestResponse): Promise<void> {
|
|
97
|
+
throw new Error(PLAYWRIGHT_REQUEST_ROUTE_ERROR);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
unwrap(): Request {
|
|
101
|
+
return this.request;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
class PlaywrightConsoleMessage implements IConsoleMessage {
|
|
106
|
+
constructor(private readonly message: ConsoleMessage) {}
|
|
107
|
+
|
|
108
|
+
type(): string {
|
|
109
|
+
return this.message.type();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
text(): string {
|
|
113
|
+
return this.message.text();
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
location(): { url?: string; lineNumber?: number; columnNumber?: number } {
|
|
117
|
+
const location = this.message.location();
|
|
118
|
+
return {
|
|
119
|
+
url: location.url,
|
|
120
|
+
lineNumber: location.lineNumber,
|
|
121
|
+
columnNumber: location.columnNumber,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
args(): unknown[] {
|
|
126
|
+
return this.message.args();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
unwrap(): ConsoleMessage {
|
|
130
|
+
return this.message;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export class PlaywrightPage implements IPage {
|
|
135
|
+
readonly name = 'playwright' as const;
|
|
136
|
+
private readonly listenerWrappers = new Map<string, Map<Function, Set<ListenerWrapper>>>();
|
|
137
|
+
private readonly requestInterceptionRoute = async (route: { continue: () => Promise<void> }) => {
|
|
138
|
+
await route.continue();
|
|
139
|
+
};
|
|
140
|
+
private requestInterceptionEnabled = false;
|
|
141
|
+
|
|
142
|
+
constructor(
|
|
143
|
+
private page: Page,
|
|
144
|
+
_browserType: PlaywrightBrowserType,
|
|
145
|
+
private capabilities: DriverCapabilities
|
|
146
|
+
) { }
|
|
147
|
+
|
|
148
|
+
async goto(url: string, options?: { waitUntil?: NavigationWaitUntil; timeoutMs?: number }): Promise<void> {
|
|
149
|
+
const waitUntil = options?.waitUntil === 'networkidle' ? 'networkidle' : options?.waitUntil;
|
|
150
|
+
|
|
151
|
+
await this.page.goto(url, {
|
|
152
|
+
waitUntil: waitUntil as any,
|
|
153
|
+
timeout: options?.timeoutMs,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
url(): string {
|
|
158
|
+
return this.page.url();
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async reload(): Promise<void> {
|
|
162
|
+
await this.page.reload();
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
mainFrame(): IFrame {
|
|
166
|
+
return new PlaywrightFrame(this.page.mainFrame(), true);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async evaluate<Arg, Ret>(fn: (arg: Arg) => Ret | Promise<Ret>, arg: Arg): Promise<Ret> {
|
|
170
|
+
return await this.page.evaluate(fn as any, arg as any) as Ret;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async evaluateScript<Ret = unknown>(script: string): Promise<Ret> {
|
|
174
|
+
return await this.page.evaluate(script) as Ret;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async addInitScript(script: string): Promise<DisposableHandle> {
|
|
178
|
+
await this.page.addInitScript(script);
|
|
179
|
+
return new PlaywrightDisposableHandle(() => undefined);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async setViewport(viewport: { width: number; height: number }): Promise<void> {
|
|
183
|
+
await this.page.setViewportSize(viewport);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async setUserAgent(ua: string): Promise<void> {
|
|
187
|
+
await this.page.setExtraHTTPHeaders({ 'User-Agent': ua });
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async setRequestInterception(enabled: boolean): Promise<void> {
|
|
191
|
+
this.require('requestInterception');
|
|
192
|
+
if (enabled === this.requestInterceptionEnabled) {
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (enabled) {
|
|
197
|
+
await this.page.route('**/*', this.requestInterceptionRoute as any);
|
|
198
|
+
this.requestInterceptionEnabled = true;
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
await this.page.unroute('**/*', this.requestInterceptionRoute as any);
|
|
203
|
+
this.requestInterceptionEnabled = false;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async waitForSelector(selector: string, options?: { timeoutMs?: number }): Promise<IElementHandle | null> {
|
|
207
|
+
const element = await this.page.waitForSelector(selector, { timeout: options?.timeoutMs });
|
|
208
|
+
return element ? new PlaywrightElementHandle(element) : null;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async waitForFunction(script: string, options?: WaitForFunctionOptions): Promise<void>;
|
|
212
|
+
async waitForFunction<Arg>(fn: (arg: Arg) => boolean, arg: Arg, options?: WaitForFunctionOptions): Promise<void>;
|
|
213
|
+
async waitForFunction<Arg>(fnOrScript: string | ((arg: Arg) => boolean), argOrOptions?: Arg | WaitForFunctionOptions, maybeOptions?: WaitForFunctionOptions): Promise<void> {
|
|
214
|
+
if (typeof fnOrScript === 'string') {
|
|
215
|
+
const options = argOrOptions as WaitForFunctionOptions | undefined;
|
|
216
|
+
await this.page.waitForFunction(fnOrScript, undefined, {
|
|
217
|
+
timeout: options?.timeoutMs,
|
|
218
|
+
polling: options?.polling as any,
|
|
219
|
+
});
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
await this.page.waitForFunction(fnOrScript as any, argOrOptions as any, {
|
|
224
|
+
timeout: maybeOptions?.timeoutMs,
|
|
225
|
+
polling: maybeOptions?.polling as any,
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async $(selector: string): Promise<IElementHandle | null> {
|
|
230
|
+
const element = await this.page.$(selector);
|
|
231
|
+
return element ? new PlaywrightElementHandle(element) : null;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
async $$(selector: string): Promise<IElementHandle[]> {
|
|
235
|
+
const elements = await this.page.$$(selector);
|
|
236
|
+
return elements.map(el => new PlaywrightElementHandle(el));
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
async click(selector: string): Promise<void> {
|
|
240
|
+
await this.page.click(selector);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async type(selector: string, text: string, options?: { delayMs?: number }): Promise<void> {
|
|
244
|
+
await this.page.type(selector, text, { delay: options?.delayMs });
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async screenshot(options?: { type?: 'png' | 'jpeg'; fullPage?: boolean }): Promise<Uint8Array> {
|
|
248
|
+
const buffer = await this.page.screenshot({
|
|
249
|
+
type: options?.type,
|
|
250
|
+
fullPage: options?.fullPage,
|
|
251
|
+
});
|
|
252
|
+
return new Uint8Array(buffer);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
async exposeFunction(name: string, fn: (...args: any[]) => any): Promise<void> {
|
|
256
|
+
await this.page.exposeFunction(name, fn);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
on<K extends string>(event: K, handler: ((...args: any[]) => void | Promise<void>) | PageEventHandler<any>): DisposableHandle {
|
|
260
|
+
const wrapped = (...args: any[]) => {
|
|
261
|
+
const payload = this.wrapEventPayload(event, args);
|
|
262
|
+
if (event === 'close') {
|
|
263
|
+
return (handler as () => void | Promise<void>)();
|
|
264
|
+
}
|
|
265
|
+
return (handler as (payload: unknown) => void | Promise<void>)(payload);
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
this.getWrapperSet(event, handler as Function).add(wrapped);
|
|
269
|
+
this.page.on(event as any, wrapped as any);
|
|
270
|
+
|
|
271
|
+
return new PlaywrightDisposableHandle(() => {
|
|
272
|
+
this.removeWrappedListener(event, handler as Function, wrapped);
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
off<K extends string>(event: K, handler: ((...args: any[]) => void | Promise<void>) | PageEventHandler<any>): void {
|
|
277
|
+
const wrappers = this.listenerWrappers.get(event)?.get(handler as Function);
|
|
278
|
+
if (!wrappers || wrappers.size === 0) {
|
|
279
|
+
this.page.off(event as any, handler as any);
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
for (const wrapped of wrappers) {
|
|
284
|
+
this.page.off(event as any, wrapped as any);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
this.listenerWrappers.get(event)?.delete(handler as Function);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
async close(): Promise<void> {
|
|
291
|
+
await this.page.close();
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
isClosed(): boolean {
|
|
295
|
+
return this.page.isClosed();
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Capability-specific methods
|
|
299
|
+
has<C extends string>(cap: C): boolean {
|
|
300
|
+
return (this.capabilities as any)[cap]?.supported === true;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
require<C extends string>(cap: C): void {
|
|
304
|
+
if (!this.has(cap)) {
|
|
305
|
+
const capability = (this.capabilities as any)[cap];
|
|
306
|
+
throw new Error(
|
|
307
|
+
`Page does not support capability '${cap}'${capability?.reason ? `: ${capability.reason}` : ''
|
|
308
|
+
}`
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// CDP Support (Chromium-based only)
|
|
314
|
+
cdp(): CDPSession {
|
|
315
|
+
this.require('cdp');
|
|
316
|
+
const context = this.page.context();
|
|
317
|
+
return (context as any).newCDPSession(this.page) as CDPSession;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Service Worker Bypass
|
|
321
|
+
async setBypassServiceWorker(bypass: boolean): Promise<void> {
|
|
322
|
+
this.require('serviceWorkerBypass');
|
|
323
|
+
await this.page.context().route('**/*', (route) => {
|
|
324
|
+
if (bypass && route.request().serviceWorker()) {
|
|
325
|
+
route.abort();
|
|
326
|
+
} else {
|
|
327
|
+
route.continue();
|
|
328
|
+
}
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// PDF Generation (Chromium only)
|
|
333
|
+
async pdf(options?: { path?: string; format?: string }): Promise<Uint8Array> {
|
|
334
|
+
this.require('pdf');
|
|
335
|
+
const buffer = await this.page.pdf(options as any);
|
|
336
|
+
return new Uint8Array(buffer);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// Tracing
|
|
340
|
+
async startTracing(options?: { path?: string; screenshots?: boolean }): Promise<void> {
|
|
341
|
+
this.require('tracing');
|
|
342
|
+
await this.page.context().tracing.start(options as any);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
async stopTracing(): Promise<Uint8Array> {
|
|
346
|
+
this.require('tracing');
|
|
347
|
+
const tempPath = `/tmp/trace-${Date.now()}.zip`;
|
|
348
|
+
await this.page.context().tracing.stop({ path: tempPath });
|
|
349
|
+
|
|
350
|
+
const fs = await import('fs/promises');
|
|
351
|
+
const buffer = await fs.readFile(tempPath);
|
|
352
|
+
await fs.unlink(tempPath).catch(() => { });
|
|
353
|
+
|
|
354
|
+
return new Uint8Array(buffer);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
unwrap(): Page {
|
|
358
|
+
return this.page;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
private getWrapperSet(event: string, handler: Function): Set<ListenerWrapper> {
|
|
362
|
+
let wrappers = this.listenerWrappers.get(event);
|
|
363
|
+
if (!wrappers) {
|
|
364
|
+
wrappers = new Map();
|
|
365
|
+
this.listenerWrappers.set(event, wrappers);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
let handlerWrappers = wrappers.get(handler);
|
|
369
|
+
if (!handlerWrappers) {
|
|
370
|
+
handlerWrappers = new Set();
|
|
371
|
+
wrappers.set(handler, handlerWrappers);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
return handlerWrappers;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
private removeWrappedListener(event: string, handler: Function, wrapped: ListenerWrapper): void {
|
|
378
|
+
this.page.off(event as any, wrapped as any);
|
|
379
|
+
|
|
380
|
+
const eventWrappers = this.listenerWrappers.get(event);
|
|
381
|
+
const handlerWrappers = eventWrappers?.get(handler);
|
|
382
|
+
if (!handlerWrappers) {
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
handlerWrappers.delete(wrapped);
|
|
387
|
+
if (handlerWrappers.size === 0) {
|
|
388
|
+
eventWrappers?.delete(handler);
|
|
389
|
+
}
|
|
390
|
+
if (eventWrappers && eventWrappers.size === 0) {
|
|
391
|
+
this.listenerWrappers.delete(event);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
private wrapEventPayload(event: string, args: any[]): unknown {
|
|
396
|
+
const [payload] = args;
|
|
397
|
+
switch (event) {
|
|
398
|
+
case 'request':
|
|
399
|
+
case 'requestfailed':
|
|
400
|
+
case 'requestfinished':
|
|
401
|
+
return new PlaywrightRequest(payload as Request, this.page);
|
|
402
|
+
case 'framenavigated':
|
|
403
|
+
return new PlaywrightFrame(payload as Frame, payload === this.page.mainFrame());
|
|
404
|
+
case 'console':
|
|
405
|
+
return new PlaywrightConsoleMessage(payload as ConsoleMessage);
|
|
406
|
+
case 'pageerror':
|
|
407
|
+
return payload as Error;
|
|
408
|
+
default:
|
|
409
|
+
return payload;
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { PlaywrightPage } from '../PlaywrightPage';
|
|
3
|
+
|
|
4
|
+
type MockHandler = (...args: any[]) => void | Promise<void>;
|
|
5
|
+
|
|
6
|
+
class MockPlaywrightPage {
|
|
7
|
+
private readonly listeners = new Map<string, Set<MockHandler>>();
|
|
8
|
+
|
|
9
|
+
readonly mainFrameValue = {
|
|
10
|
+
url: () => 'https://example.test',
|
|
11
|
+
name: () => 'main',
|
|
12
|
+
parentFrame: () => null,
|
|
13
|
+
content: vi.fn(async () => '<html></html>'),
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
readonly route = vi.fn(async () => undefined);
|
|
17
|
+
readonly unroute = vi.fn(async () => undefined);
|
|
18
|
+
readonly addInitScript = vi.fn(async () => undefined);
|
|
19
|
+
readonly setViewportSize = vi.fn(async () => undefined);
|
|
20
|
+
readonly setExtraHTTPHeaders = vi.fn(async () => undefined);
|
|
21
|
+
readonly waitForSelector = vi.fn(async () => null);
|
|
22
|
+
readonly waitForFunction = vi.fn(async () => undefined);
|
|
23
|
+
readonly $ = vi.fn(async () => null);
|
|
24
|
+
readonly $$ = vi.fn(async () => []);
|
|
25
|
+
readonly click = vi.fn(async () => undefined);
|
|
26
|
+
readonly type = vi.fn(async () => undefined);
|
|
27
|
+
readonly screenshot = vi.fn(async () => Buffer.from([]));
|
|
28
|
+
readonly exposeFunction = vi.fn(async () => undefined);
|
|
29
|
+
readonly close = vi.fn(async () => undefined);
|
|
30
|
+
readonly goto = vi.fn(async () => undefined);
|
|
31
|
+
readonly reload = vi.fn(async () => undefined);
|
|
32
|
+
readonly evaluate = vi.fn(async () => undefined);
|
|
33
|
+
readonly url = vi.fn(() => 'about:blank');
|
|
34
|
+
readonly isClosed = vi.fn(() => false);
|
|
35
|
+
readonly context = vi.fn(() => ({
|
|
36
|
+
route: vi.fn(async () => undefined),
|
|
37
|
+
tracing: {
|
|
38
|
+
start: vi.fn(async () => undefined),
|
|
39
|
+
stop: vi.fn(async () => undefined),
|
|
40
|
+
},
|
|
41
|
+
newCDPSession: vi.fn(async () => ({})),
|
|
42
|
+
}));
|
|
43
|
+
|
|
44
|
+
mainFrame() {
|
|
45
|
+
return this.mainFrameValue;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
on(event: string, handler: MockHandler): void {
|
|
49
|
+
const handlers = this.listeners.get(event) ?? new Set<MockHandler>();
|
|
50
|
+
handlers.add(handler);
|
|
51
|
+
this.listeners.set(event, handlers);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
off(event: string, handler: MockHandler): void {
|
|
55
|
+
this.listeners.get(event)?.delete(handler);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async emit(event: string, payload?: unknown): Promise<void> {
|
|
59
|
+
const handlers = [...(this.listeners.get(event) ?? [])];
|
|
60
|
+
await Promise.all(handlers.map((handler) => Promise.resolve(handler(payload))));
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const capabilities = {
|
|
65
|
+
cdp: { supported: true },
|
|
66
|
+
requestInterception: { supported: true },
|
|
67
|
+
serviceWorkerBypass: { supported: true },
|
|
68
|
+
stealth: { supported: false, reason: 'unsupported' },
|
|
69
|
+
pdf: { supported: true },
|
|
70
|
+
tracing: { supported: true },
|
|
71
|
+
persistentContext: { supported: true },
|
|
72
|
+
browserExtensions: { supported: false, reason: 'unsupported' },
|
|
73
|
+
exposeBinding: { supported: true },
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
describe('PlaywrightPage', () => {
|
|
77
|
+
it('makes request interception toggles idempotent with a stable route handler', async () => {
|
|
78
|
+
const rawPage = new MockPlaywrightPage();
|
|
79
|
+
const page = new PlaywrightPage(rawPage as any, 'chromium', capabilities as any);
|
|
80
|
+
|
|
81
|
+
await page.setRequestInterception(true);
|
|
82
|
+
await page.setRequestInterception(true);
|
|
83
|
+
await page.setRequestInterception(false);
|
|
84
|
+
await page.setRequestInterception(false);
|
|
85
|
+
|
|
86
|
+
expect(rawPage.route).toHaveBeenCalledTimes(1);
|
|
87
|
+
expect(rawPage.unroute).toHaveBeenCalledTimes(1);
|
|
88
|
+
expect(rawPage.route).toHaveBeenCalledWith('**/*', expect.any(Function));
|
|
89
|
+
const registeredRouteHandler = (rawPage.route.mock.calls[0] as any[] | undefined)?.[1];
|
|
90
|
+
expect(rawPage.unroute).toHaveBeenCalledWith('**/*', registeredRouteHandler);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('keeps duplicate listeners independently disposable', async () => {
|
|
94
|
+
const rawPage = new MockPlaywrightPage();
|
|
95
|
+
const page = new PlaywrightPage(rawPage as any, 'chromium', capabilities as any);
|
|
96
|
+
const handler = vi.fn();
|
|
97
|
+
|
|
98
|
+
const first = page.on('request', handler);
|
|
99
|
+
const second = page.on('request', handler);
|
|
100
|
+
|
|
101
|
+
const request = {
|
|
102
|
+
url: () => 'https://example.test',
|
|
103
|
+
method: () => 'GET',
|
|
104
|
+
headers: () => ({ accept: 'application/json' }),
|
|
105
|
+
resourceType: () => 'document',
|
|
106
|
+
isNavigationRequest: () => true,
|
|
107
|
+
frame: () => rawPage.mainFrameValue,
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
await rawPage.emit('request', request);
|
|
111
|
+
expect(handler).toHaveBeenCalledTimes(2);
|
|
112
|
+
|
|
113
|
+
await first.dispose();
|
|
114
|
+
await rawPage.emit('request', request);
|
|
115
|
+
expect(handler).toHaveBeenCalledTimes(3);
|
|
116
|
+
|
|
117
|
+
await second.dispose();
|
|
118
|
+
await rawPage.emit('request', request);
|
|
119
|
+
expect(handler).toHaveBeenCalledTimes(3);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('throws explicit route-bound errors for request mutation methods', async () => {
|
|
123
|
+
const rawPage = new MockPlaywrightPage();
|
|
124
|
+
const page = new PlaywrightPage(rawPage as any, 'chromium', capabilities as any);
|
|
125
|
+
let wrappedRequest: any;
|
|
126
|
+
|
|
127
|
+
page.on('request', (request: any) => {
|
|
128
|
+
wrappedRequest = request;
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
await rawPage.emit('request', {
|
|
132
|
+
url: () => 'https://example.test/api',
|
|
133
|
+
method: () => 'POST',
|
|
134
|
+
headers: () => ({ 'content-type': 'application/json' }),
|
|
135
|
+
resourceType: () => 'xhr',
|
|
136
|
+
isNavigationRequest: () => false,
|
|
137
|
+
frame: () => rawPage.mainFrameValue,
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
await expect(wrappedRequest.abort()).rejects.toThrow(/without a bound route/);
|
|
141
|
+
await expect(wrappedRequest.continue({ method: 'GET' })).rejects.toThrow(/without a bound route/);
|
|
142
|
+
await expect(wrappedRequest.respond({ status: 200, body: 'ok' })).rejects.toThrow(/without a bound route/);
|
|
143
|
+
});
|
|
144
|
+
});
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { PlaywrightDriver } from './PlaywrightDriver';
|
|
2
|
+
export type { PlaywrightBrowserType, PlaywrightLaunchOptions } from './PlaywrightDriver';
|
|
3
|
+
export { PlaywrightBrowser } from './PlaywrightBrowser';
|
|
4
|
+
export { PlaywrightPage } from './PlaywrightPage';
|
|
5
|
+
export { PlaywrightElementHandle } from './PlaywrightElementHandle';
|