browserscale-ts 1.2.1 → 1.4.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/client.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import type { Transport } from "@connectrpc/connect";
2
2
  import type { Locator } from "./locator.ts";
3
- import type { DOMResult, DragResult, ElementResult, EvaluateResult, InterceptedRequest, InterceptedResponse, InspectResult, NavigateResult, ObservationResult, PageInfo, ScreenshotResult, ReadCanvasResult, SelectOptionResult, WaitResult } from "./types.ts";
4
- import type { ClickOpts, FillOpts, GetDOMOpts, GetObservationOpts, LoadHTMLOpts, NavigateOpts, ScreenshotOpts, ReadCanvasOpts, SelectOpts, WaitOpts } from "./options.ts";
3
+ import type { DOMResult, DragResult, ElementResult, EvaluateResult, InterceptedRequest, InterceptedResponse, InspectResult, NavigateResult, PageInfo, ScreenshotResult, ReadCanvasResult, SelectOptionResult, WaitResult, IceServer, ReactionInfo } from "./types.ts";
4
+ import type { ClickOpts, FillOpts, GetDOMOpts, GetObservationOpts, LoadHTMLOpts, NavigateOpts, ReactionOpts, ScreenshotOpts, ReadCanvasOpts, SelectOpts, WaitOpts } from "./options.ts";
5
5
  import type { HeaderModification, RequestPattern } from "./network.ts";
6
6
  import type { CookieParam } from "./cookies.ts";
7
7
  import type { StorageOriginEntry } from "./storage.ts";
