@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.
- package/dist/cookies-export.d.ts +56 -0
- package/dist/cookies-export.d.ts.map +1 -0
- package/dist/cookies-export.js +69 -0
- package/dist/cookies-export.js.map +1 -0
- package/dist/errors.d.ts +126 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +135 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +15 -0
- package/dist/index.js.map +1 -0
- package/dist/playwright-attach-transport.d.ts +28 -0
- package/dist/playwright-attach-transport.d.ts.map +1 -0
- package/dist/playwright-attach-transport.js +175 -0
- package/dist/playwright-attach-transport.js.map +1 -0
- package/dist/playwright-launch-transport.d.ts +90 -0
- package/dist/playwright-launch-transport.d.ts.map +1 -0
- package/dist/playwright-launch-transport.js +305 -0
- package/dist/playwright-launch-transport.js.map +1 -0
- package/dist/profile-location.d.ts +61 -0
- package/dist/profile-location.d.ts.map +1 -0
- package/dist/profile-location.js +61 -0
- package/dist/profile-location.js.map +1 -0
- package/dist/remote-session.d.ts +22 -0
- package/dist/remote-session.d.ts.map +1 -0
- package/dist/remote-session.js +57 -0
- package/dist/remote-session.js.map +1 -0
- package/dist/seam.d.ts +212 -0
- package/dist/seam.d.ts.map +1 -0
- package/dist/seam.js +25 -0
- package/dist/seam.js.map +1 -0
- package/dist/session-endpoint.d.ts +53 -0
- package/dist/session-endpoint.d.ts.map +1 -0
- package/dist/session-endpoint.js +75 -0
- package/dist/session-endpoint.js.map +1 -0
- package/dist/session-rpc.d.ts +82 -0
- package/dist/session-rpc.d.ts.map +1 -0
- package/dist/session-rpc.js +107 -0
- package/dist/session-rpc.js.map +1 -0
- package/dist/session-server.d.ts +79 -0
- package/dist/session-server.d.ts.map +1 -0
- package/dist/session-server.js +141 -0
- package/dist/session-server.js.map +1 -0
- package/dist/setup-profile.d.ts +84 -0
- package/dist/setup-profile.d.ts.map +1 -0
- package/dist/setup-profile.js +52 -0
- package/dist/setup-profile.js.map +1 -0
- package/dist/stub-transport.d.ts +26 -0
- package/dist/stub-transport.d.ts.map +1 -0
- package/dist/stub-transport.js +76 -0
- package/dist/stub-transport.js.map +1 -0
- package/dist/test-fixtures/fixture-pages.d.ts +12 -0
- package/dist/test-fixtures/fixture-pages.d.ts.map +1 -0
- package/dist/test-fixtures/fixture-pages.js +204 -0
- package/dist/test-fixtures/fixture-pages.js.map +1 -0
- package/dist/test-fixtures/fixture-server.d.ts +19 -0
- package/dist/test-fixtures/fixture-server.d.ts.map +1 -0
- package/dist/test-fixtures/fixture-server.js +41 -0
- package/dist/test-fixtures/fixture-server.js.map +1 -0
- package/package.json +34 -0
- package/src/cookies-export.ts +91 -0
- package/src/errors.ts +185 -0
- package/src/index.ts +89 -0
- package/src/playwright-attach-transport.ts +214 -0
- package/src/playwright-launch-transport.ts +363 -0
- package/src/profile-location.ts +92 -0
- package/src/remote-session.ts +66 -0
- package/src/seam.ts +222 -0
- package/src/session-endpoint.ts +104 -0
- package/src/session-rpc.ts +143 -0
- package/src/session-server.ts +231 -0
- package/src/setup-profile.ts +134 -0
- package/src/stub-transport.ts +100 -0
- package/src/test-fixtures/fixture-pages.ts +210 -0
- 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
|
+
}
|