@webhands/core 0.1.0 → 0.2.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/LICENSE +661 -0
- package/README.md +112 -0
- package/dist/cookies-export.d.ts +5 -5
- package/dist/cookies-export.d.ts.map +1 -1
- package/dist/cookies-export.js +4 -4
- package/dist/hand-host.d.ts +217 -0
- package/dist/hand-host.d.ts.map +1 -0
- package/dist/hand-host.js +351 -0
- package/dist/hand-host.js.map +1 -0
- package/dist/hand-loading.d.ts +128 -0
- package/dist/hand-loading.d.ts.map +1 -0
- package/dist/hand-loading.js +143 -0
- package/dist/hand-loading.js.map +1 -0
- package/dist/index.d.ts +4 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/playwright-attach-transport.d.ts +9 -0
- package/dist/playwright-attach-transport.d.ts.map +1 -1
- package/dist/playwright-attach-transport.js +53 -91
- package/dist/playwright-attach-transport.js.map +1 -1
- package/dist/playwright-launch-transport.d.ts +7 -62
- package/dist/playwright-launch-transport.d.ts.map +1 -1
- package/dist/playwright-launch-transport.js +51 -204
- package/dist/playwright-launch-transport.js.map +1 -1
- package/dist/remote-session.d.ts +12 -2
- package/dist/remote-session.d.ts.map +1 -1
- package/dist/remote-session.js +37 -6
- package/dist/remote-session.js.map +1 -1
- package/dist/seam.d.ts +13 -5
- package/dist/seam.d.ts.map +1 -1
- package/dist/session-rpc.d.ts +76 -12
- package/dist/session-rpc.d.ts.map +1 -1
- package/dist/session-rpc.js +76 -8
- package/dist/session-rpc.js.map +1 -1
- package/dist/stub-transport.d.ts +2 -2
- package/dist/stub-transport.d.ts.map +1 -1
- package/dist/stub-transport.js +11 -0
- package/dist/stub-transport.js.map +1 -1
- package/package.json +21 -2
- package/src/cookies-export.ts +5 -5
- package/src/hand-host.ts +511 -0
- package/src/hand-loading.ts +254 -0
- package/src/index.ts +18 -1
- package/src/playwright-attach-transport.ts +65 -119
- package/src/playwright-launch-transport.ts +63 -244
- package/src/remote-session.ts +43 -5
- package/src/seam.ts +13 -5
- package/src/session-rpc.ts +121 -11
- package/src/stub-transport.ts +15 -3
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import {readFile} from 'node:fs/promises';
|
|
2
|
+
import {isAbsolute, resolve} from 'node:path';
|
|
3
|
+
import {pathToFileURL} from 'node:url';
|
|
4
|
+
import type {Hand} from './hand-host.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Explicit, declarative third-party-hand loading (Phase 2 of the "hands" prd,
|
|
8
|
+
* `work/prds/tasked/hands-pluggable-page-capabilities.md`; ADR-0007).
|
|
9
|
+
*
|
|
10
|
+
* A third-party **hand** is in-process Node code the host will hand the live
|
|
11
|
+
* Playwright page (see {@link Hand}). Because that is arbitrary Node code in the
|
|
12
|
+
* webhands process — a strictly LARGER surface than `eval` (which is sandboxed
|
|
13
|
+
* to the page's JS world) — loading a hand is a TRUST act: the right mental
|
|
14
|
+
* model is npm supply-chain trust, "loading a hand == trusting an in-process npm
|
|
15
|
+
* dependency" (ADR-0007). This module makes that trust act EXPLICIT and
|
|
16
|
+
* DECLARATIVE, modeled on pi's `packages[]`:
|
|
17
|
+
*
|
|
18
|
+
* - A hand loads ONLY because it is NAMED in config ({@link HandsConfig}), each
|
|
19
|
+
* entry carrying a PINNED entry point. NAMING a hand in config IS the trust
|
|
20
|
+
* act.
|
|
21
|
+
* - There is NO auto-discovery, NO `node_modules` scan, NO convention-inferred
|
|
22
|
+
* entry file. An installed-but-not-named hand never loads.
|
|
23
|
+
* - INSTALL is SEPARATE from LOAD/trust: `npm install <hand>` alone never
|
|
24
|
+
* auto-loads it; the operator installs the dependency themselves (a managed
|
|
25
|
+
* installer is explicitly OUT of scope) and then names it here to load it.
|
|
26
|
+
*
|
|
27
|
+
* The trust boundary stays LOCAL-only: hands widen the in-process surface, not
|
|
28
|
+
* the remote one (no new network listener). This module never installs, never
|
|
29
|
+
* scans a directory, and never reads anything beyond the entries the config
|
|
30
|
+
* explicitly names.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* One explicitly-named third-party hand. NAMING an entry here is the trust act
|
|
35
|
+
* (ADR-0007); webhands will load EXACTLY this entry and nothing it was not told
|
|
36
|
+
* about.
|
|
37
|
+
*/
|
|
38
|
+
export interface HandEntry {
|
|
39
|
+
/**
|
|
40
|
+
* The operator-chosen identifier for this hand. Used in error messages and
|
|
41
|
+
* to make the config self-documenting; it has no install side effect (naming
|
|
42
|
+
* is the trust act, not an install instruction).
|
|
43
|
+
*/
|
|
44
|
+
readonly name: string;
|
|
45
|
+
/**
|
|
46
|
+
* Descriptive provenance, e.g. `npm:@scope/hand` or `git:https://…`. Mirrors
|
|
47
|
+
* pi's named-source shape. It is RECORDED, not acted on: webhands does NOT
|
|
48
|
+
* install from it (install is separate from load/trust — the operator
|
|
49
|
+
* installs the dependency themselves). Optional.
|
|
50
|
+
*/
|
|
51
|
+
readonly source?: string;
|
|
52
|
+
/**
|
|
53
|
+
* The PINNED entry point: the exact module file webhands will `import()`. No
|
|
54
|
+
* convention-inferred entry, no `package.json` `main` lookup, no directory
|
|
55
|
+
* scan — the operator pins the file. A relative path is resolved against
|
|
56
|
+
* {@link LoadHandsOptions.baseDir} (the config's own directory); an absolute
|
|
57
|
+
* path is used as-is.
|
|
58
|
+
*/
|
|
59
|
+
readonly entry: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The webhands hand config: an EXPLICIT named list of third-party hands. Modeled
|
|
64
|
+
* on pi's `settings.json` `packages[]` (a named list of sources, each with a
|
|
65
|
+
* pinned entry). An empty/absent list means no third-party hands load.
|
|
66
|
+
*/
|
|
67
|
+
export interface HandsConfig {
|
|
68
|
+
readonly hands: readonly HandEntry[];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The filename webhands reads the hand config from, under the home root. */
|
|
72
|
+
export const HANDS_CONFIG_FILENAME = 'hands.json';
|
|
73
|
+
|
|
74
|
+
/** A loaded hand paired with the config entry that named it (for diagnostics). */
|
|
75
|
+
export interface LoadedHand {
|
|
76
|
+
readonly entry: HandEntry;
|
|
77
|
+
readonly hand: Hand;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Options controlling how config entries resolve to modules. */
|
|
81
|
+
export interface LoadHandsOptions {
|
|
82
|
+
/**
|
|
83
|
+
* The base directory a relative {@link HandEntry.entry} resolves against.
|
|
84
|
+
* Defaults to the current working directory. In production this is the
|
|
85
|
+
* config's own directory; in tests it points at a scratch dir so the real
|
|
86
|
+
* config/loading paths are never touched.
|
|
87
|
+
*/
|
|
88
|
+
readonly baseDir?: string;
|
|
89
|
+
/**
|
|
90
|
+
* The importer used to load a pinned entry. Defaults to a dynamic `import()`
|
|
91
|
+
* of the resolved file URL. Injectable so tests can load a fixture hand
|
|
92
|
+
* without a real on-disk module.
|
|
93
|
+
*/
|
|
94
|
+
readonly importModule?: (specifier: string) => Promise<unknown>;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Error raised when a NAMED hand cannot be loaded: its pinned entry is missing,
|
|
99
|
+
* fails to import, or does not export a {@link Hand}. A named hand that fails to
|
|
100
|
+
* resolve is a hard error (not a silent skip) so a typo or a broken/half-removed
|
|
101
|
+
* dependency surfaces loudly rather than silently dropping a capability the
|
|
102
|
+
* operator explicitly trusted.
|
|
103
|
+
*/
|
|
104
|
+
export class HandLoadError extends Error {
|
|
105
|
+
readonly entry: HandEntry;
|
|
106
|
+
constructor(entry: HandEntry, detail: string, options?: {cause?: unknown}) {
|
|
107
|
+
super(
|
|
108
|
+
`failed to load hand '${entry.name}' (entry '${entry.entry}'): ${detail}`,
|
|
109
|
+
options,
|
|
110
|
+
);
|
|
111
|
+
this.name = 'HandLoadError';
|
|
112
|
+
this.entry = entry;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Read the hand config from `<homeRoot>/hands.json`. A missing file yields an
|
|
118
|
+
* EMPTY config (no third-party hands) — the default, install-separate-from-load
|
|
119
|
+
* posture: webhands loads nothing it was not explicitly told to. A present file
|
|
120
|
+
* that is malformed is a hard error (so a broken config is not silently treated
|
|
121
|
+
* as "no hands").
|
|
122
|
+
*/
|
|
123
|
+
export async function readHandsConfig(homeRoot: string): Promise<HandsConfig> {
|
|
124
|
+
const path = resolve(homeRoot, HANDS_CONFIG_FILENAME);
|
|
125
|
+
let raw: string;
|
|
126
|
+
try {
|
|
127
|
+
raw = await readFile(path, 'utf8');
|
|
128
|
+
} catch (cause) {
|
|
129
|
+
if ((cause as NodeJS.ErrnoException)?.code === 'ENOENT') {
|
|
130
|
+
return {hands: []};
|
|
131
|
+
}
|
|
132
|
+
throw cause;
|
|
133
|
+
}
|
|
134
|
+
let parsed: unknown;
|
|
135
|
+
try {
|
|
136
|
+
parsed = JSON.parse(raw);
|
|
137
|
+
} catch (cause) {
|
|
138
|
+
throw new Error(`invalid hand config at ${path}: not valid JSON`, {cause});
|
|
139
|
+
}
|
|
140
|
+
return normalizeConfig(parsed, path);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Validate a parsed config object into a {@link HandsConfig}. Enforces the
|
|
145
|
+
* explicit-named-list + pinned-entry shape: every entry MUST carry a non-empty
|
|
146
|
+
* `name` and a non-empty `entry` (the pinned module). A missing/blank pin is
|
|
147
|
+
* rejected rather than guessed (no convention-inferred entry).
|
|
148
|
+
*/
|
|
149
|
+
export function normalizeConfig(
|
|
150
|
+
parsed: unknown,
|
|
151
|
+
whence = 'hand config',
|
|
152
|
+
): HandsConfig {
|
|
153
|
+
if (parsed === null || typeof parsed !== 'object') {
|
|
154
|
+
throw new Error(`invalid ${whence}: expected an object`);
|
|
155
|
+
}
|
|
156
|
+
const handsValue = (parsed as {hands?: unknown}).hands;
|
|
157
|
+
if (handsValue === undefined) {
|
|
158
|
+
return {hands: []};
|
|
159
|
+
}
|
|
160
|
+
if (!Array.isArray(handsValue)) {
|
|
161
|
+
throw new Error(`invalid ${whence}: 'hands' must be an array`);
|
|
162
|
+
}
|
|
163
|
+
const hands = handsValue.map((value, i) => normalizeEntry(value, i, whence));
|
|
164
|
+
return {hands};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function normalizeEntry(value: unknown, i: number, whence: string): HandEntry {
|
|
168
|
+
if (value === null || typeof value !== 'object') {
|
|
169
|
+
throw new Error(`invalid ${whence}: hands[${i}] must be an object`);
|
|
170
|
+
}
|
|
171
|
+
const {name, entry, source} = value as Record<string, unknown>;
|
|
172
|
+
if (typeof name !== 'string' || name === '') {
|
|
173
|
+
throw new Error(
|
|
174
|
+
`invalid ${whence}: hands[${i}].name must be a non-empty string`,
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
if (typeof entry !== 'string' || entry === '') {
|
|
178
|
+
throw new Error(
|
|
179
|
+
`invalid ${whence}: hands[${i}].entry (the pinned entry point) must be a non-empty string`,
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
if (source !== undefined && typeof source !== 'string') {
|
|
183
|
+
throw new Error(`invalid ${whence}: hands[${i}].source must be a string`);
|
|
184
|
+
}
|
|
185
|
+
return source === undefined ? {name, entry} : {name, entry, source};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Load every hand NAMED in `config`, in declaration order. Each entry's pinned
|
|
190
|
+
* {@link HandEntry.entry} is resolved (relative ⇒ against
|
|
191
|
+
* {@link LoadHandsOptions.baseDir}) and imported; the module must export a
|
|
192
|
+
* {@link Hand} as its DEFAULT export or as a named `hand` export. A failure to
|
|
193
|
+
* resolve/import/validate a named hand throws {@link HandLoadError} (named hands
|
|
194
|
+
* fail loud, never silently skip).
|
|
195
|
+
*
|
|
196
|
+
* Loading nothing for an empty list is the whole point of the model: only the
|
|
197
|
+
* entries the operator explicitly named load, so an installed-but-not-named hand
|
|
198
|
+
* is never reached here.
|
|
199
|
+
*/
|
|
200
|
+
export async function loadHands(
|
|
201
|
+
config: HandsConfig,
|
|
202
|
+
options: LoadHandsOptions = {},
|
|
203
|
+
): Promise<LoadedHand[]> {
|
|
204
|
+
const baseDir = options.baseDir ?? process.cwd();
|
|
205
|
+
const importModule =
|
|
206
|
+
options.importModule ??
|
|
207
|
+
((specifier) => import(specifier) as Promise<unknown>);
|
|
208
|
+
|
|
209
|
+
const loaded: LoadedHand[] = [];
|
|
210
|
+
for (const entry of config.hands) {
|
|
211
|
+
const specifier = resolveEntrySpecifier(entry.entry, baseDir);
|
|
212
|
+
let mod: unknown;
|
|
213
|
+
try {
|
|
214
|
+
mod = await importModule(specifier);
|
|
215
|
+
} catch (cause) {
|
|
216
|
+
throw new HandLoadError(entry, 'could not import the pinned entry', {
|
|
217
|
+
cause,
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
const hand = extractHand(mod);
|
|
221
|
+
if (hand === undefined) {
|
|
222
|
+
throw new HandLoadError(
|
|
223
|
+
entry,
|
|
224
|
+
'the module does not export a Hand (expected a default export or a named `hand` export that is a function)',
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
loaded.push({entry, hand});
|
|
228
|
+
}
|
|
229
|
+
return loaded;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Resolve a pinned entry to an import specifier. A relative/absolute filesystem
|
|
234
|
+
* path is resolved against `baseDir` and converted to a `file://` URL (so a
|
|
235
|
+
* Windows path or a path with spaces imports correctly); anything else is passed
|
|
236
|
+
* through verbatim (the operator may pin a bare package specifier they have
|
|
237
|
+
* installed themselves — webhands does not install it).
|
|
238
|
+
*/
|
|
239
|
+
function resolveEntrySpecifier(entry: string, baseDir: string): string {
|
|
240
|
+
if (isAbsolute(entry) || entry.startsWith('.')) {
|
|
241
|
+
return pathToFileURL(resolve(baseDir, entry)).href;
|
|
242
|
+
}
|
|
243
|
+
return entry;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Pull a {@link Hand} out of an imported module (default or named `hand`). */
|
|
247
|
+
function extractHand(mod: unknown): Hand | undefined {
|
|
248
|
+
if (mod === null || typeof mod !== 'object') {
|
|
249
|
+
return undefined;
|
|
250
|
+
}
|
|
251
|
+
const record = mod as Record<string, unknown>;
|
|
252
|
+
const candidate = record.default ?? record.hand;
|
|
253
|
+
return typeof candidate === 'function' ? (candidate as Hand) : undefined;
|
|
254
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -3,7 +3,7 @@ export type {
|
|
|
3
3
|
Driver,
|
|
4
4
|
LocatorString,
|
|
5
5
|
OpenTarget,
|
|
6
|
-
|
|
6
|
+
WebHandsPage,
|
|
7
7
|
Session,
|
|
8
8
|
Snapshot,
|
|
9
9
|
SnapshotOptions,
|
|
@@ -22,6 +22,20 @@ export {
|
|
|
22
22
|
|
|
23
23
|
export {StubTransport, type StubCall} from './stub-transport.js';
|
|
24
24
|
|
|
25
|
+
export type {Hand, HandContext, HandContribution} from './hand-host.js';
|
|
26
|
+
|
|
27
|
+
export {
|
|
28
|
+
readHandsConfig,
|
|
29
|
+
normalizeConfig,
|
|
30
|
+
loadHands,
|
|
31
|
+
HandLoadError,
|
|
32
|
+
HANDS_CONFIG_FILENAME,
|
|
33
|
+
type HandEntry,
|
|
34
|
+
type HandsConfig,
|
|
35
|
+
type LoadedHand,
|
|
36
|
+
type LoadHandsOptions,
|
|
37
|
+
} from './hand-loading.js';
|
|
38
|
+
|
|
25
39
|
export {PlaywrightLaunchTransport} from './playwright-launch-transport.js';
|
|
26
40
|
|
|
27
41
|
export {PlaywrightAttachTransport} from './playwright-attach-transport.js';
|
|
@@ -68,7 +82,10 @@ export {
|
|
|
68
82
|
SESSION_RPC_PATH,
|
|
69
83
|
applySessionRpc,
|
|
70
84
|
makeRpcPage,
|
|
85
|
+
callHandVerb,
|
|
71
86
|
type SessionRpcRequest,
|
|
87
|
+
type SessionRpcBuiltInRequest,
|
|
88
|
+
type SessionRpcHandRequest,
|
|
72
89
|
type SessionRpcResponse,
|
|
73
90
|
} from './session-rpc.js';
|
|
74
91
|
|
|
@@ -2,24 +2,11 @@ import {
|
|
|
2
2
|
chromium,
|
|
3
3
|
type Browser,
|
|
4
4
|
type BrowserContext,
|
|
5
|
-
type Page
|
|
5
|
+
type Page,
|
|
6
6
|
} from 'playwright';
|
|
7
7
|
import {AttachNoContextError, AttachNotChromiumError} from './errors.js';
|
|
8
|
-
import {
|
|
9
|
-
|
|
10
|
-
resolveLocator,
|
|
11
|
-
waitFor,
|
|
12
|
-
} from './playwright-launch-transport.js';
|
|
13
|
-
import type {
|
|
14
|
-
Cookie,
|
|
15
|
-
OpenTarget,
|
|
16
|
-
Page,
|
|
17
|
-
Session,
|
|
18
|
-
Snapshot,
|
|
19
|
-
SnapshotOptions,
|
|
20
|
-
Transport,
|
|
21
|
-
WaitCondition,
|
|
22
|
-
} from './seam.js';
|
|
8
|
+
import {composeWithHands, type Hand, type HandContext} from './hand-host.js';
|
|
9
|
+
import type {OpenTarget, Session, Transport} from './seam.js';
|
|
23
10
|
|
|
24
11
|
/**
|
|
25
12
|
* The `attach` concrete transport: connect (`chromium.connectOverCDP`) to a
|
|
@@ -45,6 +32,18 @@ import type {
|
|
|
45
32
|
* running one.
|
|
46
33
|
*/
|
|
47
34
|
export class PlaywrightAttachTransport implements Transport {
|
|
35
|
+
readonly #hands: readonly Hand[];
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* @param hands explicitly-loaded third-party hands to compose alongside the
|
|
39
|
+
* built-ins (Phase 2, ADR-0007). These come from {@link loadHands} against
|
|
40
|
+
* the operator's explicit config; the transport does NOT discover them. Omit
|
|
41
|
+
* for the built-ins-only surface.
|
|
42
|
+
*/
|
|
43
|
+
constructor(hands: readonly Hand[] = []) {
|
|
44
|
+
this.#hands = hands;
|
|
45
|
+
}
|
|
46
|
+
|
|
48
47
|
async open(target: OpenTarget): Promise<Session> {
|
|
49
48
|
if (target.mode !== 'attach') {
|
|
50
49
|
throw new Error(
|
|
@@ -78,7 +77,7 @@ export class PlaywrightAttachTransport implements Transport {
|
|
|
78
77
|
// browser exposes a context with no page yet (single active session in
|
|
79
78
|
// v1, PRD Out of Scope).
|
|
80
79
|
const pwPage = context.pages()[0] ?? (await context.newPage());
|
|
81
|
-
return makeAttachedSession(browser, pwPage);
|
|
80
|
+
return makeAttachedSession(browser, pwPage, this.#hands);
|
|
82
81
|
} catch (cause) {
|
|
83
82
|
// On any open-time refusal, disconnect from the user's browser without
|
|
84
83
|
// closing it (a CDP connection close detaches; it does not kill the
|
|
@@ -92,13 +91,23 @@ export class PlaywrightAttachTransport implements Transport {
|
|
|
92
91
|
/**
|
|
93
92
|
* Wrap a CDP-attached browser into the seam's {@link Session}.
|
|
94
93
|
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
94
|
+
* The VERB surface comes from the shared hand-host ({@link composeBuiltInPage}),
|
|
95
|
+
* the SAME single composition the launch transport uses (no duplicated
|
|
96
|
+
* page-object literal). Cookies resolve through the reused context (derived here
|
|
97
|
+
* via `pwPage.context()`) so they reflect the live, authenticated session.
|
|
98
|
+
*
|
|
99
|
+
* Only the SESSION LIFECYCLE is per-transport: this transport listens on the
|
|
100
|
+
* browser's `'disconnected'` event and its `close()` calls `browser.close()`,
|
|
101
|
+
* which DISCONNECTS the controller from the user's browser WITHOUT killing it
|
|
102
|
+
* (a `connectOverCDP` connection detaches rather than terminating the remote
|
|
103
|
+
* process, ADR-0002) — the opposite of the launch transport, which kills the
|
|
104
|
+
* browser it spawned.
|
|
100
105
|
*/
|
|
101
|
-
function makeAttachedSession(
|
|
106
|
+
function makeAttachedSession(
|
|
107
|
+
browser: Browser,
|
|
108
|
+
pwPage: Page,
|
|
109
|
+
extraHands: readonly Hand[],
|
|
110
|
+
): Session {
|
|
102
111
|
const context: BrowserContext = pwPage.context();
|
|
103
112
|
let closed = false;
|
|
104
113
|
const ensureOpen = () => {
|
|
@@ -107,108 +116,45 @@ function makeAttachedSession(browser: Browser, pwPage: PwPage): Session {
|
|
|
107
116
|
}
|
|
108
117
|
};
|
|
109
118
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
if (options?.full === true) {
|
|
122
|
-
const content = await pwPage.evaluate(
|
|
123
|
-
() => document.documentElement.outerHTML,
|
|
124
|
-
);
|
|
125
|
-
return {url, view: 'full', content};
|
|
126
|
-
}
|
|
127
|
-
// Default: the token-cheap accessibility tree + visible text with stable
|
|
128
|
-
// `[ref=...]` refs (see the launch transport and `Snapshot` for the
|
|
129
|
-
// rationale; the string crosses the seam as opaque, transport-neutral
|
|
130
|
-
// text, ADR-0003).
|
|
131
|
-
const content = await pwPage.ariaSnapshot({mode: 'ai'});
|
|
132
|
-
return {url, view: 'accessibility', content};
|
|
133
|
-
},
|
|
134
|
-
// `resolveLocator`/`clickLocator`/`waitFor` are imported from the launch
|
|
135
|
-
// transport so both transports resolve locators and run the verbs through
|
|
136
|
-
// ONE path (no parallel addressing scheme; the forward-note).
|
|
137
|
-
async click(t): Promise<void> {
|
|
138
|
-
ensureOpen();
|
|
139
|
-
// Shared `clickLocator`: normal actionability-checked click with the
|
|
140
|
-
// hidden-element dispatch fallback (PRD story 8), identical to launch.
|
|
141
|
-
await clickLocator(pwPage, t);
|
|
142
|
-
},
|
|
143
|
-
async type(t, text): Promise<void> {
|
|
144
|
-
ensureOpen();
|
|
145
|
-
await resolveLocator(pwPage, t).fill(text);
|
|
146
|
-
},
|
|
147
|
-
async eval(expression: string): Promise<unknown> {
|
|
148
|
-
ensureOpen();
|
|
149
|
-
return pwPage.evaluate(expression);
|
|
150
|
-
},
|
|
151
|
-
async wait(condition: WaitCondition): Promise<void> {
|
|
152
|
-
ensureOpen();
|
|
153
|
-
// Identical to the launch transport (shared `waitFor`): selector /
|
|
154
|
-
// navigation / timeout, so the verb behaves the same on both.
|
|
155
|
-
await waitFor(pwPage, condition);
|
|
156
|
-
},
|
|
157
|
-
async cookies(): Promise<readonly Cookie[]> {
|
|
158
|
-
ensureOpen();
|
|
159
|
-
const raw = await context.cookies();
|
|
160
|
-
return raw.map(toSeamCookie);
|
|
161
|
-
},
|
|
162
|
-
async setCookies(cookies): Promise<void> {
|
|
163
|
-
ensureOpen();
|
|
164
|
-
await context.addCookies(cookies.map(fromSeamCookie));
|
|
165
|
-
},
|
|
119
|
+
// Resolves when the session ends: either the user's browser goes away
|
|
120
|
+
// (Playwright fires 'disconnected' on a connectOverCDP browser) or our own
|
|
121
|
+
// close() disconnects. Lets a caller block until the session is gone.
|
|
122
|
+
let resolveClosed!: () => void;
|
|
123
|
+
const closedSignal = new Promise<void>((resolve) => {
|
|
124
|
+
resolveClosed = resolve;
|
|
125
|
+
});
|
|
126
|
+
const markClosed = () => {
|
|
127
|
+
if (closed) return;
|
|
128
|
+
closed = true;
|
|
129
|
+
resolveClosed();
|
|
166
130
|
};
|
|
131
|
+
browser.on('disconnected', markClosed);
|
|
132
|
+
|
|
133
|
+
// Build the verb surface from the built-in hands over a live hand-context —
|
|
134
|
+
// the same shared host the launch transport uses, so the verbs behave
|
|
135
|
+
// identically across both transports. The live `pwPage`/`context` stay
|
|
136
|
+
// in-process and never cross the seam (ADR-0003).
|
|
137
|
+
const handContext: HandContext = {pwPage, context, ensureOpen};
|
|
138
|
+
const {page, dispose: disposeHands} = composeWithHands(
|
|
139
|
+
handContext,
|
|
140
|
+
extraHands,
|
|
141
|
+
);
|
|
167
142
|
|
|
168
143
|
return {
|
|
169
144
|
page,
|
|
170
145
|
async close(): Promise<void> {
|
|
171
|
-
if (closed)
|
|
172
|
-
|
|
173
|
-
|
|
146
|
+
if (closed) {
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
// Dispose the hands first (their in-process resources), THEN detach from
|
|
150
|
+
// the user's browser without terminating it. browser.close() fires
|
|
151
|
+
// 'disconnected', which runs markClosed.
|
|
152
|
+
await disposeHands();
|
|
174
153
|
await browser.close();
|
|
154
|
+
markClosed();
|
|
155
|
+
},
|
|
156
|
+
waitForClose(): Promise<void> {
|
|
157
|
+
return closedSignal;
|
|
175
158
|
},
|
|
176
|
-
};
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
/** Map a Playwright cookie to the transport-neutral seam {@link Cookie}. */
|
|
180
|
-
function toSeamCookie(c: {
|
|
181
|
-
name: string;
|
|
182
|
-
value: string;
|
|
183
|
-
domain?: string;
|
|
184
|
-
path?: string;
|
|
185
|
-
expires?: number;
|
|
186
|
-
httpOnly?: boolean;
|
|
187
|
-
secure?: boolean;
|
|
188
|
-
sameSite?: 'Strict' | 'Lax' | 'None';
|
|
189
|
-
}): Cookie {
|
|
190
|
-
return {
|
|
191
|
-
name: c.name,
|
|
192
|
-
value: c.value,
|
|
193
|
-
domain: c.domain,
|
|
194
|
-
path: c.path,
|
|
195
|
-
expires: c.expires,
|
|
196
|
-
httpOnly: c.httpOnly,
|
|
197
|
-
secure: c.secure,
|
|
198
|
-
sameSite: c.sameSite,
|
|
199
|
-
};
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
/** Map a seam {@link Cookie} to a Playwright cookie shape. */
|
|
203
|
-
function fromSeamCookie(c: Cookie) {
|
|
204
|
-
return {
|
|
205
|
-
name: c.name,
|
|
206
|
-
value: c.value,
|
|
207
|
-
domain: c.domain,
|
|
208
|
-
path: c.path,
|
|
209
|
-
expires: c.expires,
|
|
210
|
-
httpOnly: c.httpOnly,
|
|
211
|
-
secure: c.secure,
|
|
212
|
-
sameSite: c.sameSite,
|
|
213
159
|
};
|
|
214
160
|
}
|