@@ -462,27 +462,43 @@ export declare class CloudBrowser {
462
462
  */
463
463
  getDOMHash(frameId?: string): Promise<string>;
464
464
  /**
465
- * Returns a compact, agent-friendly description of every interactable
466
- * element currently visible on the page, together with a truncated view
467
- * of the surrounding text.
465
+ * Returns a compact, frame-aware view of the visible page — the first thing
466
+ * to reach for on an unfamiliar page, and the cheapest way to re-read the
467
+ * current state afterwards.
468
468
  *
469
- * Intended as input for LLM/agent loops where a full DOM dump would be
470
- * too large; the server filters down to elements that are actually
471
- * visible and interactable.
469
+ * Each frame opens with header lines carrying the URL, the title and the
470
+ * scroll position, then one line per visible element:
472
471
  *
473
- * @param opts - optional caps: `maxElementsPerFrame`, `maxTextLength`;
474
- * omit either to use the server default
472
+ * ```
473
+ * input#email[47] type="email" name="loginId" value="a@b.com" required click "E-Mail"
474
+ * ```
475
475
  *
476
- * @returns ObservationResult with both a human-readable `text` rendering
477
- * and a `json` payload of the structured observation
476
+ * It spans every frame, pierces open and closed shadow roots, enumerates
477
+ * `<select>` options, and reports live form state: `value=` is what is typed
478
+ * in right now (passwords as a length), `checked=` for boxes. The trailing
479
+ * quoted string is always the label or text, never the value, so an empty and
480
+ * a prefilled field stay distinguishable. Because the headers already carry
481
+ * URL, title and scroll offset, this replaces the usual handful of
482
+ * {@link evaluate} probes after each step.
483
+ *
484
+ * On what to do with the result: backendNodeId (the `47` above) is a handle
485
+ * for this session and can be passed straight to click/fill via
486
+ * {@link node}. It does not survive a new document, so for anything you write
487
+ * into a script, target with {@link css} or {@link js} instead — those calls
488
+ * return the backendNodeId they resolved to, which lets you confirm the
489
+ * durable anchor hits the element you saw.
490
+ *
491
+ * @param opts - optional format and budget overrides; see {@link GetObservationOpts}
492
+ *
493
+ * @returns the observation in the requested format, ready to hand to a model
478
494
  *
479
495
  * @throws UNKNOWN_ERROR - the observation could not be produced
480
496
  *
481
497
  * @example
482
- * const obs = await browser.getObservation({ maxElementsPerFrame: 200 });
483
- * console.log(obs.text);
498
+ * const obs = await browser.getObservation();
499
+ * console.log(obs);
484
500
  */
485
- getObservation(opts?: GetObservationOpts): Promise<ObservationResult>;
501
+ getObservation(opts?: GetObservationOpts): Promise<string>;
486
502
  /**
487
503
  * Captures a single image of the page's current frame and returns it as
488
504
  * base64-encoded image bytes.
@@ -818,6 +834,36 @@ export declare class CloudBrowser {
818
834
  * await browser.insertText("hello world");
819
835
  */
820
836
  insertText(text: string): Promise<void>;
837
+ /**
838
+ * Types `text` into the currently focused element as a per-key stream of real
839
+ * keyboard events (keyDown/char/keyUp with the context's QWERTZ/QWERTY layout
840
+ * and human cadence) — unlike {@link insertText}, a single IME-style commit
841
+ * with no key events.
842
+ *
843
+ * `type` is intentionally UNtargeted and loose: it does not locate or focus
844
+ * any element and does NOT pin focus, so the page is free to route keys and
845
+ * move focus between fields mid-stream — ideal for one-time-code / OTP inputs
846
+ * that auto-advance to the next box on each digit. To type one specific field
847
+ * that must stay focused for the whole value, use {@link fill} instead
848
+ * (strict, target-bound, per-key focus-verified).
849
+ *
850
+ * Nothing is focused for you: {@link click} (or {@link fill}) the field first,
851
+ * or otherwise ensure focus, before calling `type`.
852
+ *
853
+ * @param text - the text to type as real key events
854
+ * @param opts - optional: `clearFirst` clears the focused field (Ctrl+A,
855
+ * Delete) before typing
856
+ *
857
+ * @throws UNKNOWN_ERROR - the page/context was torn down mid-stream
858
+ *
859
+ * @example
860
+ * // OTP field that auto-advances across boxes.
861
+ * await browser.click(css("input.otp-0"));
862
+ * await browser.type("123456");
863
+ */
864
+ type(text: string, opts?: {
865
+ clearFirst?: boolean;
866
+ }): Promise<void>;
821
867
  /**
822
868
  * Fires a single key-down event.
823
869
  *
@@ -901,4 +947,113 @@ export declare class CloudBrowser {
901
947
  timeoutMs?: number;
902
948
  retryAmount?: number;
903
949
  }): Promise<string>;
950
+ /**
951
+ * Returns the ICE servers (TURN URL + short-lived credentials) to put in
952
+ * your `RTCPeerConnection` BEFORE creating the offer, so it can gather relay
953
+ * candidates.
954
+ *
955
+ * Live streaming is a two-step, client-offerer handshake: call
956
+ * `getStreamConfig`, build your peer with the returned servers, create an
957
+ * offer, then pass its SDP to {@link startStream} and apply the answer.
958
+ *
959
+ * @returns the ICE servers for the client `RTCPeerConnection`
960
+ *
961
+ * @throws UNKNOWN_ERROR - TURN is not configured on the server
962
+ *
963
+ * @example
964
+ * const ice = await browser.getStreamConfig();
965
+ * const pc = new RTCPeerConnection({ iceServers: ice });
966
+ */
967
+ getStreamConfig(): Promise<IceServer[]>;
968
+ /**
969
+ * Answers your WebRTC SDP offer and starts streaming the page as a video
970
+ * track, returning the SDP answer to set as your peer's remote description.
971
+ * The browser is the answerer; you are the offerer (see
972
+ * {@link getStreamConfig} for the credentials to build the offer).
973
+ *
974
+ * @param offerSdp - your `RTCPeerConnection`'s SDP offer
975
+ *
976
+ * @returns the SDP answer to apply as the remote description
977
+ *
978
+ * @throws UNKNOWN_ERROR - the offer was empty, TURN is unconfigured, or the
979
+ * browser could not negotiate the stream
980
+ *
981
+ * @example
982
+ * const answer = await browser.startStream(offer.sdp);
983
+ * await pc.setRemoteDescription({ type: "answer", sdp: answer });
984
+ */
985
+ startStream(offerSdp: string): Promise<string>;
986
+ /**
987
+ * Tears down the live video stream for the session's page. Safe to call even
988
+ * if no stream is running.
989
+ *
990
+ * @throws UNKNOWN_ERROR - the stream could not be stopped
991
+ *
992
+ * @example
993
+ * await browser.stopStream();
994
+ */
995
+ stopStream(): Promise<void>;
996
+ /**
997
+ * Registers a one-shot "reaction": a background poller (one shared loop per
998
+ * page) watches for the `match` locator and, as soon as it matches, clicks it
999
+ * with the full smart-click machinery (scroll, human path, occlusion gate,
1000
+ * evade) — then removes itself. The poller yields to any in-flight input
1001
+ * action and only fires while the pointer is idle, so a reaction naturally
1002
+ * slots into the gaps of a retrying foreground action (e.g. it dismisses a
1003
+ * newsletter modal blocking a {@link CloudBrowser.click}, after which the
1004
+ * click's own retry succeeds). Reactions are scoped to the page and torn
1005
+ * down automatically when the page/session ends.
1006
+ *
1007
+ * `match` must be a `css()` or `js()` {@link Locator} — `node()`/`at()` are
1008
+ * rejected. Use `.inAllFrames()` to watch every frame and `.visible(false)`
1009
+ * to opt out of the default visibility gate. Pass a `ReactionOpts` to click a
1010
+ * different target (`on`), change the button/click count, or the poll cadence.
1011
+ *
1012
+ * @param match - the css()/js() locator to watch for
1013
+ * @param opts - optional reaction customization; see {@link ReactionOpts}
1014
+ *
1015
+ * @returns the reactionId (pass to {@link CloudBrowser.removeReaction})
1016
+ *
1017
+ * @throws {@link BrowserScaleError} - `match` (or `opts.on`) is not a css()/js()
1018
+ * locator, or a server/transport error
1019
+ *
1020
+ * @example
1021
+ * // Auto-dismiss a consent button whenever it appears, in any frame.
1022
+ * const id = await browser.addReaction(css("button#accept").inAllFrames());
1023
+ *
1024
+ * @example
1025
+ * // Watch for a newsletter modal, but click its close "X" instead.
1026
+ * const id = await browser.addReaction(css("#newsletter-modal"), {
1027
+ * on: css(".modal-close"),
1028
+ * });
1029
+ */
1030
+ addReaction(match: Locator, opts?: ReactionOpts): Promise<string>;
1031
+ /**
1032
+ * Removes a pending reaction by id. Returns `false` if the reaction had
1033
+ * already fired (one-shot) or was never registered.
1034
+ *
1035
+ * @param reactionId - id returned by {@link CloudBrowser.addReaction}
1036
+ *
1037
+ * @returns true if a pending reaction with this id existed and was removed
1038
+ *
1039
+ * @example
1040
+ * const removed = await browser.removeReaction(id);
1041
+ */
1042
+ removeReaction(reactionId: string): Promise<boolean>;
1043
+ /**
1044
+ * Returns the still-pending reactions registered for the current page.
1045
+ * Reactions that have already fired (one-shot) are not included.
1046
+ *
1047
+ * @returns the pending reactions for the page
1048
+ *
1049
+ * @example
1050
+ * const pending = await browser.listReactions();
1051
+ * for (const r of pending) console.log(r.reactionId, r.matchSelector);
1052
+ */
1053
+ listReactions(): Promise<ReactionInfo[]>;
1054
+ /**
1055
+ * @internal — a reaction match/action must be a css()/js() locator: node()
1056
+ * and at() are rejected (a reaction watches for a condition, like a wait).
1057
+ */
1058
+ private assertReactionLocator;
904
1059
  }
