@webhands/core 0.4.0 → 0.6.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/README.md +69 -6
- package/dist/errors.d.ts +112 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +121 -0
- package/dist/errors.js.map +1 -1
- package/dist/hand-host.d.ts +198 -5
- package/dist/hand-host.d.ts.map +1 -1
- package/dist/hand-host.js +664 -21
- package/dist/hand-host.js.map +1 -1
- package/dist/index.d.ts +5 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -3
- package/dist/index.js.map +1 -1
- package/dist/playwright-attach-transport.d.ts +8 -1
- package/dist/playwright-attach-transport.d.ts.map +1 -1
- package/dist/playwright-attach-transport.js +19 -4
- package/dist/playwright-attach-transport.js.map +1 -1
- package/dist/playwright-launch-transport.d.ts +23 -0
- package/dist/playwright-launch-transport.d.ts.map +1 -1
- package/dist/playwright-launch-transport.js +40 -6
- package/dist/playwright-launch-transport.js.map +1 -1
- package/dist/profile-location.d.ts +19 -0
- package/dist/profile-location.d.ts.map +1 -1
- package/dist/profile-location.js +21 -0
- package/dist/profile-location.js.map +1 -1
- package/dist/seam.d.ts +501 -7
- package/dist/seam.d.ts.map +1 -1
- package/dist/seam.js +31 -0
- package/dist/seam.js.map +1 -1
- package/dist/session-rpc.d.ts +63 -1
- package/dist/session-rpc.d.ts.map +1 -1
- package/dist/session-rpc.js +174 -11
- package/dist/session-rpc.js.map +1 -1
- package/dist/socks-proxy.d.ts +61 -0
- package/dist/socks-proxy.d.ts.map +1 -0
- package/dist/socks-proxy.js +84 -0
- package/dist/socks-proxy.js.map +1 -0
- package/dist/stub-transport.d.ts.map +1 -1
- package/dist/stub-transport.js +74 -6
- package/dist/stub-transport.js.map +1 -1
- package/dist/test-fixtures/fixture-pages.d.ts.map +1 -1
- package/dist/test-fixtures/fixture-pages.js +994 -0
- package/dist/test-fixtures/fixture-pages.js.map +1 -1
- package/dist/test-fixtures/fixture-server.d.ts.map +1 -1
- package/dist/test-fixtures/fixture-server.js +33 -3
- package/dist/test-fixtures/fixture-server.js.map +1 -1
- package/package.json +1 -1
- package/src/errors.ts +164 -1
- package/src/hand-host.ts +797 -21
- package/src/index.ts +27 -1
- package/src/playwright-attach-transport.ts +25 -3
- package/src/playwright-launch-transport.ts +63 -4
- package/src/profile-location.ts +25 -0
- package/src/seam.ts +535 -7
- package/src/session-rpc.ts +276 -14
- package/src/socks-proxy.ts +127 -0
- package/src/stub-transport.ts +83 -6
- package/src/test-fixtures/fixture-pages.ts +1010 -0
- package/src/test-fixtures/fixture-server.ts +32 -3
package/src/session-rpc.ts
CHANGED
|
@@ -1,7 +1,18 @@
|
|
|
1
1
|
import {
|
|
2
2
|
locator,
|
|
3
|
+
validateSnapshotOptions,
|
|
4
|
+
type ActionOptions,
|
|
3
5
|
type Cookie,
|
|
6
|
+
type EvalOptions,
|
|
7
|
+
type MouseInput,
|
|
8
|
+
type QueryOptions,
|
|
9
|
+
type QueryRow,
|
|
10
|
+
type Screenshot,
|
|
11
|
+
type ScreenshotOptions,
|
|
12
|
+
type ScrollTarget,
|
|
13
|
+
type SelectChoice,
|
|
4
14
|
type Snapshot,
|
|
15
|
+
type SnapshotOptions,
|
|
5
16
|
type WaitCondition,
|
|
6
17
|
} from './seam.js';
|
|
7
18
|
import type {WebHandsPage} from './seam.js';
|
|
@@ -64,12 +75,78 @@ export type SessionRpcRequest =
|
|
|
64
75
|
export type SessionRpcBuiltInRequest =
|
|
65
76
|
| {readonly verb: 'navigate'; readonly url: string}
|
|
66
77
|
| {readonly verb: 'snapshot'; readonly full?: boolean}
|
|
67
|
-
| {
|
|
68
|
-
|
|
69
|
-
|
|
78
|
+
| {
|
|
79
|
+
readonly verb: 'click';
|
|
80
|
+
readonly locator: string;
|
|
81
|
+
/**
|
|
82
|
+
* Optional {@link ActionOptions}: `{byRef: true}` marks `locator` as a
|
|
83
|
+
* durable `query` `ref` so the server resolves it with the loud-stale
|
|
84
|
+
* EXACTLY-ONE contract ({@link StaleRefError}). Omitted == plain locator
|
|
85
|
+
* click (additive, R1).
|
|
86
|
+
*/
|
|
87
|
+
readonly options?: ActionOptions;
|
|
88
|
+
}
|
|
89
|
+
| {
|
|
90
|
+
readonly verb: 'type';
|
|
91
|
+
readonly locator: string;
|
|
92
|
+
readonly text: string;
|
|
93
|
+
/** Optional {@link ActionOptions} (`{byRef}`); see the `click` request. */
|
|
94
|
+
readonly options?: ActionOptions;
|
|
95
|
+
}
|
|
96
|
+
| {
|
|
97
|
+
readonly verb: 'eval';
|
|
98
|
+
readonly expression: string;
|
|
99
|
+
/**
|
|
100
|
+
* Optional SAME-ORIGIN child-frame selector to evaluate in (Tier-3 frame
|
|
101
|
+
* scope). A plain string on the wire; the server passes it as the seam
|
|
102
|
+
* {@link EvalOptions.frame}. Omitted == top-document `eval`.
|
|
103
|
+
*/
|
|
104
|
+
readonly frame?: string;
|
|
105
|
+
}
|
|
70
106
|
| {readonly verb: 'wait'; readonly condition: WaitCondition}
|
|
71
107
|
| {readonly verb: 'cookies'}
|
|
72
|
-
| {readonly verb: 'setCookies'; readonly cookies: readonly Cookie[]}
|
|
108
|
+
| {readonly verb: 'setCookies'; readonly cookies: readonly Cookie[]}
|
|
109
|
+
| {
|
|
110
|
+
readonly verb: 'query';
|
|
111
|
+
readonly locator: string;
|
|
112
|
+
readonly options?: QueryOptions;
|
|
113
|
+
}
|
|
114
|
+
| {readonly verb: 'count'; readonly locator: string}
|
|
115
|
+
| {readonly verb: 'exists'; readonly locator: string}
|
|
116
|
+
| {readonly verb: 'isVisible'; readonly locator: string}
|
|
117
|
+
| {
|
|
118
|
+
readonly verb: 'getAttribute';
|
|
119
|
+
readonly locator: string;
|
|
120
|
+
readonly name: string;
|
|
121
|
+
}
|
|
122
|
+
| {
|
|
123
|
+
readonly verb: 'press';
|
|
124
|
+
readonly key: string;
|
|
125
|
+
readonly locator?: string;
|
|
126
|
+
}
|
|
127
|
+
| {readonly verb: 'hover'; readonly locator: string}
|
|
128
|
+
| {
|
|
129
|
+
readonly verb: 'select';
|
|
130
|
+
readonly locator: string;
|
|
131
|
+
readonly choice: SelectChoice;
|
|
132
|
+
}
|
|
133
|
+
| {readonly verb: 'scroll'; readonly target: ScrollTarget}
|
|
134
|
+
| {
|
|
135
|
+
readonly verb: 'drag';
|
|
136
|
+
readonly source: string;
|
|
137
|
+
readonly target: string;
|
|
138
|
+
}
|
|
139
|
+
| {readonly verb: 'mouse'; readonly input: MouseInput}
|
|
140
|
+
| {
|
|
141
|
+
readonly verb: 'screenshot';
|
|
142
|
+
/**
|
|
143
|
+
* The {@link ScreenshotOptions}, flattened onto the request. `locator`
|
|
144
|
+
* (the `element` scope's clip target) is a branded {@link LocatorString}
|
|
145
|
+
* that JSON flattens to a plain string; the server re-brands it with
|
|
146
|
+
* {@link locator}. No image bytes ever cross — only the path comes back.
|
|
147
|
+
*/
|
|
148
|
+
readonly options?: ScreenshotOptions;
|
|
149
|
+
};
|
|
73
150
|
|
|
74
151
|
/**
|
|
75
152
|
* The OPEN escape for a dynamically-loaded third-party hand verb (Phase 2,
|
|
@@ -119,15 +196,35 @@ export async function applySessionRpc(
|
|
|
119
196
|
await page.navigate(request.url);
|
|
120
197
|
return undefined;
|
|
121
198
|
case 'snapshot':
|
|
122
|
-
|
|
199
|
+
// Validate on the SERVER side so a malformed request from ANY client
|
|
200
|
+
// (not just the typed `makeRpcPage`) is rejected faithfully across the
|
|
201
|
+
// seam, mirroring how a page/eval throw rejects. A raw client could POST
|
|
202
|
+
// e.g. `{verb: 'snapshot', view: 'full'}`; we rebuild the snapshot
|
|
203
|
+
// options from the request's non-`verb` keys and reject unknown ones
|
|
204
|
+
// rather than silently dropping them and returning the wrong view.
|
|
205
|
+
return page.snapshot(snapshotOptionsFromRequest(request));
|
|
123
206
|
case 'click':
|
|
124
|
-
|
|
207
|
+
// Forward the optional ActionOptions only when given, so a plain-locator
|
|
208
|
+
// click stays `click(target)` (the byRef path is opt-in).
|
|
209
|
+
await page.click(
|
|
210
|
+
locator(request.locator),
|
|
211
|
+
request.options !== undefined ? request.options : undefined,
|
|
212
|
+
);
|
|
125
213
|
return undefined;
|
|
126
214
|
case 'type':
|
|
127
|
-
await page.type(
|
|
215
|
+
await page.type(
|
|
216
|
+
locator(request.locator),
|
|
217
|
+
request.text,
|
|
218
|
+
request.options !== undefined ? request.options : undefined,
|
|
219
|
+
);
|
|
128
220
|
return undefined;
|
|
129
221
|
case 'eval':
|
|
130
|
-
|
|
222
|
+
// Forward the optional frame selector only when present, so a bare
|
|
223
|
+
// top-document eval stays `eval(expression)` (no options object).
|
|
224
|
+
return page.eval(
|
|
225
|
+
request.expression,
|
|
226
|
+
request.frame !== undefined ? {frame: request.frame} : undefined,
|
|
227
|
+
);
|
|
131
228
|
case 'wait':
|
|
132
229
|
await page.wait(rebrandWait(request.condition));
|
|
133
230
|
return undefined;
|
|
@@ -136,11 +233,73 @@ export async function applySessionRpc(
|
|
|
136
233
|
case 'setCookies':
|
|
137
234
|
await page.setCookies(request.cookies);
|
|
138
235
|
return undefined;
|
|
236
|
+
case 'query':
|
|
237
|
+
return page.query(locator(request.locator), request.options);
|
|
238
|
+
case 'count':
|
|
239
|
+
return page.count(locator(request.locator));
|
|
240
|
+
case 'exists':
|
|
241
|
+
return page.exists(locator(request.locator));
|
|
242
|
+
case 'isVisible':
|
|
243
|
+
return page.isVisible(locator(request.locator));
|
|
244
|
+
case 'getAttribute':
|
|
245
|
+
return page.getAttribute(locator(request.locator), request.name);
|
|
246
|
+
case 'press':
|
|
247
|
+
await page.press(
|
|
248
|
+
request.key,
|
|
249
|
+
request.locator !== undefined ? locator(request.locator) : undefined,
|
|
250
|
+
);
|
|
251
|
+
return undefined;
|
|
252
|
+
case 'hover':
|
|
253
|
+
await page.hover(locator(request.locator));
|
|
254
|
+
return undefined;
|
|
255
|
+
case 'select':
|
|
256
|
+
await page.select(locator(request.locator), request.choice);
|
|
257
|
+
return undefined;
|
|
258
|
+
case 'scroll':
|
|
259
|
+
await page.scroll(rebrandScroll(request.target));
|
|
260
|
+
return undefined;
|
|
261
|
+
case 'drag':
|
|
262
|
+
await page.drag(locator(request.source), locator(request.target));
|
|
263
|
+
return undefined;
|
|
264
|
+
case 'mouse':
|
|
265
|
+
await page.mouse(request.input);
|
|
266
|
+
return undefined;
|
|
267
|
+
case 'screenshot':
|
|
268
|
+
// Re-brand the `element`-scope clip locator (plain string over the wire)
|
|
269
|
+
// before it reaches the page; the rest of the options are plain. Only the
|
|
270
|
+
// returned {path,width,height} crosses back — never image bytes.
|
|
271
|
+
return page.screenshot(rebrandScreenshot(request.options));
|
|
139
272
|
case 'hand':
|
|
140
273
|
return applyHandVerb(page, request);
|
|
141
274
|
}
|
|
142
275
|
}
|
|
143
276
|
|
|
277
|
+
/**
|
|
278
|
+
* Rebuild and validate the {@link SnapshotOptions} carried by a wire `snapshot`
|
|
279
|
+
* request. The wire flattens `{full}` onto the request alongside `verb`; a raw
|
|
280
|
+
* (untyped) client may also send a misspelled key such as `view`. We collect
|
|
281
|
+
* every non-`verb` key into an options object and run it through the shared
|
|
282
|
+
* {@link validateSnapshotOptions} so the server rejects an unknown/misshapen
|
|
283
|
+
* option exactly as the in-process host does. Returns `undefined` when no
|
|
284
|
+
* options were sent (the bare `snapshot()` case).
|
|
285
|
+
*/
|
|
286
|
+
function snapshotOptionsFromRequest(
|
|
287
|
+
request: SessionRpcRequest & {readonly verb: 'snapshot'},
|
|
288
|
+
): SnapshotOptions | undefined {
|
|
289
|
+
const {verb: _verb, ...rest} = request as Record<string, unknown> & {
|
|
290
|
+
verb: 'snapshot';
|
|
291
|
+
};
|
|
292
|
+
// Drop an explicitly-absent `full` (the typed client always sends the key,
|
|
293
|
+
// even as `undefined`) so a bare `snapshot()` stays `undefined`.
|
|
294
|
+
if ('full' in rest && rest.full === undefined) {
|
|
295
|
+
delete rest.full;
|
|
296
|
+
}
|
|
297
|
+
if (Object.keys(rest).length === 0) {
|
|
298
|
+
return undefined;
|
|
299
|
+
}
|
|
300
|
+
return validateSnapshotOptions(rest as SnapshotOptions);
|
|
301
|
+
}
|
|
302
|
+
|
|
144
303
|
/**
|
|
145
304
|
* Invoke a dynamically-loaded hand verb by name against the live composed page.
|
|
146
305
|
*
|
|
@@ -191,19 +350,41 @@ export function makeRpcPage(
|
|
|
191
350
|
await send({verb: 'navigate', url});
|
|
192
351
|
},
|
|
193
352
|
async snapshot(options) {
|
|
353
|
+
// Fail fast on the client too, so a typed caller's mistake (e.g.
|
|
354
|
+
// `{view: 'full'}`) is caught before a round-trip. The server
|
|
355
|
+
// re-validates as the load-bearing check for untyped clients.
|
|
356
|
+
validateSnapshotOptions(options);
|
|
194
357
|
return (await send({
|
|
195
358
|
verb: 'snapshot',
|
|
196
359
|
full: options?.full,
|
|
197
360
|
})) as Snapshot;
|
|
198
361
|
},
|
|
199
|
-
async click(target) {
|
|
200
|
-
|
|
362
|
+
async click(target, options) {
|
|
363
|
+
// Carry the optional ActionOptions only when given, so a plain click sends
|
|
364
|
+
// no `options` key (mirrors `eval`'s optional frame).
|
|
365
|
+
await send({
|
|
366
|
+
verb: 'click',
|
|
367
|
+
locator: target,
|
|
368
|
+
...(options !== undefined ? {options} : {}),
|
|
369
|
+
});
|
|
201
370
|
},
|
|
202
|
-
async type(target, text) {
|
|
203
|
-
await send({
|
|
371
|
+
async type(target, text, options) {
|
|
372
|
+
await send({
|
|
373
|
+
verb: 'type',
|
|
374
|
+
locator: target,
|
|
375
|
+
text,
|
|
376
|
+
...(options !== undefined ? {options} : {}),
|
|
377
|
+
});
|
|
204
378
|
},
|
|
205
|
-
async eval(expression) {
|
|
206
|
-
|
|
379
|
+
async eval(expression, options) {
|
|
380
|
+
// Carry the optional frame selector only when given, so the focused
|
|
381
|
+
// top-document form sends no `frame` key (mirrors `press`'s optional
|
|
382
|
+
// locator).
|
|
383
|
+
return send({
|
|
384
|
+
verb: 'eval',
|
|
385
|
+
expression,
|
|
386
|
+
...(options?.frame !== undefined ? {frame: options.frame} : {}),
|
|
387
|
+
});
|
|
207
388
|
},
|
|
208
389
|
async wait(condition) {
|
|
209
390
|
await send({verb: 'wait', condition});
|
|
@@ -214,6 +395,59 @@ export function makeRpcPage(
|
|
|
214
395
|
async setCookies(cookies) {
|
|
215
396
|
await send({verb: 'setCookies', cookies});
|
|
216
397
|
},
|
|
398
|
+
async query(target, options) {
|
|
399
|
+
return (await send({
|
|
400
|
+
verb: 'query',
|
|
401
|
+
locator: target,
|
|
402
|
+
options,
|
|
403
|
+
})) as QueryRow[];
|
|
404
|
+
},
|
|
405
|
+
async count(target) {
|
|
406
|
+
return (await send({verb: 'count', locator: target})) as number;
|
|
407
|
+
},
|
|
408
|
+
async exists(target) {
|
|
409
|
+
return (await send({verb: 'exists', locator: target})) as boolean;
|
|
410
|
+
},
|
|
411
|
+
async isVisible(target) {
|
|
412
|
+
return (await send({verb: 'isVisible', locator: target})) as boolean;
|
|
413
|
+
},
|
|
414
|
+
async getAttribute(target, name) {
|
|
415
|
+
return (await send({
|
|
416
|
+
verb: 'getAttribute',
|
|
417
|
+
locator: target,
|
|
418
|
+
name,
|
|
419
|
+
})) as string | null;
|
|
420
|
+
},
|
|
421
|
+
async press(key, target) {
|
|
422
|
+
// Forward the optional locator only when given, so the wire request stays
|
|
423
|
+
// minimal (the focused-element form carries no locator key).
|
|
424
|
+
await send({
|
|
425
|
+
verb: 'press',
|
|
426
|
+
key,
|
|
427
|
+
...(target !== undefined ? {locator: target} : {}),
|
|
428
|
+
});
|
|
429
|
+
},
|
|
430
|
+
async hover(target) {
|
|
431
|
+
await send({verb: 'hover', locator: target});
|
|
432
|
+
},
|
|
433
|
+
async select(target, choice) {
|
|
434
|
+
await send({verb: 'select', locator: target, choice});
|
|
435
|
+
},
|
|
436
|
+
async scroll(target) {
|
|
437
|
+
await send({verb: 'scroll', target});
|
|
438
|
+
},
|
|
439
|
+
async drag(source, target) {
|
|
440
|
+
await send({verb: 'drag', source, target});
|
|
441
|
+
},
|
|
442
|
+
async mouse(input) {
|
|
443
|
+
await send({verb: 'mouse', input});
|
|
444
|
+
},
|
|
445
|
+
async screenshot(options) {
|
|
446
|
+
return (await send({
|
|
447
|
+
verb: 'screenshot',
|
|
448
|
+
...(options !== undefined ? {options} : {}),
|
|
449
|
+
})) as Screenshot;
|
|
450
|
+
},
|
|
217
451
|
};
|
|
218
452
|
}
|
|
219
453
|
|
|
@@ -251,3 +485,31 @@ function rebrandWait(condition: WaitCondition): WaitCondition {
|
|
|
251
485
|
}
|
|
252
486
|
return condition;
|
|
253
487
|
}
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* Re-brand a {@link ScrollTarget} that arrived as plain JSON: only the `to` form
|
|
491
|
+
* carries a branded {@link LocatorString}, flattened to a plain `string` over
|
|
492
|
+
* the wire, so we re-tag it before it reaches the page (mirrors
|
|
493
|
+
* {@link rebrandWait}). The `by` form carries only plain numbers.
|
|
494
|
+
*/
|
|
495
|
+
function rebrandScroll(target: ScrollTarget): ScrollTarget {
|
|
496
|
+
if ('to' in target) {
|
|
497
|
+
return {to: locator(target.to)};
|
|
498
|
+
}
|
|
499
|
+
return target;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* Re-brand a {@link ScreenshotOptions} that arrived as plain JSON: only the
|
|
504
|
+
* `element`-scope `locator` carries a branded {@link LocatorString}, flattened
|
|
505
|
+
* to a plain `string` over the wire, so we re-tag it before it reaches the page
|
|
506
|
+
* (mirrors {@link rebrandWait}/{@link rebrandScroll}). `scope`/`out` are plain.
|
|
507
|
+
*/
|
|
508
|
+
function rebrandScreenshot(
|
|
509
|
+
options?: ScreenshotOptions,
|
|
510
|
+
): ScreenshotOptions | undefined {
|
|
511
|
+
if (options?.locator === undefined) {
|
|
512
|
+
return options;
|
|
513
|
+
}
|
|
514
|
+
return {...options, locator: locator(options.locator)};
|
|
515
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import {InvalidProxyError} from './errors.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A parsed SOCKS proxy ready to hand to Playwright/Chromium.
|
|
5
|
+
*
|
|
6
|
+
* This is the SINGLE place webhands turns a user-facing `--proxy` SOCKS URL into
|
|
7
|
+
* the concrete launch inputs Chromium needs: the `proxy.server`/credentials
|
|
8
|
+
* Playwright forwards, plus the extra command-line arg that forces DNS through
|
|
9
|
+
* the proxy (no DNS leak). Keeping the brittle parsing + Chromium-flag knowledge
|
|
10
|
+
* in one module mirrors how the launch transport confines its other
|
|
11
|
+
* Playwright/Chromium details.
|
|
12
|
+
*/
|
|
13
|
+
export interface ParsedSocksProxy {
|
|
14
|
+
/**
|
|
15
|
+
* The Playwright `proxy.server` value, always normalized to `socks5://host:port`.
|
|
16
|
+
*
|
|
17
|
+
* Chromium's `--proxy-server` understands `socks5://` but NOT the `socks5h://`
|
|
18
|
+
* convention, so we normalize the scheme here and carry the "resolve DNS at
|
|
19
|
+
* the proxy / block local DNS" intent separately in {@link noLeak} instead of
|
|
20
|
+
* in the scheme string.
|
|
21
|
+
*/
|
|
22
|
+
readonly server: string;
|
|
23
|
+
/** Optional proxy username (from a `user:pass@` userinfo component). */
|
|
24
|
+
readonly username?: string;
|
|
25
|
+
/** Optional proxy password (from a `user:pass@` userinfo component). */
|
|
26
|
+
readonly password?: string;
|
|
27
|
+
/** The proxy host, used to build the DNS catch-all EXCLUDE rule. */
|
|
28
|
+
readonly host: string;
|
|
29
|
+
/**
|
|
30
|
+
* Whether to enforce NO local DNS (force every hostname to resolve at the
|
|
31
|
+
* proxy). When `true`, the transport adds a `--host-resolver-rules` catch-all
|
|
32
|
+
* so even Chromium components that bypass `--proxy-server` (DNS prefetcher,
|
|
33
|
+
* etc.) cannot leak a raw DNS query (see {@link hostResolverRulesArg}).
|
|
34
|
+
*/
|
|
35
|
+
readonly noLeak: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The SOCKS schemes we accept on a `--proxy` value.
|
|
40
|
+
*
|
|
41
|
+
* - `socks5h://` is the widely-used convention meaning "resolve DNS at the
|
|
42
|
+
* proxy" (no local DNS, no leak). We map it to {@link ParsedSocksProxy.noLeak}
|
|
43
|
+
* `true`.
|
|
44
|
+
* - `socks5://` means "SOCKS5, local DNS allowed" by the same convention. NOTE:
|
|
45
|
+
* Chromium ALREADY resolves URL hostnames at the proxy under `--proxy-server`,
|
|
46
|
+
* but other components (the DNS prefetcher) can still issue raw local DNS, so
|
|
47
|
+
* plain `socks5://` does NOT by itself guarantee no leak.
|
|
48
|
+
*
|
|
49
|
+
* `socks://` is accepted as an alias for `socks5://` (some tools emit it).
|
|
50
|
+
*/
|
|
51
|
+
const SCHEME_NO_LEAK: Readonly<Record<string, boolean>> = {
|
|
52
|
+
'socks5h:': true,
|
|
53
|
+
'socks5:': false,
|
|
54
|
+
'socks:': false,
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Parse a user-facing `--proxy` SOCKS URL into a {@link ParsedSocksProxy}.
|
|
59
|
+
*
|
|
60
|
+
* Accepts `socks5h://`, `socks5://`, or `socks://` with a host and port and an
|
|
61
|
+
* optional `user:pass@` userinfo. Anything else (missing host/port, an http(s)
|
|
62
|
+
* proxy, a bare host with no scheme) is a typed {@link InvalidProxyError} so the
|
|
63
|
+
* caller refuses LOUDLY rather than launching unproxied.
|
|
64
|
+
*
|
|
65
|
+
* `forceNoLeak`, when set, overrides the scheme's implied DNS behaviour: pass
|
|
66
|
+
* `true` to enforce no local DNS even for a plain `socks5://` URL, or `false` to
|
|
67
|
+
* allow local DNS even for `socks5h://`. When omitted, the SCHEME decides
|
|
68
|
+
* (`socks5h` => no-leak, `socks5`/`socks` => local DNS allowed).
|
|
69
|
+
*/
|
|
70
|
+
export function parseSocksProxy(
|
|
71
|
+
value: string,
|
|
72
|
+
forceNoLeak?: boolean,
|
|
73
|
+
): ParsedSocksProxy {
|
|
74
|
+
const trimmed = value.trim();
|
|
75
|
+
if (trimmed === '') {
|
|
76
|
+
throw new InvalidProxyError(value);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
let url: URL;
|
|
80
|
+
try {
|
|
81
|
+
url = new URL(trimmed);
|
|
82
|
+
} catch (cause) {
|
|
83
|
+
throw new InvalidProxyError(value, undefined, {cause});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const schemeNoLeak = SCHEME_NO_LEAK[url.protocol];
|
|
87
|
+
if (schemeNoLeak === undefined) {
|
|
88
|
+
// Not a SOCKS scheme (e.g. http://, https://, socks4://, or no scheme).
|
|
89
|
+
throw new InvalidProxyError(value);
|
|
90
|
+
}
|
|
91
|
+
if (url.hostname === '' || url.port === '') {
|
|
92
|
+
// A host AND an explicit port are both required: Chromium's --proxy-server
|
|
93
|
+
// needs the port, and we will not guess a default.
|
|
94
|
+
throw new InvalidProxyError(value);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const noLeak = forceNoLeak ?? schemeNoLeak;
|
|
98
|
+
const server = `socks5://${url.hostname}:${url.port}`;
|
|
99
|
+
|
|
100
|
+
const proxy: ParsedSocksProxy = {
|
|
101
|
+
server,
|
|
102
|
+
host: url.hostname,
|
|
103
|
+
noLeak,
|
|
104
|
+
...(url.username !== ''
|
|
105
|
+
? {username: decodeURIComponent(url.username)}
|
|
106
|
+
: {}),
|
|
107
|
+
...(url.password !== ''
|
|
108
|
+
? {password: decodeURIComponent(url.password)}
|
|
109
|
+
: {}),
|
|
110
|
+
};
|
|
111
|
+
return proxy;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Build the Chromium `--host-resolver-rules` argument that prevents ANY local
|
|
116
|
+
* DNS resolution, the catch-all the Chromium SOCKS design doc prescribes for a
|
|
117
|
+
* leak-free SOCKS proxy.
|
|
118
|
+
*
|
|
119
|
+
* `MAP * ~NOTFOUND` maps every hostname to an invalid address so Chromium never
|
|
120
|
+
* issues a real local DNS query; `EXCLUDE <host>` lets Chromium still resolve
|
|
121
|
+
* the proxy server's own address (otherwise every request fails with
|
|
122
|
+
* PROXY_CONNECTION_FAILED). URL loads themselves resolve at the proxy via
|
|
123
|
+
* `--proxy-server`; this arg closes the side channels (DNS prefetcher, etc.).
|
|
124
|
+
*/
|
|
125
|
+
export function hostResolverRulesArg(host: string): string {
|
|
126
|
+
return `--host-resolver-rules=MAP * ~NOTFOUND , EXCLUDE ${host}`;
|
|
127
|
+
}
|
package/src/stub-transport.ts
CHANGED
|
@@ -1,13 +1,23 @@
|
|
|
1
1
|
import type {
|
|
2
|
+
ActionOptions,
|
|
2
3
|
Cookie,
|
|
4
|
+
EvalOptions,
|
|
5
|
+
MouseInput,
|
|
3
6
|
OpenTarget,
|
|
4
7
|
WebHandsPage,
|
|
8
|
+
QueryOptions,
|
|
9
|
+
QueryRow,
|
|
10
|
+
Screenshot,
|
|
11
|
+
ScreenshotOptions,
|
|
12
|
+
ScrollTarget,
|
|
13
|
+
SelectChoice,
|
|
5
14
|
Session,
|
|
6
15
|
Snapshot,
|
|
7
16
|
SnapshotOptions,
|
|
8
17
|
Transport,
|
|
9
18
|
WaitCondition,
|
|
10
19
|
} from './seam.js';
|
|
20
|
+
import {validateSnapshotOptions} from './seam.js';
|
|
11
21
|
|
|
12
22
|
/**
|
|
13
23
|
* A record of one verb call against the stub, for assertions in seam tests.
|
|
@@ -60,6 +70,7 @@ export class StubTransport implements Transport {
|
|
|
60
70
|
},
|
|
61
71
|
async snapshot(options?: SnapshotOptions): Promise<Snapshot> {
|
|
62
72
|
ensureOpen();
|
|
73
|
+
validateSnapshotOptions(options);
|
|
63
74
|
calls.push({verb: 'snapshot', args: [options]});
|
|
64
75
|
return {
|
|
65
76
|
url,
|
|
@@ -67,17 +78,26 @@ export class StubTransport implements Transport {
|
|
|
67
78
|
content: '',
|
|
68
79
|
};
|
|
69
80
|
},
|
|
70
|
-
async click(t): Promise<void> {
|
|
81
|
+
async click(t, options?: ActionOptions): Promise<void> {
|
|
71
82
|
ensureOpen();
|
|
72
|
-
|
|
83
|
+
// Record the ActionOptions only when given, so a plain-locator click
|
|
84
|
+
// stays `[t]` (the existing seam assertions) and a `{byRef}` click
|
|
85
|
+
// records `[t, {byRef: true}]`.
|
|
86
|
+
calls.push({
|
|
87
|
+
verb: 'click',
|
|
88
|
+
args: options !== undefined ? [t, options] : [t],
|
|
89
|
+
});
|
|
73
90
|
},
|
|
74
|
-
async type(t, text): Promise<void> {
|
|
91
|
+
async type(t, text, options?: ActionOptions): Promise<void> {
|
|
75
92
|
ensureOpen();
|
|
76
|
-
calls.push({
|
|
93
|
+
calls.push({
|
|
94
|
+
verb: 'type',
|
|
95
|
+
args: options !== undefined ? [t, text, options] : [t, text],
|
|
96
|
+
});
|
|
77
97
|
},
|
|
78
|
-
async eval(expression: string): Promise<unknown> {
|
|
98
|
+
async eval(expression: string, options?: EvalOptions): Promise<unknown> {
|
|
79
99
|
ensureOpen();
|
|
80
|
-
calls.push({verb: 'eval', args: [expression]});
|
|
100
|
+
calls.push({verb: 'eval', args: [expression, options]});
|
|
81
101
|
return undefined;
|
|
82
102
|
},
|
|
83
103
|
async wait(condition: WaitCondition): Promise<void> {
|
|
@@ -93,6 +113,63 @@ export class StubTransport implements Transport {
|
|
|
93
113
|
ensureOpen();
|
|
94
114
|
calls.push({verb: 'setCookies', args: [cookies]});
|
|
95
115
|
},
|
|
116
|
+
async query(t, options?: QueryOptions): Promise<QueryRow[]> {
|
|
117
|
+
ensureOpen();
|
|
118
|
+
calls.push({verb: 'query', args: [t, options]});
|
|
119
|
+
return [];
|
|
120
|
+
},
|
|
121
|
+
async count(t): Promise<number> {
|
|
122
|
+
ensureOpen();
|
|
123
|
+
calls.push({verb: 'count', args: [t]});
|
|
124
|
+
return 0;
|
|
125
|
+
},
|
|
126
|
+
async exists(t): Promise<boolean> {
|
|
127
|
+
ensureOpen();
|
|
128
|
+
calls.push({verb: 'exists', args: [t]});
|
|
129
|
+
return false;
|
|
130
|
+
},
|
|
131
|
+
async isVisible(t): Promise<boolean> {
|
|
132
|
+
ensureOpen();
|
|
133
|
+
calls.push({verb: 'isVisible', args: [t]});
|
|
134
|
+
return false;
|
|
135
|
+
},
|
|
136
|
+
async getAttribute(t, name): Promise<string | null> {
|
|
137
|
+
ensureOpen();
|
|
138
|
+
calls.push({verb: 'getAttribute', args: [t, name]});
|
|
139
|
+
return null;
|
|
140
|
+
},
|
|
141
|
+
async press(key: string, t): Promise<void> {
|
|
142
|
+
ensureOpen();
|
|
143
|
+
calls.push({verb: 'press', args: [key, t]});
|
|
144
|
+
},
|
|
145
|
+
async hover(t): Promise<void> {
|
|
146
|
+
ensureOpen();
|
|
147
|
+
calls.push({verb: 'hover', args: [t]});
|
|
148
|
+
},
|
|
149
|
+
async select(t, choice: SelectChoice): Promise<void> {
|
|
150
|
+
ensureOpen();
|
|
151
|
+
calls.push({verb: 'select', args: [t, choice]});
|
|
152
|
+
},
|
|
153
|
+
async scroll(t: ScrollTarget): Promise<void> {
|
|
154
|
+
ensureOpen();
|
|
155
|
+
calls.push({verb: 'scroll', args: [t]});
|
|
156
|
+
},
|
|
157
|
+
async drag(source, t): Promise<void> {
|
|
158
|
+
ensureOpen();
|
|
159
|
+
calls.push({verb: 'drag', args: [source, t]});
|
|
160
|
+
},
|
|
161
|
+
async mouse(input: MouseInput): Promise<void> {
|
|
162
|
+
ensureOpen();
|
|
163
|
+
calls.push({verb: 'mouse', args: [input]});
|
|
164
|
+
},
|
|
165
|
+
async screenshot(options?: ScreenshotOptions): Promise<Screenshot> {
|
|
166
|
+
ensureOpen();
|
|
167
|
+
calls.push({verb: 'screenshot', args: [options]});
|
|
168
|
+
// A deterministic stand-in path/dimensions so a wiring test can assert
|
|
169
|
+
// the verb round-trip + the attachment-capable `path` field WITHOUT a
|
|
170
|
+
// real browser (no PNG is written; the stub implements no behaviour).
|
|
171
|
+
return {path: 'stub://screenshot.png', width: 0, height: 0};
|
|
172
|
+
},
|
|
96
173
|
};
|
|
97
174
|
|
|
98
175
|
return {
|