@omercnet/paseo-shared-browser 1.2.2-next.241.1 → 1.2.2-next.242.1

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
@@ -353,6 +353,11 @@ bun run check
353
353
  bun run test:unit
354
354
  ```
355
355
 
356
+ The `test:tabs` native check requires explicit
357
+ `PASEO_SHARED_BROWSER_AGENT_BROWSER_BINARY` and
358
+ `PASEO_SHARED_BROWSER_CHROMIUM_EXECUTABLE` paths. It creates and cleans up its own
359
+ temporary Paseo home and browser profile.
360
+
356
361
  `node scripts/test-image-capture-mounted.mjs --tooling-root /path/to/test-workspace`
357
362
  opt-in checks the real React/React Query image scheduling and viewing recovery hooks
358
363
  in JSDOM. The tooling workspace must already provide compatible React, react-dom,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@omercnet/paseo-shared-browser",
3
- "version": "1.2.2-next.241.1",
3
+ "version": "1.2.2-next.242.1",
4
4
  "description": "One real Chromium session per Paseo workspace, shared live across every connected client.",
5
5
  "keywords": [
6
6
  "browser",
@@ -50,6 +50,7 @@
50
50
  "test": "vitest run tests/contracts.test.ts tests/lease.test.ts tests/runtime-lifecycle.test.ts tests/agent-access.test.ts tests/agent-input-safety.test.ts tests/tabs.test.ts tests/tabs-runtime-loss.test.ts tests/composer-pills.test.ts tests/sidebar.test.ts tests/pointer-mapping.test.ts tests/frame-lifecycle.test.ts tests/viewport-resync.test.ts client server shared",
51
51
  "test:unit": "vitest run tests/contracts.test.ts tests/lease.test.ts tests/runtime-lifecycle.test.ts tests/agent-access.test.ts tests/agent-input-safety.test.ts tests/tabs.test.ts tests/tabs-runtime-loss.test.ts tests/composer-pills.test.ts tests/sidebar.test.ts tests/pointer-mapping.test.ts tests/frame-lifecycle.test.ts tests/viewport-resync.test.ts client server shared",
52
52
  "test:smoke": "vitest run tests/browser.smoke.test.ts tests/browser.drag-cancel.smoke.test.ts tests/xvfb.smoke.test.ts tests/browser.failed-open.smoke.test.ts tests/browser.background-tab.smoke.test.ts --testTimeout=30000",
53
+ "test:tabs": "vitest run tests/browser-tabs.smoke.test.ts --testTimeout=30000",
53
54
  "test:video": "vitest run tests/video.smoke.test.ts --testTimeout=30000"
54
55
  },
55
56
  "dependencies": {
@@ -368,6 +368,9 @@ export class AgentBrowserRuntime {
368
368
  const existingArguments = process.env.PASEO_SHARED_BROWSER_CHROMIUM_ARGS;
369
369
  const chromiumArguments = [
370
370
  existingArguments,
371
+ // agent-browser creates the launch page itself. Chromium's default
372
+ // startup window otherwise leaves an additional chrome://newtab page.
373
+ "--no-startup-window",
371
374
  videoExtension ? `--allowlisted-extension-id=${NATIVE_VIDEO_EXTENSION_ID}` : undefined,
372
375
  ]
373
376
  .filter(Boolean)
@@ -7,7 +7,9 @@ import {
7
7
  AgentBrowserRuntime,
8
8
  type AgentBrowserRuntimeOptions,
9
9
  type BrowserViewport,
10
+ type RuntimeTarget,
10
11
  } from "./agent-browser-runtime";
12
+ import { CdpUnknownOutcomeError } from "./cdp";
11
13
  import { resolveBrowserRuntimeRoot } from "./runtime-path";
12
14
  import type { JsonValue } from "./runtime-protocol";
13
15
  import type { RuntimeOwner } from "./supervisor";
@@ -25,6 +27,7 @@ interface OwnedRuntime {
25
27
  rootTargetId: string;
26
28
  runtimeOptions: AgentBrowserRuntimeOptions;
27
29
  tabs: Map<string, Promise<AgentBrowserRuntime>>;
30
+ tabOrder: string[];
28
31
  display: PrivateVirtualDisplay | null;
29
32
  /** Bounded, secret-free reason an opted-in private display is not in use. */
30
33
  displayNotice: string | null;
@@ -53,6 +56,22 @@ function optionalMouseModifiers(data: Record<string, JsonValue>): [] | [number]
53
56
  : [browserGestureKeySchema.shape.modifiers.parse(data.modifiers)];
54
57
  }
55
58
 
59
+ /** Retain known tab positions across unordered CDP snapshots; append newly discovered pages. */
60
+ async function orderedTargets(owned: OwnedRuntime): Promise<RuntimeTarget[]> {
61
+ const targets = await owned.runtime.targets();
62
+ const byId = new Map(targets.map((target) => [target.targetId, target]));
63
+ const surviving = owned.tabOrder.filter((targetId) => byId.has(targetId));
64
+ const known = new Set(surviving);
65
+ for (const target of targets) {
66
+ if (!known.has(target.targetId)) {
67
+ surviving.push(target.targetId);
68
+ known.add(target.targetId);
69
+ }
70
+ }
71
+ owned.tabOrder = surviving;
72
+ return surviving.map((targetId) => byId.get(targetId)!);
73
+ }
74
+
56
75
  /** Give each page its own CDP attachment and media state within one Chromium profile. */
57
76
  async function tabRuntime(owned: OwnedRuntime, targetId: string): Promise<AgentBrowserRuntime> {
58
77
  const existing = owned.tabs.get(targetId);
@@ -151,6 +170,7 @@ export async function createRuntimeOwner(
151
170
  rootTargetId: identity.targetId,
152
171
  runtimeOptions,
153
172
  tabs: new Map(),
173
+ tabOrder: [identity.targetId],
154
174
  display,
155
175
  displayNotice,
156
176
  };
@@ -179,20 +199,22 @@ export async function createRuntimeOwner(
179
199
  ? (input as Record<string, JsonValue>)
180
200
  : {};
181
201
  if (operation === "tabs.list") {
182
- return (await owned.runtime.targets()).map(({ targetId, title, url }) => ({
202
+ return (await orderedTargets(owned)).map(({ targetId, title, url }) => ({
183
203
  targetId,
184
204
  title,
185
205
  url,
186
206
  }));
187
207
  }
188
208
  if (operation === "tabs.create") {
189
- if ((await owned.runtime.targets()).length >= MAX_BROWSER_TABS)
209
+ if ((await orderedTargets(owned)).length >= MAX_BROWSER_TABS)
190
210
  throw new Error("Browser tab limit reached");
191
- return { targetId: await owned.runtime.createTarget() };
211
+ const targetId = await owned.runtime.createTarget();
212
+ owned.tabOrder.push(targetId);
213
+ return { targetId };
192
214
  }
193
215
  if (operation === "tabs.close") {
194
216
  const targetId = String(data.targetId);
195
- const targets = await owned.runtime.targets();
217
+ const targets = await orderedTargets(owned);
196
218
  if (!targets.some((target) => target.targetId === targetId)) {
197
219
  throw new Error("Browser tab is no longer available");
198
220
  }
@@ -207,7 +229,20 @@ export async function createRuntimeOwner(
207
229
  owned.rootTargetId = successor.targetId;
208
230
  }
209
231
  await owned.runtime.closeTarget(targetId);
232
+ // Target.closeTarget can acknowledge before Chromium drops the page from
233
+ // its target list. Success requires observing its absence; the close is
234
+ // never replayed, and exhaustion is reported rather than assumed.
235
+ let gone = false;
236
+ for (let attempt = 0; attempt < 20 && !gone; attempt++) {
237
+ gone = !(await owned.runtime.targets()).some((target) => target.targetId === targetId);
238
+ if (gone) break;
239
+ const { promise, resolve } = Promise.withResolvers<void>();
240
+ setTimeout(resolve, 50);
241
+ await promise;
242
+ }
210
243
  owned.tabs.delete(targetId);
244
+ if (!gone) throw new CdpUnknownOutcomeError("Browser tab close could not be confirmed");
245
+ owned.tabOrder = owned.tabOrder.filter((id) => id !== targetId);
211
246
  return null;
212
247
  }
213
248
  const targetId = typeof data.targetId === "string" ? data.targetId : null;
@@ -828,8 +828,8 @@ export class RuntimeSupervisor<Runtime extends RuntimeInstance = RuntimeInstance
828
828
  private async selectAgentTab(binding: AgentBinding, tabId: string): Promise<BrowserState> {
829
829
  this.assertAgentBindingCurrent(binding);
830
830
  if (binding.tabId === tabId && binding.viewerToken) {
831
- const current = await this.browserPolicy.status(binding.viewerToken);
832
- this.assertAgentBindingCurrent(binding);
831
+ // Same-tab selection needs the same expiry recovery as status/capture.
832
+ const current = await this.requestAgentObservation(binding, "status", {});
833
833
  if (binding.tabId !== tabId) {
834
834
  throw new RuntimeProtocolError("INVALID_REQUEST", "Agent tab changed during selection");
835
835
  }
@@ -0,0 +1,97 @@
1
+ import { afterEach, beforeEach, expect, it, vi } from "vitest";
2
+ import { createRuntimeOwner } from "./runtime-owner";
3
+
4
+ type Owner = Awaited<ReturnType<typeof createRuntimeOwner>>;
5
+ type Owned = Parameters<Owner["request"]>[0];
6
+
7
+ beforeEach(() => vi.useFakeTimers());
8
+ afterEach(() => vi.useRealTimers());
9
+
10
+ async function fixture(lingerSnapshots: number | null) {
11
+ const owner = await createRuntimeOwner({ virtualDisplay: false, nativeVideo: false });
12
+ const state = { ids: ["root", "closing"], closing: false, polls: 0, next: 0 };
13
+ const native = {
14
+ targets: vi.fn(async () => {
15
+ if (state.closing && lingerSnapshots !== null && state.polls++ >= lingerSnapshots) {
16
+ state.ids = state.ids.filter((id) => id !== "closing");
17
+ } else if (state.closing && lingerSnapshots === null) {
18
+ state.polls++;
19
+ }
20
+ return state.ids.map((targetId) => ({ targetId, title: targetId, url: "about:blank" }));
21
+ }),
22
+ closeTarget: vi.fn(async () => {
23
+ state.closing = true;
24
+ }),
25
+ createTarget: vi.fn(async () => {
26
+ const id = `new-${++state.next}`;
27
+ state.ids.unshift(id);
28
+ return id;
29
+ }),
30
+ selectTarget: vi.fn(async () => {}),
31
+ };
32
+ const owned = {
33
+ runtimeId: "review-runtime",
34
+ runtime: native,
35
+ rootTargetId: "root",
36
+ runtimeOptions: {},
37
+ tabs: new Map(),
38
+ tabOrder: ["root", "closing"],
39
+ display: null,
40
+ displayNotice: null,
41
+ } as unknown as Owned;
42
+ return { owner, owned, native, state };
43
+ }
44
+
45
+ it("confirms an acknowledged close only after the target disappears without replay", async () => {
46
+ const f = await fixture(2);
47
+ const pending = f.owner.request(f.owned, "tabs.close", { targetId: "closing" });
48
+ await vi.runAllTimersAsync();
49
+ await expect(pending).resolves.toBeNull();
50
+ expect(f.native.closeTarget).toHaveBeenCalledTimes(1);
51
+ expect(f.state.polls).toBe(3);
52
+ expect(
53
+ ((await f.owner.request(f.owned, "tabs.list", null)) as { targetId: string }[]).map(
54
+ (x) => x.targetId,
55
+ ),
56
+ ).toEqual(["root"]);
57
+ });
58
+
59
+ it("fails explicitly, without replaying close or reordering, when the target never disappears", async () => {
60
+ const f = await fixture(null);
61
+ const outcome = f.owner.request(f.owned, "tabs.close", { targetId: "closing" }).then(
62
+ () => "ok",
63
+ (error) => String(error),
64
+ );
65
+ await vi.runAllTimersAsync();
66
+ expect(await outcome).toContain(
67
+ "CdpUnknownOutcomeError: Browser tab close could not be confirmed",
68
+ );
69
+ expect(f.native.closeTarget).toHaveBeenCalledTimes(1);
70
+ expect(f.state.polls).toBe(20);
71
+ const ids = (await f.owner.request(f.owned, "tabs.list", null)) as { targetId: string }[];
72
+ expect(ids.map((tab) => tab.targetId)).toEqual(["root", "closing"]);
73
+ });
74
+
75
+ it("retains known ordering across unordered snapshots and appends explicit creations", async () => {
76
+ const f = await fixture(null);
77
+ f.state.ids = ["third", "closing", "root"];
78
+ const ids = async () =>
79
+ ((await f.owner.request(f.owned, "tabs.list", null)) as { targetId: string }[]).map(
80
+ (x) => x.targetId,
81
+ );
82
+ expect(await ids()).toEqual(["root", "closing", "third"]);
83
+ f.state.ids = ["closing", "root", "third"];
84
+ expect(await ids()).toEqual(["root", "closing", "third"]);
85
+ await f.owner.request(f.owned, "tabs.create", null);
86
+ await f.owner.request(f.owned, "tabs.create", null);
87
+ expect(await ids()).toEqual(["root", "closing", "third", "new-1", "new-2"]);
88
+ });
89
+
90
+ it("preserves the existing last-tab refusal", async () => {
91
+ const f = await fixture(null);
92
+ f.state.ids = ["root"];
93
+ await expect(f.owner.request(f.owned, "tabs.close", { targetId: "root" })).rejects.toThrow(
94
+ "last browser tab",
95
+ );
96
+ expect(f.native.closeTarget).not.toHaveBeenCalled();
97
+ });