package/dist/client.js CHANGED
@@ -2,7 +2,7 @@ import { createClient } from "@connectrpc/connect";
2
2
  import { create } from "@bufbuild/protobuf";
3
3
  import { Browser,
4
4
  // request / response schemas
5
- SetProxyRequestSchema, GetPagesRequestSchema, NavigateRequestSchema, LoadHTMLRequestSchema, EvaluateRequestSchema, WaitForAnyParamsSchema, WaitConditionSchema, ClickRequestSchema, FillRequestSchema, MoveToRequestSchema, ScrollToRequestSchema, DragRequestSchema, SelectOptionRequestSchema, GetDOMRequestSchema, GetDOMHashRequestSchema, GetObservationRequestSchema, ScreenshotRequestSchema, ReadCanvasRequestSchema, SetBlockListRequestSchema, SetStaticPathsRequestSchema, WaitForAnyRequestRequestSchema, WaitForAnyResponseRequestSchema, ModifyRequestRequestSchema, GetCookiesRequestSchema, SetCookiesRequestSchema, ClearCookiesRequestSchema, GetStorageRequestSchema, SetStorageRequestSchema, ClearStorageRequestSchema, InspectAtPositionRequestSchema, HighlightNodeRequestSchema, InsertTextRequestSchema, PressKeyRequestSchema, ReleaseKeyRequestSchema, GetSelectionRequestSchema, SolveCaptchaRequestSchema, } from "./gen/wrc_pb.js";
5
+ SetProxyRequestSchema, GetPagesRequestSchema, NavigateRequestSchema, LoadHTMLRequestSchema, EvaluateRequestSchema, WaitForAnyParamsSchema, WaitConditionSchema, ClickRequestSchema, FillRequestSchema, MoveToRequestSchema, ScrollToRequestSchema, DragRequestSchema, SelectOptionRequestSchema, GetDOMRequestSchema, GetDOMHashRequestSchema, GetObservationRequestSchema, ScreenshotRequestSchema, ReadCanvasRequestSchema, SetBlockListRequestSchema, SetStaticPathsRequestSchema, WaitForAnyRequestRequestSchema, WaitForAnyResponseRequestSchema, ModifyRequestRequestSchema, GetCookiesRequestSchema, SetCookiesRequestSchema, ClearCookiesRequestSchema, GetStorageRequestSchema, SetStorageRequestSchema, ClearStorageRequestSchema, InspectAtPositionRequestSchema, HighlightNodeRequestSchema, InsertTextRequestSchema, TypeRequestSchema, PressKeyRequestSchema, ReleaseKeyRequestSchema, GetSelectionRequestSchema, SolveCaptchaRequestSchema, GetStreamConfigRequestSchema, StartStreamRequestSchema, StopStreamRequestSchema, AddReactionRequestSchema, RemoveReactionRequestSchema, ListReactionsRequestSchema, } from "./gen/wrc_pb.js";
6
6
  import { DefaultWaitTimeoutMs } from "./defaults.js";
