@ddwang/magnitude-core 0.3.1-ddwang.2 → 0.3.1-ddwang.3

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.
@@ -1,4 +1,4 @@
1
- import { Image as BamlImage } from '@boundaryml/baml';
1
+ import { type Image as BamlImage } from '@boundaryml/baml';
2
2
  import { StoredMedia } from './serde';
3
3
  import { Sharp } from 'sharp';
4
4
  import sharp from 'sharp';
@@ -1,4 +1,5 @@
1
- import { Image as BamlImage } from '@boundaryml/baml';
1
+ // Default import avoids Node 18/22's incomplete CJS named-export detection for Image.
2
+ import baml from '@boundaryml/baml';
2
3
  import sharp from 'sharp';
3
4
  export class Image {
4
5
  /**
@@ -60,7 +61,7 @@ export class Image {
60
61
  }
61
62
  async toBaml() {
62
63
  const { format, base64 } = await this.toJson();
63
- return BamlImage.fromBase64(`image/${format}`, base64);
64
+ return baml.Image.fromBase64(`image/${format}`, base64);
64
65
  }
65
66
  async saveToFile(filepath) {
66
67
  await this.img.clone().toFile(filepath);
@@ -1,5 +1,5 @@
1
1
  import { Image } from "@/memory/image";
2
- import { Image as BamlImage } from '@boundaryml/baml';
2
+ import baml from '@boundaryml/baml';
3
3
  async function buildJsonPartsRecursive(data, partsList, indent, currentLevel = 0, isRoot = false) {
4
4
  if (data instanceof Image) {
5
5
  const bamlImg = await data.toBaml();
@@ -8,7 +8,7 @@ async function buildJsonPartsRecursive(data, partsList, indent, currentLevel = 0
8
8
  }
9
9
  return;
10
10
  }
11
- if (data instanceof BamlImage) {
11
+ if (data instanceof baml.Image) {
12
12
  partsList.push(data);
13
13
  return;
14
14
  }
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Convert observations to form that can be passed as BAML context, rendered as a custom XML-like structure.
3
3
  */
4
- import { Image as BamlImage } from '@boundaryml/baml';
4
+ import { type Image as BamlImage } from '@boundaryml/baml';
5
5
  import { RenderableContent } from '@/memory/observation';
6
6
  export type MultiMediaContentPart = BamlImage | string;
7
7
  export declare function renderXmlParts(data: RenderableContent): Promise<MultiMediaContentPart[]>;
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Convert observations to form that can be passed as BAML context, rendered as a custom XML-like structure.
3
3
  */
4
- import { Image as BamlImage } from '@boundaryml/baml';
4
+ import baml from '@boundaryml/baml';
5
5
  import { Image } from '@/memory/image';
6
6
  //export type BamlRenderable = BamlImage | string;
7
7
  // export type BamlRenderable = {
@@ -17,7 +17,7 @@ async function buildXmlPartsRecursive(data, indentLevel, partsList, isInsideList
17
17
  }
18
18
  return;
19
19
  }
