@handstage/core 0.0.6 → 0.0.8

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 (35) hide show
  1. package/README.md +43 -1
  2. package/dist/v3/logger.js +22 -31
  3. package/dist/v3/types/public/index.js +1 -0
  4. package/dist/v3/types/public/sdkErrors.js +22 -141
  5. package/dist/v3/understudy/a11y/snapshot/capture.js +1 -2
  6. package/dist/v3/understudy/cdp.js +64 -10
  7. package/dist/v3/understudy/context.js +163 -424
  8. package/dist/v3/understudy/fileUploadUtils.js +3 -3
  9. package/dist/v3/understudy/frame.js +11 -2
  10. package/dist/v3/understudy/initScripts.js +1 -1
  11. package/dist/v3/understudy/locator.js +3 -3
  12. package/dist/v3/understudy/page.js +40 -23
  13. package/dist/v3/understudy/piercer.js +4 -3
  14. package/dist/v3/understudy/selectorResolver.js +2 -3
  15. package/dist/v3/understudy/targetRouter.js +189 -0
  16. package/dist/v3/v3.js +340 -258
  17. package/package.json +2 -2
  18. package/src/v3/logger.ts +35 -38
  19. package/src/v3/types/private/internal.ts +24 -7
  20. package/src/v3/types/private/locator.ts +1 -1
  21. package/src/v3/types/public/index.ts +6 -4
  22. package/src/v3/types/public/locator.ts +1 -1
  23. package/src/v3/types/public/sdkErrors.ts +25 -191
  24. package/src/v3/understudy/a11y/snapshot/capture.ts +1 -2
  25. package/src/v3/understudy/cdp.ts +73 -7
  26. package/src/v3/understudy/context.ts +221 -492
  27. package/src/v3/understudy/fileUploadUtils.ts +3 -3
  28. package/src/v3/understudy/frame.ts +12 -0
  29. package/src/v3/understudy/initScripts.ts +1 -1
  30. package/src/v3/understudy/locator.ts +3 -3
  31. package/src/v3/understudy/page.ts +42 -33
  32. package/src/v3/understudy/piercer.ts +4 -2
  33. package/src/v3/understudy/selectorResolver.ts +2 -3
  34. package/src/v3/understudy/targetRouter.ts +236 -0
  35. package/src/v3/v3.ts +387 -271
package/README.md CHANGED
@@ -1,3 +1,45 @@
1
1
  # @handstage/core
2
2
 