7
7
  import { BrowserScaleError } from "./errors.js";
8
8
  import { cookieParamFromProto, cookieParamsToProto, elementFields, headerModsToProto, headersToProto, interceptedRequestFromProto, interceptedResponseFromProto, pageInfoFromProto, rectFromProto, splitRequestPatterns, storageEntriesToProto, storageEntryFromProto, unwrapClick, unwrapDrag, unwrapFill, unwrapMove, unwrapScroll, unwrapSelect, unwrapWait, } from "./internal/convert.js";
@@ -437,6 +437,10 @@ export class CloudBrowser {
437
437
  });
438
438
  if (opts?.clearFirst)
439
439
  req.clearFirst = true;
440
+ if (opts?.timeoutMs !== undefined)
441
+ req.timeout = opts.timeoutMs;
442
+ if (opts?.steadyMs !== undefined)
443
+ req.steadyTime = opts.steadyMs;
440
444
  return unwrapFill(await this.client.fill(req));
441
445
  }
442
446
  /**
@@ -701,38 +705,70 @@ export class CloudBrowser {
701
705
  return resp.hash;
702
706
  }
703
707
  /**
704
- * Returns a compact, agent-friendly description of every interactable
705
- * element currently visible on the page, together with a truncated view
706
- * of the surrounding text.
708
+ * Returns a compact, frame-aware view of the visible page — the first thing
709
+ * to reach for on an unfamiliar page, and the cheapest way to re-read the
710
+ * current state afterwards.
711
+ *
712
+ * Each frame opens with header lines carrying the URL, the title and the
713
+ * scroll position, then one line per visible element:
714
+ *
715
+ * ```
716
+ * input#email[47] type="email" name="loginId" value="a@b.com" required click "E-Mail"
717
+ * ```
718
+ *
719
+ * It spans every frame, pierces open and closed shadow roots, enumerates
720
+ * `<select>` options, and reports live form state: `value=` is what is typed
721
+ * in right now (passwords as a length), `checked=` for boxes. The trailing
722
+ * quoted string is always the label or text, never the value, so an empty and
723
+ * a prefilled field stay distinguishable. Because the headers already carry
724
+ * URL, title and scroll offset, this replaces the usual handful of
725
+ * {@link evaluate} probes after each step.
707
726
  *
708
- * Intended as input for LLM/agent loops where a full DOM dump would be
709
- * too large; the server filters down to elements that are actually
710
- * visible and interactable.
727
+ * On what to do with the result: backendNodeId (the `47` above) is a handle
728
+ * for this session and can be passed straight to click/fill via
729
+ * {@link node}. It does not survive a new document, so for anything you write
730
+ * into a script, target with {@link css} or {@link js} instead — those calls
731
+ * return the backendNodeId they resolved to, which lets you confirm the
732
+ * durable anchor hits the element you saw.
711
733
  *
712
- * @param opts - optional caps: `maxElementsPerFrame`, `maxTextLength`;
713
- * omit either to use the server default
734
+ * @param opts - optional format and budget overrides; see {@link GetObservationOpts}
714
735
  *
715
- * @returns ObservationResult with both a human-readable `text` rendering
716
- * and a `json` payload of the structured observation
736
+ * @returns the observation in the requested format, ready to hand to a model
717
737
  *
718
738
  * @throws UNKNOWN_ERROR - the observation could not be produced
719
739
  *
720
740
  * @example
721
- * const obs = await browser.getObservation({ maxElementsPerFrame: 200 });
722
- * console.log(obs.text);
741
+ * const obs = await browser.getObservation();
742
+ * console.log(obs);
723
743
  */
