@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.
|
|
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)
|
package/server/runtime-owner.ts
CHANGED
|
@@ -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
|
|
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
|
|
209
|
+
if ((await orderedTargets(owned)).length >= MAX_BROWSER_TABS)
|
|
190
210
|
throw new Error("Browser tab limit reached");
|
|
191
|
-
|
|
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
|
|
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;
|
package/server/supervisor.ts
CHANGED
|
@@ -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
|
-
|
|
832
|
-
this.
|
|
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
|
+
});
|