3
- Core browser automation engine for Handstage. Manages browser connections, Chrome DevTools Protocol (CDP) communication, page context, frame locators, and script injection for reliable browser automation.
3
+ Core browser automation engine for Handstage. Manages CDP connections,
4
+ target routing, page/frame lifecycle, and script injection for reliable
5
+ browser automation.
6
+
7
+ ## Connection ownership
8
+
9
+ `V3` (alias `Handstage`) owns the CDP connection it constructs:
10
+
11
+ - `V3.connectLocal({ cdpUrl?, ... })` — opens (or attaches via WS) and owns
12
+ the connection. `close()` closes the WebSocket.
13
+ - `V3.connectTransport(transport)` — wraps and owns a raw `CDPTransport`.
14
+ Wrapping the same `transport` again throws
15
+ `HandstageTransportAlreadyOwnedError`.
16
+ - `V3.connectSession(session)` — wraps and owns an `ExternalCDPSession`.
17
+ Same ownership rule as transports.
18
+ - `V3.connectConnection(existingConnection)` — explicit sharing entrypoint.
19
+ V3 does NOT close the connection on `close()`; the caller does.
20
+
21
+ `V3Context.close()` only ever calls `Target.disposeBrowserContext` (for
22
+ dedicated contexts). It never tears down the underlying CDP connection —
23
+ that responsibility lives with `V3` or, for shared connections, the caller.
24
+
25
+ ## Default-context attach
26
+
27
+ Handstage creates instances containing the `defaultBrowserContext()` by default (which aligns with Puppeteer's `puppeteer.connect` and `puppeteer.launch`).
28
+ Two Handstage clients on the same CDP websocket therefore share the default browser context natively without breaking. If you want isolation, call `v3.createBrowserContext()`, which returns an isolated browser context. Targets owned by other browser contexts are resumed/detached at the target router rather than left paused.
29
+
30
+ To intentionally attach to the shared default context (and accept that
31
+ other actors may race with you on a shared tab), simply use the default browser context (i.e. `v3.newPage()`).
32
+
33
+ ## Active page is gone
34
+
35
+ Contexts no longer auto-create an initial page and there is no
36
+ `context.activePage()` / `setActivePage()` / `awaitActivePage()`. Track
37
+ `Page` references yourself (from `newPage()` / `pages()`) and call
38
+ `page.bringToFront()` if you want to foreground a tab.
39
+
40
+ ## Per-instance logging
41
+
42
+ Pass `logger:` to any `V3.connect*` factory and that logger receives every
43
+ log from that V3's contexts / pages / network managers / target-router
44
+ delegate. Two V3 instances on a shared connection each receive router-level
45
+ debug lines via broadcast.
package/dist/v3/logger.js CHANGED
@@ -1,37 +1,28 @@
1
- import { AsyncLocalStorage } from "node:async_hooks";
2
1
  import { createConsoleLogger } from "./types/public/consoleLogger";
2
+ import { LogLevel, shouldEmitLogLine } from "./types/public/logs";
3
3
  /**
4
- * Handstage V3 per-instance log routing (AsyncLocalStorage).
5
- *
6
- * - `bindInstanceLogger` / `unbindInstanceLogger`: register the effective logger for an instance id.
7
- * - `withInstanceLogContext`: run a function with that instance id on the async context.
8
- * - `v3Logger`: emit a line for the current instance, or fall back to `createConsoleLogger()` when no context.
4
+ * Build a level-filtered logger from the caller-supplied logger (or a
5
+ * console fallback). Used by V3 at construction time to wrap the user's
6
+ * raw logger into one that already respects `verbose`.
9
7
  */
10
- const logContext = new AsyncLocalStorage();
11
- const instanceLoggers = new Map();
12
- const fallbackLogger = createConsoleLogger();
13
- export function bindInstanceLogger(instanceId, logger) {
14
- instanceLoggers.set(instanceId, logger);
8
+ export function createFilteredLogger(rawLogger, verbose) {
9
+ const sink = rawLogger ?? createConsoleLogger();
10
+ const minLevel = verbose ?? LogLevel.Info;
11
+ return (line) => {
12
+ if (!shouldEmitLogLine(line.level, minLevel))
13
+ return;
14
+ sink({ ...line, level: line.level ?? LogLevel.Info });
15
+ };
15
16
  }
16
- export function unbindInstanceLogger(instanceId) {
17
- instanceLoggers.delete(instanceId);
18
- }
19
- export function withInstanceLogContext(instanceId, fn) {
20
- return logContext.run(instanceId, fn);
21
- }
22
- export function v3Logger(line) {
23
- const id = logContext.getStore();
24
- if (id) {
25
- const fn = instanceLoggers.get(id);
26
- if (fn) {
27
- try {
28
- fn(line);
29
- return;
30
- }
31
- catch {
32
- // fall through to fallback
33
- }
34
- }
17
+ /**
18
+ * Lazily-constructed console fallback used for code paths that fire before
19
+ * a real logger is plumbed in (e.g. the few static utility functions that
20
+ * still take an optional logger arg).
21
+ */
22
+ let _defaultLogger = null;
23
+ export function defaultLogger() {
24
+ if (!_defaultLogger) {
25
+ _defaultLogger = createFilteredLogger(undefined, LogLevel.Info);
35
26
  }
36
- fallbackLogger(line);
27
+ return _defaultLogger;
37
28
  }
@@ -1,4 +1,5 @@
1
1
  // Export api.ts under namespace to avoid name collisions
2
+ export { CDPConnection, } from "../../understudy/cdp";
2
3
  export * as Api from "./api";
3
4
  export * from "./consoleLogger";
4
5
  export * from "./context";
@@ -10,68 +10,6 @@ export class HandstageError extends Error {
10
10
  }
11
11
  }
12
12
  }
13
- export class HandstageDefaultError extends HandstageError {
14
- constructor(error) {
15
- if (error instanceof Error || error instanceof HandstageError) {
16
- super(`\nHey! We're sorry you ran into an error. \nHandstage version: ${HANDSTAGE_VERSION} \nIf you need help, please open a Github issue or reach out to us on Discord: https://handstage.dev/discord\n\nFull error:\n${error.message}`);
17
- }
18
- }
19
- }
20
- export class HandstageEnvironmentError extends HandstageError {
21
- constructor(currentEnvironment, requiredEnvironment, feature) {
22
- super(`You seem to be setting the current environment to ${currentEnvironment}.` +
23
- `Ensure the environment is set to ${requiredEnvironment} if you want to use ${feature}.`);
24
- }
25
- }
26
- export class MissingEnvironmentVariableError extends HandstageError {
27
- constructor(missingEnvironmentVariable, feature) {
28
- super(`${missingEnvironmentVariable} is required to use ${feature}.` +
29
- `Please set ${missingEnvironmentVariable} in your environment.`);
30
- }
31
- }
32
- export class UnsupportedModelError extends HandstageError {
33
- constructor(supportedModels, feature) {
34
- const message = feature
35
- ? `${feature} requires a valid model.`
36
- : `Unsupported model.`;
37
- const guidance = `\n\nPlease use the provider/model format (e.g., "openai/gpt-4o", "anthropic/claude-sonnet-4-5", "google/gemini-3-flash-preview").` +
38
- `\n\nFor a complete list of supported models and providers, see: https://docs.handstage.dev/v3/configuration/models#configuration-setup`;
39
- super(`${message}${guidance}`);
40
- }
41
- }
42
- export class UnsupportedModelProviderError extends HandstageError {
43
- constructor(supportedProviders, feature) {
44
- super(feature
45
- ? `${feature} requires one of the following model providers: ${supportedProviders}`
46
- : `please use one of the supported model providers: ${supportedProviders}`);
47
- }
48
- }
49
- export class UnsupportedAISDKModelProviderError extends HandstageError {
50
- constructor(provider, supportedProviders) {
51
- super(`${provider} is not currently supported for aiSDK. please use one of the supported model providers: ${supportedProviders}`);
52
- }
53
- }
54
- export class InvalidAISDKModelFormatError extends HandstageError {
55
- constructor(modelName) {
56
- super(`${modelName} does not follow correct format for specifying aiSDK models. Please define your model as 'provider/model-name'. For example: \`model: 'openai/gpt-4o-mini'\``);
57
- }
58
- }
59
- export class CaptchaTimeoutError extends HandstageError {
60
- constructor() {
61
- super("Captcha timeout");
62
- }
63
- }
64
- export class MissingLLMConfigurationError extends HandstageError {
65
- constructor() {
66
- super("No LLM API key or LLM Client configured. An LLM API key or a custom LLM Client " +
67
- "is required to use act, extract, or observe.");
68
- }
69
- }
70
- export class HandlerNotInitializedError extends HandstageError {
71
- constructor(handlerType) {
72
- super(`${handlerType} handler not initialized`);
73
- }
74
- }
75
13
  export class HandstageInvalidArgumentError extends HandstageError {
76
14
  constructor(message) {
77
15
  super(`InvalidArgumentError: ${message}`);
@@ -92,21 +30,6 @@ export class HandstageElementNotFoundError extends HandstageError {
92
30
  super(`Could not find an element for the given xPath(s): ${xpaths}`);
93
31
  }
94
32
  }
95
- export class AgentScreenshotProviderError extends HandstageError {
96
- constructor(message) {
97
- super(`ScreenshotProviderError: ${message}`);
98
- }
99
- }
100
- export class HandstageMissingArgumentError extends HandstageError {
101
- constructor(message) {
102
- super(`MissingArgumentError: ${message}`);
103
- }
104
- }
105
- export class CreateChatCompletionResponseError extends HandstageError {
106
- constructor(message) {
107
- super(`CreateChatCompletionResponseError: ${message}`);
108
- }
109
- }
110
33
  export class HandstageEvalError extends HandstageError {
111
34
  constructor(message) {
112
35
  super(`HandstageEvalError: ${message}`);
@@ -122,16 +45,6 @@ export class HandstageLocatorError extends HandstageError {
122
45
  super(`Error ${action} Element with selector: ${selector} Reason: ${message}`);
123
46
  }
124
47
  }
125
- export class HandstageClickError extends HandstageError {
126
- constructor(message, selector) {
127
- super(`Error Clicking Element with selector: ${selector} Reason: ${message}`);
128
- }
129
- }
130
- export class LLMResponseError extends HandstageError {
131
- constructor(primitive, message) {
132
- super(`${primitive} LLM response error: ${message}`);
133
- }
134
- }
135
48
  export class HandstageIframeError extends HandstageError {
136
49
  constructor(frameUrl, message) {
137
50
  super(`Unable to resolve frameId for iframe with URL: ${frameUrl} Full error: ${message}`);
@@ -142,49 +55,6 @@ export class ContentFrameNotFoundError extends HandstageError {
142
55
  super(`Unable to obtain a content frame for selector: ${selector}`);
143
56
  }
144
57
  }
145
- export class XPathResolutionError extends HandstageError {
146
- constructor(xpath) {
147
- super(`XPath "${xpath}" does not resolve in the current page or frames`);
148
- }
149
- }
150
- export class ZodSchemaValidationError extends Error {
151
- received;
152
- issues;
153
- constructor(received, issues) {
154
- super(`Zod schema validation failed
155
-
156
- — Received —
157
- ${JSON.stringify(received, null, 2)}
158
-
159
- — Issues —
160
- ${JSON.stringify(issues, null, 2)}`);
161
- this.received = received;
162
- this.issues = issues;
163
- this.name = "ZodSchemaValidationError";
164
- }
165
- }
166
- export class HandstageInitError extends HandstageError {
167
- constructor(message) {
168
- super(message);
169
- }
170
- }
171
- export class HandstageShadowRootMissingError extends HandstageError {
172
- constructor(detail) {
173
- super(`No shadow root present on the resolved host` +
174
- (detail ? `: ${detail}` : ""));
175
- }
176
- }
177
- export class HandstageShadowSegmentEmptyError extends HandstageError {
178
- constructor() {
179
- super(`Empty selector segment after shadow-DOM hop ("//")`);
180
- }
181
- }
182
- export class HandstageShadowSegmentNotFoundError extends HandstageError {
183
- constructor(segment, hint) {
184
- super(`Shadow segment '${segment}' matched no element inside shadow root` +
185
- (hint ? ` ${hint}` : ""));
186
- }
187
- }
188
58
  export class ElementNotVisibleError extends HandstageError {
189
59
  constructor(selector) {
190
60
  super(`Element not visible (no box model): ${selector}`);
@@ -215,16 +85,33 @@ export class ConnectionTimeoutError extends HandstageError {
215
85
  super(`Connection timeout: ${message}`);
216
86
  }
217
87
  }
218
- export class HandstageClosedError extends HandstageError {
219
- constructor() {
220
- super("Handstage session was closed");
221
- }
222
- }
223
88
  export class CDPConnectionClosedError extends HandstageError {
224
89
  constructor(reason) {
225
90
  super(`CDP connection closed: ${reason}`);
226
91
  }
227
92
  }
93
+ /**
94
+ * Raised when a caller tries to wrap a `CDPTransport` (via
95
+ * `new CDPConnection(transport)` / `V3.connectTransport`) or an
96
+ * `ExternalCDPSession` (via `V3.connectSession`) that is already owned by
97
+ * another `CDPConnection` / `ExternalConnectionAdapter`.
98
+ *
99
+ * Silently double-wrapping would clobber `transport.onmessage` / `.onclose` /
100
+ * `.onerror` and stall the first owner. If you actually want two `V3`
101
+ * instances sharing one CDP connection, construct the connection once and
102
+ * use `V3.connectConnection(existingConnection)` for both instances.
103
+ */
104
+ export class HandstageTransportAlreadyOwnedError extends HandstageError {
105
+ constructor(kind) {
106
+ super(kind === "transport"
107
+ ? "CDPTransport already owned by another CDPConnection. " +
108
+ "Construct the CDPConnection once and share it via V3.connectConnection() " +
109
+ "instead of wrapping the same transport twice."
110
+ : "ExternalCDPSession already owned by another ExternalConnectionAdapter. " +
111
+ "Construct the adapter once and share the resulting CDPConnectionLike via " +
112
+ "V3.connectConnection() instead of calling connectSession twice with the same session.");
113
+ }
114
+ }
228
115
  export class HandstageSetExtraHTTPHeadersError extends HandstageError {
229
116
  failures;
230
117
  constructor(failures) {
@@ -242,9 +129,3 @@ export class HandstageSnapshotError extends HandstageError {
242
129
  super(`error taking snapshot${suffix}`, cause);
243
130
  }
244
131
  }
245
- export class UnderstudyCommandException extends HandstageError {
246
- constructor(message, cause) {
247
- super(message, cause);
248
- this.name = "UnderstudyCommandException";
249
- }
250
- }
@@ -1,4 +1,3 @@
1
- import { v3Logger } from "../../../logger";
2
1
  import { LogLevel } from "../../../types/public/logs";
3
2
  import { a11yForFrame } from "./a11yTree";
4
3
  import { buildSessionDomIndex, domMapsForSession, relativizeXPath, } from "./domTree";
@@ -71,7 +70,7 @@ export async function tryScopedSnapshot(page, options, context, pierce) {
71
70
  if (!requestedFocus)
72
71
  return null;
73
72
  const logScopeFallback = () => {
74
- v3Logger({
73
+ page.logger({
75
74
  message: `Unable to narrow scope with selector. Falling back to using full DOM`,
76
75
  level: LogLevel.Info,
77
76
  attributes: {
@@ -1,5 +1,29 @@
1
1
  import { HANDSTAGE_VERSION } from "../../version";
2
- import { CDPConnectionClosedError, PageNotFoundError, } from "../types/public/sdkErrors";
2
+ import { CDPConnectionClosedError, HandstageTransportAlreadyOwnedError, PageNotFoundError, } from "../types/public/sdkErrors";
3
+ /**
4
+ * Marker placed on a `CDPTransport` once a `CDPConnection` has bound its
5
+ * `onmessage` / `onclose` / `onerror` callbacks. A second wrap throws so
6
+ * the caller can't silently destroy the first owner. Use `Symbol.for(...)`
7
+ * so the marker survives across module realms (rare, but cheap to guard).
8
+ */
9
+ const TRANSPORT_OWNED = Symbol.for("handstage.cdp.transportOwned");
10
+ /**
11
+ * Same marker as {@link TRANSPORT_OWNED} but for `ExternalCDPSession`
12
+ * wrapped by `ExternalConnectionAdapter`.
13
+ */
14
+ const SESSION_OWNED = Symbol.for("handstage.cdp.sessionOwned");
15
+ function invokeEventHandler(handler, params) {
16
+ try {
17
+ const result = handler(params);
18
+ if (result &&
19
+ typeof result === "object" &&
20
+ "then" in result &&
21
+ typeof result.then === "function") {
22
+ void result.catch(() => { });
23
+ }
24
+ }
25
+ catch { }
26
+ }
3
27
  export class BaseCDPConnection {
4
28
  // Memoize the in-flight enable so concurrent V3Contexts sharing the
5
29
  // connection don't all re-fire setAutoAttach on the browser session.
@@ -71,6 +95,12 @@ export class CDPConnection extends BaseCDPConnection {
71
95
  }
72
96
  constructor(transport) {
73
97
  super();
98
+ const owned = transport[TRANSPORT_OWNED];
99
+ if (owned) {
100
+ throw new HandstageTransportAlreadyOwnedError("transport");
101
+ }
102
+ ;
103
+ transport[TRANSPORT_OWNED] = this;
74
104
  this.transport = transport;
75
105
  this.transport.onclose = (reason) => {
76
106
  this._isClosed = true;
@@ -154,7 +184,18 @@ export class CDPConnection extends BaseCDPConnection {
154
184
  }
155
185
  async close() {
156
186
  this._isClosed = true;
157
- this.transport.close();
187
+ try {
188
+ this.transport.close();
189
+ }
190
+ finally {
191
+ // Release ownership so a future caller could re-wrap a fresh
192
+ // transport with the same identity (rare; mainly relevant in
193
+ // long-running tests that reuse fake transports).
194
+ try {
195
+ delete this.transport[TRANSPORT_OWNED];
196
+ }
197
+ catch { }
198
+ }
158
199
  }
159
200
  rejectAllInflight(why) {
160
201
  for (const [id, entry] of this.inflight.entries()) {
@@ -272,14 +313,14 @@ export class CDPConnection extends BaseCDPConnection {
272
313
  const handlers = this.eventHandlers.get(method);
273
314
  if (handlers)
274
315
  for (const h of handlers)
275
- h(params);
316
+ invokeEventHandler(h, params);
276
317
  }
277
318
  return;
278
319
  }
279
320
  const handlers = this.eventHandlers.get(method);
280
321
  if (handlers)
281
322
  for (const h of handlers)
282
- h(params);
323
+ invokeEventHandler(h, params);
283
324
  };
284
325
  dispatch();
285
326
  }
@@ -339,7 +380,7 @@ export class CDPConnection extends BaseCDPConnection {
339
380
  const handlers = this.eventHandlers.get(key);
340
381
  if (handlers)
341
382
  for (const h of handlers)
342
- h(params);
383
+ invokeEventHandler(h, params);
343
384
  }
344
385
  }
345
386
  export class ExternalConnectionAdapter extends BaseCDPConnection {
@@ -353,6 +394,13 @@ export class ExternalConnectionAdapter extends BaseCDPConnection {
353
394
  constructor(externalSession) {
354
395
  super();
355
396
  this.externalSession = externalSession;
397
+ const owned = externalSession[SESSION_OWNED];
398
+ if (owned) {
399
+ throw new HandstageTransportAlreadyOwnedError("session");
400
+ }
401
+ ;
402
+ externalSession[SESSION_OWNED] =
403
+ this;
356
404
  // Listen for flattened child session events if the external wrapper passes them
357
405
  this.on("Target.attachedToTarget", (params) => {
358
406
  if (params?.sessionId && !this.sessions.has(params.sessionId)) {
@@ -390,21 +438,23 @@ export class ExternalConnectionAdapter extends BaseCDPConnection {
390
438
  const childHandlers = this.eventHandlers.get(childKey);
391
439
  if (childHandlers) {
392
440
  for (const h of childHandlers)
393
- h(params);
441
+ invokeEventHandler(h, params);
394
442
  }
395
443
  // Forward target lifecycle events to root listeners as well.
396
444
  if (event.startsWith("Target.")) {
397
445
  const rootHandlers = this.eventHandlers.get(event);
398
- if (rootHandlers)
446
+ if (rootHandlers) {
399
447
  for (const h of rootHandlers)
400
- h(params);
448
+ invokeEventHandler(h, params);
449
+ }
401
450
  }
402
451
  }
403
452
  else {
404
453
  const rootHandlers = this.eventHandlers.get(event);
405
- if (rootHandlers)
454
+ if (rootHandlers) {
406
455
  for (const h of rootHandlers)
407
- h(params);
456
+ invokeEventHandler(h, params);
457
+ }
408
458
  }
409
459
  };
410
460
  this.rootEventHandlers.set(event, rootHandler);
@@ -441,6 +491,10 @@ export class ExternalConnectionAdapter extends BaseCDPConnection {
441
491
  if (this.externalSession.onclose) {
442
492
  this.externalSession.onclose = undefined;
443
493
  }
494
+ try {
495
+ delete this.externalSession[SESSION_OWNED];
496
+ }
497
+ catch { }
444
498
  // If external session has a close method, invoke it, otherwise no-op.
445
499
  if (typeof this.externalSession.close === "function") {
446
500
  await this.externalSession.close();