724
744
  async getObservation(opts) {
725
745
  const req = create(GetObservationRequestSchema, {
726
746
  sessionId: this.sessionId,
727
747
  apiKey: this.apiKey,
728
748
  });
749
+ if (opts?.format !== undefined)
750
+ req.format = opts.format;
729
751
  if (opts?.maxElementsPerFrame !== undefined) {
730
752
  req.maxElementsPerFrame = opts.maxElementsPerFrame;
731
753
  }
732
754
  if (opts?.maxTextLength !== undefined)
733
755
  req.maxTextLength = opts.maxTextLength;
756
+ if (opts?.maxTotalTokens !== undefined)
757
+ req.maxTotalTokens = opts.maxTotalTokens;
758
+ if (opts?.includeBounds !== undefined)
759
+ req.includeBounds = opts.includeBounds;
760
+ if (opts?.viewportOnly !== undefined)
761
+ req.viewportOnly = opts.viewportOnly;
762
+ if (opts?.backendNodeId !== undefined)
763
+ req.backendNodeId = opts.backendNodeId;
764
+ if (opts?.selector !== undefined)
765
+ req.selector = opts.selector;
766
+ if (opts?.jsExpression !== undefined)
767
+ req.jsExpression = opts.jsExpression;
768
+ if (opts?.frameId !== undefined)
769
+ req.frameId = opts.frameId;
734
770
  const resp = await this.client.getObservation(req);
735
- return { text: resp.observationText, json: resp.observationJson };
771
+ return resp.observation;
736
772
  }
