@webhands/core 0.1.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 (76) hide show
  1. package/dist/cookies-export.d.ts +56 -0
  2. package/dist/cookies-export.d.ts.map +1 -0
  3. package/dist/cookies-export.js +69 -0
  4. package/dist/cookies-export.js.map +1 -0
  5. package/dist/errors.d.ts +126 -0
  6. package/dist/errors.d.ts.map +1 -0
  7. package/dist/errors.js +135 -0
  8. package/dist/errors.js.map +1 -0
  9. package/dist/index.d.ts +16 -0
  10. package/dist/index.d.ts.map +1 -0
  11. package/dist/index.js +15 -0
  12. package/dist/index.js.map +1 -0
  13. package/dist/playwright-attach-transport.d.ts +28 -0
  14. package/dist/playwright-attach-transport.d.ts.map +1 -0
  15. package/dist/playwright-attach-transport.js +175 -0
  16. package/dist/playwright-attach-transport.js.map +1 -0
  17. package/dist/playwright-launch-transport.d.ts +90 -0
  18. package/dist/playwright-launch-transport.d.ts.map +1 -0
  19. package/dist/playwright-launch-transport.js +305 -0
  20. package/dist/playwright-launch-transport.js.map +1 -0
  21. package/dist/profile-location.d.ts +61 -0
  22. package/dist/profile-location.d.ts.map +1 -0
  23. package/dist/profile-location.js +61 -0
  24. package/dist/profile-location.js.map +1 -0
  25. package/dist/remote-session.d.ts +22 -0
  26. package/dist/remote-session.d.ts.map +1 -0
  27. package/dist/remote-session.js +57 -0
  28. package/dist/remote-session.js.map +1 -0
  29. package/dist/seam.d.ts +212 -0
  30. package/dist/seam.d.ts.map +1 -0
  31. package/dist/seam.js +25 -0
  32. package/dist/seam.js.map +1 -0
  33. package/dist/session-endpoint.d.ts +53 -0
  34. package/dist/session-endpoint.d.ts.map +1 -0
  35. package/dist/session-endpoint.js +75 -0
  36. package/dist/session-endpoint.js.map +1 -0
  37. package/dist/session-rpc.d.ts +82 -0
  38. package/dist/session-rpc.d.ts.map +1 -0
  39. package/dist/session-rpc.js +107 -0
  40. package/dist/session-rpc.js.map +1 -0
  41. package/dist/session-server.d.ts +79 -0
  42. package/dist/session-server.d.ts.map +1 -0
  43. package/dist/session-server.js +141 -0
  44. package/dist/session-server.js.map +1 -0
  45. package/dist/setup-profile.d.ts +84 -0
  46. package/dist/setup-profile.d.ts.map +1 -0
  47. package/dist/setup-profile.js +52 -0
  48. package/dist/setup-profile.js.map +1 -0
  49. package/dist/stub-transport.d.ts +26 -0
  50. package/dist/stub-transport.d.ts.map +1 -0
  51. package/dist/stub-transport.js +76 -0
  52. package/dist/stub-transport.js.map +1 -0
  53. package/dist/test-fixtures/fixture-pages.d.ts +12 -0
  54. package/dist/test-fixtures/fixture-pages.d.ts.map +1 -0
  55. package/dist/test-fixtures/fixture-pages.js +204 -0
  56. package/dist/test-fixtures/fixture-pages.js.map +1 -0
  57. package/dist/test-fixtures/fixture-server.d.ts +19 -0
  58. package/dist/test-fixtures/fixture-server.d.ts.map +1 -0
  59. package/dist/test-fixtures/fixture-server.js +41 -0
  60. package/dist/test-fixtures/fixture-server.js.map +1 -0
  61. package/package.json +34 -0
  62. package/src/cookies-export.ts +91 -0
  63. package/src/errors.ts +185 -0
  64. package/src/index.ts +89 -0
  65. package/src/playwright-attach-transport.ts +214 -0
  66. package/src/playwright-launch-transport.ts +363 -0
  67. package/src/profile-location.ts +92 -0
  68. package/src/remote-session.ts +66 -0
  69. package/src/seam.ts +222 -0
  70. package/src/session-endpoint.ts +104 -0
  71. package/src/session-rpc.ts +143 -0
  72. package/src/session-server.ts +231 -0
  73. package/src/setup-profile.ts +134 -0
  74. package/src/stub-transport.ts +100 -0
  75. package/src/test-fixtures/fixture-pages.ts +210 -0
  76. package/src/test-fixtures/fixture-server.ts +54 -0
