@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.
Files changed (59) hide show
  1. package/README.md +69 -6
  2. package/dist/errors.d.ts +112 -1
  3. package/dist/errors.d.ts.map +1 -1
  4. package/dist/errors.js +121 -0
  5. package/dist/errors.js.map +1 -1
  6. package/dist/hand-host.d.ts +198 -5
  7. package/dist/hand-host.d.ts.map +1 -1
  8. package/dist/hand-host.js +664 -21
  9. package/dist/hand-host.js.map +1 -1
  10. package/dist/index.d.ts +5 -4
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +4 -3
  13. package/dist/index.js.map +1 -1
  14. package/dist/playwright-attach-transport.d.ts +8 -1
  15. package/dist/playwright-attach-transport.d.ts.map +1 -1
  16. package/dist/playwright-attach-transport.js +19 -4
  17. package/dist/playwright-attach-transport.js.map +1 -1
  18. package/dist/playwright-launch-transport.d.ts +23 -0
  19. package/dist/playwright-launch-transport.d.ts.map +1 -1
  20. package/dist/playwright-launch-transport.js +40 -6
  21. package/dist/playwright-launch-transport.js.map +1 -1
  22. package/dist/profile-location.d.ts +19 -0
  23. package/dist/profile-location.d.ts.map +1 -1
  24. package/dist/profile-location.js +21 -0
  25. package/dist/profile-location.js.map +1 -1
  26. package/dist/seam.d.ts +501 -7
  27. package/dist/seam.d.ts.map +1 -1
  28. package/dist/seam.js +31 -0
  29. package/dist/seam.js.map +1 -1
  30. package/dist/session-rpc.d.ts +63 -1
  31. package/dist/session-rpc.d.ts.map +1 -1
  32. package/dist/session-rpc.js +174 -11
  33. package/dist/session-rpc.js.map +1 -1
  34. package/dist/socks-proxy.d.ts +61 -0
  35. package/dist/socks-proxy.d.ts.map +1 -0
  36. package/dist/socks-proxy.js +84 -0
  37. package/dist/socks-proxy.js.map +1 -0
  38. package/dist/stub-transport.d.ts.map +1 -1
  39. package/dist/stub-transport.js +74 -6
  40. package/dist/stub-transport.js.map +1 -1
  41. package/dist/test-fixtures/fixture-pages.d.ts.map +1 -1
  42. package/dist/test-fixtures/fixture-pages.js +994 -0
  43. package/dist/test-fixtures/fixture-pages.js.map +1 -1
  44. package/dist/test-fixtures/fixture-server.d.ts.map +1 -1
  45. package/dist/test-fixtures/fixture-server.js +33 -3
  46. package/dist/test-fixtures/fixture-server.js.map +1 -1
  47. package/package.json +1 -1
  48. package/src/errors.ts +164 -1
  49. package/src/hand-host.ts +797 -21
  50. package/src/index.ts +27 -1
  51. package/src/playwright-attach-transport.ts +25 -3
  52. package/src/playwright-launch-transport.ts +63 -4
  53. package/src/profile-location.ts +25 -0
  54. package/src/seam.ts +535 -7
  55. package/src/session-rpc.ts +276 -14
  56. package/src/socks-proxy.ts +127 -0
  57. package/src/stub-transport.ts +83 -6
  58. package/src/test-fixtures/fixture-pages.ts +1010 -0
  59. package/src/test-fixtures/fixture-server.ts +32 -3