737
773
  /**
738
774
  * Captures a single image of the page's current frame and returns it as
@@ -1243,6 +1279,43 @@ export class CloudBrowser {
1243
1279
  text,
1244
1280
  }));
1245
1281
  }
1282
+ /**
1283
+ * Types `text` into the currently focused element as a per-key stream of real
1284
+ * keyboard events (keyDown/char/keyUp with the context's QWERTZ/QWERTY layout
1285
+ * and human cadence) — unlike {@link insertText}, a single IME-style commit
1286
+ * with no key events.
1287
+ *
1288
+ * `type` is intentionally UNtargeted and loose: it does not locate or focus
1289
+ * any element and does NOT pin focus, so the page is free to route keys and
1290
+ * move focus between fields mid-stream — ideal for one-time-code / OTP inputs
1291
+ * that auto-advance to the next box on each digit. To type one specific field
1292
+ * that must stay focused for the whole value, use {@link fill} instead
1293
+ * (strict, target-bound, per-key focus-verified).
1294
+ *
1295
+ * Nothing is focused for you: {@link click} (or {@link fill}) the field first,
1296
+ * or otherwise ensure focus, before calling `type`.
1297
+ *
1298
+ * @param text - the text to type as real key events
1299
+ * @param opts - optional: `clearFirst` clears the focused field (Ctrl+A,
1300
+ * Delete) before typing
1301
+ *
1302
+ * @throws UNKNOWN_ERROR - the page/context was torn down mid-stream
1303
+ *
1304
+ * @example
1305
+ * // OTP field that auto-advances across boxes.
1306
+ * await browser.click(css("input.otp-0"));
1307
+ * await browser.type("123456");
1308
+ */
1309
+ async type(text, opts) {
1310
+ const req = create(TypeRequestSchema, {
1311
+ sessionId: this.sessionId,
1312
+ apiKey: this.apiKey,
1313
+ text,
1314
+ });
1315
+ if (opts?.clearFirst)
1316
+ req.clearFirst = true;
1317
+ await this.client.type(req);
1318
+ }
1246
1319
  /**
1247
1320
  * Fires a single key-down event.
1248
1321
  *
@@ -1358,4 +1431,198 @@ export class CloudBrowser {
1358
1431
  }));
1359
1432
  return resp.result;
1360
1433
  }
1434
+ // ──────────────────────────────────────────────────────────────────
1435
+ // Live streaming (WebRTC)
1436
+ // ──────────────────────────────────────────────────────────────────
1437
+ /**
1438
+ * Returns the ICE servers (TURN URL + short-lived credentials) to put in
1439
+ * your `RTCPeerConnection` BEFORE creating the offer, so it can gather relay
1440
+ * candidates.
1441
+ *
1442
+ * Live streaming is a two-step, client-offerer handshake: call
1443
+ * `getStreamConfig`, build your peer with the returned servers, create an
1444
+ * offer, then pass its SDP to {@link startStream} and apply the answer.
1445
+ *
1446
+ * @returns the ICE servers for the client `RTCPeerConnection`
1447
+ *
1448
+ * @throws UNKNOWN_ERROR - TURN is not configured on the server
1449
+ *
1450
+ * @example
1451
+ * const ice = await browser.getStreamConfig();
1452
+ * const pc = new RTCPeerConnection({ iceServers: ice });
1453
+ */
1454
+ async getStreamConfig() {
1455
+ const resp = await this.client.getStreamConfig(create(GetStreamConfigRequestSchema, {
1456
+ sessionId: this.sessionId,
1457
+ apiKey: this.apiKey,
1458
+ }));
1459
+ return resp.iceServers.map((s) => ({
1460
+ urls: s.urls,
1461
+ username: s.username ?? "",
1462
+ credential: s.credential ?? "",
1463
+ }));
1464
+ }
1465
+ /**
1466
+ * Answers your WebRTC SDP offer and starts streaming the page as a video
1467
+ * track, returning the SDP answer to set as your peer's remote description.
1468
+ * The browser is the answerer; you are the offerer (see
1469
+ * {@link getStreamConfig} for the credentials to build the offer).
1470
+ *
1471
+ * @param offerSdp - your `RTCPeerConnection`'s SDP offer
1472
+ *
1473
+ * @returns the SDP answer to apply as the remote description
1474
+ *
1475
+ * @throws UNKNOWN_ERROR - the offer was empty, TURN is unconfigured, or the
1476
+ * browser could not negotiate the stream
1477
+ *
1478
+ * @example
1479
+ * const answer = await browser.startStream(offer.sdp);
1480
+ * await pc.setRemoteDescription({ type: "answer", sdp: answer });
1481
+ */
1482
+ async startStream(offerSdp) {
1483
+ const resp = await this.client.startStream(create(StartStreamRequestSchema, {
1484
+ sessionId: this.sessionId,
1485
+ apiKey: this.apiKey,
1486
+ offerSdp,
1487
+ }));
1488
+ return resp.answerSdp;
1489
+ }
1490
+ /**
1491
+ * Tears down the live video stream for the session's page. Safe to call even
1492
+ * if no stream is running.
1493
+ *
1494
+ * @throws UNKNOWN_ERROR - the stream could not be stopped
1495
+ *
1496
+ * @example
1497
+ * await browser.stopStream();
1498
+ */
1499
+ async stopStream() {
1500
+ await this.client.stopStream(create(StopStreamRequestSchema, {
1501
+ sessionId: this.sessionId,
1502
+ apiKey: this.apiKey,
1503
+ }));
1504
+ }
1505
+ // ──────────────────────────────────────────────────────────────────
1506
+ // Reactions
1507
+ // ──────────────────────────────────────────────────────────────────
1508
+ /**
1509
+ * Registers a one-shot "reaction": a background poller (one shared loop per
1510
+ * page) watches for the `match` locator and, as soon as it matches, clicks it
1511
+ * with the full smart-click machinery (scroll, human path, occlusion gate,
1512
+ * evade) — then removes itself. The poller yields to any in-flight input
1513
+ * action and only fires while the pointer is idle, so a reaction naturally
1514
+ * slots into the gaps of a retrying foreground action (e.g. it dismisses a
1515
+ * newsletter modal blocking a {@link CloudBrowser.click}, after which the
1516
+ * click's own retry succeeds). Reactions are scoped to the page and torn
1517
+ * down automatically when the page/session ends.
1518
+ *
1519
+ * `match` must be a `css()` or `js()` {@link Locator} — `node()`/`at()` are
1520
+ * rejected. Use `.inAllFrames()` to watch every frame and `.visible(false)`
1521
+ * to opt out of the default visibility gate. Pass a `ReactionOpts` to click a
1522
+ * different target (`on`), change the button/click count, or the poll cadence.
1523
+ *
1524
+ * @param match - the css()/js() locator to watch for
1525
+ * @param opts - optional reaction customization; see {@link ReactionOpts}
1526
+ *
1527
+ * @returns the reactionId (pass to {@link CloudBrowser.removeReaction})
1528
+ *
1529
+ * @throws {@link BrowserScaleError} - `match` (or `opts.on`) is not a css()/js()
1530
+ * locator, or a server/transport error
1531
+ *
1532
+ * @example
1533
+ * // Auto-dismiss a consent button whenever it appears, in any frame.
1534
+ * const id = await browser.addReaction(css("button#accept").inAllFrames());
1535
+ *
1536
+ * @example
1537
+ * // Watch for a newsletter modal, but click its close "X" instead.
1538
+ * const id = await browser.addReaction(css("#newsletter-modal"), {
1539
+ * on: css(".modal-close"),
1540
+ * });
1541
+ */
1542
+ async addReaction(match, opts) {
1543
+ this.assertReactionLocator("addReaction", match, "match");
1544
+ const req = create(AddReactionRequestSchema, {
1545
+ sessionId: this.sessionId,
1546
+ apiKey: this.apiKey,
1547
+ });
1548
+ if (match.selector)
1549
+ req.matchSelector = match.selector;
1550
+ if (match.jsExpression)
1551
+ req.matchJsExpression = match.jsExpression;
1552
+ if (match.frameId)
1553
+ req.frameId = match.frameId;
1554
+ if (match.visibleFlag !== undefined)
1555
+ req.visible = match.visibleFlag;
1556
+ if (opts?.on) {
1557
+ this.assertReactionLocator("addReaction", opts.on, "on");
1558
+ if (opts.on.selector)
1559
+ req.actionSelector = opts.on.selector;
1560
+ if (opts.on.jsExpression)
1561
+ req.actionJsExpression = opts.on.jsExpression;
1562
+ }
1563
+ if (opts?.button)
1564
+ req.button = opts.button;
1565
+ if (opts?.clickCount)
1566
+ req.clickCount = opts.clickCount;
1567
+ if (opts?.intervalMs)
1568
+ req.interval = opts.intervalMs;
1569
+ const resp = await this.client.addReaction(req);
1570
+ return resp.reactionId;
1571
+ }
1572
+ /**
1573
+ * Removes a pending reaction by id. Returns `false` if the reaction had
1574
+ * already fired (one-shot) or was never registered.
1575
+ *
1576
+ * @param reactionId - id returned by {@link CloudBrowser.addReaction}
1577
+ *
1578
+ * @returns true if a pending reaction with this id existed and was removed
1579
+ *
1580
+ * @example
1581
+ * const removed = await browser.removeReaction(id);
1582
+ */
1583
+ async removeReaction(reactionId) {
1584
+ const resp = await this.client.removeReaction(create(RemoveReactionRequestSchema, {
1585
+ sessionId: this.sessionId,
1586
+ apiKey: this.apiKey,
1587
+ reactionId,
1588
+ }));
1589
+ return resp.removed;
1590
+ }
1591
+ /**
1592
+ * Returns the still-pending reactions registered for the current page.
1593
+ * Reactions that have already fired (one-shot) are not included.
1594
+ *
1595
+ * @returns the pending reactions for the page
1596
+ *
1597
+ * @example
1598
+ * const pending = await browser.listReactions();
1599
+ * for (const r of pending) console.log(r.reactionId, r.matchSelector);
1600
+ */
1601
+ async listReactions() {
1602
+ const resp = await this.client.listReactions(create(ListReactionsRequestSchema, {
1603
+ sessionId: this.sessionId,
1604
+ apiKey: this.apiKey,
1605
+ }));
1606
+ return resp.reactions.map((r) => ({
1607
+ reactionId: r.reactionId,
1608
+ matchSelector: r.matchSelector ?? "",
1609
+ matchJsExpression: r.matchJsExpression ?? "",
1610
+ actionSelector: r.actionSelector ?? "",
1611
+ actionJsExpression: r.actionJsExpression ?? "",
1612
+ frameId: r.frameId,
1613
+ visible: r.visible,
1614
+ }));
1615
+ }
1616
+ /**
1617
+ * @internal — a reaction match/action must be a css()/js() locator: node()
1618
+ * and at() are rejected (a reaction watches for a condition, like a wait).
1619
+ */
1620
+ assertReactionLocator(cmd, l, role) {
1621
+ if (l.backendNodeId !== 0 || l.x !== undefined || l.y !== undefined) {
1622
+ throw new BrowserScaleError(`browserscale.${cmd}: ${role} must be a css()/js() locator (node()/at() are not valid)`);
1623
+ }
1624
+ if (!l.selector && !l.jsExpression) {
1625
+ throw new BrowserScaleError(`browserscale.${cmd}: ${role} must have a CSS selector or JS expression`);
1626
+ }
1627
+ }
1361
1628
  }
