@apifuse/connector-sdk 2.0.0-beta.1
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/README.md +44 -0
- package/bin/apifuse-check.ts +408 -0
- package/bin/apifuse-dev.ts +222 -0
- package/bin/apifuse-init.ts +390 -0
- package/bin/apifuse-perf.ts +1101 -0
- package/bin/apifuse-record.ts +446 -0
- package/bin/apifuse-test.ts +688 -0
- package/bin/apifuse.ts +51 -0
- package/package.json +64 -0
- package/src/__tests__/auth.test.ts +396 -0
- package/src/__tests__/browser-auth.test.ts +180 -0
- package/src/__tests__/browser.test.ts +632 -0
- package/src/__tests__/connectors-yaml.test.ts +135 -0
- package/src/__tests__/define.test.ts +225 -0
- package/src/__tests__/errors.test.ts +69 -0
- package/src/__tests__/executor.test.ts +214 -0
- package/src/__tests__/http.test.ts +238 -0
- package/src/__tests__/insights.test.ts +210 -0
- package/src/__tests__/instrumentation.test.ts +290 -0
- package/src/__tests__/otlp.test.ts +141 -0
- package/src/__tests__/perf.test.ts +60 -0
- package/src/__tests__/proxy.test.ts +359 -0
- package/src/__tests__/recipes.test.ts +36 -0
- package/src/__tests__/serve.test.ts +233 -0
- package/src/__tests__/session.test.ts +231 -0
- package/src/__tests__/state.test.ts +100 -0
- package/src/__tests__/stealth.test.ts +57 -0
- package/src/__tests__/testing.test.ts +97 -0
- package/src/__tests__/tls.test.ts +345 -0
- package/src/__tests__/types.test.ts +142 -0
- package/src/__tests__/utils.test.ts +62 -0
- package/src/__tests__/waterfall.test.ts +270 -0
- package/src/config/connectors-yaml.ts +373 -0
- package/src/config/loader.ts +122 -0
- package/src/define.ts +137 -0
- package/src/dev.ts +38 -0
- package/src/errors.ts +68 -0
- package/src/index.test.ts +1 -0
- package/src/index.ts +100 -0
- package/src/protocol.ts +183 -0
- package/src/recipes/gov-api.ts +97 -0
- package/src/recipes/rest-api.ts +152 -0
- package/src/runtime/auth.ts +245 -0
- package/src/runtime/browser.ts +724 -0
- package/src/runtime/connector.ts +20 -0
- package/src/runtime/executor.ts +51 -0
- package/src/runtime/http.ts +248 -0
- package/src/runtime/insights.ts +456 -0
- package/src/runtime/instrumentation.ts +424 -0
- package/src/runtime/otlp.ts +171 -0
- package/src/runtime/perf.ts +73 -0
- package/src/runtime/session.ts +573 -0
- package/src/runtime/state.ts +124 -0
- package/src/runtime/tls.ts +410 -0
- package/src/runtime/trace.ts +261 -0
- package/src/runtime/waterfall.ts +245 -0
- package/src/serve.ts +665 -0
- package/src/stealth/profiles.ts +391 -0
- package/src/testing/helpers.ts +144 -0
- package/src/testing/index.ts +2 -0
- package/src/testing/run.ts +88 -0
- package/src/types/playwright-stealth.d.ts +9 -0
- package/src/types.ts +243 -0
- package/src/utils/date.ts +163 -0
- package/src/utils/parse.ts +66 -0
- package/src/utils/text.ts +20 -0
- package/src/utils/transform.ts +62 -0
|
@@ -0,0 +1,632 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test";
|
|
2
|
+
|
|
3
|
+
type LaunchCall = {
|
|
4
|
+
args?: string[];
|
|
5
|
+
executablePath?: string;
|
|
6
|
+
headless?: boolean;
|
|
7
|
+
proxy?: { server: string };
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
type MockPlaywrightPage = {
|
|
11
|
+
click: (selector: string) => Promise<void>;
|
|
12
|
+
close: () => Promise<void>;
|
|
13
|
+
content: () => Promise<string>;
|
|
14
|
+
evaluate: <T>(fn: string | (() => T)) => Promise<T>;
|
|
15
|
+
fill: (selector: string, text: string) => Promise<void>;
|
|
16
|
+
goto: (url: string) => Promise<void>;
|
|
17
|
+
locator: (selector: string) => { fill(value: string): Promise<void> };
|
|
18
|
+
screenshot: (options?: { fullPage?: boolean }) => Promise<Buffer>;
|
|
19
|
+
type: (selector: string, text: string) => Promise<void>;
|
|
20
|
+
waitForSelector: (
|
|
21
|
+
selector: string,
|
|
22
|
+
options?: { timeout?: number },
|
|
23
|
+
) => Promise<void>;
|
|
24
|
+
state: {
|
|
25
|
+
clicks: string[];
|
|
26
|
+
closed: boolean;
|
|
27
|
+
content: string;
|
|
28
|
+
fills: Array<{ selector: string; text: string }>;
|
|
29
|
+
gotoUrls: string[];
|
|
30
|
+
screenshots: Array<{ fullPage?: boolean }>;
|
|
31
|
+
types: Array<{ selector: string; text: string }>;
|
|
32
|
+
waits: Array<{ selector: string; timeout?: number }>;
|
|
33
|
+
};
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
type MockBrowserState = {
|
|
37
|
+
browser: {
|
|
38
|
+
close: () => Promise<void>;
|
|
39
|
+
isConnected: () => boolean;
|
|
40
|
+
newPage: () => Promise<MockPlaywrightPage>;
|
|
41
|
+
};
|
|
42
|
+
closeCalls: number;
|
|
43
|
+
connected: boolean;
|
|
44
|
+
newPageCalls: number;
|
|
45
|
+
pages: MockPlaywrightPage[];
|
|
46
|
+
launchOptions?: LaunchCall;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const browserState = {
|
|
50
|
+
browsers: [] as MockBrowserState[],
|
|
51
|
+
launchCalls: [] as LaunchCall[],
|
|
52
|
+
requireError: null as Error | null,
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const stealthState = {
|
|
56
|
+
callCount: 0,
|
|
57
|
+
pages: [] as MockPlaywrightPage[],
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const optionalModuleState = {
|
|
61
|
+
nodriverError: null as Error | null,
|
|
62
|
+
nodriverImports: 0,
|
|
63
|
+
seleniumBaseError: null as Error | null,
|
|
64
|
+
seleniumBaseImports: 0,
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const cdpState = {
|
|
68
|
+
acquireCalls: 0,
|
|
69
|
+
clicks: [] as string[],
|
|
70
|
+
closedEndpoints: [] as string[],
|
|
71
|
+
focusedSelectors: [] as string[],
|
|
72
|
+
insertedTexts: [] as string[],
|
|
73
|
+
navigateUrls: [] as string[],
|
|
74
|
+
poolReleaseCalls: [] as string[],
|
|
75
|
+
runtimeEnabled: 0,
|
|
76
|
+
pageEnabled: 0,
|
|
77
|
+
screenshotCalls: [] as boolean[],
|
|
78
|
+
webdriverPatches: 0,
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const originalWebSocket = globalThis.WebSocket;
|
|
82
|
+
const originalCdpPoolUrl = process.env.CDP_POOL_URL;
|
|
83
|
+
|
|
84
|
+
function createMockPlaywrightPage(): MockPlaywrightPage {
|
|
85
|
+
const state = {
|
|
86
|
+
clicks: [] as string[],
|
|
87
|
+
closed: false,
|
|
88
|
+
content: "<html><body>local</body></html>",
|
|
89
|
+
fills: [] as Array<{ selector: string; text: string }>,
|
|
90
|
+
gotoUrls: [] as string[],
|
|
91
|
+
screenshots: [] as Array<{ fullPage?: boolean }>,
|
|
92
|
+
types: [] as Array<{ selector: string; text: string }>,
|
|
93
|
+
waits: [] as Array<{ selector: string; timeout?: number }>,
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
state,
|
|
98
|
+
async click(selector) {
|
|
99
|
+
state.clicks.push(selector);
|
|
100
|
+
},
|
|
101
|
+
async close() {
|
|
102
|
+
state.closed = true;
|
|
103
|
+
},
|
|
104
|
+
async content() {
|
|
105
|
+
return state.content;
|
|
106
|
+
},
|
|
107
|
+
async evaluate<T>(fn: string | (() => T)) {
|
|
108
|
+
if (typeof fn === "function") {
|
|
109
|
+
return fn();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (fn === "document.title") {
|
|
113
|
+
return "local-title" as T;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
throw new Error(`Unexpected local evaluate expression: ${fn}`);
|
|
117
|
+
},
|
|
118
|
+
async fill(selector, text) {
|
|
119
|
+
state.fills.push({ selector, text });
|
|
120
|
+
},
|
|
121
|
+
async goto(url) {
|
|
122
|
+
state.gotoUrls.push(url);
|
|
123
|
+
},
|
|
124
|
+
locator(selector) {
|
|
125
|
+
return {
|
|
126
|
+
async fill(value) {
|
|
127
|
+
state.fills.push({ selector, text: value });
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
},
|
|
131
|
+
async screenshot(options) {
|
|
132
|
+
state.screenshots.push(options ?? {});
|
|
133
|
+
return Buffer.from("local-shot");
|
|
134
|
+
},
|
|
135
|
+
async type(selector, text) {
|
|
136
|
+
state.types.push({ selector, text });
|
|
137
|
+
},
|
|
138
|
+
async waitForSelector(selector, options) {
|
|
139
|
+
state.waits.push({ selector, timeout: options?.timeout });
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function registerBrowserMocks() {
|
|
145
|
+
mock.module("playwright", () => {
|
|
146
|
+
if (browserState.requireError) {
|
|
147
|
+
throw browserState.requireError;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return {
|
|
151
|
+
chromium: {
|
|
152
|
+
launch: async (options: LaunchCall = {}) => {
|
|
153
|
+
browserState.launchCalls.push(options);
|
|
154
|
+
|
|
155
|
+
const state: MockBrowserState = {
|
|
156
|
+
connected: true,
|
|
157
|
+
closeCalls: 0,
|
|
158
|
+
newPageCalls: 0,
|
|
159
|
+
pages: [],
|
|
160
|
+
launchOptions: options,
|
|
161
|
+
browser: {
|
|
162
|
+
close: async () => {
|
|
163
|
+
state.closeCalls += 1;
|
|
164
|
+
state.connected = false;
|
|
165
|
+
},
|
|
166
|
+
isConnected: () => state.connected,
|
|
167
|
+
newPage: async () => {
|
|
168
|
+
state.newPageCalls += 1;
|
|
169
|
+
const page = createMockPlaywrightPage();
|
|
170
|
+
state.pages.push(page);
|
|
171
|
+
return page;
|
|
172
|
+
},
|
|
173
|
+
},
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
browserState.browsers.push(state);
|
|
177
|
+
return state.browser;
|
|
178
|
+
},
|
|
179
|
+
},
|
|
180
|
+
};
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
mock.module("playwright-stealth", () => ({
|
|
184
|
+
stealth: async (page: MockPlaywrightPage) => {
|
|
185
|
+
stealthState.callCount += 1;
|
|
186
|
+
stealthState.pages.push(page);
|
|
187
|
+
},
|
|
188
|
+
}));
|
|
189
|
+
|
|
190
|
+
mock.module("nodriver", () => {
|
|
191
|
+
optionalModuleState.nodriverImports += 1;
|
|
192
|
+
if (optionalModuleState.nodriverError) {
|
|
193
|
+
throw optionalModuleState.nodriverError;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return {};
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
mock.module("seleniumbase", () => {
|
|
200
|
+
optionalModuleState.seleniumBaseImports += 1;
|
|
201
|
+
if (optionalModuleState.seleniumBaseError) {
|
|
202
|
+
throw optionalModuleState.seleniumBaseError;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return {};
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function parseSelector(expression: string): string | null {
|
|
210
|
+
const match = expression.match(/document\.querySelector\((".*?")\)/);
|
|
211
|
+
if (!match?.[1]) {
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return JSON.parse(match[1]) as string;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
class MockWebSocket {
|
|
219
|
+
static CONNECTING = 0;
|
|
220
|
+
static OPEN = 1;
|
|
221
|
+
static CLOSING = 2;
|
|
222
|
+
static CLOSED = 3;
|
|
223
|
+
|
|
224
|
+
readyState = MockWebSocket.CONNECTING;
|
|
225
|
+
private listeners = new Map<
|
|
226
|
+
string,
|
|
227
|
+
Set<(event?: { data?: string }) => void>
|
|
228
|
+
>();
|
|
229
|
+
|
|
230
|
+
constructor(private readonly endpoint: string) {
|
|
231
|
+
queueMicrotask(() => {
|
|
232
|
+
this.readyState = MockWebSocket.OPEN;
|
|
233
|
+
this.emit("open");
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
addEventListener(
|
|
238
|
+
event: string,
|
|
239
|
+
listener: (event?: { data?: string }) => void,
|
|
240
|
+
) {
|
|
241
|
+
const listeners = this.listeners.get(event) ?? new Set();
|
|
242
|
+
listeners.add(listener);
|
|
243
|
+
this.listeners.set(event, listeners);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
close() {
|
|
247
|
+
this.readyState = MockWebSocket.CLOSED;
|
|
248
|
+
cdpState.closedEndpoints.push(this.endpoint);
|
|
249
|
+
this.emit("close");
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
send(raw: string) {
|
|
253
|
+
const message = JSON.parse(raw) as {
|
|
254
|
+
id: number;
|
|
255
|
+
method: string;
|
|
256
|
+
params?: Record<string, unknown>;
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
if (this.endpoint === "ws://pool.test") {
|
|
260
|
+
this.handlePoolMessage(message);
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
this.handlePageMessage(message);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
private emit(event: string, payload?: { data?: string }) {
|
|
268
|
+
for (const listener of this.listeners.get(event) ?? []) {
|
|
269
|
+
listener(payload);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
private reply(id: number, result: Record<string, unknown>) {
|
|
274
|
+
this.emit("message", {
|
|
275
|
+
data: JSON.stringify({ id, jsonrpc: "2.0", result }),
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
private emitPageEvent(method: string, params: Record<string, unknown> = {}) {
|
|
280
|
+
this.emit("message", {
|
|
281
|
+
data: JSON.stringify({ jsonrpc: "2.0", method, params }),
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
private handlePoolMessage(message: {
|
|
286
|
+
id: number;
|
|
287
|
+
method: string;
|
|
288
|
+
params?: Record<string, unknown>;
|
|
289
|
+
}) {
|
|
290
|
+
switch (message.method) {
|
|
291
|
+
case "acquire":
|
|
292
|
+
cdpState.acquireCalls += 1;
|
|
293
|
+
this.reply(message.id, {
|
|
294
|
+
pageId: "pool-page-1",
|
|
295
|
+
wsEndpoint: "ws://page.test/devtools/page/pool-page-1",
|
|
296
|
+
});
|
|
297
|
+
break;
|
|
298
|
+
case "release":
|
|
299
|
+
cdpState.poolReleaseCalls.push(String(message.params?.pageId ?? ""));
|
|
300
|
+
this.reply(message.id, { released: true });
|
|
301
|
+
break;
|
|
302
|
+
default:
|
|
303
|
+
this.reply(message.id, {});
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
private handlePageMessage(message: {
|
|
308
|
+
id: number;
|
|
309
|
+
method: string;
|
|
310
|
+
params?: Record<string, unknown>;
|
|
311
|
+
}) {
|
|
312
|
+
switch (message.method) {
|
|
313
|
+
case "Page.enable":
|
|
314
|
+
cdpState.pageEnabled += 1;
|
|
315
|
+
this.reply(message.id, {});
|
|
316
|
+
break;
|
|
317
|
+
case "Runtime.enable":
|
|
318
|
+
cdpState.runtimeEnabled += 1;
|
|
319
|
+
this.reply(message.id, {});
|
|
320
|
+
break;
|
|
321
|
+
case "Page.navigate":
|
|
322
|
+
cdpState.navigateUrls.push(String(message.params?.url ?? ""));
|
|
323
|
+
this.reply(message.id, {});
|
|
324
|
+
queueMicrotask(() => this.emitPageEvent("Page.loadEventFired"));
|
|
325
|
+
break;
|
|
326
|
+
case "Runtime.evaluate": {
|
|
327
|
+
const expression = String(message.params?.expression ?? "");
|
|
328
|
+
const selector = parseSelector(expression);
|
|
329
|
+
|
|
330
|
+
if (expression.includes('window.navigator, "webdriver"')) {
|
|
331
|
+
cdpState.webdriverPatches += 1;
|
|
332
|
+
this.reply(message.id, { result: { value: undefined } });
|
|
333
|
+
break;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
if (expression === "document.readyState") {
|
|
337
|
+
this.reply(message.id, { result: { value: "complete" } });
|
|
338
|
+
break;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
if (expression === "document.documentElement.outerHTML") {
|
|
342
|
+
this.reply(message.id, {
|
|
343
|
+
result: { value: '<html><body><input id="name" /></body></html>' },
|
|
344
|
+
});
|
|
345
|
+
break;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
if (expression === "document.title") {
|
|
349
|
+
this.reply(message.id, { result: { value: "remote-title" } });
|
|
350
|
+
break;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
if (expression.includes("Boolean(document.querySelector")) {
|
|
354
|
+
this.reply(message.id, { result: { value: Boolean(selector) } });
|
|
355
|
+
break;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
if (expression.includes("element.click()") && selector) {
|
|
359
|
+
cdpState.clicks.push(selector);
|
|
360
|
+
this.reply(message.id, { result: { value: undefined } });
|
|
361
|
+
break;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
if (expression.includes("element.focus()") && selector) {
|
|
365
|
+
cdpState.focusedSelectors.push(selector);
|
|
366
|
+
this.reply(message.id, { result: { value: undefined } });
|
|
367
|
+
break;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
this.reply(message.id, { result: { value: undefined } });
|
|
371
|
+
break;
|
|
372
|
+
}
|
|
373
|
+
case "Input.insertText":
|
|
374
|
+
cdpState.insertedTexts.push(String(message.params?.text ?? ""));
|
|
375
|
+
this.reply(message.id, {});
|
|
376
|
+
break;
|
|
377
|
+
case "Page.captureScreenshot":
|
|
378
|
+
cdpState.screenshotCalls.push(
|
|
379
|
+
Boolean(message.params?.captureBeyondViewport),
|
|
380
|
+
);
|
|
381
|
+
this.reply(message.id, {
|
|
382
|
+
data: Buffer.from("remote-shot").toString("base64"),
|
|
383
|
+
});
|
|
384
|
+
break;
|
|
385
|
+
default:
|
|
386
|
+
this.reply(message.id, {});
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
describe("createBrowserClient", () => {
|
|
392
|
+
beforeEach(() => {
|
|
393
|
+
browserState.browsers.length = 0;
|
|
394
|
+
browserState.launchCalls.length = 0;
|
|
395
|
+
browserState.requireError = null;
|
|
396
|
+
stealthState.callCount = 0;
|
|
397
|
+
stealthState.pages.length = 0;
|
|
398
|
+
optionalModuleState.nodriverError = null;
|
|
399
|
+
optionalModuleState.nodriverImports = 0;
|
|
400
|
+
optionalModuleState.seleniumBaseError = null;
|
|
401
|
+
optionalModuleState.seleniumBaseImports = 0;
|
|
402
|
+
cdpState.acquireCalls = 0;
|
|
403
|
+
cdpState.clicks.length = 0;
|
|
404
|
+
cdpState.closedEndpoints.length = 0;
|
|
405
|
+
cdpState.focusedSelectors.length = 0;
|
|
406
|
+
cdpState.insertedTexts.length = 0;
|
|
407
|
+
cdpState.navigateUrls.length = 0;
|
|
408
|
+
cdpState.poolReleaseCalls.length = 0;
|
|
409
|
+
cdpState.runtimeEnabled = 0;
|
|
410
|
+
cdpState.pageEnabled = 0;
|
|
411
|
+
cdpState.screenshotCalls.length = 0;
|
|
412
|
+
cdpState.webdriverPatches = 0;
|
|
413
|
+
process.env.CDP_POOL_URL = undefined;
|
|
414
|
+
globalThis.WebSocket = originalWebSocket;
|
|
415
|
+
registerBrowserMocks();
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
afterEach(() => {
|
|
419
|
+
if (originalCdpPoolUrl === undefined) {
|
|
420
|
+
delete process.env.CDP_POOL_URL;
|
|
421
|
+
} else {
|
|
422
|
+
process.env.CDP_POOL_URL = originalCdpPoolUrl;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
globalThis.WebSocket = originalWebSocket;
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
it("throws ConnectorError with install hint when Playwright is unavailable", async () => {
|
|
429
|
+
browserState.requireError = Object.assign(
|
|
430
|
+
new Error("Cannot find module 'playwright'"),
|
|
431
|
+
{
|
|
432
|
+
code: "MODULE_NOT_FOUND",
|
|
433
|
+
},
|
|
434
|
+
);
|
|
435
|
+
registerBrowserMocks();
|
|
436
|
+
|
|
437
|
+
const { ConnectorError } = await import("../errors");
|
|
438
|
+
const { createBrowserClient } = await import("../runtime/browser");
|
|
439
|
+
const client = createBrowserClient();
|
|
440
|
+
const pagePromise = client.newPage();
|
|
441
|
+
|
|
442
|
+
await expect(pagePromise).rejects.toBeInstanceOf(ConnectorError);
|
|
443
|
+
await expect(pagePromise).rejects.toMatchObject({
|
|
444
|
+
fix: "Run: bun add playwright",
|
|
445
|
+
message: "Playwright is not installed",
|
|
446
|
+
});
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
it("falls back to local Playwright and implements browser page methods", async () => {
|
|
450
|
+
const { createBrowserClient } = await import("../runtime/browser");
|
|
451
|
+
const client = createBrowserClient();
|
|
452
|
+
|
|
453
|
+
const page = (await client.newPage()) as {
|
|
454
|
+
click(selector: string): Promise<void>;
|
|
455
|
+
content(): Promise<string>;
|
|
456
|
+
evaluate<T>(fn: string | (() => T)): Promise<T>;
|
|
457
|
+
fill(selector: string, text: string): Promise<void>;
|
|
458
|
+
goto(url: string): Promise<void>;
|
|
459
|
+
screenshot(options?: { fullPage?: boolean }): Promise<Buffer>;
|
|
460
|
+
type(selector: string, text: string): Promise<void>;
|
|
461
|
+
waitForSelector(
|
|
462
|
+
selector: string,
|
|
463
|
+
options?: { timeout?: number },
|
|
464
|
+
): Promise<void>;
|
|
465
|
+
close(): Promise<void>;
|
|
466
|
+
};
|
|
467
|
+
|
|
468
|
+
await page.goto("https://example.com/login");
|
|
469
|
+
await page.waitForSelector("#email", { timeout: 1234 });
|
|
470
|
+
await page.click("button[type=submit]");
|
|
471
|
+
await page.type("#email", "demo@example.com");
|
|
472
|
+
await page.fill("#otp", "123456");
|
|
473
|
+
const html = await page.content();
|
|
474
|
+
const title = await page.evaluate<string>("document.title");
|
|
475
|
+
const screenshot = await page.screenshot({ fullPage: true });
|
|
476
|
+
await page.close();
|
|
477
|
+
|
|
478
|
+
expect(browserState.launchCalls).toEqual([
|
|
479
|
+
{
|
|
480
|
+
args: undefined,
|
|
481
|
+
executablePath: undefined,
|
|
482
|
+
headless: true,
|
|
483
|
+
proxy: undefined,
|
|
484
|
+
},
|
|
485
|
+
]);
|
|
486
|
+
expect(stealthState.callCount).toBe(1);
|
|
487
|
+
expect(stealthState.pages).toHaveLength(1);
|
|
488
|
+
expect(browserState.browsers[0]?.newPageCalls).toBe(1);
|
|
489
|
+
expect(browserState.browsers[0]?.pages[0]?.state.gotoUrls).toEqual([
|
|
490
|
+
"https://example.com/login",
|
|
491
|
+
]);
|
|
492
|
+
expect(browserState.browsers[0]?.pages[0]?.state.waits).toEqual([
|
|
493
|
+
{ selector: "#email", timeout: 1234 },
|
|
494
|
+
]);
|
|
495
|
+
expect(browserState.browsers[0]?.pages[0]?.state.clicks).toEqual([
|
|
496
|
+
"button[type=submit]",
|
|
497
|
+
]);
|
|
498
|
+
expect(browserState.browsers[0]?.pages[0]?.state.types).toEqual([
|
|
499
|
+
{ selector: "#email", text: "demo@example.com" },
|
|
500
|
+
]);
|
|
501
|
+
expect(browserState.browsers[0]?.pages[0]?.state.fills).toEqual([
|
|
502
|
+
{ selector: "#email", text: "" },
|
|
503
|
+
{ selector: "#otp", text: "123456" },
|
|
504
|
+
]);
|
|
505
|
+
expect(html).toBe("<html><body>local</body></html>");
|
|
506
|
+
expect(title).toBe("local-title");
|
|
507
|
+
expect(screenshot.toString()).toBe("local-shot");
|
|
508
|
+
expect(browserState.browsers[0]?.pages[0]?.state.closed).toBeTrue();
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
it("passes launch options through and can disable stealth", async () => {
|
|
512
|
+
const { createBrowserClient } = await import("../runtime/browser");
|
|
513
|
+
const client = createBrowserClient({
|
|
514
|
+
extraArgs: ["--disable-dev-shm-usage"],
|
|
515
|
+
executablePath:
|
|
516
|
+
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
517
|
+
headless: false,
|
|
518
|
+
proxy: "http://127.0.0.1:8080",
|
|
519
|
+
stealth: false,
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
await client.newPage();
|
|
523
|
+
|
|
524
|
+
expect(browserState.launchCalls).toEqual([
|
|
525
|
+
{
|
|
526
|
+
args: ["--disable-dev-shm-usage"],
|
|
527
|
+
executablePath:
|
|
528
|
+
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
529
|
+
headless: false,
|
|
530
|
+
proxy: { server: "http://127.0.0.1:8080" },
|
|
531
|
+
},
|
|
532
|
+
]);
|
|
533
|
+
expect(stealthState.callCount).toBe(0);
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
it("uses CDP Pool when CDP_POOL_URL is configured", async () => {
|
|
537
|
+
globalThis.WebSocket = MockWebSocket as unknown as typeof WebSocket;
|
|
538
|
+
process.env.CDP_POOL_URL = "ws://pool.test";
|
|
539
|
+
|
|
540
|
+
const { createBrowserClient } = await import("../runtime/browser");
|
|
541
|
+
const client = createBrowserClient();
|
|
542
|
+
const page = (await client.newPage()) as {
|
|
543
|
+
pageId: string;
|
|
544
|
+
click(selector: string): Promise<void>;
|
|
545
|
+
content(): Promise<string>;
|
|
546
|
+
evaluate<T>(fn: string | (() => T)): Promise<T>;
|
|
547
|
+
goto(url: string): Promise<void>;
|
|
548
|
+
screenshot(options?: { fullPage?: boolean }): Promise<Buffer>;
|
|
549
|
+
type(selector: string, text: string): Promise<void>;
|
|
550
|
+
waitForSelector(selector: string): Promise<void>;
|
|
551
|
+
close(): Promise<void>;
|
|
552
|
+
};
|
|
553
|
+
|
|
554
|
+
await page.goto("https://bank.example.com/login");
|
|
555
|
+
await page.waitForSelector("#name");
|
|
556
|
+
await page.click("#submit");
|
|
557
|
+
await page.type("#name", "demo");
|
|
558
|
+
const title = await page.evaluate<string>("document.title");
|
|
559
|
+
const html = await page.content();
|
|
560
|
+
const screenshot = await page.screenshot({ fullPage: true });
|
|
561
|
+
await page.close();
|
|
562
|
+
await client.close();
|
|
563
|
+
|
|
564
|
+
expect(page.pageId).toBe("pool-page-1");
|
|
565
|
+
expect(browserState.launchCalls).toHaveLength(0);
|
|
566
|
+
expect(cdpState.acquireCalls).toBe(1);
|
|
567
|
+
expect(cdpState.pageEnabled).toBe(1);
|
|
568
|
+
expect(cdpState.runtimeEnabled).toBe(1);
|
|
569
|
+
expect(cdpState.webdriverPatches).toBe(1);
|
|
570
|
+
expect(cdpState.navigateUrls).toEqual(["https://bank.example.com/login"]);
|
|
571
|
+
expect(cdpState.clicks).toEqual(["#submit"]);
|
|
572
|
+
expect(cdpState.focusedSelectors).toEqual(["#name"]);
|
|
573
|
+
expect(cdpState.insertedTexts).toEqual(["demo"]);
|
|
574
|
+
expect(cdpState.screenshotCalls).toEqual([true]);
|
|
575
|
+
expect(cdpState.poolReleaseCalls).toEqual(["pool-page-1"]);
|
|
576
|
+
expect(title).toBe("remote-title");
|
|
577
|
+
expect(html).toContain('<input id="name" />');
|
|
578
|
+
expect(screenshot.toString()).toBe("remote-shot");
|
|
579
|
+
expect(cdpState.closedEndpoints).toContain(
|
|
580
|
+
"ws://page.test/devtools/page/pool-page-1",
|
|
581
|
+
);
|
|
582
|
+
expect(cdpState.closedEndpoints).toContain("ws://pool.test");
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
it("exposes the resolved engine on the browser client", async () => {
|
|
586
|
+
const { createBrowserClient } = await import("../runtime/browser");
|
|
587
|
+
const defaultClient = createBrowserClient();
|
|
588
|
+
const nodriverClient = createBrowserClient({ engine: "nodriver" });
|
|
589
|
+
|
|
590
|
+
expect(defaultClient.engine).toBe("playwright-stealth");
|
|
591
|
+
expect(nodriverClient.engine).toBe("nodriver");
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
it("throws a Python runtime error for the nodriver engine", async () => {
|
|
595
|
+
const { ConnectorError } = await import("../errors");
|
|
596
|
+
const { createBrowserClient } = await import("../runtime/browser");
|
|
597
|
+
const client = createBrowserClient({ engine: "nodriver" });
|
|
598
|
+
const pagePromise = client.newPage();
|
|
599
|
+
|
|
600
|
+
await expect(pagePromise).rejects.toBeInstanceOf(ConnectorError);
|
|
601
|
+
await expect(pagePromise).rejects.toMatchObject({
|
|
602
|
+
fix: "Use connector language: python and ctx.browser in Python",
|
|
603
|
+
message: "nodriver engine requires Python runtime",
|
|
604
|
+
});
|
|
605
|
+
expect(optionalModuleState.nodriverImports).toBe(1);
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
it("throws a Python runtime error for the selenium-uc engine", async () => {
|
|
609
|
+
const { ConnectorError } = await import("../errors");
|
|
610
|
+
const { createBrowserClient } = await import("../runtime/browser");
|
|
611
|
+
const client = createBrowserClient({ engine: "selenium-uc" });
|
|
612
|
+
const pagePromise = client.newPage();
|
|
613
|
+
|
|
614
|
+
await expect(pagePromise).rejects.toBeInstanceOf(ConnectorError);
|
|
615
|
+
await expect(pagePromise).rejects.toMatchObject({
|
|
616
|
+
fix: "Use connector language: python",
|
|
617
|
+
message: "selenium-uc engine requires Python runtime",
|
|
618
|
+
});
|
|
619
|
+
expect(optionalModuleState.seleniumBaseImports).toBe(1);
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
it("closes the underlying local browser instance", async () => {
|
|
623
|
+
const { createBrowserClient } = await import("../runtime/browser");
|
|
624
|
+
const client = createBrowserClient();
|
|
625
|
+
|
|
626
|
+
await client.newPage();
|
|
627
|
+
await client.close();
|
|
628
|
+
|
|
629
|
+
expect(browserState.browsers[0]?.closeCalls).toBe(1);
|
|
630
|
+
expect(browserState.browsers[0]?.connected).toBeFalse();
|
|
631
|
+
});
|
|
632
|
+
});
|