@webhands/core 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +177 -0
  3. package/dist/cookies-export.d.ts +5 -5
  4. package/dist/cookies-export.d.ts.map +1 -1
  5. package/dist/cookies-export.js +4 -4
  6. package/dist/errors.d.ts +24 -1
  7. package/dist/errors.d.ts.map +1 -1
  8. package/dist/errors.js +24 -0
  9. package/dist/errors.js.map +1 -1
  10. package/dist/hand-host.d.ts +217 -0
  11. package/dist/hand-host.d.ts.map +1 -0
  12. package/dist/hand-host.js +351 -0
  13. package/dist/hand-host.js.map +1 -0
  14. package/dist/hand-loading.d.ts +128 -0
  15. package/dist/hand-loading.d.ts.map +1 -0
  16. package/dist/hand-loading.js +143 -0
  17. package/dist/hand-loading.js.map +1 -0
  18. package/dist/index.d.ts +6 -4
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +4 -3
  21. package/dist/index.js.map +1 -1
  22. package/dist/playwright-attach-transport.d.ts +9 -0
  23. package/dist/playwright-attach-transport.d.ts.map +1 -1
  24. package/dist/playwright-attach-transport.js +53 -91
  25. package/dist/playwright-attach-transport.js.map +1 -1
  26. package/dist/playwright-launch-transport.d.ts +81 -62
  27. package/dist/playwright-launch-transport.d.ts.map +1 -1
  28. package/dist/playwright-launch-transport.js +143 -210
  29. package/dist/playwright-launch-transport.js.map +1 -1
  30. package/dist/remote-session.d.ts +12 -2
  31. package/dist/remote-session.d.ts.map +1 -1
  32. package/dist/remote-session.js +37 -6
  33. package/dist/remote-session.js.map +1 -1
  34. package/dist/seam.d.ts +13 -5
  35. package/dist/seam.d.ts.map +1 -1
  36. package/dist/session-rpc.d.ts +76 -12
  37. package/dist/session-rpc.d.ts.map +1 -1
  38. package/dist/session-rpc.js +76 -8
  39. package/dist/session-rpc.js.map +1 -1
  40. package/dist/stub-transport.d.ts +2 -2
  41. package/dist/stub-transport.d.ts.map +1 -1
  42. package/dist/stub-transport.js +11 -0
  43. package/dist/stub-transport.js.map +1 -1
  44. package/package.json +24 -2
  45. package/src/cookies-export.ts +5 -5
  46. package/src/errors.ts +31 -0
  47. package/src/hand-host.ts +511 -0
  48. package/src/hand-loading.ts +254 -0
  49. package/src/index.ts +24 -2
  50. package/src/playwright-attach-transport.ts +65 -119
  51. package/src/playwright-launch-transport.ts +235 -249
  52. package/src/remote-session.ts +43 -5
  53. package/src/seam.ts +13 -5
  54. package/src/session-rpc.ts +121 -11
  55. package/src/stub-transport.ts +15 -3