package/src/index.ts CHANGED
@@ -1,8 +1,22 @@
1
1
  export type {
2
+ ActionOptions,
3
+ BoundingBox,
2
4
  Cookie,
3
5
  Driver,
6
+ EvalOptions,
4
7
  LocatorString,
8
+ MouseAction,
9
+ MouseButton,
10
+ MouseInput,
5
11
  OpenTarget,
12
+ PwExtra,
13
+ QueryOptions,
14
+ QueryRow,
15
+ Screenshot,
16
+ ScreenshotOptions,
17
+ ScreenshotScope,
18
+ ScrollTarget,
19
+ SelectChoice,
6
20
  WebHandsPage,
7
21
  Session,
8
22
  Snapshot,
@@ -11,7 +25,7 @@ export type {
11
25
  Transport,
12
26
  WaitCondition,
13
27
  } from './seam.js';
14
- export {locator} from './seam.js';
28
+ export {locator, validateSnapshotOptions} from './seam.js';
15
29
 
16
30
  export {
17
31
  serializeCookies,
@@ -42,6 +56,12 @@ export {
42
56
  type StealthChromiumImporter,
43
57
  } from './playwright-launch-transport.js';
44
58
 
59
+ export {
60
+ parseSocksProxy,
61
+ hostResolverRulesArg,
62
+ type ParsedSocksProxy,
63
+ } from './socks-proxy.js';
64
+
45
65
  export {PlaywrightAttachTransport} from './playwright-attach-transport.js';
46
66
 
47
67
  export {
@@ -56,11 +76,15 @@ export {
56
76
  ControllerError,
57
77
  MissingBrowserBinaryError,
58
78
  MissingStealthDependencyError,
79
+ InvalidProxyError,
59
80
  MissingProfileError,
60
81
  AttachNotChromiumError,
61
82
  AttachNoContextError,
62
83
  NoLiveServerError,
63
84
  SessionAlreadyActiveError,
85
+ CrossOriginFrameError,
86
+ ScreenshotPathError,
87
+ StaleRefError,
64
88
  isControllerError,
65
89
  type ControllerErrorCode,
66
90
  } from './errors.js';
@@ -97,9 +121,11 @@ export {
97
121
  export {
98
122
  resolveHomeRoot,
99
123
  resolveProfileLocation,
124
+ resolveScreenshotsDir,
100
125
  CONTROLLER_HOME_ENV,
101
126
  DEFAULT_HOME_DIRNAME,
102
127
  PROFILES_DIRNAME,
128
+ SCREENSHOTS_DIRNAME,
103
129
  type ProfileLocation,
104
130
  type ProfileLocationOptions,
105
131
  } from './profile-location.js';
@@ -6,6 +6,10 @@ import {
6
6
  } from 'playwright';
7
7
  import {AttachNoContextError, AttachNotChromiumError} from './errors.js';
8
8
  import {composeWithHands, type Hand, type HandContext} from './hand-host.js';
9
+ import {
10
+ resolveScreenshotsDir,
11
+ type ProfileLocationOptions,
12
+ } from './profile-location.js';
9
13
  import type {OpenTarget, Session, Transport} from './seam.js';
10
14
 
11
15
  /**
@@ -33,15 +37,26 @@ import type {OpenTarget, Session, Transport} from './seam.js';
33
37
  */
34
38
  export class PlaywrightAttachTransport implements Transport {
35
39
  readonly #hands: readonly Hand[];
40
+ readonly #location: ProfileLocationOptions;
36
41
 
37
42
  /**
38
43
  * @param hands explicitly-loaded third-party hands to compose alongside the
39
44
  * built-ins (Phase 2, ADR-0007). These come from {@link loadHands} against
40
45
  * the operator's explicit config; the transport does NOT discover them. Omit
41
46
  * for the built-ins-only surface.
47
+ * @param location overrides for the controller home root, used ONLY to resolve
48
+ * the managed SCREENSHOTS dir (`<homeRoot>/screenshots`) the Tier-4
49
+ * `screenshot` verb mints under — attach reuses the user's own browser, so it
50
+ * owns no profile dir, but the screenshot output location still honours the
51
+ * same `root`/`WEBHANDS_HOME` override so a test can isolate it. Omit in
52
+ * production to use `~/.webhands/screenshots`.
42
53
  */
43
- constructor(hands: readonly Hand[] = []) {
54
+ constructor(
55
+ hands: readonly Hand[] = [],
56
+ location: ProfileLocationOptions = {},
57
+ ) {
44
58
  this.#hands = hands;
59
+ this.#location = location;
45
60
  }
46
61
 
47
62
  async open(target: OpenTarget): Promise<Session> {
@@ -77,7 +92,8 @@ export class PlaywrightAttachTransport implements Transport {
77
92
  // browser exposes a context with no page yet (single active session in
78
93
  // v1, PRD Out of Scope).
79
94
  const pwPage = context.pages()[0] ?? (await context.newPage());
80
- return makeAttachedSession(browser, pwPage, this.#hands);
95
+ const screenshotsDir = resolveScreenshotsDir(this.#location);
96
+ return makeAttachedSession(browser, pwPage, this.#hands, screenshotsDir);
81
97
  } catch (cause) {
82
98
  // On any open-time refusal, disconnect from the user's browser without
83
99
  // closing it (a CDP connection close detaches; it does not kill the
@@ -107,6 +123,7 @@ function makeAttachedSession(
107
123
  browser: Browser,
108
124
  pwPage: Page,
109
125
  extraHands: readonly Hand[],
126
+ screenshotsDir: string,
110
127
  ): Session {
111
128
  const context: BrowserContext = pwPage.context();
112
129
  let closed = false;
@@ -134,7 +151,12 @@ function makeAttachedSession(
134
151
  // the same shared host the launch transport uses, so the verbs behave
135
152
  // identically across both transports. The live `pwPage`/`context` stay
136
153
  // in-process and never cross the seam (ADR-0003).
137
- const handContext: HandContext = {pwPage, context, ensureOpen};
154
+ const handContext: HandContext = {
155
+ pwPage,
156
+ context,
157
+ ensureOpen,
158
+ screenshotsDir,
159
+ };
138
160
  const {page, dispose: disposeHands} = composeWithHands(
139
161
  handContext,
140
162
  extraHands,
@@ -8,8 +8,10 @@ import {
8
8
  import {composeWithHands, type Hand, type HandContext} from './hand-host.js';
9
9
  import {
10
10
  resolveProfileLocation,
11
+ resolveScreenshotsDir,
11
12
  type ProfileLocationOptions,
12
13
  } from './profile-location.js';
14
+ import {hostResolverRulesArg, parseSocksProxy} from './socks-proxy.js';
13
15
  import type {OpenTarget, Session, Transport} from './seam.js';
14
16
 
15
17
  /**
@@ -116,6 +118,29 @@ export interface PlaywrightLaunchTransportOptions {
116
118
  * Default: none.
117
119
  */
118
120
  readonly ignoreDefaultArgs?: boolean | readonly string[];
121
+ /**
122
+ * Route ALL browser traffic AND DNS through a single SOCKS proxy, given as a
123
+ * SOCKS URL: `socks5h://host:1080` (or `socks5://host:1080`, optionally with a
124
+ * `user:pass@` userinfo). When set, the transport forwards the proxy to
125
+ * Playwright's `proxy` launch option AND adds Chromium's `--host-resolver-rules`
126
+ * catch-all so no DNS query escapes locally (see {@link proxyNoLeak}).
127
+ *
128
+ * Scheme convention: `socks5h://` means "resolve DNS at the proxy" (no leak),
129
+ * `socks5://` means "SOCKS5, local DNS allowed" (Chromium still resolves URL
130
+ * hostnames at the proxy, but its DNS prefetcher etc. may issue local DNS). Use
131
+ * {@link proxyNoLeak} to override the scheme's implied DNS behaviour. A
132
+ * malformed value throws the typed {@link InvalidProxyError} rather than
133
+ * launching unproxied. Default: no proxy.
134
+ */
135
+ readonly proxy?: string;
136
+ /**
137
+ * Override whether the {@link proxy} enforces NO local DNS. `true` forces the
138
+ * leak-free catch-all even for a plain `socks5://` URL; `false` allows local
139
+ * DNS even for a `socks5h://` URL. When omitted, the SCHEME decides
140
+ * (`socks5h` => no leak, `socks5`/`socks` => local DNS allowed). Ignored when
141
+ * {@link proxy} is unset.
142
+ */
143
+ readonly proxyNoLeak?: boolean;
119
144
  /**
120
145
  * INTERNAL test seam: override how the stealth chromium is imported. Omit in
121
146
  * production (defaults to `import('patchright')`). See
@@ -173,6 +198,8 @@ export class PlaywrightLaunchTransport implements Transport {
173
198
  readonly #noViewport: boolean | undefined;
174
199
  readonly #extraLaunchArgs: readonly string[] | undefined;
175
200
  readonly #ignoreDefaultArgs: boolean | readonly string[] | undefined;
201
+ readonly #proxy: string | undefined;
202
+ readonly #proxyNoLeak: boolean | undefined;
176
203
  readonly #importStealthChromium: StealthChromiumImporter;
177
204
 
178
205
  /**
@@ -202,6 +229,8 @@ export class PlaywrightLaunchTransport implements Transport {
202
229
  this.#noViewport = options.noViewport;
203
230
  this.#extraLaunchArgs = options.extraLaunchArgs;
204
231
  this.#ignoreDefaultArgs = options.ignoreDefaultArgs;
232
+ this.#proxy = options.proxy;
233
+ this.#proxyNoLeak = options.proxyNoLeak;
205
234
  this.#importStealthChromium =
206
235
  options.importStealthChromium ?? defaultStealthImporter;
207
236
  }
@@ -266,15 +295,35 @@ export class PlaywrightLaunchTransport implements Transport {
266
295
  } else if (this.#stealth) {
267
296
  launchOptions.ignoreDefaultArgs = ['--enable-automation'];
268
297
  }
298
+ // Proxy: route ALL traffic + DNS through one SOCKS proxy. We parse the URL
299
+ // HERE (a malformed value is the typed InvalidProxyError, never a silent
300
+ // unproxied launch), forward it to Playwright's `proxy` option, and when
301
+ // no-leak is in effect add Chromium's --host-resolver-rules catch-all so even
302
+ // the DNS prefetcher cannot leak a raw local DNS query.
303
+ const hardeningArgs: string[] = [];
304
+ if (this.#proxy !== undefined && this.#proxy.trim() !== '') {
305
+ const parsed = parseSocksProxy(this.#proxy, this.#proxyNoLeak);
306
+ launchOptions.proxy = {
307
+ server: parsed.server,
308
+ ...(parsed.username !== undefined ? {username: parsed.username} : {}),
309
+ ...(parsed.password !== undefined ? {password: parsed.password} : {}),
310
+ };
311
+ if (parsed.noLeak) {
312
+ hardeningArgs.push(hostResolverRulesArg(parsed.host));
313
+ }
314
+ }
269
315
  // Extra launch args (the hardening escape hatch) are appended verbatim. We do
270
316
  // NOT set user-agent/locale/timezone/headers here: a wrong UA is a bigger
271
317
  // tell than none (Patchright warns against overriding them), so those stay
272
- // untouched by default.
318
+ // untouched by default. The proxy's no-leak DNS arg (if any) rides alongside.
273
319
  if (
274
320
  this.#extraLaunchArgs !== undefined &&
275
321
  this.#extraLaunchArgs.length > 0
276
322
  ) {
277
- launchOptions.args = [...this.#extraLaunchArgs];
323
+ hardeningArgs.push(...this.#extraLaunchArgs);
324
+ }
325
+ if (hardeningArgs.length > 0) {
326
+ launchOptions.args = hardeningArgs;
278
327
  }
279
328
 
280
329
  let context: BrowserContext;
@@ -298,7 +347,11 @@ export class PlaywrightLaunchTransport implements Transport {
298
347
  // the single active page (PRD: single active session in v1). Create one if
299
348
  // the build ever changes that invariant.
300
349
  const pwPage = context.pages()[0] ?? (await context.newPage());
301
- return makeSession(context, pwPage, this.#hands);
350
+ // The managed screenshots dir resolves from the SAME location override as
351
+ // profiles (`<homeRoot>/screenshots`), so a test's temp `root` isolates
352
+ // screenshots too and the real `~/.webhands/screenshots` stays untouched.
353
+ const screenshotsDir = resolveScreenshotsDir(this.#location);
354
+ return makeSession(context, pwPage, this.#hands, screenshotsDir);
302
355
  }
303
356
 
304
357
  /**
@@ -379,6 +432,7 @@ function makeSession(
379
432
  context: BrowserContext,
380
433
  pwPage: Page,
381
434
  extraHands: readonly Hand[],
435
+ screenshotsDir: string,
382
436
  ): Session {
383
437
  let closed = false;
384
438
  const ensureOpen = () => {
@@ -405,7 +459,12 @@ function makeSession(
405
459
  // Build the verb surface from the built-in hands over a live hand-context.
406
460
  // The host keeps the live `pwPage`/`context` in-process (they never cross the
407
461
  // seam, ADR-0003); the hand-context carries live page access only.
408
- const handContext: HandContext = {pwPage, context, ensureOpen};
462
+ const handContext: HandContext = {
463
+ pwPage,
464
+ context,
465
+ ensureOpen,
466
+ screenshotsDir,
467
+ };
409
468
  const {page, dispose: disposeHands} = composeWithHands(
410
469
  handContext,
411
470
  extraHands,
@@ -30,6 +30,16 @@ export const CONTROLLER_HOME_ENV = 'WEBHANDS_HOME';
30
30
  /** The subdirectory under the home root that holds dedicated profiles. */
31
31
  export const PROFILES_DIRNAME = 'profiles';
32
32
 
33
+ /**
34
+ * The subdirectory under the home root where the `screenshot` verb MINTS its
35
+ * PNG files (the Tier-4 managed screenshots dir, prd
36
+ * `broaden-agent-verb-surface`, R3). It lives BESIDE `profiles/` under the SAME
37
+ * overridable home root, so the same `root`/`WEBHANDS_HOME` override that
38
+ * isolates profiles in a test also isolates screenshots — nothing writes to the
39
+ * real `~/.webhands/screenshots` unless the home root points there.
40
+ */
41
+ export const SCREENSHOTS_DIRNAME = 'screenshots';
42
+
33
43
  /** Inputs that influence where a profile resolves (all optional, for tests). */
34
44
  export interface ProfileLocationOptions {
35
45
  /**
@@ -90,3 +100,18 @@ export function resolveProfileLocation(
90
100
  profile,
91
101
  };
92
102
  }
103
+
104
+ /**
105
+ * Resolve the managed SCREENSHOTS directory (`<homeRoot>/screenshots`) the
106
+ * `screenshot` verb mints PNGs under (prd `broaden-agent-verb-surface`, R3).
107
+ * Like {@link resolveProfileLocation} it is PURE (creates no directory) and
108
+ * honours the same `root`/`WEBHANDS_HOME` precedence, so a test that points the
109
+ * home root at a temp dir isolates screenshots there and the real
110
+ * `~/.webhands/screenshots` stays untouched. The verb (in the transport) is
111
+ * responsible for creating the dir lazily on first write.
112
+ */
113
+ export function resolveScreenshotsDir(
114
+ options: ProfileLocationOptions = {},
115
+ ): string {
116
+ return join(resolveHomeRoot(options), SCREENSHOTS_DIRNAME);
117
+ }