@omercnet/paseo-shared-browser 1.1.0-next.211.1 → 1.1.0-next.213.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/package.json +3 -3
- package/server/agent-browser-runtime.ts +82 -33
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@omercnet/paseo-shared-browser",
|
|
3
|
-
"version": "1.1.0-next.
|
|
3
|
+
"version": "1.1.0-next.213.1",
|
|
4
4
|
"description": "One real Chromium session per Paseo workspace, shared live across every connected client.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"browser",
|
|
@@ -43,8 +43,8 @@
|
|
|
43
43
|
"check:write": "biome check --write .",
|
|
44
44
|
"typecheck": "tsc --noEmit",
|
|
45
45
|
"prepare:runtime": "node scripts/prepare-runtime.mjs",
|
|
46
|
-
"test": "vitest run tests/contracts.test.ts tests/lease.test.ts tests/runtime-lifecycle.test.ts tests/agent-access.test.ts tests/composer-pills.test.ts tests/sidebar.test.ts tests/pointer-mapping.test.ts tests/frame-lifecycle.test.ts",
|
|
47
|
-
"test:unit": "vitest run tests/contracts.test.ts tests/lease.test.ts tests/runtime-lifecycle.test.ts tests/agent-access.test.ts tests/composer-pills.test.ts tests/sidebar.test.ts tests/pointer-mapping.test.ts tests/frame-lifecycle.test.ts",
|
|
46
|
+
"test": "vitest run tests/contracts.test.ts tests/lease.test.ts tests/runtime-lifecycle.test.ts tests/agent-access.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",
|
|
47
|
+
"test:unit": "vitest run tests/contracts.test.ts tests/lease.test.ts tests/runtime-lifecycle.test.ts tests/agent-access.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",
|
|
48
48
|
"test:smoke": "vitest run tests/browser.smoke.test.ts --testTimeout=30000"
|
|
49
49
|
},
|
|
50
50
|
"dependencies": {
|
|
@@ -183,6 +183,26 @@ function findString(value: unknown, keys: readonly string[]): string | null {
|
|
|
183
183
|
return null;
|
|
184
184
|
}
|
|
185
185
|
|
|
186
|
+
export function jpegDimensions(base64: string): { width: number; height: number } | null {
|
|
187
|
+
const bytes = Buffer.from(base64, "base64");
|
|
188
|
+
let offset = 2;
|
|
189
|
+
while (bytes[0] === 0xff && bytes[1] === 0xd8 && offset + 9 <= bytes.length) {
|
|
190
|
+
if (bytes[offset] !== 0xff) return null;
|
|
191
|
+
const marker = bytes[offset + 1];
|
|
192
|
+
// SOFn carries the frame size; DHT (c4), JPG (c8) and DAC (cc) share the range but do not.
|
|
193
|
+
if (
|
|
194
|
+
marker !== undefined &&
|
|
195
|
+
marker >= 0xc0 &&
|
|
196
|
+
marker <= 0xcf &&
|
|
197
|
+
![0xc4, 0xc8, 0xcc].includes(marker)
|
|
198
|
+
)
|
|
199
|
+
return { width: bytes.readUInt16BE(offset + 7), height: bytes.readUInt16BE(offset + 5) };
|
|
200
|
+
if (marker === 0xda) return null;
|
|
201
|
+
offset += 2 + bytes.readUInt16BE(offset + 2);
|
|
202
|
+
}
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
|
|
186
206
|
export class AgentBrowserRuntime {
|
|
187
207
|
readonly binaryPath: string;
|
|
188
208
|
readonly executablePath: string;
|
|
@@ -318,7 +338,7 @@ export class AgentBrowserRuntime {
|
|
|
318
338
|
await this.requireConnection().send("Target.activateTarget", { targetId }, { mutation: true });
|
|
319
339
|
this.page = await attachToTarget(this.requireConnection(), targetId);
|
|
320
340
|
this.targetId = targetId;
|
|
321
|
-
await this.
|
|
341
|
+
await this.preparePage(this.page);
|
|
322
342
|
if (this.screencastActive) await this.startScreencastSession(this.page);
|
|
323
343
|
}
|
|
324
344
|
|
|
@@ -425,34 +445,7 @@ export class AgentBrowserRuntime {
|
|
|
425
445
|
async emulate(device: DeviceEmulation): Promise<void> {
|
|
426
446
|
this.assertViewport(device);
|
|
427
447
|
await this.withInvalidatedScreencast(async (page) => {
|
|
428
|
-
await
|
|
429
|
-
"Emulation.setDeviceMetricsOverride",
|
|
430
|
-
{
|
|
431
|
-
width: device.width,
|
|
432
|
-
height: device.height,
|
|
433
|
-
deviceScaleFactor: device.deviceScaleFactor,
|
|
434
|
-
mobile: device.mobile,
|
|
435
|
-
screenWidth: device.screenWidth ?? device.width,
|
|
436
|
-
screenHeight: device.screenHeight ?? device.height,
|
|
437
|
-
screenOrientation: {
|
|
438
|
-
type: device.width > device.height ? "landscapePrimary" : "portraitPrimary",
|
|
439
|
-
angle: device.width > device.height ? 90 : 0,
|
|
440
|
-
},
|
|
441
|
-
},
|
|
442
|
-
{ mutation: true },
|
|
443
|
-
);
|
|
444
|
-
await page.send(
|
|
445
|
-
"Emulation.setTouchEmulationEnabled",
|
|
446
|
-
{ enabled: device.touch, maxTouchPoints: device.touch ? 5 : 1 },
|
|
447
|
-
{ mutation: true },
|
|
448
|
-
);
|
|
449
|
-
if (device.userAgent) {
|
|
450
|
-
await page.send(
|
|
451
|
-
"Emulation.setUserAgentOverride",
|
|
452
|
-
{ userAgent: device.userAgent, platform: device.platform ?? "" },
|
|
453
|
-
{ mutation: true },
|
|
454
|
-
);
|
|
455
|
-
}
|
|
448
|
+
await this.applyEmulation(page, device);
|
|
456
449
|
this.viewport = { ...device };
|
|
457
450
|
});
|
|
458
451
|
}
|
|
@@ -483,7 +476,17 @@ export class AgentBrowserRuntime {
|
|
|
483
476
|
throw new RangeError("maxBytes must be positive");
|
|
484
477
|
const streamed = this.screencastFrame ?? (await this.waitForFrame(waitMs));
|
|
485
478
|
if (streamed && streamed.byteLength <= maxBytes) return streamed;
|
|
486
|
-
|
|
479
|
+
let frame = await this.screenshot(await this.requirePage(), maxBytes, quality);
|
|
480
|
+
if (!this.sizeMismatch(frame)) return frame;
|
|
481
|
+
// The browser stopped honouring the cached viewport. Re-apply it once; never loop.
|
|
482
|
+
await this.emulate(this.requireViewport());
|
|
483
|
+
frame = await this.screenshot(await this.requirePage(), maxBytes, quality);
|
|
484
|
+
const mismatch = this.sizeMismatch(frame);
|
|
485
|
+
if (mismatch) throw new Error(`Browser returned a ${mismatch} after viewport resync`);
|
|
486
|
+
return frame;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
private async screenshot(page: CdpSession, maxBytes: number, quality: number) {
|
|
487
490
|
for (const candidate of [quality, 50, 35, 20, 10, 1]) {
|
|
488
491
|
const boundedQuality = Math.max(1, Math.min(100, Math.round(candidate)));
|
|
489
492
|
const result = await page.send<{ data: string }>("Page.captureScreenshot", {
|
|
@@ -502,12 +505,25 @@ export class AgentBrowserRuntime {
|
|
|
502
505
|
height: viewport.height,
|
|
503
506
|
transport: "screenshot",
|
|
504
507
|
capturedAt: new Date().toISOString(),
|
|
505
|
-
};
|
|
508
|
+
} satisfies RuntimeFrame;
|
|
506
509
|
}
|
|
507
510
|
}
|
|
508
511
|
throw new Error(`JPEG screenshot exceeds ${maxBytes} bytes at minimum quality`);
|
|
509
512
|
}
|
|
510
513
|
|
|
514
|
+
private sizeMismatch(frame: RuntimeFrame): string | null {
|
|
515
|
+
const { width, height, deviceScaleFactor: scale } = this.requireViewport();
|
|
516
|
+
const size = jpegDimensions(frame.dataBase64);
|
|
517
|
+
// Chromium rounds fractional device pixels, so allow one pixel of slack.
|
|
518
|
+
if (
|
|
519
|
+
size &&
|
|
520
|
+
Math.abs(size.width - width * scale) <= 1 &&
|
|
521
|
+
Math.abs(size.height - height * scale) <= 1
|
|
522
|
+
)
|
|
523
|
+
return null;
|
|
524
|
+
return `${size ? `${size.width}x${size.height}` : "unreadable"} JPEG for a ${width}x${height} viewport at ${scale}x`;
|
|
525
|
+
}
|
|
526
|
+
|
|
511
527
|
async mouseMove(x: number, y: number): Promise<void> {
|
|
512
528
|
this.assertPoint(x, y);
|
|
513
529
|
await (await this.requirePage()).send(
|
|
@@ -786,13 +802,46 @@ export class AgentBrowserRuntime {
|
|
|
786
802
|
return this.viewport;
|
|
787
803
|
}
|
|
788
804
|
|
|
789
|
-
private async
|
|
805
|
+
private async preparePage(page: CdpSession): Promise<void> {
|
|
790
806
|
page.on("Page.screencastFrame", this.onScreencastFrame);
|
|
791
807
|
page.on("event", (event: CdpEvent) => {
|
|
792
808
|
if (event.method === "Inspector.targetCrashed") this.invalidateScreencastFrame();
|
|
793
809
|
});
|
|
794
810
|
await Promise.all([page.send("Page.enable"), page.send("Runtime.enable")]);
|
|
795
811
|
await this.navigationHistory(page);
|
|
812
|
+
// Emulation is scoped to the CDP session, so every fresh attach starts without it.
|
|
813
|
+
if (this.viewport) await this.applyEmulation(page, this.viewport);
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
private async applyEmulation(page: CdpSession, device: DeviceEmulation): Promise<void> {
|
|
817
|
+
await page.send(
|
|
818
|
+
"Emulation.setDeviceMetricsOverride",
|
|
819
|
+
{
|
|
820
|
+
width: device.width,
|
|
821
|
+
height: device.height,
|
|
822
|
+
deviceScaleFactor: device.deviceScaleFactor,
|
|
823
|
+
mobile: device.mobile,
|
|
824
|
+
screenWidth: device.screenWidth ?? device.width,
|
|
825
|
+
screenHeight: device.screenHeight ?? device.height,
|
|
826
|
+
screenOrientation: {
|
|
827
|
+
type: device.width > device.height ? "landscapePrimary" : "portraitPrimary",
|
|
828
|
+
angle: device.width > device.height ? 90 : 0,
|
|
829
|
+
},
|
|
830
|
+
},
|
|
831
|
+
{ mutation: true },
|
|
832
|
+
);
|
|
833
|
+
await page.send(
|
|
834
|
+
"Emulation.setTouchEmulationEnabled",
|
|
835
|
+
{ enabled: device.touch, maxTouchPoints: device.touch ? 5 : 1 },
|
|
836
|
+
{ mutation: true },
|
|
837
|
+
);
|
|
838
|
+
if (device.userAgent) {
|
|
839
|
+
await page.send(
|
|
840
|
+
"Emulation.setUserAgentOverride",
|
|
841
|
+
{ userAgent: device.userAgent, platform: device.platform ?? "" },
|
|
842
|
+
{ mutation: true },
|
|
843
|
+
);
|
|
844
|
+
}
|
|
796
845
|
}
|
|
797
846
|
|
|
798
847
|
private async startScreencastSession(page: CdpSession): Promise<void> {
|
|
@@ -837,7 +886,7 @@ export class AgentBrowserRuntime {
|
|
|
837
886
|
{ mutation: true },
|
|
838
887
|
);
|
|
839
888
|
const replacement = await attachToTarget(connection, target.targetId);
|
|
840
|
-
await this.
|
|
889
|
+
await this.preparePage(replacement);
|
|
841
890
|
if (this.page === previous) {
|
|
842
891
|
this.page = replacement;
|
|
843
892
|
this.targetId = target.targetId;
|