@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@omercnet/paseo-shared-browser",
3
- "version": "1.1.0-next.211.1",
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.bindPageEvents(this.page);
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 page.send(
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
- const page = await this.requirePage();
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 bindPageEvents(page: CdpSession): Promise<void> {
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.bindPageEvents(replacement);
889
+ await this.preparePage(replacement);
841
890
  if (this.page === previous) {
842
891
  this.page = replacement;
843
892
  this.targetId = target.targetId;