@@ -0,0 +1,66 @@
1
+ import {
2
+ makeRpcPage,
3
+ SESSION_RPC_PATH,
4
+ type SessionRpcRequest,
5
+ type SessionRpcResponse,
6
+ } from './session-rpc.js';
7
+ import type {Session} from './seam.js';
8
+
9
+ /**
10
+ * A client-side {@link Session} that drives a session living in a SEPARATE
11
+ * long-lived `serve` process over HTTP (ADR-0005).
12
+ *
13
+ * Each `webhands <verb>` is a thin client: it cannot hold a JS
14
+ * reference to the server's live page, so this proxy turns every {@link Page}
15
+ * verb into a session-RPC call to the running server (see `session-rpc.ts`) and
16
+ * returns the result. The verb command code is UNCHANGED — it still calls
17
+ * `provider(target)` then runs verbs against the returned `Session.page` then
18
+ * calls `Session.close()`; only WHAT the session is changes.
19
+ *
20
+ * The critical difference from a local session: {@link Session.close} here is a
21
+ * NO-OP. The served process owns the single live session's lifetime; a thin
22
+ * client closing after one verb must NOT tear down the shared browser, or the
23
+ * next verb invocation would have nothing to drive. Teardown is explicit
24
+ * (`stop`), exactly as ADR-0005 requires. This is the whole reason cross-
25
+ * invocation persistence works: the page state survives because the client's
26
+ * `close()` does not reach across to the server's session.
27
+ */
28
+ export function connectRemoteSession(baseUrl: string): Session {
29
+ const endpoint = new URL(SESSION_RPC_PATH, baseUrl).toString();
30
+
31
+ const send = async (request: SessionRpcRequest): Promise<unknown> => {
32
+ let res: Response;
33
+ try {
34
+ res = await fetch(endpoint, {
35
+ method: 'POST',
36
+ headers: {'content-type': 'application/json'},
37
+ body: JSON.stringify(request),
38
+ });
39
+ } catch (cause) {
40
+ // The advertised server is unreachable (it died without clearing its
41
+ // endpoint file, say). Surface a plain Error; the CLI maps the discovery
42
+ // MISS (no endpoint file) to "run serve first", but a stale-but-present
43
+ // endpoint that no longer answers is a genuine connection failure.
44
+ const message = cause instanceof Error ? cause.message : String(cause);
45
+ throw new Error(
46
+ `could not reach the session server at ${baseUrl}: ${message}`,
47
+ );
48
+ }
49
+ const reply = (await res.json()) as SessionRpcResponse;
50
+ if (reply.ok) {
51
+ return reply.value;
52
+ }
53
+ // Re-throw a faithful Error so a page-side throw REJECTS on the client too,
54
+ // preserving the seam's `eval` "a page throw rejects" contract across the
55
+ // process boundary.
56
+ throw new Error(reply.error);
57
+ };
58
+
59
+ return {
60
+ page: makeRpcPage(send),
61
+ async close() {
62
+ // Intentionally a no-op: the served process owns the session's lifetime
63
+ // (see this module's overview). Teardown is the explicit `stop` verb.
64
+ },
65
+ };
66
+ }
package/src/seam.ts ADDED
@@ -0,0 +1,222 @@
1
+ /**
2
+ * The verb-level transport seam.
3
+ *
4
+ * This is the highest test seam and the internal structure boundary of
5
+ * `core` (see PRD "Testing Decisions" and `docs/adr/0003`). It is expressed
6
+ * purely in terms of high-level VERBS (navigate, snapshot, click, type, eval,
7
+ * wait, cookies), NOT in terms of CDP or Playwright primitives, so that a
8
+ * future browser-extension transport or a non-Chromium (Firefox) transport
9
+ * can implement it without changing the verb surface.
10
+ *
11
+ * RULES (load-bearing, do not relax without an ADR):
12
+ * - No CDP / Chromium-only types may appear in this public surface
13
+ * (`docs/adr/0003`).
14
+ * - Element addressing is a RAW PLAYWRIGHT LOCATOR STRING the active
15
+ * transport resolves (`docs/adr/0004`): "transport-neutral" means
16
+ * Playwright-equivalent addressing, not a reduced selector subset and not a
17
+ * structured JSON locator. We deliberately type it as a branded `string`
18
+ * rather than importing any Playwright `Locator` type, so no Playwright
19
+ * type leaks across the seam.
20
+ */
21
+
22
+ /**
23
+ * A raw Playwright locator string, e.g. `getByRole('button', { name: 'Search' })`.
24
+ *
25
+ * It is a plain `string` at runtime; the brand exists only so call sites are
26
+ * explicit that this is a locator EXPRESSION the transport resolves (a sibling
27
+ * to the `eval` escape hatch), not an opaque CSS selector or a structured
28
+ * locator. Construct one with {@link locator}.
29
+ */
30
+ export type LocatorString = string & {
31
+ readonly __brand: 'PlaywrightLocatorString';
32
+ };
33
+
34
+ /** Tag a raw Playwright locator string as a {@link LocatorString}. */
35
+ export function locator(expression: string): LocatorString {
36
+ return expression as LocatorString;
37
+ }
38
+
39
+ /**
40
+ * How a {@link Transport} should obtain a browser session.
41
+ *
42
+ * Expressed in domain terms (a profile to launch, or a target to attach to),
43
+ * never in CDP/Playwright terms. Concrete transports map these to their own
44
+ * mechanism (Playwright `launchPersistentContext` / `connectOverCDP`, an
45
+ * extension bridge, ...).
46
+ */
47
+ export type OpenTarget =
48
+ | {
49
+ readonly mode: 'launch';
50
+ /** Name of the dedicated profile the controller owns. */
51
+ readonly profile: string;
52
+ /** Whether the browser is visible. Defaults are a transport concern. */
53
+ readonly headed?: boolean;
54
+ }
55
+ | {
56
+ readonly mode: 'attach';
57
+ /**
58
+ * Opaque, transport-resolved endpoint of an already-running browser
59
+ * (e.g. a remote-debugging URL). Kept as a plain string so no
60
+ * CDP type leaks across the seam.
61
+ */
62
+ readonly endpoint: string;
63
+ };
64
+
65
+ /** A single browser cookie, in transport-neutral terms. */
66
+ export interface Cookie {
67
+ readonly name: string;
68
+ readonly value: string;
69
+ readonly domain?: string;
70
+ readonly path?: string;
71
+ readonly expires?: number;
72
+ readonly httpOnly?: boolean;
73
+ readonly secure?: boolean;
74
+ readonly sameSite?: 'Strict' | 'Lax' | 'None';
75
+ }
76
+
77
+ /** What to wait for in the {@link Page.wait} verb. */
78
+ export type WaitCondition =
79
+ | {readonly kind: 'timeout'; readonly ms: number}
80
+ | {readonly kind: 'locator'; readonly target: LocatorString}
81
+ | {readonly kind: 'navigation'};
82
+
83
+ /**
84
+ * Which page view a {@link Snapshot} carries.
85
+ *
86
+ * - `'accessibility'` — the DEFAULT, token-cheap structured view: the
87
+ * accessibility tree (roles + names) plus visible text, with stable element
88
+ * refs (see {@link Snapshot.content}). This is the cheap view an agent reads
89
+ * to decide what to act on WITHOUT parsing raw HTML.
90
+ * - `'full'` — the raw DOM (serialized outer HTML), returned when the verb is
91
+ * called with {@link SnapshotOptions.full}. A settled PRD decision (story 7,
92
+ * `needsAnswers` Q3): default is the accessibility view, `--full` is raw DOM.
93
+ */
94
+ export type SnapshotView = 'accessibility' | 'full';
95
+
96
+ /** Options for the {@link Page.snapshot} verb. */
97
+ export interface SnapshotOptions {
98
+ /**
99
+ * When `true`, return the raw DOM (`view: 'full'`) instead of the default
100
+ * accessibility-tree + visible-text view. Maps to the CLI `--full` flag.
101
+ */
102
+ readonly full?: boolean;
103
+ }
104
+
105
+ /**
106
+ * A structured, token-cheap view of the current page with stable element refs.
107
+ *
108
+ * In the default `'accessibility'` view, {@link Snapshot.content} is the
109
+ * accessibility tree (roles + accessible names) plus visible text, with each
110
+ * actionable node carrying a stable `[ref=...]` element reference. The refs
111
+ * are stable for an unchanged page (re-snapshotting yields the same refs), so
112
+ * an agent can read the cheap view, pick a ref, and address that element
113
+ * later. Snapshot refs and the raw Playwright-locator addressing (ADR-0004)
114
+ * are COMPLEMENTARY ways to address elements, not competitors.
115
+ *
116
+ * The `content` string is a transport-neutral, human/agent-readable text
117
+ * serialization (no CDP/Playwright types cross the seam, per ADR-0003). Its
118
+ * concrete grammar is a transport detail; callers treat it as opaque,
119
+ * token-cheap text to read, and parse refs out of it only by the documented
120
+ * `[ref=...]` convention.
121
+ */
122
+ export interface Snapshot {
123
+ /** The page URL at snapshot time. */
124
+ readonly url: string;
125
+ /** Which view this snapshot carries (default vs `--full` raw DOM). */
126
+ readonly view: SnapshotView;
127
+ /** Human/agent-readable structured page content (see {@link Snapshot}). */
128
+ readonly content: string;
129
+ }
130
+
131
+ /**
132
+ * The page-level verb surface. One method per verb in the domain glossary.
133
+ * All element addressing flows through {@link LocatorString}.
134
+ */
135
+ export interface Page {
136
+ /** Navigate the active page to a URL and let it settle. */
137
+ navigate(url: string): Promise<void>;
138
+ /**
139
+ * Return a structured, token-cheap view of the page. Defaults to the
140
+ * accessibility-tree + visible-text view with stable refs; pass
141
+ * `{full: true}` to get the raw DOM instead (PRD story 7).
142
+ */
143
+ snapshot(options?: SnapshotOptions): Promise<Snapshot>;
144
+ /** Click the element addressed by a raw Playwright locator string. */
145
+ click(target: LocatorString): Promise<void>;
146
+ /** Type text into the element addressed by a raw Playwright locator string. */
147
+ type(target: LocatorString, text: string): Promise<void>;
148
+ /**
149
+ * Run a JavaScript EXPRESSION in the active page's context and return its
150
+ * result, the `eval` escape hatch for cases no other verb covers (PRD story
151
+ * 9). It sits naturally beside the raw-locator addressing (ADR-0004): both are
152
+ * page-context expressions the transport resolves.
153
+ *
154
+ * `expression` is evaluated AS AN EXPRESSION (not a function body), so its
155
+ * value is the result: `'1 + 2'` yields `3`, `"document.title"` yields the
156
+ * title. If it evaluates to a Promise, the transport awaits it and returns the
157
+ * resolved value.
158
+ *
159
+ * SERIALIZATION CONTRACT (the load-bearing part). The result must cross the
160
+ * seam by VALUE: it is structurally cloned out of the page context, not
161
+ * handed back as a live reference. The transport, not this verb, owns the
162
+ * serialization, and its behaviour is the documented contract callers rely
163
+ * on. It is RICHER than `JSON.stringify` (do not reason about it as JSON):
164
+ * - Primitives, plain objects, and arrays round-trip faithfully, including
165
+ * nested structures.
166
+ * - `undefined` round-trips as `undefined`; `null` as `null`.
167
+ * - Non-finite numbers (`NaN`, `Infinity`, `-0`) and `BigInt` are PRESERVED
168
+ * as their real JS values (unlike JSON, which would lose them).
169
+ * - A circular structure is PRESERVED, with each back-reference replaced by a
170
+ * `[Circular]` marker (it does NOT throw).
171
+ * - Values with no transferable form (functions, symbols) come back as
172
+ * `undefined`; a `Date` comes back as a `Date`; `Map`/`Set` come back as an
173
+ * empty object `{}` (their entries do not survive the clone).
174
+ * - Live host objects (a DOM node, `window`) come back as an OPAQUE PREVIEW
175
+ * STRING, NOT the live object: it cannot cross the process boundary, so the
176
+ * escape hatch hands back a readable stand-in rather than a broken handle.
177
+ * An agent that needs a DOM value reads a serializable property of it
178
+ * (`...textContent`, `...value`) inside the expression, exactly as the
179
+ * tests do.
180
+ * - An expression that THROWS in the page REJECTS with a transport-neutral
181
+ * `Error` carrying the page-side message (no CDP/Playwright type leaks
182
+ * across the seam, ADR-0003).
183
+ *
184
+ * The return type is `unknown` because the page decides the shape; callers
185
+ * narrow it. This is deliberately a thin passthrough to the transport's
186
+ * serialize-and-return: `eval` does not re-encode or wrap the result, so an
187
+ * agent gets exactly what the page produced.
188
+ */
189
+ eval(expression: string): Promise<unknown>;
190
+ /** Pace actions by waiting for a condition. */
191
+ wait(condition: WaitCondition): Promise<void>;
192
+ /** Read the session's cookies. */
193
+ cookies(): Promise<readonly Cookie[]>;
194
+ /** Seed the session's cookies. */
195
+ setCookies(cookies: readonly Cookie[]): Promise<void>;
196
+ }
197
+
198
+ /**
199
+ * A live browser session owning one active {@link Page}. The session lifetime
200
+ * spans from {@link Transport.open} to {@link Session.close}; it is the unit a
201
+ * long-lived controller process keeps between CLI invocations (PRD
202
+ * "session/daemon question").
203
+ */
204
+ export interface Session {
205
+ /** The active page the verbs act on. */
206
+ readonly page: Page;
207
+ /** Tear down the session and release the underlying browser resources. */
208
+ close(): Promise<void>;
209
+ }
210
+
211
+ /**
212
+ * The transport seam. A `Transport` (a.k.a. driver) knows how to OPEN a
213
+ * {@link Session} for a given {@link OpenTarget}. v1 concrete transport is
214
+ * Playwright (built in a later task); an extension or Firefox transport can
215
+ * implement the same interface.
216
+ */
217
+ export interface Transport {
218
+ open(target: OpenTarget): Promise<Session>;
219
+ }
220
+
221
+ /** Alias: the seam is referred to as the `Driver` in the domain glossary. */
222
+ export type Driver = Transport;
@@ -0,0 +1,104 @@
1
+ import {mkdir, readFile, rm, writeFile} from 'node:fs/promises';
2
+ import {dirname, join} from 'node:path';
3
+ import {
4
+ resolveHomeRoot,
5
+ type ProfileLocationOptions,
6
+ } from './profile-location.js';
7
+
8
+ /**
9
+ * Cross-invocation session DISCOVERY (ADR-0005).
10
+ *
11
+ * The long-lived `incur serve` process owns the one live browser session; each
12
+ * `webhands <verb>` is a thin client that must FIND that running
13
+ * server. The server advertises itself by writing a small endpoint file under
14
+ * the controller home root (the same SHARED location profiles live under, see
15
+ * {@link resolveHomeRoot}); client verbs read it to learn where to send their
16
+ * verb calls. When no endpoint file exists, no server is live, and a verb errors
17
+ * with "run `serve` first" rather than auto-spawning a browser (ADR-0005:
18
+ * lifecycle is EXPLICIT in v1).
19
+ *
20
+ * Because this writes under the real `~/.webhands` by default,
21
+ * TESTS MUST override the root to a temp dir (via {@link ProfileLocationOptions})
22
+ * and assert the real location is untouched, exactly as the profile location
23
+ * does.
24
+ */
25
+
26
+ /** The endpoint file name under the controller home root. */
27
+ export const SESSION_ENDPOINT_FILENAME = 'session-endpoint.json';
28
+
29
+ /**
30
+ * What the served process advertises about itself for client discovery. Kept
31
+ * deliberately small: the base `url` a client posts verb calls to, plus the
32
+ * `pid` so a human/test can confirm or signal the owning process.
33
+ */
34
+ export interface SessionEndpoint {
35
+ /** The base HTTP URL the served session listens on (e.g. `http://127.0.0.1:53113`). */
36
+ readonly url: string;
37
+ /** The PID of the served process, for confirmation / signalling. */
38
+ readonly pid: number;
39
+ }
40
+
41
+ /**
42
+ * Resolve the absolute path of the endpoint file for a given home root. Pure
43
+ * (touches no filesystem); precedence matches {@link resolveHomeRoot} so the
44
+ * endpoint file always sits beside the profiles dir under the same root.
45
+ */
46
+ export function resolveSessionEndpointPath(
47
+ options: ProfileLocationOptions = {},
48
+ ): string {
49
+ return join(resolveHomeRoot(options), SESSION_ENDPOINT_FILENAME);
50
+ }
51
+
52
+ /**
53
+ * Advertise a live served session by writing its endpoint file (creating the
54
+ * home root if absent). Overwrites any stale file; the server owns this file's
55
+ * lifetime and clears it on stop.
56
+ */
57
+ export async function writeSessionEndpoint(
58
+ endpoint: SessionEndpoint,
59
+ options: ProfileLocationOptions = {},
60
+ ): Promise<string> {
61
+ const path = resolveSessionEndpointPath(options);
62
+ await mkdir(dirname(path), {recursive: true});
63
+ await writeFile(path, JSON.stringify(endpoint, null, 2), 'utf8');
64
+ return path;
65
+ }
66
+
67
+ /**
68
+ * Read the advertised endpoint, or `undefined` when no server is live (the file
69
+ * is absent or unreadable). Discovery is best-effort: a malformed file is
70
+ * treated as "no live server" so a client falls through to the clear
71
+ * "run `serve` first" error rather than crashing on a partial write.
72
+ */
73
+ export async function readSessionEndpoint(
74
+ options: ProfileLocationOptions = {},
75
+ ): Promise<SessionEndpoint | undefined> {
76
+ const path = resolveSessionEndpointPath(options);
77
+ let text: string;
78
+ try {
79
+ text = await readFile(path, 'utf8');
80
+ } catch {
81
+ return undefined;
82
+ }
83
+ try {
84
+ const parsed = JSON.parse(text) as Partial<SessionEndpoint>;
85
+ if (
86
+ typeof parsed.url === 'string' &&
87
+ parsed.url !== '' &&
88
+ typeof parsed.pid === 'number'
89
+ ) {
90
+ return {url: parsed.url, pid: parsed.pid};
91
+ }
92
+ } catch {
93
+ // fall through
94
+ }
95
+ return undefined;
96
+ }
97
+
98
+ /** Remove the endpoint file (teardown). Absent file is not an error. */
99
+ export async function clearSessionEndpoint(
100
+ options: ProfileLocationOptions = {},
101
+ ): Promise<void> {
102
+ const path = resolveSessionEndpointPath(options);
103
+ await rm(path, {force: true});
104
+ }
@@ -0,0 +1,143 @@
1
+ import {
2
+ locator,
3
+ type Cookie,
4
+ type Snapshot,
5
+ type WaitCondition,
6
+ } from './seam.js';
7
+ import type {Page} from './seam.js';
8
+
9
+ /**
10
+ * The wire protocol for driving the long-lived session over HTTP (ADR-0005).
11
+ *
12
+ * The served process holds ONE live {@link Page} in memory; a thin client verb
13
+ * cannot hold a JS reference to it across the process boundary, so each verb
14
+ * call is sent as a small JSON request to the server, which runs it against its
15
+ * live page and returns the result. This module is the SINGLE source of truth
16
+ * for that request/response shape, imported by BOTH the server handler and the
17
+ * client proxy so they cannot drift (mirrors how `serializeCookies` is shared
18
+ * by the cookies verb and its test).
19
+ *
20
+ * It is a thin transport detail, NOT a second verb surface: every request maps
21
+ * 1:1 to a {@link Page} method, and the seam's verb semantics (ADR-0003/0004)
22
+ * are unchanged. The {@link LocatorString} brand and the structured
23
+ * {@link WaitCondition} cross as plain JSON and are re-branded on the server
24
+ * with {@link locator}; no Playwright/CDP type is ever named here.
25
+ */
26
+
27
+ /** The path the session RPC is served under, below the server's base URL. */
28
+ export const SESSION_RPC_PATH = '/session/call';
29
+
30
+ /** A single verb call to run against the served live page. */
31
+ export type SessionRpcRequest =
32
+ | {readonly verb: 'navigate'; readonly url: string}
33
+ | {readonly verb: 'snapshot'; readonly full?: boolean}
34
+ | {readonly verb: 'click'; readonly locator: string}
35
+ | {readonly verb: 'type'; readonly locator: string; readonly text: string}
36
+ | {readonly verb: 'eval'; readonly expression: string}
37
+ | {readonly verb: 'wait'; readonly condition: WaitCondition}
38
+ | {readonly verb: 'cookies'}
39
+ | {readonly verb: 'setCookies'; readonly cookies: readonly Cookie[]};
40
+
41
+ /**
42
+ * The server's reply to a {@link SessionRpcRequest}. `ok: true` carries the
43
+ * verb's return value (for verbs that return data); `ok: false` carries the
44
+ * page-side error message so the client can re-throw a faithful `Error` (a
45
+ * page throw must REJECT on the client too, per the seam's `eval` contract).
46
+ */
47
+ export type SessionRpcResponse =
48
+ | {readonly ok: true; readonly value?: unknown}
49
+ | {readonly ok: false; readonly error: string};
50
+
51
+ /**
52
+ * Run one {@link SessionRpcRequest} against a live {@link Page}, returning the
53
+ * value the wire should carry back. The server's HTTP handler is just this plus
54
+ * JSON framing; keeping the dispatch here (not inline in the handler) means the
55
+ * verb-to-page mapping is in one place and unit-testable without HTTP.
56
+ *
57
+ * The locator string and wait condition arrive as plain JSON; we re-brand the
58
+ * locator with {@link locator} before handing it to the page so the seam's
59
+ * branded-string contract holds.
60
+ */
61
+ export async function applySessionRpc(
62
+ page: Page,
63
+ request: SessionRpcRequest,
64
+ ): Promise<unknown> {
65
+ switch (request.verb) {
66
+ case 'navigate':
67
+ await page.navigate(request.url);
68
+ return undefined;
69
+ case 'snapshot':
70
+ return page.snapshot({full: request.full});
71
+ case 'click':
72
+ await page.click(locator(request.locator));
73
+ return undefined;
74
+ case 'type':
75
+ await page.type(locator(request.locator), request.text);
76
+ return undefined;
77
+ case 'eval':
78
+ return page.eval(request.expression);
79
+ case 'wait':
80
+ await page.wait(rebrandWait(request.condition));
81
+ return undefined;
82
+ case 'cookies':
83
+ return page.cookies();
84
+ case 'setCookies':
85
+ await page.setCookies(request.cookies);
86
+ return undefined;
87
+ }
88
+ }
89
+
90
+ /**
91
+ * A {@link Page} whose verbs forward to a server via the supplied transport.
92
+ *
93
+ * Used by the client-side proxy (see `remote-session.ts`): each verb builds a
94
+ * {@link SessionRpcRequest}, hands it to `send`, and shapes the reply back into
95
+ * the verb's return type. The `send` function owns the actual HTTP; this keeps
96
+ * the verb-to-request mapping (the other half of {@link applySessionRpc}) in
97
+ * one place so request and response shapes cannot drift between the two sides.
98
+ */
99
+ export function makeRpcPage(
100
+ send: (request: SessionRpcRequest) => Promise<unknown>,
101
+ ): Page {
102
+ return {
103
+ async navigate(url) {
104
+ await send({verb: 'navigate', url});
105
+ },
106
+ async snapshot(options) {
107
+ return (await send({
108
+ verb: 'snapshot',
109
+ full: options?.full,
110
+ })) as Snapshot;
111
+ },
112
+ async click(target) {
113
+ await send({verb: 'click', locator: target});
114
+ },
115
+ async type(target, text) {
116
+ await send({verb: 'type', locator: target, text});
117
+ },
118
+ async eval(expression) {
119
+ return send({verb: 'eval', expression});
120
+ },
121
+ async wait(condition) {
122
+ await send({verb: 'wait', condition});
123
+ },
124
+ async cookies() {
125
+ return (await send({verb: 'cookies'})) as readonly Cookie[];
126
+ },
127
+ async setCookies(cookies) {
128
+ await send({verb: 'setCookies', cookies});
129
+ },
130
+ };
131
+ }
132
+
133
+ /**
134
+ * Re-brand a {@link WaitCondition} that arrived as plain JSON: only the
135
+ * `locator` form carries a branded string, which JSON flattens to a plain
136
+ * `string`, so we re-tag it before it reaches the page.
137
+ */
138
+ function rebrandWait(condition: WaitCondition): WaitCondition {
139
+ if (condition.kind === 'locator') {
140
+ return {kind: 'locator', target: locator(condition.target)};
141
+ }
142
+ return condition;
143
+ }