@alfe.ai/browser 0.2.1 → 0.3.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/README.md CHANGED
@@ -17,5 +17,13 @@ and human-clicked navigation. The package bounds viewports, screencast frames,
17
17
  automation input/results, and keeps replacement Chrome/CDP generations from
18
18
  mutating current state.
19
19
 
20
+ ## Browser integration verification
21
+
22
+ Run `BROWSER_TEST_EXECUTABLE_PATH=/path/to/chrome pnpm test:integration` from
23
+ this package. This dedicated suite launches a real headless Chrome with a fresh
24
+ temporary profile and local HTTP/WebSocket fixtures. It exercises streamed
25
+ JPEGs, human keyboard/mouse input, login popups, hand-back, and cookie persistence
26
+ after browser restart; it never attaches to an existing browser profile.
27
+
20
28
  Part of [Alfe](https://alfe.ai). See the
21
29
  [documentation](https://docs.alfe.ai) for platform setup.
package/dist/index.cjs CHANGED
@@ -242,6 +242,15 @@ var BrowserSession = class {
242
242
  });
243
243
  }).catch(() => {});
244
244
  });
245
+ browser.on("targetdestroyed", (target) => {
246
+ const closedPage = this.activePage;
247
+ if (this.browser !== browser || !closedPage || target.type() !== puppeteer_core.TargetType.PAGE) return;
248
+ target.page().then(async (page) => {
249
+ if (page === closedPage && this.browser === browser && this.activePage === closedPage) await this.restoreAfterClose(browser, closedPage, target.opener());
250
+ }).catch(() => {
251
+ this.log.warn("Could not restore the browser page after a popup closed");
252
+ });
253
+ });
245
254
  } catch (error) {
246
255
  await browser.close().catch(() => void 0);
247
256
  throw error;
@@ -259,6 +268,27 @@ var BrowserSession = class {
259
268
  this.log.debug("Active page switched to new target");
260
269
  return true;
261
270
  }
271
+ /** A login popup can close while the agent is parked for human control.
272
+ * Recover here, without waiting for another automation call to discover
273
+ * that the active page is closed, and rebind the viewer to its opener. */
274
+ async restoreAfterClose(browser, closedPage, openerTarget) {
275
+ let opener = await openerTarget?.page();
276
+ while (this.browser === browser && this.activePage === closedPage) {
277
+ const pages = await browser.pages();
278
+ if (this.browser !== browser || this.activePage !== closedPage) return;
279
+ const page = opener && !opener.isClosed() ? opener : pages.find((candidate) => !candidate.isClosed() && isNavigablePageUrl(candidate.url())) ?? pages.find((candidate) => !candidate.isClosed()) ?? await browser.newPage();
280
+ opener = null;
281
+ try {
282
+ await this.preparePage(page);
283
+ } catch (error) {
284
+ if (page.isClosed()) continue;
285
+ throw error;
286
+ }
287
+ if (page.isClosed()) continue;
288
+ if (this.browser === browser && this.activePage === closedPage) this.setActivePage(page);
289
+ return;
290
+ }
291
+ }
262
292
  /** The current active page, launching Chrome first if needed. */
263
293
  async getActivePage() {
264
294
  await this.ensureLaunched();
@@ -278,6 +308,55 @@ var BrowserSession = class {
278
308
  this.activePageListeners.delete(listener);
279
309
  };
280
310
  }
311
+ /** Internal to the serialized automation turn. The caller holds Chrome until
312
+ * the adapter has disconnected its client/awaited its child process exit. */
313
+ async withCdpTarget(operation, signal) {
314
+ signal.throwIfAborted();
315
+ const page = await this.getActivePage();
316
+ const browser = this.browser;
317
+ const generation = this.generation;
318
+ if (!browser) throw new Error("Browser is unavailable");
319
+ const disconnected = new AbortController();
320
+ const abort = () => {
321
+ disconnected.abort(/* @__PURE__ */ new Error("Browser operation interrupted"));
322
+ };
323
+ const operationSignal = AbortSignal.any([signal, disconnected.signal]);
324
+ const assertCurrent = () => {
325
+ operationSignal.throwIfAborted();
326
+ if (this.browser !== browser || this.generation !== generation || !browser.connected || page.isClosed()) throw new Error("Browser operation interrupted");
327
+ };
328
+ browser.on("disconnected", abort);
329
+ page.on("close", abort);
330
+ try {
331
+ assertCurrent();
332
+ const cdp = await page.createCDPSession();
333
+ let targetId;
334
+ try {
335
+ const { targetInfo } = await cdp.send("Target.getTargetInfo");
336
+ targetId = targetInfo.targetId;
337
+ } finally {
338
+ await cdp.detach().catch(() => void 0);
339
+ }
340
+ assertCurrent();
341
+ const browserWSEndpoint = browser.wsEndpoint();
342
+ const endpoint = new URL(browserWSEndpoint);
343
+ if (endpoint.protocol !== "ws:" || ![
344
+ "127.0.0.1",
345
+ "[::1]",
346
+ "localhost"
347
+ ].includes(endpoint.hostname) || endpoint.username !== "" || endpoint.password !== "" || !endpoint.pathname.startsWith("/devtools/browser/")) throw new Error("Browser local attachment is unavailable");
348
+ const result = await operation({
349
+ browserWSEndpoint,
350
+ targetId,
351
+ signal: operationSignal
352
+ });
353
+ assertCurrent();
354
+ return result;
355
+ } finally {
356
+ browser.off("disconnected", abort);
357
+ page.off("close", abort);
358
+ }
359
+ }
281
360
  /** Prevent idle shutdown while a viewer or op is active. */
282
361
  addHold() {
283
362
  this.holds += 1;
@@ -505,6 +584,43 @@ const MOUSE_TYPE = {
505
584
  mousepressed: "mousePressed",
506
585
  mousereleased: "mouseReleased"
507
586
  };
587
+ const VIRTUAL_KEYS = {
588
+ Backspace: 8,
589
+ Tab: 9,
590
+ Enter: 13,
591
+ NumpadEnter: 13,
592
+ Shift: 16,
593
+ Control: 17,
594
+ Alt: 18,
595
+ Pause: 19,
596
+ CapsLock: 20,
597
+ Escape: 27,
598
+ Space: 32,
599
+ " ": 32,
600
+ PageUp: 33,
601
+ PageDown: 34,
602
+ End: 35,
603
+ Home: 36,
604
+ ArrowLeft: 37,
605
+ ArrowUp: 38,
606
+ ArrowRight: 39,
607
+ ArrowDown: 40,
608
+ Insert: 45,
609
+ Delete: 46,
610
+ Meta: 91
611
+ };
612
+ function virtualKeyCode(input) {
613
+ const named = VIRTUAL_KEYS[input.key ?? ""] ?? VIRTUAL_KEYS[input.code ?? ""];
614
+ if (typeof named === "number") return named;
615
+ const letter = /^Key([A-Z])$/.exec(input.code ?? "");
616
+ if (letter) return letter[1].charCodeAt(0);
617
+ const digit = /^Digit([0-9])$/.exec(input.code ?? "");
618
+ if (digit) return digit[1].charCodeAt(0);
619
+ const key = input.key ?? "";
620
+ if (/^[A-Za-z0-9]$/.test(key)) return key.toUpperCase().charCodeAt(0);
621
+ const functionKey = /^F([1-9]|1[0-9]|2[0-4])$/.exec(input.key ?? "");
622
+ if (functionKey) return 111 + Number(functionKey[1]);
623
+ }
508
624
  var InputInjector = class {
509
625
  cdp = null;
510
626
  viewport;
@@ -569,19 +685,18 @@ var InputInjector = class {
569
685
  key(p) {
570
686
  if (!this.cdp) return;
571
687
  if (p.type === "char") {
572
- this.cdp.send("Input.dispatchKeyEvent", {
573
- type: "char",
574
- text: p.text ?? "",
575
- modifiers: p.modifiers ?? 0
576
- }).catch(() => {});
688
+ this.cdp.send("Input.insertText", { text: p.text ?? "" }).catch(() => {});
577
689
  return;
578
690
  }
691
+ const modifiers = p.modifiers ?? 0;
692
+ const text = p.type === "keydown" && !(modifiers & 7) ? p.text ?? (p.key === "Enter" ? "\r" : void 0) : void 0;
579
693
  this.cdp.send("Input.dispatchKeyEvent", {
580
- type: p.type === "keydown" ? "keyDown" : "keyUp",
694
+ type: p.type === "keyup" ? "keyUp" : text ? "keyDown" : "rawKeyDown",
581
695
  key: p.key,
582
696
  code: p.code,
583
- text: p.type === "keydown" ? p.text : void 0,
584
- modifiers: p.modifiers ?? 0
697
+ text,
698
+ windowsVirtualKeyCode: virtualKeyCode(p),
699
+ modifiers
585
700
  }).catch(() => {});
586
701
  }
587
702
  };
@@ -594,6 +709,7 @@ function clamp01(n) {
594
709
  const OPERATION_TIMEOUT_MS = 3e4;
595
710
  var BrowserAutomation = class {
596
711
  operationTail = Promise.resolve();
712
+ stopping = new AbortController();
597
713
  constructor(session, turn, isNavigationAllowed) {
598
714
  this.session = session;
599
715
  this.turn = turn;
@@ -677,13 +793,38 @@ var BrowserAutomation = class {
677
793
  async waitUntilIdle() {
678
794
  await this.operationTail;
679
795
  }
680
- run(operation) {
796
+ /** Trusted local adapters share the built-in automation queue and exact page.
797
+ * The callback must disconnect/await children in finally, including on abort.
798
+ * Do not invoke another automation operation or handoff from the callback. */
799
+ withCdpOperation(operation, options = {}) {
800
+ return this.run((signal) => this.session.withCdpTarget(operation, signal), options);
801
+ }
802
+ /** Insert the claim at a precise queue position; later automation parks. */
803
+ async yieldToHuman(grant) {
804
+ await this.run(() => {
805
+ grant();
806
+ return Promise.resolve();
807
+ });
808
+ }
809
+ /** Abort first, then await callback cleanup before the owner closes Chrome. */
810
+ async shutdown() {
811
+ this.stopping.abort(/* @__PURE__ */ new Error("Browser automation stopped"));
812
+ this.turn.releaseHuman();
813
+ await this.operationTail;
814
+ }
815
+ run(operation, options = {}) {
816
+ const signal = options.signal ? AbortSignal.any([this.stopping.signal, options.signal]) : this.stopping.signal;
681
817
  const result = this.operationTail.catch(() => void 0).then(async () => {
682
- await this.turn.acquireAgent();
818
+ signal.throwIfAborted();
819
+ this.session.addHold();
683
820
  try {
684
- return await operation();
821
+ await this.turn.acquireAgent(signal);
822
+ signal.throwIfAborted();
823
+ const result = await operation(signal);
824
+ signal.throwIfAborted();
825
+ return result;
685
826
  } finally {
686
- this.session.touch();
827
+ this.session.removeHold();
687
828
  }
688
829
  });
689
830
  this.operationTail = result.then(() => void 0, () => void 0);
@@ -724,13 +865,13 @@ var BrowserSurface = class {
724
865
  handoff = null;
725
866
  controllerSessionId = null;
726
867
  pendingControllerSessionId = null;
868
+ claimGeneration = 0;
727
869
  streamGeneration = 0;
728
870
  streamQueue = Promise.resolve();
729
871
  closed = false;
730
872
  removePageListener;
731
873
  log;
732
874
  constructor(options, sendFrame) {
733
- this.options = options;
734
875
  this.sendFrame = sendFrame;
735
876
  this.log = options.logger ?? noopLogger;
736
877
  const navigationPolicy = options.isNavigationAllowed ?? ((url) => {
@@ -756,8 +897,11 @@ var BrowserSurface = class {
756
897
  });
757
898
  this.injector = new InputInjector(this.viewport);
758
899
  this.automation = new BrowserAutomation(this.session, this.turn, navigationPolicy);
759
- this.removePageListener = this.session.onActivePageChange(() => {
760
- if (this.viewers.size > 0) this.restartStreaming();
900
+ this.removePageListener = this.session.onActivePageChange(async () => {
901
+ if (this.viewers.size > 0) {
902
+ await this.restartStreaming();
903
+ this.broadcastState();
904
+ }
761
905
  });
762
906
  }
763
907
  async openSession(sessionId, open) {
@@ -815,25 +959,28 @@ var BrowserSurface = class {
815
959
  if (input) this.injector.key(input);
816
960
  }
817
961
  break;
818
- case _alfe_ai_remote.RemoteFrameType.TAKEOVER_REQUEST:
962
+ case _alfe_ai_remote.RemoteFrameType.TAKEOVER_REQUEST: {
819
963
  if (this.controllerSessionId !== null || this.pendingControllerSessionId !== null) {
820
964
  this.sendFrameSafe((0, _alfe_ai_remote.encodeFrame)(_alfe_ai_remote.RemoteFrameType.TAKEOVER_DENIED, frame.sessionId));
821
965
  break;
822
966
  }
823
967
  this.pendingControllerSessionId = frame.sessionId;
824
- this.automation.waitUntilIdle().then(() => {
825
- if (this.pendingControllerSessionId !== frame.sessionId || !this.viewers.has(frame.sessionId) || this.closed) return;
968
+ const claimGeneration = ++this.claimGeneration;
969
+ this.automation.yieldToHuman(() => {
970
+ if (this.claimGeneration !== claimGeneration || this.pendingControllerSessionId !== frame.sessionId || !this.viewers.has(frame.sessionId) || this.closed) return;
826
971
  this.pendingControllerSessionId = null;
827
972
  if (this.turn.grantHuman()) {
828
973
  this.controllerSessionId = frame.sessionId;
829
974
  this.broadcast(_alfe_ai_remote.RemoteFrameType.TAKEOVER_GRANTED);
830
975
  } else this.sendFrameSafe((0, _alfe_ai_remote.encodeFrame)(_alfe_ai_remote.RemoteFrameType.TAKEOVER_DENIED, frame.sessionId));
831
976
  }).catch((error) => {
832
- if (this.pendingControllerSessionId === frame.sessionId) this.pendingControllerSessionId = null;
977
+ if (this.claimGeneration !== claimGeneration || this.pendingControllerSessionId !== frame.sessionId) return;
978
+ this.pendingControllerSessionId = null;
833
979
  this.log.warn(`Could not grant browser control: ${error instanceof Error ? error.message : String(error)}`);
834
980
  this.sendFrameSafe((0, _alfe_ai_remote.encodeFrame)(_alfe_ai_remote.RemoteFrameType.TAKEOVER_DENIED, frame.sessionId));
835
981
  });
836
982
  break;
983
+ }
837
984
  case _alfe_ai_remote.RemoteFrameType.RELEASE_CONTROL:
838
985
  if (frame.sessionId === this.controllerSessionId) this.releaseToAgent();
839
986
  break;
@@ -843,7 +990,10 @@ var BrowserSurface = class {
843
990
  closeSession(sessionId) {
844
991
  if (!this.viewers.delete(sessionId)) return;
845
992
  this.session.removeHold();
846
- if (this.pendingControllerSessionId === sessionId) this.pendingControllerSessionId = null;
993
+ if (this.pendingControllerSessionId === sessionId) {
994
+ this.pendingControllerSessionId = null;
995
+ this.claimGeneration += 1;
996
+ }
847
997
  if (this.controllerSessionId === sessionId) this.releaseToAgent();
848
998
  if (this.viewers.size === 0) {
849
999
  this.streamGeneration += 1;
@@ -865,20 +1015,28 @@ var BrowserSurface = class {
865
1015
  * under the user (mislabeled 409 on the real claim; agent only resuming when
866
1016
  * the tab closed).
867
1017
  */
868
- async requestHandoff(timeoutMs) {
1018
+ async requestHandoff(timeoutMs, signal) {
869
1019
  if (!Number.isInteger(timeoutMs) || timeoutMs < 1e3 || timeoutMs > 1800 * 1e3) throw new Error("Browser handoff timeout must be an integer from 1000 to 1800000ms");
1020
+ if (this.closed) throw new Error("Browser surface is shut down");
1021
+ signal?.throwIfAborted();
870
1022
  if (this.handoff) this.releaseToAgent();
871
- return new Promise((resolve) => {
1023
+ return new Promise((resolve, reject) => {
1024
+ const settle = (result) => {
1025
+ signal?.removeEventListener("abort", onAbort);
1026
+ resolve(result);
1027
+ };
872
1028
  const timer = setTimeout(() => {
1029
+ if (this.handoff !== waiter) return;
873
1030
  this.handoff = null;
874
1031
  this.pendingControllerSessionId = null;
1032
+ this.claimGeneration += 1;
875
1033
  if (this.turn.humanInControl) {
876
1034
  this.turn.releaseHuman();
877
1035
  this.controllerSessionId = null;
878
1036
  this.broadcast(_alfe_ai_remote.RemoteFrameType.CONTROL_REVOKED);
879
1037
  }
880
1038
  this.currentPageInfo().then(({ url, title }) => {
881
- resolve({
1039
+ settle({
882
1040
  released: false,
883
1041
  timedOut: true,
884
1042
  url,
@@ -887,10 +1045,27 @@ var BrowserSurface = class {
887
1045
  });
888
1046
  }, timeoutMs);
889
1047
  timer.unref();
890
- this.handoff = {
891
- resolve,
1048
+ const waiter = {
1049
+ resolve: settle,
892
1050
  timer
893
1051
  };
1052
+ const onAbort = () => {
1053
+ if (this.handoff !== waiter) return;
1054
+ this.handoff = null;
1055
+ clearTimeout(timer);
1056
+ signal?.removeEventListener("abort", onAbort);
1057
+ this.pendingControllerSessionId = null;
1058
+ this.claimGeneration += 1;
1059
+ if (this.turn.humanInControl) {
1060
+ this.turn.releaseHuman();
1061
+ this.controllerSessionId = null;
1062
+ this.broadcast(_alfe_ai_remote.RemoteFrameType.CONTROL_REVOKED);
1063
+ }
1064
+ reject(/* @__PURE__ */ new Error("Browser handoff cancelled"));
1065
+ };
1066
+ this.handoff = waiter;
1067
+ signal?.addEventListener("abort", onAbort, { once: true });
1068
+ if (signal?.aborted) onAbort();
894
1069
  });
895
1070
  }
896
1071
  /**
@@ -911,6 +1086,7 @@ var BrowserSurface = class {
911
1086
  this.removePageListener();
912
1087
  this.streamGeneration += 1;
913
1088
  this.pendingControllerSessionId = null;
1089
+ this.claimGeneration += 1;
914
1090
  this.controllerSessionId = null;
915
1091
  const waiter = this.handoff;
916
1092
  this.handoff = null;
@@ -924,11 +1100,13 @@ var BrowserSurface = class {
924
1100
  });
925
1101
  }
926
1102
  this.turn.releaseHuman();
1103
+ await this.automation.shutdown();
927
1104
  await this.enqueueStreamCleanup();
928
1105
  await this.session.shutdown();
929
1106
  }
930
1107
  releaseToAgent() {
931
1108
  this.pendingControllerSessionId = null;
1109
+ this.claimGeneration += 1;
932
1110
  this.controllerSessionId = null;
933
1111
  this.turn.releaseHuman();
934
1112
  const waiter = this.handoff;
package/dist/index.d.cts CHANGED
@@ -96,9 +96,10 @@ declare class TurnController {
96
96
  /**
97
97
  * Resolve once the agent is (again) allowed to drive. Resolves immediately if
98
98
  * the agent already holds the token; otherwise parks until `releaseHuman()`.
99
- * Every automation op awaits this before touching the page.
99
+ * Every automation op awaits this before touching the page. Cancellation
100
+ * removes only this waiter and never changes the current human owner.
100
101
  */
101
- acquireAgent(): Promise<void>;
102
+ acquireAgent(signal?: AbortSignal): Promise<void>;
102
103
  /**
103
104
  * Grant control to a human. Succeeds unless a human already holds it (single
104
105
  * controller). "Human request always wins over the agent" — the agent parks
@@ -112,6 +113,18 @@ declare class TurnController {
112
113
  //#endregion
113
114
  //#endregion
114
115
  //#region src/types.d.ts
116
+ /** Private local capability for a trusted adapter, never a tool result. */
117
+ interface BrowserOperationContext {
118
+ /** Loopback Chrome endpoint. Do not log, persist, or expose to a viewer/model. */
119
+ browserWSEndpoint: string;
120
+ /** The exact active page's CDP target; never choose the first context/page. */
121
+ targetId: string;
122
+ /** On abort, stop and await all external work before returning. */
123
+ signal: AbortSignal;
124
+ }
125
+ interface BrowserOperationOptions {
126
+ signal?: AbortSignal;
127
+ }
115
128
  interface BrowserSessionOptions {
116
129
  /** Path to the Chrome/Chromium binary (from the headless-browser integration). */
117
130
  executablePath: string;
@@ -154,9 +167,16 @@ declare class BrowserSession {
154
167
  * screencast to a transient `about:blank` throwaway target.
155
168
  */
156
169
  private adoptIfNavigable;
170
+ /** A login popup can close while the agent is parked for human control.
171
+ * Recover here, without waiting for another automation call to discover
172
+ * that the active page is closed, and rebind the viewer to its opener. */
173
+ private restoreAfterClose;
157
174
  /** The current active page, launching Chrome first if needed. */
158
175
  getActivePage(): Promise<Page>;
159
176
  onActivePageChange(listener: (page: Page) => void | Promise<void>): () => void;
177
+ /** Internal to the serialized automation turn. The caller holds Chrome until
178
+ * the adapter has disconnected its client/awaited its child process exit. */
179
+ withCdpTarget<T>(operation: (context: BrowserOperationContext) => Promise<T>, signal: AbortSignal): Promise<T>;
160
180
  /** Prevent idle shutdown while a viewer or op is active. */
161
181
  addHold(): void;
162
182
  /** Release a hold; arm idle shutdown when the last one is released. */
@@ -181,6 +201,7 @@ declare class BrowserAutomation {
181
201
  private readonly turn;
182
202
  private readonly isNavigationAllowed;
183
203
  private operationTail;
204
+ private readonly stopping;
184
205
  constructor(session: BrowserSession, turn: TurnController, isNavigationAllowed: (url: string) => boolean);
185
206
  navigate(url: string): Promise<NavigateResult>;
186
207
  click(selector: string): Promise<void>;
@@ -197,6 +218,14 @@ declare class BrowserAutomation {
197
218
  evaluate(expression: string): Promise<unknown>;
198
219
  /** Wait until all agent operations that were already queued have settled. */
199
220
  waitUntilIdle(): Promise<void>;
221
+ /** Trusted local adapters share the built-in automation queue and exact page.
222
+ * The callback must disconnect/await children in finally, including on abort.
223
+ * Do not invoke another automation operation or handoff from the callback. */
224
+ withCdpOperation<T>(operation: (context: BrowserOperationContext) => Promise<T>, options?: BrowserOperationOptions): Promise<T>;
225
+ /** Insert the claim at a precise queue position; later automation parks. */
226
+ yieldToHuman(grant: () => void): Promise<void>;
227
+ /** Abort first, then await callback cleanup before the owner closes Chrome. */
228
+ shutdown(): Promise<void>;
200
229
  private run;
201
230
  }
202
231
  //#endregion
@@ -208,7 +237,6 @@ interface HandoffResult {
208
237
  title: string;
209
238
  }
210
239
  declare class BrowserSurface implements SurfaceHandler {
211
- private readonly options;
212
240
  private readonly sendFrame;
213
241
  readonly surface: "browser";
214
242
  private readonly session;
@@ -221,6 +249,7 @@ declare class BrowserSurface implements SurfaceHandler {
221
249
  private handoff;
222
250
  private controllerSessionId;
223
251
  private pendingControllerSessionId;
252
+ private claimGeneration;
224
253
  private streamGeneration;
225
254
  private streamQueue;
226
255
  private closed;
@@ -245,7 +274,7 @@ declare class BrowserSurface implements SurfaceHandler {
245
274
  * under the user (mislabeled 409 on the real claim; agent only resuming when
246
275
  * the tab closed).
247
276
  */
248
- requestHandoff(timeoutMs: number): Promise<HandoffResult>;
277
+ requestHandoff(timeoutMs: number, signal?: AbortSignal): Promise<HandoffResult>;
249
278
  /**
250
279
  * Keep the shared Chrome alive across an awaiting-human window. Delegates to
251
280
  * the session's hold counter (which also backs per-viewer holds). Idempotent
@@ -268,4 +297,4 @@ declare class BrowserSurface implements SurfaceHandler {
268
297
  private sendFrameSafe;
269
298
  }
270
299
  //#endregion
271
- export { BrowserAutomation, BrowserSession, type BrowserSessionOptions, BrowserSurface, type BrowserSurfaceOptions, type HandoffResult, type Logger, type NavigateResult };
300
+ export { BrowserAutomation, type BrowserOperationContext, type BrowserOperationOptions, BrowserSession, type BrowserSessionOptions, BrowserSurface, type BrowserSurfaceOptions, type HandoffResult, type Logger, type NavigateResult };
package/dist/index.d.ts CHANGED
@@ -96,9 +96,10 @@ declare class TurnController {
96
96
  /**
97
97
  * Resolve once the agent is (again) allowed to drive. Resolves immediately if
98
98
  * the agent already holds the token; otherwise parks until `releaseHuman()`.
99
- * Every automation op awaits this before touching the page.
99
+ * Every automation op awaits this before touching the page. Cancellation
100
+ * removes only this waiter and never changes the current human owner.
100
101
  */
101
- acquireAgent(): Promise<void>;
102
+ acquireAgent(signal?: AbortSignal): Promise<void>;
102
103
  /**
103
104
  * Grant control to a human. Succeeds unless a human already holds it (single
104
105
  * controller). "Human request always wins over the agent" — the agent parks
@@ -112,6 +113,18 @@ declare class TurnController {
112
113
  //#endregion
113
114
  //#endregion
114
115
  //#region src/types.d.ts
116
+ /** Private local capability for a trusted adapter, never a tool result. */
117
+ interface BrowserOperationContext {
118
+ /** Loopback Chrome endpoint. Do not log, persist, or expose to a viewer/model. */
119
+ browserWSEndpoint: string;
120
+ /** The exact active page's CDP target; never choose the first context/page. */
121
+ targetId: string;
122
+ /** On abort, stop and await all external work before returning. */
123
+ signal: AbortSignal;
124
+ }
125
+ interface BrowserOperationOptions {
126
+ signal?: AbortSignal;
127
+ }
115
128
  interface BrowserSessionOptions {
116
129
  /** Path to the Chrome/Chromium binary (from the headless-browser integration). */
117
130
  executablePath: string;
@@ -154,9 +167,16 @@ declare class BrowserSession {
154
167
  * screencast to a transient `about:blank` throwaway target.
155
168
  */
156
169
  private adoptIfNavigable;
170
+ /** A login popup can close while the agent is parked for human control.
171
+ * Recover here, without waiting for another automation call to discover
172
+ * that the active page is closed, and rebind the viewer to its opener. */
173
+ private restoreAfterClose;
157
174
  /** The current active page, launching Chrome first if needed. */
158
175
  getActivePage(): Promise<Page>;
159
176
  onActivePageChange(listener: (page: Page) => void | Promise<void>): () => void;
177
+ /** Internal to the serialized automation turn. The caller holds Chrome until
178
+ * the adapter has disconnected its client/awaited its child process exit. */
179
+ withCdpTarget<T>(operation: (context: BrowserOperationContext) => Promise<T>, signal: AbortSignal): Promise<T>;
160
180
  /** Prevent idle shutdown while a viewer or op is active. */
161
181
  addHold(): void;
162
182
  /** Release a hold; arm idle shutdown when the last one is released. */
@@ -181,6 +201,7 @@ declare class BrowserAutomation {
181
201
  private readonly turn;
182
202
  private readonly isNavigationAllowed;
183
203
  private operationTail;
204
+ private readonly stopping;
184
205
  constructor(session: BrowserSession, turn: TurnController, isNavigationAllowed: (url: string) => boolean);
185
206
  navigate(url: string): Promise<NavigateResult>;
186
207
  click(selector: string): Promise<void>;
@@ -197,6 +218,14 @@ declare class BrowserAutomation {
197
218
  evaluate(expression: string): Promise<unknown>;
198
219
  /** Wait until all agent operations that were already queued have settled. */
199
220
  waitUntilIdle(): Promise<void>;
221
+ /** Trusted local adapters share the built-in automation queue and exact page.
222
+ * The callback must disconnect/await children in finally, including on abort.
223
+ * Do not invoke another automation operation or handoff from the callback. */
224
+ withCdpOperation<T>(operation: (context: BrowserOperationContext) => Promise<T>, options?: BrowserOperationOptions): Promise<T>;
225
+ /** Insert the claim at a precise queue position; later automation parks. */
226
+ yieldToHuman(grant: () => void): Promise<void>;
227
+ /** Abort first, then await callback cleanup before the owner closes Chrome. */
228
+ shutdown(): Promise<void>;
200
229
  private run;
201
230
  }
202
231
  //#endregion
@@ -208,7 +237,6 @@ interface HandoffResult {
208
237
  title: string;
209
238
  }
210
239
  declare class BrowserSurface implements SurfaceHandler {
211
- private readonly options;
212
240
  private readonly sendFrame;
213
241
  readonly surface: "browser";
214
242
  private readonly session;
@@ -221,6 +249,7 @@ declare class BrowserSurface implements SurfaceHandler {
221
249
  private handoff;
222
250
  private controllerSessionId;
223
251
  private pendingControllerSessionId;
252
+ private claimGeneration;
224
253
  private streamGeneration;
225
254
  private streamQueue;
226
255
  private closed;
@@ -245,7 +274,7 @@ declare class BrowserSurface implements SurfaceHandler {
245
274
  * under the user (mislabeled 409 on the real claim; agent only resuming when
246
275
  * the tab closed).
247
276
  */
248
- requestHandoff(timeoutMs: number): Promise<HandoffResult>;
277
+ requestHandoff(timeoutMs: number, signal?: AbortSignal): Promise<HandoffResult>;
249
278
  /**
250
279
  * Keep the shared Chrome alive across an awaiting-human window. Delegates to
251
280
  * the session's hold counter (which also backs per-viewer holds). Idempotent
@@ -268,4 +297,4 @@ declare class BrowserSurface implements SurfaceHandler {
268
297
  private sendFrameSafe;
269
298
  }
270
299
  //#endregion
271
- export { BrowserAutomation, BrowserSession, type BrowserSessionOptions, BrowserSurface, type BrowserSurfaceOptions, type HandoffResult, type Logger, type NavigateResult };
300
+ export { BrowserAutomation, type BrowserOperationContext, type BrowserOperationOptions, BrowserSession, type BrowserSessionOptions, BrowserSurface, type BrowserSurfaceOptions, type HandoffResult, type Logger, type NavigateResult };
package/dist/index.js CHANGED
@@ -218,6 +218,15 @@ var BrowserSession = class {
218
218
  });
219
219
  }).catch(() => {});
220
220
  });
221
+ browser.on("targetdestroyed", (target) => {
222
+ const closedPage = this.activePage;
223
+ if (this.browser !== browser || !closedPage || target.type() !== TargetType.PAGE) return;
224
+ target.page().then(async (page) => {
225
+ if (page === closedPage && this.browser === browser && this.activePage === closedPage) await this.restoreAfterClose(browser, closedPage, target.opener());
226
+ }).catch(() => {
227
+ this.log.warn("Could not restore the browser page after a popup closed");
228
+ });
229
+ });
221
230
  } catch (error) {
222
231
  await browser.close().catch(() => void 0);
223
232
  throw error;
@@ -235,6 +244,27 @@ var BrowserSession = class {
235
244
  this.log.debug("Active page switched to new target");
236
245
  return true;
237
246
  }
247
+ /** A login popup can close while the agent is parked for human control.
248
+ * Recover here, without waiting for another automation call to discover
249
+ * that the active page is closed, and rebind the viewer to its opener. */
250
+ async restoreAfterClose(browser, closedPage, openerTarget) {
251
+ let opener = await openerTarget?.page();
252
+ while (this.browser === browser && this.activePage === closedPage) {
253
+ const pages = await browser.pages();
254
+ if (this.browser !== browser || this.activePage !== closedPage) return;
255
+ const page = opener && !opener.isClosed() ? opener : pages.find((candidate) => !candidate.isClosed() && isNavigablePageUrl(candidate.url())) ?? pages.find((candidate) => !candidate.isClosed()) ?? await browser.newPage();
256
+ opener = null;
257
+ try {
258
+ await this.preparePage(page);
259
+ } catch (error) {
260
+ if (page.isClosed()) continue;
261
+ throw error;
262
+ }
263
+ if (page.isClosed()) continue;
264
+ if (this.browser === browser && this.activePage === closedPage) this.setActivePage(page);
265
+ return;
266
+ }
267
+ }
238
268
  /** The current active page, launching Chrome first if needed. */
239
269
  async getActivePage() {
240
270
  await this.ensureLaunched();
@@ -254,6 +284,55 @@ var BrowserSession = class {
254
284
  this.activePageListeners.delete(listener);
255
285
  };
256
286
  }
287
+ /** Internal to the serialized automation turn. The caller holds Chrome until
288
+ * the adapter has disconnected its client/awaited its child process exit. */
289
+ async withCdpTarget(operation, signal) {
290
+ signal.throwIfAborted();
291
+ const page = await this.getActivePage();
292
+ const browser = this.browser;
293
+ const generation = this.generation;
294
+ if (!browser) throw new Error("Browser is unavailable");
295
+ const disconnected = new AbortController();
296
+ const abort = () => {
297
+ disconnected.abort(/* @__PURE__ */ new Error("Browser operation interrupted"));
298
+ };
299
+ const operationSignal = AbortSignal.any([signal, disconnected.signal]);
300
+ const assertCurrent = () => {
301
+ operationSignal.throwIfAborted();
302
+ if (this.browser !== browser || this.generation !== generation || !browser.connected || page.isClosed()) throw new Error("Browser operation interrupted");
303
+ };
304
+ browser.on("disconnected", abort);
305
+ page.on("close", abort);
306
+ try {
307
+ assertCurrent();
308
+ const cdp = await page.createCDPSession();
309
+ let targetId;
310
+ try {
311
+ const { targetInfo } = await cdp.send("Target.getTargetInfo");
312
+ targetId = targetInfo.targetId;
313
+ } finally {
314
+ await cdp.detach().catch(() => void 0);
315
+ }
316
+ assertCurrent();
317
+ const browserWSEndpoint = browser.wsEndpoint();
318
+ const endpoint = new URL(browserWSEndpoint);
319
+ if (endpoint.protocol !== "ws:" || ![
320
+ "127.0.0.1",
321
+ "[::1]",
322
+ "localhost"
323
+ ].includes(endpoint.hostname) || endpoint.username !== "" || endpoint.password !== "" || !endpoint.pathname.startsWith("/devtools/browser/")) throw new Error("Browser local attachment is unavailable");
324
+ const result = await operation({
325
+ browserWSEndpoint,
326
+ targetId,
327
+ signal: operationSignal
328
+ });
329
+ assertCurrent();
330
+ return result;
331
+ } finally {
332
+ browser.off("disconnected", abort);
333
+ page.off("close", abort);
334
+ }
335
+ }
257
336
  /** Prevent idle shutdown while a viewer or op is active. */
258
337
  addHold() {
259
338
  this.holds += 1;
@@ -481,6 +560,43 @@ const MOUSE_TYPE = {
481
560
  mousepressed: "mousePressed",
482
561
  mousereleased: "mouseReleased"
483
562
  };
563
+ const VIRTUAL_KEYS = {
564
+ Backspace: 8,
565
+ Tab: 9,
566
+ Enter: 13,
567
+ NumpadEnter: 13,
568
+ Shift: 16,
569
+ Control: 17,
570
+ Alt: 18,
571
+ Pause: 19,
572
+ CapsLock: 20,
573
+ Escape: 27,
574
+ Space: 32,
575
+ " ": 32,
576
+ PageUp: 33,
577
+ PageDown: 34,
578
+ End: 35,
579
+ Home: 36,
580
+ ArrowLeft: 37,
581
+ ArrowUp: 38,
582
+ ArrowRight: 39,
583
+ ArrowDown: 40,
584
+ Insert: 45,
585
+ Delete: 46,
586
+ Meta: 91
587
+ };
588
+ function virtualKeyCode(input) {
589
+ const named = VIRTUAL_KEYS[input.key ?? ""] ?? VIRTUAL_KEYS[input.code ?? ""];
590
+ if (typeof named === "number") return named;
591
+ const letter = /^Key([A-Z])$/.exec(input.code ?? "");
592
+ if (letter) return letter[1].charCodeAt(0);
593
+ const digit = /^Digit([0-9])$/.exec(input.code ?? "");
594
+ if (digit) return digit[1].charCodeAt(0);
595
+ const key = input.key ?? "";
596
+ if (/^[A-Za-z0-9]$/.test(key)) return key.toUpperCase().charCodeAt(0);
597
+ const functionKey = /^F([1-9]|1[0-9]|2[0-4])$/.exec(input.key ?? "");
598
+ if (functionKey) return 111 + Number(functionKey[1]);
599
+ }
484
600
  var InputInjector = class {
485
601
  cdp = null;
486
602
  viewport;
@@ -545,19 +661,18 @@ var InputInjector = class {
545
661
  key(p) {
546
662
  if (!this.cdp) return;
547
663
  if (p.type === "char") {
548
- this.cdp.send("Input.dispatchKeyEvent", {
549
- type: "char",
550
- text: p.text ?? "",
551
- modifiers: p.modifiers ?? 0
552
- }).catch(() => {});
664
+ this.cdp.send("Input.insertText", { text: p.text ?? "" }).catch(() => {});
553
665
  return;
554
666
  }
667
+ const modifiers = p.modifiers ?? 0;
668
+ const text = p.type === "keydown" && !(modifiers & 7) ? p.text ?? (p.key === "Enter" ? "\r" : void 0) : void 0;
555
669
  this.cdp.send("Input.dispatchKeyEvent", {
556
- type: p.type === "keydown" ? "keyDown" : "keyUp",
670
+ type: p.type === "keyup" ? "keyUp" : text ? "keyDown" : "rawKeyDown",
557
671
  key: p.key,
558
672
  code: p.code,
559
- text: p.type === "keydown" ? p.text : void 0,
560
- modifiers: p.modifiers ?? 0
673
+ text,
674
+ windowsVirtualKeyCode: virtualKeyCode(p),
675
+ modifiers
561
676
  }).catch(() => {});
562
677
  }
563
678
  };
@@ -570,6 +685,7 @@ function clamp01(n) {
570
685
  const OPERATION_TIMEOUT_MS = 3e4;
571
686
  var BrowserAutomation = class {
572
687
  operationTail = Promise.resolve();
688
+ stopping = new AbortController();
573
689
  constructor(session, turn, isNavigationAllowed) {
574
690
  this.session = session;
575
691
  this.turn = turn;
@@ -653,13 +769,38 @@ var BrowserAutomation = class {
653
769
  async waitUntilIdle() {
654
770
  await this.operationTail;
655
771
  }
656
- run(operation) {
772
+ /** Trusted local adapters share the built-in automation queue and exact page.
773
+ * The callback must disconnect/await children in finally, including on abort.
774
+ * Do not invoke another automation operation or handoff from the callback. */
775
+ withCdpOperation(operation, options = {}) {
776
+ return this.run((signal) => this.session.withCdpTarget(operation, signal), options);
777
+ }
778
+ /** Insert the claim at a precise queue position; later automation parks. */
779
+ async yieldToHuman(grant) {
780
+ await this.run(() => {
781
+ grant();
782
+ return Promise.resolve();
783
+ });
784
+ }
785
+ /** Abort first, then await callback cleanup before the owner closes Chrome. */
786
+ async shutdown() {
787
+ this.stopping.abort(/* @__PURE__ */ new Error("Browser automation stopped"));
788
+ this.turn.releaseHuman();
789
+ await this.operationTail;
790
+ }
791
+ run(operation, options = {}) {
792
+ const signal = options.signal ? AbortSignal.any([this.stopping.signal, options.signal]) : this.stopping.signal;
657
793
  const result = this.operationTail.catch(() => void 0).then(async () => {
658
- await this.turn.acquireAgent();
794
+ signal.throwIfAborted();
795
+ this.session.addHold();
659
796
  try {
660
- return await operation();
797
+ await this.turn.acquireAgent(signal);
798
+ signal.throwIfAborted();
799
+ const result = await operation(signal);
800
+ signal.throwIfAborted();
801
+ return result;
661
802
  } finally {
662
- this.session.touch();
803
+ this.session.removeHold();
663
804
  }
664
805
  });
665
806
  this.operationTail = result.then(() => void 0, () => void 0);
@@ -700,13 +841,13 @@ var BrowserSurface = class {
700
841
  handoff = null;
701
842
  controllerSessionId = null;
702
843
  pendingControllerSessionId = null;
844
+ claimGeneration = 0;
703
845
  streamGeneration = 0;
704
846
  streamQueue = Promise.resolve();
705
847
  closed = false;
706
848
  removePageListener;
707
849
  log;
708
850
  constructor(options, sendFrame) {
709
- this.options = options;
710
851
  this.sendFrame = sendFrame;
711
852
  this.log = options.logger ?? noopLogger;
712
853
  const navigationPolicy = options.isNavigationAllowed ?? ((url) => {
@@ -732,8 +873,11 @@ var BrowserSurface = class {
732
873
  });
733
874
  this.injector = new InputInjector(this.viewport);
734
875
  this.automation = new BrowserAutomation(this.session, this.turn, navigationPolicy);
735
- this.removePageListener = this.session.onActivePageChange(() => {
736
- if (this.viewers.size > 0) this.restartStreaming();
876
+ this.removePageListener = this.session.onActivePageChange(async () => {
877
+ if (this.viewers.size > 0) {
878
+ await this.restartStreaming();
879
+ this.broadcastState();
880
+ }
737
881
  });
738
882
  }
739
883
  async openSession(sessionId, open) {
@@ -791,25 +935,28 @@ var BrowserSurface = class {
791
935
  if (input) this.injector.key(input);
792
936
  }
793
937
  break;
794
- case RemoteFrameType.TAKEOVER_REQUEST:
938
+ case RemoteFrameType.TAKEOVER_REQUEST: {
795
939
  if (this.controllerSessionId !== null || this.pendingControllerSessionId !== null) {
796
940
  this.sendFrameSafe(encodeFrame(RemoteFrameType.TAKEOVER_DENIED, frame.sessionId));
797
941
  break;
798
942
  }
799
943
  this.pendingControllerSessionId = frame.sessionId;
800
- this.automation.waitUntilIdle().then(() => {
801
- if (this.pendingControllerSessionId !== frame.sessionId || !this.viewers.has(frame.sessionId) || this.closed) return;
944
+ const claimGeneration = ++this.claimGeneration;
945
+ this.automation.yieldToHuman(() => {
946
+ if (this.claimGeneration !== claimGeneration || this.pendingControllerSessionId !== frame.sessionId || !this.viewers.has(frame.sessionId) || this.closed) return;
802
947
  this.pendingControllerSessionId = null;
803
948
  if (this.turn.grantHuman()) {
804
949
  this.controllerSessionId = frame.sessionId;
805
950
  this.broadcast(RemoteFrameType.TAKEOVER_GRANTED);
806
951
  } else this.sendFrameSafe(encodeFrame(RemoteFrameType.TAKEOVER_DENIED, frame.sessionId));
807
952
  }).catch((error) => {
808
- if (this.pendingControllerSessionId === frame.sessionId) this.pendingControllerSessionId = null;
953
+ if (this.claimGeneration !== claimGeneration || this.pendingControllerSessionId !== frame.sessionId) return;
954
+ this.pendingControllerSessionId = null;
809
955
  this.log.warn(`Could not grant browser control: ${error instanceof Error ? error.message : String(error)}`);
810
956
  this.sendFrameSafe(encodeFrame(RemoteFrameType.TAKEOVER_DENIED, frame.sessionId));
811
957
  });
812
958
  break;
959
+ }
813
960
  case RemoteFrameType.RELEASE_CONTROL:
814
961
  if (frame.sessionId === this.controllerSessionId) this.releaseToAgent();
815
962
  break;
@@ -819,7 +966,10 @@ var BrowserSurface = class {
819
966
  closeSession(sessionId) {
820
967
  if (!this.viewers.delete(sessionId)) return;
821
968
  this.session.removeHold();
822
- if (this.pendingControllerSessionId === sessionId) this.pendingControllerSessionId = null;
969
+ if (this.pendingControllerSessionId === sessionId) {
970
+ this.pendingControllerSessionId = null;
971
+ this.claimGeneration += 1;
972
+ }
823
973
  if (this.controllerSessionId === sessionId) this.releaseToAgent();
824
974
  if (this.viewers.size === 0) {
825
975
  this.streamGeneration += 1;
@@ -841,20 +991,28 @@ var BrowserSurface = class {
841
991
  * under the user (mislabeled 409 on the real claim; agent only resuming when
842
992
  * the tab closed).
843
993
  */
844
- async requestHandoff(timeoutMs) {
994
+ async requestHandoff(timeoutMs, signal) {
845
995
  if (!Number.isInteger(timeoutMs) || timeoutMs < 1e3 || timeoutMs > 1800 * 1e3) throw new Error("Browser handoff timeout must be an integer from 1000 to 1800000ms");
996
+ if (this.closed) throw new Error("Browser surface is shut down");
997
+ signal?.throwIfAborted();
846
998
  if (this.handoff) this.releaseToAgent();
847
- return new Promise((resolve) => {
999
+ return new Promise((resolve, reject) => {
1000
+ const settle = (result) => {
1001
+ signal?.removeEventListener("abort", onAbort);
1002
+ resolve(result);
1003
+ };
848
1004
  const timer = setTimeout(() => {
1005
+ if (this.handoff !== waiter) return;
849
1006
  this.handoff = null;
850
1007
  this.pendingControllerSessionId = null;
1008
+ this.claimGeneration += 1;
851
1009
  if (this.turn.humanInControl) {
852
1010
  this.turn.releaseHuman();
853
1011
  this.controllerSessionId = null;
854
1012
  this.broadcast(RemoteFrameType.CONTROL_REVOKED);
855
1013
  }
856
1014
  this.currentPageInfo().then(({ url, title }) => {
857
- resolve({
1015
+ settle({
858
1016
  released: false,
859
1017
  timedOut: true,
860
1018
  url,
@@ -863,10 +1021,27 @@ var BrowserSurface = class {
863
1021
  });
864
1022
  }, timeoutMs);
865
1023
  timer.unref();
866
- this.handoff = {
867
- resolve,
1024
+ const waiter = {
1025
+ resolve: settle,
868
1026
  timer
869
1027
  };
1028
+ const onAbort = () => {
1029
+ if (this.handoff !== waiter) return;
1030
+ this.handoff = null;
1031
+ clearTimeout(timer);
1032
+ signal?.removeEventListener("abort", onAbort);
1033
+ this.pendingControllerSessionId = null;
1034
+ this.claimGeneration += 1;
1035
+ if (this.turn.humanInControl) {
1036
+ this.turn.releaseHuman();
1037
+ this.controllerSessionId = null;
1038
+ this.broadcast(RemoteFrameType.CONTROL_REVOKED);
1039
+ }
1040
+ reject(/* @__PURE__ */ new Error("Browser handoff cancelled"));
1041
+ };
1042
+ this.handoff = waiter;
1043
+ signal?.addEventListener("abort", onAbort, { once: true });
1044
+ if (signal?.aborted) onAbort();
870
1045
  });
871
1046
  }
872
1047
  /**
@@ -887,6 +1062,7 @@ var BrowserSurface = class {
887
1062
  this.removePageListener();
888
1063
  this.streamGeneration += 1;
889
1064
  this.pendingControllerSessionId = null;
1065
+ this.claimGeneration += 1;
890
1066
  this.controllerSessionId = null;
891
1067
  const waiter = this.handoff;
892
1068
  this.handoff = null;
@@ -900,11 +1076,13 @@ var BrowserSurface = class {
900
1076
  });
901
1077
  }
902
1078
  this.turn.releaseHuman();
1079
+ await this.automation.shutdown();
903
1080
  await this.enqueueStreamCleanup();
904
1081
  await this.session.shutdown();
905
1082
  }
906
1083
  releaseToAgent() {
907
1084
  this.pendingControllerSessionId = null;
1085
+ this.claimGeneration += 1;
908
1086
  this.controllerSessionId = null;
909
1087
  this.turn.releaseHuman();
910
1088
  const waiter = this.handoff;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alfe.ai/browser",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "CDP-driven browser surface for the Alfe interactive remote-control relay — one shared headless Chrome for agent automation + human co-browse takeover",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -17,7 +17,7 @@
17
17
  ],
18
18
  "dependencies": {
19
19
  "puppeteer-core": "^24.0.0",
20
- "@alfe.ai/remote": "^0.1.1"
20
+ "@alfe.ai/remote": "^0.2.0"
21
21
  },
22
22
  "license": "UNLICENSED",
23
23
  "homepage": "https://alfe.ai",
@@ -32,6 +32,7 @@
32
32
  "build": "tsdown",
33
33
  "dev": "tsdown --watch",
34
34
  "test": "vitest run --passWithNoTests",
35
+ "test:integration": "tsc --noEmit -p tsconfig.integration.json && vitest run --config test/vitest.config.ts",
35
36
  "typecheck": "tsc --noEmit",
36
37
  "lint": "eslint ."
37
38
  }