package/dist/errors.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { DragResult, ElementResult, OccluderInfo, SelectOptionResult, WaitConditionStatus, WaitResult } from "./types.ts";
1
+ import type { DragResult, ElementRef, ElementResult, OccluderInfo, SelectOptionResult, WaitConditionStatus, WaitResult } from "./types.ts";
2
2
  /**
3
3
  * BrowserScaleError is the base class for every error thrown by the SDK. It
4
4
  * wraps either:
@@ -60,21 +60,48 @@ export declare class ClickError extends BrowserScaleError {
60
60
  }
61
61
  /**
62
62
  * FillError is thrown by {@link CloudBrowser.fill} when the field could not be
63
- * focused/typed. Fill focuses with the exact same smart click as
64
- * {@link CloudBrowser.click}, so a pre-typing failure is a click failure:
65
- * `code` mirrors it and the full click diagnostics live under `clickError`.
63
+ * focused/typed. The click-phase codes (`"not_found"`,
64
+ * `"occluded_no_reachable_point"`, `"occluded_after_evade"`) mirror the
65
+ * underlying focus click, with diagnostics under `clickError`. The focus codes
66
+ * are `"focus_stolen"` (another element took focus — `focusedElement` names it;
67
+ * fill is strictly target-bound and will not type into the thief) and
68
+ * `"focus_lost"` (focus left the target and nothing is focused). For untargeted
69
+ * stream typing that lets focus move (e.g. OTP), use {@link CloudBrowser.type}.
66
70
  */