@@ -0,0 +1,511 @@
1
+ import {errors as pwErrors, type BrowserContext, type Page} from 'playwright';
2
+ import type {
3
+ Cookie,
4
+ WebHandsPage,
5
+ Snapshot,
6
+ SnapshotOptions,
7
+ WaitCondition,
8
+ } from './seam.js';
9
+
10
+ /**
11
+ * The hand-host primitive (Phase 1 of the "hands" prd,
12
+ * `work/prds/tasked/hands-pluggable-page-capabilities.md`).
13
+ *
14
+ * A **hand** is in-process code that closes over the WebHandsPage and contributes named
15
+ * verbs (+ an optional `dispose`). This module is the host: it builds the
16
+ * scoped-but-LIVE {@link HandContext} from the live Playwright objects, lets
17
+ * each hand contribute its verbs, and composes them into the same {@link WebHandsPage}
18
+ * object the seam already exposes (see {@link composePage}).
19
+ *
20
+ * webhands' OWN eight verbs are themselves built-in hands over this host
21
+ * ({@link BUILT_IN_HANDS}), so the primitive is proven by self-application: if
22
+ * it can express webhands' `click`/`snapshot`/`cookies`, it can host a
23
+ * third-party hand the same way (Phase 2). This is a purely INTERNAL,
24
+ * behavior-preserving refactor — the verb composition that lived as a
25
+ * duplicated `page` object literal in BOTH Playwright transports now lives here
26
+ * once.
27
+ *
28
+ * INTERNAL-ONLY BOUNDARY (the prd's resolved Q2): this whole module is
29
+ * package-internal. {@link Hand}/{@link HandContext}/{@link composePage} are
30
+ * NOT exported from the package entry point (`index.ts`) in Phase 1; they go
31
+ * public in the separate Phase 2 task. The public seam (`seam.ts`) is
32
+ * unchanged.
33
+ *
34
+ * NO-LEAK / CROSS-BROWSER (ADR-0003, refined by the prd): the host is built
35
+ * INSIDE the Playwright transport(s) and uses only the Playwright
36
+ * `Page`/`BrowserContext` API — no CDP/Chromium-only types — so the live
37
+ * `pwPage` stays in-process and never crosses the seam, and the host introduces
38
+ * no Chromium-only dependency that would foreclose a future Firefox launch
39
+ * (only CDP-`attach` stays Chromium-bound, as today).
40
+ *
41
+ * TRUST MODEL (stated, not enforced here): hands are trusted, local, in-process
42
+ * peers with ZERO isolation between them (one live page, one process).
43
+ * Inter-hand reuse is ordinary Node composition (import & call), NOT a
44
+ * sibling-hand registry in the context — so {@link HandContext} carries live
45
+ * page access only.
46
+ */
47
+
48
+ /**
49
+ * The scoped-but-LIVE access a hand receives. It carries live page access ONLY
50
+ * (the trust model note above): the real Playwright {@link Page} and
51
+ * {@link BrowserContext} the hand operates against in-process, plus the
52
+ * lifecycle guard.
53
+ *
54
+ * - `pwPage` — the live Node-side Playwright `Page`. NEVER crosses the seam.
55
+ * - `context` — the live `BrowserContext`; the built-in `cookies`/`setCookies`
56
+ * hand proves it is needed (cookies are a context-level, not page-level,
57
+ * concern).
58
+ * - `ensureOpen` — the per-session lifecycle guard. Each verb calls it first so
59
+ * a verb invoked after the session closed rejects with `session is closed`
60
+ * (the seam's lifetime contract). The guard's "closed" state is owned by the
61
+ * per-transport session wiring (launch vs attach differ); the host only reads
62
+ * it through this function.
63
+ */
64
+ export interface HandContext {
65
+ readonly pwPage: Page;
66
+ readonly context: BrowserContext;
67
+ readonly ensureOpen: () => void;
68
+ }
69
+
70
+ /**
71
+ * What a hand contributes once given its {@link HandContext}: a set of named
72
+ * verbs (a subset of webhands' (eight) seam verbs, i.e. a `Partial` of the
73
+ * seam {@link WebHandsPage}) and an optional `dispose` for any in-process
74
+ * resource it set up.
75
+ *
76
+ * A hand may contribute several verbs (the built-in interaction hand contributes
77
+ * both `click` and `type`) — a hand is NOT a single verb. It is NOT a transport
78
+ * either: it does not `open` sessions. Nothing more than this is allowed (no
79
+ * lifecycle hooks, no event handlers, no MCP-definition objects) — those are
80
+ * either the transport's job (session lifecycle) or a later phase's.
81
+ */
82
+ export interface HandContribution {
83
+ readonly verbs: Partial<WebHandsPage>;
84
+ readonly dispose?: () => Promise<void> | void;
85
+ }
86
+
87
+ /**
88
+ * A hand: a capability MODULE that, given live page access, contributes verbs.
89
+ * It is a plain factory function so a hand is just ordinary in-process Node
90
+ * code closing over the {@link HandContext} — the exact shape webhands' own
91
+ * verbs already had, made explicit.
92
+ */
93
+ export type Hand = (ctx: HandContext) => HandContribution;
94
+
95
+ /**
96
+ * The composed result the host hands back to a transport's session wiring: the
97
+ * {@link WebHandsPage} (the seam object the verbs were merged into) and a single
98
+ * `dispose` that tears down every hand.
99
+ */
100
+ export interface ComposedHands {
101
+ readonly page: WebHandsPage;
102
+ /**
103
+ * Dispose every hand's resources. Hands are disposed in REVERSE registration
104
+ * order (LIFO, the natural teardown order for layered setup), and every
105
+ * hand's `dispose` is awaited even if an earlier one rejects, so one failing
106
+ * hand cannot strand another's cleanup. This disposes the HANDS only; tearing
107
+ * down the browser/context (and the order relative to this) is the
108
+ * per-transport session lifecycle's job, NOT the host's.
109
+ */
110
+ dispose(): Promise<void>;
111
+ }
112
+
113
+ /**
114
+ * Compose a set of hands over one live {@link HandContext} into a single
115
+ * {@link WebHandsPage}. This is the host primitive both Playwright transports call to
116
+ * build their session's verb surface — the SINGLE shared composition (no
117
+ * duplicated page-object literal).
118
+ *
119
+ * Composition is EAGER (exactly as the page object literal was built before):
120
+ * each hand is invoked once at session-open time and its verbs are merged into
121
+ * one page object. There is no lazy registration and no ordering effect on the
122
+ * verbs themselves (the eight built-in verbs have disjoint names). The returned
123
+ * {@link WebHandsPage} is validated to carry every verb the seam requires, so a missing
124
+ * built-in verb is a build-time/open-time failure here rather than an `undefined
125
+ * is not a function` at the call site.
126
+ */
127
+ export function composePage(
128
+ ctx: HandContext,
129
+ hands: readonly Hand[],
130
+ ): ComposedHands {
131
+ const verbs: Partial<WebHandsPage> = {};
132
+ const disposers: Array<NonNullable<HandContribution['dispose']>> = [];
133
+
134
+ for (const hand of hands) {
135
+ const contribution = hand(ctx);
136
+ Object.assign(verbs, contribution.verbs);
137
+ if (contribution.dispose !== undefined) {
138
+ disposers.push(contribution.dispose);
139
+ }
140
+ }
141
+
142
+ const page = assertCompletePage(verbs);
143
+
144
+ return {
145
+ page,
146
+ async dispose(): Promise<void> {
147
+ // LIFO teardown; await every disposer even if one rejects so a single
148
+ // failing hand cannot strand the others' cleanup.
149
+ const failures: unknown[] = [];
150
+ for (let i = disposers.length - 1; i >= 0; i--) {
151
+ try {
152
+ await disposers[i]!();
153
+ } catch (cause) {
154
+ failures.push(cause);
155
+ }
156
+ }
157
+ if (failures.length > 0) {
158
+ throw failures[0];
159
+ }
160
+ },
161
+ };
162
+ }
163
+
164
+ /** The seam's full verb set; used to validate a composed page is complete. */
165
+ const REQUIRED_VERBS = [
166
+ 'navigate',
167
+ 'snapshot',
168
+ 'click',
169
+ 'type',
170
+ 'eval',
171
+ 'wait',
172
+ 'cookies',
173
+ 'setCookies',
174
+ ] as const satisfies ReadonlyArray<keyof WebHandsPage>;
175
+
176
+ /**
177
+ * Assert the composed verbs cover the whole seam {@link WebHandsPage}, then return it
178
+ * as a `WebHandsPage`. A gap here means a built-in hand was dropped from the
179
+ * composition — surfacing it at open time is far cheaper than a runtime
180
+ * `undefined is not a function`.
181
+ */
182
+ function assertCompletePage(verbs: Partial<WebHandsPage>): WebHandsPage {
183
+ const missing = REQUIRED_VERBS.filter(
184
+ (name) => typeof verbs[name] !== 'function',
185
+ );
186
+ if (missing.length > 0) {
187
+ throw new Error(
188
+ `hand-host: composed page is missing verb(s): ${missing.join(', ')}`,
189
+ );
190
+ }
191
+ return verbs as WebHandsPage;
192
+ }
193
+
194
+ /**
195
+ * How long a normal, actionability-checked `click` may wait before we treat the
196
+ * element as un-clickable and fall back to a dispatched click. Short on purpose:
197
+ * a hidden custom input never becomes actionable, so the regular click would
198
+ * otherwise burn Playwright's full default timeout (30s) before the escape path
199
+ * runs. The visible-element happy path clicks immediately and never hits this;
200
+ * this bound is the latency cost paid ONLY on the hidden/non-actionable path,
201
+ * and is long enough to tolerate a slow-but-eventually-actionable element
202
+ * (animations, late layout) before deciding to dispatch.
203
+ */
204
+ const NORMAL_CLICK_TIMEOUT_MS = 1_000;
205
+
206
+ // ---------------------------------------------------------------------------
207
+ // Built-in hands: webhands' OWN eight verbs, each a hand over the host.
208
+ //
209
+ // Grouped into cohesive capability modules (navigation, snapshot, interaction,
210
+ // eval, wait, cookies) to demonstrate that a hand can contribute several verbs
211
+ // + in-process logic (it is NOT one-verb-per-hand). The verb BODIES are moved
212
+ // verbatim from the two transports' page-object literals, so behavior is
213
+ // preserved byte-for-byte (the existing verb suite is the proof).
214
+ // ---------------------------------------------------------------------------
215
+
216
+ /** The `navigate` verb: go to a URL and let it settle on the `load` event. */
217
+ export const navigationHand: Hand = ({pwPage, ensureOpen}) => ({
218
+ verbs: {
219
+ async navigate(url: string): Promise<void> {
220
+ ensureOpen();
221
+ // "Settled" for `goto` = the `load` event: the document and its
222
+ // subresources have loaded (PRD story 6, "navigate ... and wait for it
223
+ // to settle"). We deliberately do NOT wait for `networkidle`:
224
+ // Playwright discourages it, and it hangs forever on pages with
225
+ // long-poll / streaming / analytics beacons (exactly the logged-in apps
226
+ // this tool targets). Content rendered AFTER load (XHR-injected prices,
227
+ // hydrated lists) is the job of the explicit `wait` verb (story 10), not
228
+ // of `goto`.
229
+ await pwPage.goto(url, {waitUntil: 'load'});
230
+ },
231
+ },
232
+ });
233
+
234
+ /** The `snapshot` verb: the token-cheap a11y view, or `--full` raw DOM. */
235
+ export const snapshotHand: Hand = ({pwPage, ensureOpen}) => ({
236
+ verbs: {
237
+ async snapshot(options?: SnapshotOptions): Promise<Snapshot> {
238
+ ensureOpen();
239
+ const url = pwPage.url();
240
+ if (options?.full === true) {
241
+ // `--full`: the raw DOM. `documentElement.outerHTML` is the serialized
242
+ // live DOM (post-script render), which is what an agent that wants the
243
+ // real HTML expects — not the original network response.
244
+ const content = await pwPage.evaluate(
245
+ () => document.documentElement.outerHTML,
246
+ );
247
+ return {url, view: 'full', content};
248
+ }
249
+ // Default: the token-cheap accessibility tree + visible text with stable
250
+ // `[ref=...]` element refs. Playwright's `ariaSnapshot({mode: 'ai'})`
251
+ // emits exactly that — a YAML aria tree (roles + accessible names +
252
+ // text) where each node carries a stable `[ref=eN]` reference, assigned
253
+ // deterministically by traversal order so re-snapshotting an unchanged
254
+ // page yields the same refs. The string crosses the seam as opaque,
255
+ // transport-neutral text (no Playwright type leaks, ADR-0003).
256
+ const content = await pwPage.ariaSnapshot({mode: 'ai'});
257
+ return {url, view: 'accessibility', content};
258
+ },
259
+ },
260
+ });
261
+
262
+ /** The `click` + `type` verbs: page interaction by raw locator (ADR-0004). */
263
+ export const interactionHand: Hand = ({pwPage, ensureOpen}) => ({
264
+ verbs: {
265
+ async click(t): Promise<void> {
266
+ ensureOpen();
267
+ await clickLocator(pwPage, t);
268
+ },
269
+ async type(t, text): Promise<void> {
270
+ ensureOpen();
271
+ await resolveLocator(pwPage, t).fill(text);
272
+ },
273
+ },
274
+ });
275
+
276
+ /** The `eval` escape hatch: run a JS EXPRESSION in the page, return by value. */
277
+ export const evalHand: Hand = ({pwPage, ensureOpen}) => ({
278
+ verbs: {
279
+ async eval(expression: string): Promise<unknown> {
280
+ ensureOpen();
281
+ // The `eval` escape hatch (PRD story 9): run the raw JS EXPRESSION in the
282
+ // page and return its serializable result. Playwright's `evaluate`
283
+ // already IS the seam's serialization contract (see {@link WebHandsPage.eval}):
284
+ // it passes a string as an expression, awaits a returned Promise, and
285
+ // structurally clones the result out of the page by VALUE. That clone is
286
+ // richer than JSON: it preserves NaN/Infinity/BigInt and circular
287
+ // structures (back-refs become a `[Circular]` marker), yields `undefined`
288
+ // for functions/symbols, and returns an opaque preview string for a live
289
+ // host object (a DOM node never crosses the process boundary). A page-side
290
+ // throw rejects. We pass it straight through rather than re-encode it:
291
+ // wrapping the value in a transport-specific envelope would invent a
292
+ // dialect the seam deliberately avoids. The thrown error is a plain
293
+ // `Error`, so no Playwright/CDP type leaks across the seam (ADR-0003).
294
+ return pwPage.evaluate(expression);
295
+ },
296
+ },
297
+ });
298
+
299
+ /** The `wait` verb: pace actions by a condition (timeout/locator/navigation). */
300
+ export const waitHand: Hand = ({pwPage, ensureOpen}) => ({
301
+ verbs: {
302
+ async wait(condition: WaitCondition): Promise<void> {
303
+ ensureOpen();
304
+ await waitFor(pwPage, condition);
305
+ },
306
+ },
307
+ });
308
+
309
+ /**
310
+ * The `cookies` + `setCookies` verbs. These prove the {@link HandContext} needs
311
+ * the `context`: cookies are a context-level, not page-level, concern, so this
312
+ * hand reaches `ctx.context`, not `ctx.pwPage`.
313
+ */
314
+ export const cookiesHand: Hand = ({context, ensureOpen}) => ({
315
+ verbs: {
316
+ async cookies(): Promise<readonly Cookie[]> {
317
+ ensureOpen();
318
+ const raw = await context.cookies();
319
+ return raw.map(toSeamCookie);
320
+ },
321
+ async setCookies(cookies): Promise<void> {
322
+ ensureOpen();
323
+ await context.addCookies(cookies.map(fromSeamCookie));
324
+ },
325
+ },
326
+ });
327
+
328
+ /**
329
+ * webhands' eight built-in verbs as built-in hands, in composition order. Both
330
+ * Playwright transports compose THIS exact set, so the verb surface is
331
+ * identical across launch and attach (the only legitimate difference is the
332
+ * per-transport SESSION LIFECYCLE, which is not a hand's concern).
333
+ */
334
+ export const BUILT_IN_HANDS: readonly Hand[] = [
335
+ navigationHand,
336
+ snapshotHand,
337
+ interactionHand,
338
+ evalHand,
339
+ waitHand,
340
+ cookiesHand,
341
+ ];
342
+
343
+ /**
344
+ * Compose webhands' built-in hands over a live context into the seam's
345
+ * {@link WebHandsPage}. The convenience both transports call: `composePage(ctx,
346
+ * BUILT_IN_HANDS)`. The built-in hands set up no in-process resources, so the
347
+ * returned `dispose` is a no-op today; it exists so a transport can sequence
348
+ * hand-teardown before its own browser/context teardown once third-party hands
349
+ * (which may hold resources) are added in Phase 2.
350
+ */
351
+ export function composeBuiltInPage(ctx: HandContext): ComposedHands {
352
+ return composePage(ctx, BUILT_IN_HANDS);
353
+ }
354
+
355
+ /**
356
+ * Compose webhands' built-in hands together with any explicitly-loaded
357
+ * third-party hands (Phase 2) over a live context. The third-party hands are
358
+ * composed AFTER the built-ins through the EXACT same {@link composePage} the
359
+ * built-ins use, so a loaded hand plugs into the same host: its verbs merge into
360
+ * the same seam {@link WebHandsPage} and its `dispose` is sequenced LIFO with the rest.
361
+ * A third-party hand may add NEW verbs (the common case) and, because later
362
+ * contributions win the merge, may also override a built-in verb — that is the
363
+ * operator's choice, made by the trust act of naming the hand (ADR-0007).
364
+ */
365
+ export function composeWithHands(
366
+ ctx: HandContext,
367
+ extraHands: readonly Hand[],
368
+ ): ComposedHands {
369
+ return composePage(ctx, [...BUILT_IN_HANDS, ...extraHands]);
370
+ }
371
+
372
+ // ---------------------------------------------------------------------------
373
+ // Shared verb building blocks (moved here with the verb bodies they back).
374
+ // Re-exported from the launch transport for its existing public-API consumers.
375
+ // ---------------------------------------------------------------------------
376
+
377
+ /**
378
+ * Run the `wait` verb's three forms (PRD story 10) against a Playwright page.
379
+ *
380
+ * - `timeout` — pace by a fixed delay (`waitForTimeout`), so an agent can act
381
+ * like a human and let XHR-rendered content land.
382
+ * - `locator` — block until the addressed element appears (`Locator.waitFor()`),
383
+ * the form for content rendered AFTER `goto` settled on `load`.
384
+ * - `navigation` — block until the NEXT navigation settles to `load`. We use
385
+ * `waitForNavigation()` even though Playwright marks it `@deprecated` ("racy,
386
+ * use waitForURL"): that deprecation targets in-process TEST code that can arm
387
+ * the wait BEFORE the action and pass a target URL. Neither holds here. Across
388
+ * this seam verbs are DISCRETE sequential calls (`click` then `wait`), so we
389
+ * CANNOT arm before the trigger; and the realistic trigger is an async,
390
+ * JS-driven transition (a redirect / SPA route change that fires AFTER the
391
+ * agent's action, the "let XHR-rendered content load" case of story 10), so
392
+ * "wait for the NEXT navigation" is exactly right — whereas `waitForLoadState`
393
+ * would see the already-loaded current page and return before the pending
394
+ * transition. `waitForURL` is unusable because the verb has no target URL by
395
+ * design (the agent waits for "a navigation", not a known address). (See the
396
+ * task's ## Decisions note.)
397
+ *
398
+ * Shared by both Playwright transports (via the `wait` built-in hand) so the
399
+ * verb behaviour stays identical (no parallel second implementation).
400
+ */
401
+ export async function waitFor(
402
+ page: Page,
403
+ condition: WaitCondition,
404
+ ): Promise<void> {
405
+ switch (condition.kind) {
406
+ case 'timeout':
407
+ await page.waitForTimeout(condition.ms);
408
+ return;
409
+ case 'locator':
410
+ await resolveLocator(page, condition.target).waitFor();
411
+ return;
412
+ case 'navigation':
413
+ // eslint-disable-next-line @typescript-eslint/no-deprecated
414
+ await page.waitForNavigation();
415
+ return;
416
+ }
417
+ }
418
+
419
+ /**
420
+ * Resolve a raw Playwright locator EXPRESSION (ADR-0004) against the page. The
421
+ * verb surface passes locator expressions like `getByRole('button', …)`; we
422
+ * evaluate them in a small sandbox where `page`/`p` is the page, so the full
423
+ * Playwright locator grammar is available without leaking the type across the
424
+ * seam.
425
+ *
426
+ * One resolution path for both transports (via the built-in interaction/wait
427
+ * hands), so there is no parallel addressing scheme.
428
+ */
429
+ export function resolveLocator(page: Page, expression: string) {
430
+ // eslint-disable-next-line no-new-func
431
+ const factory = new Function('page', 'p', `return (${expression});`) as (
432
+ page: Page,
433
+ p: Page,
434
+ ) => ReturnType<Page['locator']>;
435
+ return factory(page, page);
436
+ }
437
+
438
+ /**
439
+ * Run the `click` verb against a Playwright page (PRD story 8), shared by both
440
+ * Playwright transports (via the built-in interaction hand) so the verb behaves
441
+ * identically (mirrors {@link waitFor}; no parallel second implementation).
442
+ *
443
+ * First try a normal `Locator.click()`, which AUTO-WAITS for the element to be
444
+ * visible and actionable — the right behaviour for a real button. A hidden
445
+ * custom input (the case the prd calls out) NEVER becomes actionable, so that
446
+ * click times out; on a Playwright `TimeoutError` we fall back to
447
+ * `dispatchEvent('click')`, which fires a click WITHOUT the actionability
448
+ * checks. The fallback is deliberately the documented Playwright escape (a
449
+ * sibling to the `eval` hatch, ADR-0004), not a reimplemented click: we keep
450
+ * the locator a raw resolved expression and only change HOW the resolved
451
+ * locator is clicked.
452
+ *
453
+ * Only a timeout triggers the fallback. The fallback `dispatchEvent` is itself
454
+ * bounded by the same short timeout, so a locator that resolves NO element (a
455
+ * bad locator) surfaces its timeout quickly instead of hanging the dispatch on
456
+ * Playwright's 30s default — the dispatch escape is for elements that EXIST but
457
+ * are not actionable (hidden custom inputs), not for absent ones.
458
+ */
459
+ export async function clickLocator(
460
+ page: Page,
461
+ expression: string,
462
+ ): Promise<void> {
463
+ const target = resolveLocator(page, expression);
464
+ try {
465
+ await target.click({timeout: NORMAL_CLICK_TIMEOUT_MS});
466
+ } catch (cause) {
467
+ if (!(cause instanceof pwErrors.TimeoutError)) {
468
+ throw cause;
469
+ }
470
+ // The element never became actionable (e.g. a hidden custom input). Fire
471
+ // the click without actionability checks, the prd's explicit escape path.
472
+ await target.dispatchEvent('click', {timeout: NORMAL_CLICK_TIMEOUT_MS});
473
+ }
474
+ }
475
+
476
+ /** Map a Playwright cookie to the transport-neutral seam {@link Cookie}. */
477
+ function toSeamCookie(c: {
478
+ name: string;
479
+ value: string;
480
+ domain?: string;
481
+ path?: string;
482
+ expires?: number;
483
+ httpOnly?: boolean;
484
+ secure?: boolean;
485
+ sameSite?: 'Strict' | 'Lax' | 'None';
486
+ }): Cookie {
487
+ return {
488
+ name: c.name,
489
+ value: c.value,
490
+ domain: c.domain,
491
+ path: c.path,
492
+ expires: c.expires,
493
+ httpOnly: c.httpOnly,
494
+ secure: c.secure,
495
+ sameSite: c.sameSite,
496
+ };
497
+ }
498
+
499
+ /** Map a seam {@link Cookie} to a Playwright cookie shape. */
500
+ function fromSeamCookie(c: Cookie) {
501
+ return {
502
+ name: c.name,
503
+ value: c.value,
504
+ domain: c.domain,
505
+ path: c.path,
506
+ expires: c.expires,
507
+ httpOnly: c.httpOnly,
508
+ secure: c.secure,
509
+ sameSite: c.sameSite,
510
+ };
511
+ }