20
- if (data instanceof BamlImage) {
20
+ if (data instanceof baml.Image) {
21
21
  partsList.push(data);
22
22
  return;
23
23
  }
@@ -66,7 +66,7 @@ async function buildXmlPartsRecursive(data, indentLevel, partsList, isInsideList
66
66
  if (currentStr)
67
67
  mergedValueParts.push(currentStr);
68
68
  // Apply styling rules
69
- if (mergedValueParts.length === 1 && mergedValueParts[0] instanceof BamlImage) {
69
+ if (mergedValueParts.length === 1 && mergedValueParts[0] instanceof baml.Image) {
70
70
  partsList.push(`${indent}<${tagName}>`);
71
71
  partsList.push(mergedValueParts[0]); // The BamlImage
72
72
  partsList.push(`</${tagName}>`);
@@ -6,7 +6,7 @@ import { TabManager } from "./tabs";
6
6
  import { DOMTransformer } from "./transformer";
7
7
  import { Image } from '@/memory/image';
8
8
  import EventEmitter from "eventemitter3";
9
- import { checkOperation, drainAll, operationSleep } from '@/common/operation';
9
+ import { checkOperation, drainAll, measureOperation, operationSleep } from '@/common/operation';
10
10
  export class WebHarness {
11
11
  /**
12
12
  * Executes web actions on a page
@@ -77,47 +77,35 @@ export class WebHarness {
77
77
  return this.tabs.getActivePage();
78
78
  }
79
79
  async screenshot(options = {}) {
80
- /**
81
- * Get b64 encoded string of screenshot (PNG) with screen dimensions
82
- */
83
- // Target page, context or browser has been closed
84
- let dpr;
85
- let buffer;
86
- const retries = 3;
87
- for (let attempt = 0; attempt <= retries; attempt++) {
88
- checkOperation();
89
- try {
90
- dpr = await this.page.evaluate(() => window.devicePixelRatio);
91
- checkOperation();
92
- buffer = await this.page.screenshot({ type: 'png', ...options });
93
- checkOperation();
94
- break; // Success! Exit the retry loop
95
- }
96
- catch (err) {
80
+ return measureOperation('screenshot', async () => {
81
+ let dpr;
82
+ let buffer;
83
+ const retries = 3;
84
+ for (let attempt = 0; attempt <= retries; attempt++) {
97
85
  checkOperation();
98
- // A few possibilities:
99
- // 1. Target page, context or browser has been closed
100
- // 2. Page navigation in progress
101
- // In theory 2 shouldn't shouldn't happen during typical execution as we wait for page load - unless screenshot is triggered at an usual time.
102
- const error = err;
103
- if (error.message.includes('Target page, context or browser has been closed')) {
104
- // Irrecoverable, no point in retrying
105
- throw new Error("Attempted to take screenshot but page, context or browser is closed");
86
+ try {
87
+ dpr = await this.page.evaluate(() => window.devicePixelRatio);
88
+ checkOperation();
89
+ buffer = await this.page.screenshot({ type: 'png', ...options });
90
+ checkOperation();
91
+ break;
106
92
  }
107
- if (attempt >= retries) {
108
- throw new Error(`Unable to capture screenshot after retries, error: ${error.message}`);
93
+ catch (err) {
94
+ checkOperation();
95
+ const error = err;
96
+ if (error.message.includes('Target page, context or browser has been closed')) {
97
+ throw new Error("Attempted to take screenshot but page, context or browser is closed");
98
+ }
99
+ if (attempt >= retries) {
100
+ throw new Error(`Unable to capture screenshot after retries, error: ${error.message}`);
101
+ }
109
102
  }
110
103
  }
111
- }
112
- const base64data = buffer.toString('base64');
113
- const image = Image.fromBase64(base64data);
114
- // Now, need to rescale the image based on DPR. This is so that:
115
- // (1) Save on tokens, dont need huge high res images
116
- // (2) More importantly, clicks happen in the standard resolution space, so need to do this for coordinates to be correct
117
- // for any agent not using a virtual screen space (e.g. those that aren't Claude)
118
- const { width, height } = await image.getDimensions();
119
- const rescaledImage = await image.resize(width / dpr, height / dpr);
120
- return rescaledImage;
104
+ const image = Image.fromBase64(buffer.toString('base64'));
105
+ // Match browser coordinate space while avoiding high-DPR image tokens.
106
+ const { width, height } = await image.getDimensions();
107
+ return await image.resize(width / dpr, height / dpr);
108
+ });
121
109
  }
122
110
  // async goto(url: string) {
123
111
  // // No need to redraw here anymore, the 'load' event listener handles it
@@ -395,7 +383,7 @@ export class WebHarness {
395
383
  }
396
384
  async waitForStability(timeout) {
397
385
  checkOperation();
398
- await this.stability.waitForStability(timeout);
386
+ await measureOperation('stability', () => this.stability.waitForStability(timeout));
399
387
  checkOperation();
400
388
  }
401
389
  }
@@ -1,4 +1,5 @@
1
1
  import type { Action } from '@/actions/types';
2
+ import type { OperationDiagnostics } from '@/common/operation';
2
3
  export type BlockReason = 'rate_limit' | 'subscription' | 'authentication' | 'no_progress';
3
4
  export interface BrowserBlock {
4
5
  reason: BlockReason;
@@ -7,6 +8,7 @@ export interface BrowserBlock {
7
8
  }
8
9
  export declare class BrowserBlockedError extends Error {
9
10
  readonly block: BrowserBlock;
11
+ readonly operation?: OperationDiagnostics;
10
12
  constructor(block: BrowserBlock);
11
13
  }
12
14
  export interface HttpDiagnostic {
@@ -1,12 +1,13 @@
1
1
  import { Page } from "playwright";
2
2
  export declare class CursorVisual {
3
+ private animate;
3
4
  /**
4
5
  * Manages the visual indicator for actions on a page
5
6
  */
6
7
  private page;
7
8
  private visualElementId;
8
9
  private lastPosition;
9
- constructor();
10
+ constructor(animate?: boolean);
10
11
  setActivePage(page: Page): Promise<void>;
11
12
  move(x: number, y: number): Promise<void>;
12
13
  setupOnPage(): Promise<void>;
@@ -1,15 +1,16 @@
1
1
  import { retryOnErrorIsSuccess } from "@/common";
2
2
  import logger from "@/logger";
3
- import { operationSleep } from '@/common/operation';
3
+ import { measureOperation, operationSleep } from '@/common/operation';
4
4
  export class CursorVisual {
5
+ animate;
5
6
  /**
6
7
  * Manages the visual indicator for actions on a page
7
8
  */
8
9
  page;
9
10
  visualElementId = 'action-visual-indicator';
10
11
  lastPosition = null;
11
- constructor() {
12
- //this.page = page;
12
+ constructor(animate = true) {
13
+ this.animate = animate;
13
14
  }
14
15
  async setActivePage(page) {
15
16
  this.page = page;
@@ -19,13 +20,12 @@ export class CursorVisual {
19
20
  await retryOnErrorIsSuccess(this.setupOnPage.bind(this), { mode: 'retry_all', delayMs: 200, retryLimit: 5 });
20
21
  }
21
22
  async move(x, y) {
22
- // Store the position
23
- this.lastPosition = { x, y };
24
- // Create or update the mouse pointer visual, showing the click effect
25
- await this._drawVisual(x, y, false);
26
- // The pointer visual takes 0.3s on the transition, but awaiting script evaluation does not wait for this to complete.
27
- // So we wait 300ms manually.
28
- await operationSleep(300);
23
+ await measureOperation('cursor', async () => {
24
+ this.lastPosition = { x, y };
25
+ await this._drawVisual(x, y, false);
26
+ if (this.animate)
27
+ await operationSleep(300);
28
+ });
29
29
  }
30
30
  async setupOnPage() {
31
31
  if (this.lastPosition) {
@@ -36,7 +36,7 @@ export class CursorVisual {
36
36
  // Internal method to handle the actual drawing logic
37
37
  async _drawVisual(x, y, showClickEffect) {
38
38
  try {
39
- await this.page.evaluate(({ x, y, id, showClickEffect }) => {
39
+ await this.page.evaluate(({ x, y, id, showClickEffect, animate }) => {
40
40
  // Use viewport coordinates directly (no scroll adjustment for fixed positioning)
41
41
  const viewportX = x;
42
42
  const viewportY = y;
@@ -83,8 +83,6 @@ export class CursorVisual {
83
83
  pointerElement.style.height = '32px';
84
84
  pointerElement.style.zIndex = '2147483647'; // Max z-index
85
85
  pointerElement.style.pointerEvents = 'none'; // Don't interfere with actual clicks
86
- // Notice that transition is 300ms
87
- pointerElement.style.transition = 'left 0.3s cubic-bezier(0.25, 0.1, 0.25, 1), top 0.3s cubic-bezier(0.25, 0.1, 0.25, 1)';
88
86
  // Create SVG using DOM methods to avoid Trusted Types issues
89
87
  const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
90
88
  svg.setAttribute('width', '32');
@@ -109,13 +107,16 @@ export class CursorVisual {
109
107
  document.body.appendChild(pointerElement);
110
108
  }
111
109
  //pointerElement.style.display = 'none';
110
+ pointerElement.style.transition = animate
111
+ ? 'left 0.3s cubic-bezier(0.25, 0.1, 0.25, 1), top 0.3s cubic-bezier(0.25, 0.1, 0.25, 1)'
112
+ : 'none';
112
113
  // Update position - use viewport coordinates for fixed positioning
113
114
  // Set the top-left corner to (viewportX, viewportY) and then translate by (-1px, -3px)
114
115
  // to align the pointer tip (approx. at 1.27, 4.17 within the SVG) with the click point.
115
116
  pointerElement.style.left = `${viewportX}px`;
116
117
  pointerElement.style.top = `${viewportY}px`;
117
118
  pointerElement.style.transform = 'translate(-1px, -3px)';
118
- }, { x, y, id: this.visualElementId, showClickEffect });
119
+ }, { x, y, id: this.visualElementId, showClickEffect, animate: this.animate });
119
120
  }
120
121
  catch (error) {
121
122
  // For example when:
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,63 @@
1
+ import { expect, test } from 'bun:test';
2
+ import { ActionVisualizer } from './index';
3
+ import { CursorVisual } from './cursor';
4
+ import { Operation } from '@/common/operation';
5
+ import { OperationCancelledError } from '@/agent/errors';
6
+ function pageFixture() {
7
+ const evaluations = [];
8
+ const page = {
9
+ on: () => { },
10
+ evaluate: async (_fn, arg) => { evaluations.push(arg); },
11
+ };
12
+ return { page, evaluations };
13
+ }
14
+ test('hidden cursor does not draw, register cursor timings, or wait', async () => {
15
+ const { page, evaluations } = pageFixture();
16
+ const visualizer = new ActionVisualizer({}, { showCursor: false });
17
+ await visualizer.setActivePage(page);
18
+ evaluations.length = 0;
19
+ const operation = new Operation({}, {});
20
+ await operation.run(async () => {
21
+ await visualizer.moveVirtualCursor(12, 34);
22
+ await visualizer.hideAll();
23
+ await visualizer.showAll();
24
+ });
25
+ operation.finish();
26
+ expect(evaluations).toEqual([]);
27
+ expect(operation.snapshot().timings.cursor).toBeUndefined();
28
+ });
29
+ test('animation can be disabled while preserving cursor position and visibility controls', async () => {
30
+ const { page, evaluations } = pageFixture();
31
+ const visualizer = new ActionVisualizer({}, { animateCursor: false });
32
+ await visualizer.setActivePage(page);
33
+ evaluations.length = 0;
34
+ const operation = new Operation({}, {});
35
+ // No timer is needed: the move finishes before the next timers phase.
36
+ const nextTimer = new Promise(resolve => setTimeout(() => resolve('timer'), 0));
37
+ const move = operation.run(() => visualizer.moveVirtualCursor(12, 34)).then(() => 'move');
38
+ expect(await Promise.race([move, nextTimer])).toBe('move');
39
+ await visualizer.hideAll();
40
+ await visualizer.showAll();
41
+ operation.finish();
42
+ expect(evaluations).toEqual([
43
+ { x: 12, y: 34, id: 'action-visual-indicator', showClickEffect: false, animate: false },
44
+ 'action-visual-indicator', 'action-visual-indicator',
45
+ ]);
46
+ expect(operation.snapshot().timings.cursor?.count).toBe(1);
47
+ });
48
+ test('default animation still waits and its wait is cancellable', async () => {
49
+ const { page, evaluations } = pageFixture();
50
+ const cursor = new CursorVisual();
51
+ await cursor.setActivePage(page);
52
+ const operation = new Operation({}, {});
53
+ const move = operation.run(() => cursor.move(12, 34));
54
+ // Drawing is synchronous in this fixture; allow the animation wait to start.
55
+ await new Promise(resolve => setTimeout(resolve, 0));
56
+ expect(evaluations[0]).toMatchObject({ animate: true });
57
+ let complete = false;
58
+ void move.then(() => { complete = true; }, () => { });
59
+ expect(complete).toBe(false);
60
+ operation.cancel();
61
+ await expect(move).rejects.toBeInstanceOf(OperationCancelledError);
62
+ operation.finish();
63
+ });
@@ -1,6 +1,8 @@
1
1
  import { BrowserContext, Page } from "playwright";
2
2
  export interface ActionVisualizerOptions {
3
3
  showCursor?: boolean;
4
+ /** Set false to skip the 300 ms movement animation. Defaults to true. */
5
+ animateCursor?: boolean;
4
6
  showHoverCircle?: boolean;
5
7
  showClickRipple?: boolean;
6
8
  showDragLine?: boolean;
@@ -12,12 +12,13 @@ export class ActionVisualizer {
12
12
  this.context = context;
13
13
  this.options = {
14
14
  showCursor: options.showCursor ?? true,
15
+ animateCursor: options.animateCursor ?? true,
15
16
  showHoverCircle: options.showHoverCircle ?? false,
16
17
  showClickRipple: options.showClickRipple ?? true,
17
18
  showDragLine: options.showDragLine ?? false,
18
19
  showTypeEffects: options.showTypeEffects ?? true
19
20
  };
20
- this.cursor = new CursorVisual();
21
+ this.cursor = new CursorVisual(this.options.animateCursor);
21
22
  this.mouseEffects = new MouseEffectVisual(this.options);
22
23
  this.typeEffects = new TypeEffectVisual();
23
24
  }
@@ -30,18 +31,21 @@ export class ActionVisualizer {
30
31
  }
31
32
  async setActivePage(page) {
32
33
  this.page = page;
33
- await this.cursor.setActivePage(page);
34
+ if (this.options.showCursor)
35
+ await this.cursor.setActivePage(page);
34
36
  await this.mouseEffects.setActivePage(page);
35
37
  await this.typeEffects.setActivePage(page);
36
38
  }
37
39
  async moveVirtualCursor(x, y) {
38
- // Takes like 300ms for smooth anim
39
- await this.cursor.move(x, y);
40
+ if (this.options.showCursor)
41
+ await this.cursor.move(x, y);
40
42
  }
41
43
  async hideAll() {
42
- await this.cursor.hide();
44
+ if (this.options.showCursor)
45
+ await this.cursor.hide();
43
46
  }
44
47
  async showAll() {
45
- await this.cursor.show();
48
+ if (this.options.showCursor)
49
+ await this.cursor.show();
46
50
  }
47
51
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ddwang/magnitude-core",
3
- "version": "0.3.1-ddwang.2",
3
+ "version": "0.3.1-ddwang.3",
4
4
  "description": "Magnitude e2e testing agent",
5
5
  "publishConfig": {
6
6
  "access": "public"