67
71
  export declare class FillError extends BrowserScaleError {
68
- /** Mirrored from the underlying click failure. */
72
+ /** Machine-stable failure code (see the class doc for the full set). */
69
73
  readonly code: string;
70
- /** The underlying click-core failure that prevented focusing/typing. */
74
+ /**
75
+ * The underlying click-core failure that prevented focusing/typing. Present
76
+ * for the click-phase codes; undefined for `"focus_stolen"`/`"focus_lost"`.
77
+ */
71
78
  readonly clickError?: ClickError;
79
+ /**
80
+ * Node that held focus when fill gave up (0 if nothing was focused), for the
81
+ * `"focus_stolen"`/`"focus_lost"` codes.
82
+ */
83
+ readonly focusedBackendNodeId?: number;
84
+ /**
85
+ * The element that grabbed focus instead of the target (`"focus_stolen"`), so
86
+ * you can act on it (e.g. a consent button).
87
+ */
88
+ readonly focusedElement?: ElementRef;
89
+ /**
90
+ * The fill target's own state at the point of failure (the focus codes):
91
+ * whether it is still an editable text sink and its current text length.
92
+ */
93
+ readonly targetEditable?: boolean;
94
+ readonly targetValueLength?: number;
72
95
  /** Resolved element + coordinates at the failed action (success is false). */
73
96
  readonly result: ElementResult;
74
97
  constructor(init: {
75
98
  code: string;
76
99
  message: string;
77
100
  clickError?: ClickError;
101
+ focusedBackendNodeId?: number;
102
+ focusedElement?: ElementRef;
103
+ targetEditable?: boolean;
104
+ targetValueLength?: number;
78
105
  result: ElementResult;
79
106
  });
80
107
  }
package/dist/errors.js CHANGED
@@ -48,9 +48,13 @@ export class ClickError extends BrowserScaleError {
48
48
  }
49
49
  /**
50
50
  * FillError is thrown by {@link CloudBrowser.fill} when the field could not be
51
- * focused/typed. Fill focuses with the exact same smart click as
52
- * {@link CloudBrowser.click}, so a pre-typing failure is a click failure:
53
- * `code` mirrors it and the full click diagnostics live under `clickError`.
51
+ * focused/typed. The click-phase codes (`"not_found"`,
52
+ * `"occluded_no_reachable_point"`, `"occluded_after_evade"`) mirror the
53
+ * underlying focus click, with diagnostics under `clickError`. The focus codes
54
+ * are `"focus_stolen"` (another element took focus — `focusedElement` names it;
55
+ * fill is strictly target-bound and will not type into the thief) and
56
+ * `"focus_lost"` (focus left the target and nothing is focused). For untargeted
57
+ * stream typing that lets focus move (e.g. OTP), use {@link CloudBrowser.type}.
54
58
  */
55
59
  export class FillError extends BrowserScaleError {
56
60
  constructor(init) {
@@ -58,6 +62,10 @@ export class FillError extends BrowserScaleError {
58
62
  this.name = "FillError";
59
63
  this.code = init.code;
60
64
  this.clickError = init.clickError;
65
+ this.focusedBackendNodeId = init.focusedBackendNodeId;
66
+ this.focusedElement = init.focusedElement;
67
+ this.targetEditable = init.targetEditable;
68
+ this.targetValueLength = init.targetValueLength;
61
69
  this.result = init.result;
62
70
  }
63
71
  }