speqs 0.6.0 → 0.7.0

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.
@@ -16,8 +16,16 @@ function parseMaxInteractions(value) {
16
16
  throw new Error(`Invalid --max-interactions value: ${value}`);
17
17
  return n;
18
18
  }
19
+ function parseSlowMo(value) {
20
+ const n = parseInt(value, 10);
21
+ if (isNaN(n) || n < 0)
22
+ throw new Error(`Invalid --slow-mo value: ${value}`);
23
+ return n;
24
+ }
19
25
  import { MEDIA_MODALITIES } from "../lib/types.js";
20
26
  import { resolveContentUrl, resolveContentUrls, resolveTextContent } from "../lib/upload.js";
27
+ import { runLocalSimulations } from "../lib/local-sim/loop.js";
28
+ import { ensureBrowser } from "../lib/local-sim/install.js";
21
29
  function isMediaModality(modality) {
22
30
  return !!modality && MEDIA_MODALITIES.includes(modality);
23
31
  }
@@ -164,6 +172,13 @@ export function registerSimulationCommands(program) {
164
172
  .option("--language <lang>", "Language code (e.g. en, sv)")
165
173
  .option("--locale <locale>", "Locale code (e.g. en-US)")
166
174
  .option("-y, --yes", "Skip confirmation prompt")
175
+ // Local simulation options
176
+ .option("--local", "Run simulation with local browser (Playwright) instead of remote")
177
+ .option("--headed", "Show browser window (local mode only)")
178
+ .option("--slow-mo <ms>", "Slow down actions by ms (local mode only)")
179
+ .option("--devtools", "Open Chrome DevTools (local mode only)")
180
+ .option("--debug", "Enable detailed debug logging to stderr and ~/.speqs/local-sim.log")
181
+ .option("--parallel <n>", "Run N testers in parallel (local mode only, default: all)")
167
182
  .addHelpText("after", `
168
183
  Note: --workspace and --study are optional if you have set active context
169
184
  via \`speqs workspace use <alias>\` and \`speqs study use <alias>\`.
@@ -196,7 +211,11 @@ Examples:
196
211
  $ speqs sim run --image-urls ./post.png --copy-text @./caption.txt --social-platform instagram --config c-c3c
197
212
 
198
213
  # Re-run existing iteration:
199
- $ speqs sim run --iteration i-d4e`)
214
+ $ speqs sim run --iteration i-d4e
215
+
216
+ # Local browser simulation (no remote Browserbase):
217
+ $ speqs sim run --local --url http://localhost:3000
218
+ $ speqs sim run --local --url http://localhost:3000 --headed --slow-mo 500`)
200
219
  .action(async (opts, cmd) => {
201
220
  await withClient(cmd, async (client, globals) => {
202
221
  const log = (msg) => { if (!globals.quiet)
@@ -373,6 +392,10 @@ Examples:
373
392
  }
374
393
  log("");
375
394
  }
395
+ // Ensure browser is ready before creating server-side state
396
+ if (opts.local) {
397
+ await ensureBrowser({ quiet: globals.quiet, skipPrompt: globals.json });
398
+ }
376
399
  // Step 1: Create or use existing iteration
377
400
  if (!iterationId) {
378
401
  const iterName = resolvedOpts.iterationName || `CLI ${new Date().toISOString().slice(0, 16)}`;
@@ -385,6 +408,11 @@ Examples:
385
408
  iterationId = iter.id;
386
409
  log(`Created iteration "${iterName}"`);
387
410
  }
411
+ else if (!opts.iteration) {
412
+ // Auto-reused iteration — update its details to reflect current run
413
+ const newDetails = buildIterationDetails(modality, resolvedOpts);
414
+ await client.put(`/iterations/${iterationId}`, { details: newDetails });
415
+ }
388
416
  // Step 2: Create testers from profiles (or reuse from explicit iteration)
389
417
  let createdTesters;
390
418
  if (opts.iteration && !opts.profiles) {
@@ -412,6 +440,43 @@ Examples:
412
440
  log(`Created ${createdTesters.length} tester${createdTesters.length > 1 ? "s" : ""}`);
413
441
  }
414
442
  // Step 3: Start simulations
443
+ // Local mode: run simulations with local browser
444
+ if (opts.local) {
445
+ if (isMedia) {
446
+ throw new Error("Local mode is only supported for interactive simulations.");
447
+ }
448
+ const testerNameMap = new Map();
449
+ for (const t of createdTesters) {
450
+ testerNameMap.set(t.id, t.tester_profile?.name ?? "Unknown");
451
+ }
452
+ await runLocalSimulations(client, {
453
+ workspaceId: resolvedWorkspace,
454
+ studyId: resolvedStudy,
455
+ iterationId: iterationId,
456
+ testerIds: createdTesters.map((t) => t.id),
457
+ testerNames: testerNameMap,
458
+ url: resolvedOpts.url,
459
+ screenFormat: resolvedOpts.screenFormat,
460
+ locale: opts.locale,
461
+ maxInteractions: opts.maxInteractions ? parseMaxInteractions(opts.maxInteractions) : undefined,
462
+ headed: !!opts.headed,
463
+ slowMo: opts.slowMo ? parseSlowMo(opts.slowMo) : undefined,
464
+ devtools: opts.devtools,
465
+ debug: opts.debug,
466
+ parallel: opts.parallel ? parseInt(opts.parallel, 10) : undefined,
467
+ quiet: globals.quiet,
468
+ json: globals.json,
469
+ });
470
+ if (globals.json) {
471
+ output({
472
+ iteration_id: iterationId,
473
+ testers: createdTesters.map((t) => ({ id: t.id, profile_name: t.tester_profile?.name })),
474
+ mode: "local",
475
+ }, true);
476
+ }
477
+ return;
478
+ }
479
+ // Remote mode: delegate to backend
415
480
  log(`Starting ${createdTesters.length} simulation${createdTesters.length > 1 ? "s" : ""}...`);
416
481
  let simResults;
417
482
  if (isMedia) {
@@ -24,5 +24,35 @@ export declare class ApiClient {
24
24
  }): Promise<T>;
25
25
  put<T = unknown>(path: string, body?: unknown): Promise<T>;
26
26
  del(path: string): Promise<void>;
27
+ localSimInit(body: {
28
+ tester_id: string;
29
+ study_id: string;
30
+ product_id: string;
31
+ iteration_id: string;
32
+ }): Promise<import("./local-sim/types.js").LocalSimInitResponse>;
33
+ localSimStep(body: import("./local-sim/types.js").LocalSimStepRequest): Promise<import("./local-sim/types.js").LocalSimStepResponseRaw>;
34
+ localSimRecord(body: import("./local-sim/types.js").LocalSimRecordRequest): Promise<import("./local-sim/types.js").LocalSimRecordResponse>;
35
+ localSimMatchFrame(body: {
36
+ product_id: string;
37
+ study_id: string;
38
+ screenshot_base64: string;
39
+ screenshot_url?: string;
40
+ location_name: string;
41
+ screen_format?: string;
42
+ }): Promise<{
43
+ frame_version_id: string;
44
+ }>;
45
+ localSimScreenshotUpload(body: {
46
+ product_id: string;
47
+ screenshot_id: string;
48
+ content_type: string;
49
+ }): Promise<{
50
+ upload_info: {
51
+ signed_upload_url: string;
52
+ file_path: string;
53
+ expires_in_seconds: number;
54
+ };
55
+ screenshot_url: string;
56
+ }>;
27
57
  private handleResponse;
28
58
  }
@@ -143,6 +143,22 @@ export class ApiClient {
143
143
  throw new ApiError(res.status, res.statusText, body);
144
144
  }
145
145
  }
146
+ // --- Local simulation endpoints ---
147
+ async localSimInit(body) {
148
+ return this.post("/simulation/local/init", body, { timeout: 30_000 });
149
+ }
150
+ async localSimStep(body) {
151
+ return this.post("/simulation/local/step", body, { timeout: 60_000 });
152
+ }
153
+ async localSimRecord(body) {
154
+ return this.post("/simulation/local/record", body, { timeout: 60_000 });
155
+ }
156
+ async localSimMatchFrame(body) {
157
+ return this.post("/simulation/local/match-frame", body, { timeout: 30_000 });
158
+ }
159
+ async localSimScreenshotUpload(body) {
160
+ return this.post("/simulation/local/screenshot/upload", body);
161
+ }
146
162
  async handleResponse(resp) {
147
163
  if (!resp.ok) {
148
164
  let body;
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Action executor — resolves elements and executes Playwright actions.
3
+ *
4
+ * Resolution strategy:
5
+ * 1. CDP node resolution (using node_id from tree data)
6
+ * 2. Playwright locator fallback (using element_name + element_type)
7
+ * 3. Coordinate fallback (if returned by backend)
8
+ */
9
+ import type { Page } from "playwright-core";
10
+ import type { LocalStepAction, ActionResult, ContextValue, TreeData } from "./types.js";
11
+ /**
12
+ * Execute a single action on the page.
13
+ */
14
+ export declare function executeAction(page: Page, action: LocalStepAction, treeData: TreeData, contextValues: ContextValue[]): Promise<ActionResult>;
15
+ /**
16
+ * Compare two base64 screenshots to detect visible change.
17
+ */
18
+ export declare function detectNoVisibleChange(before: string, after: string): boolean;
19
+ /**
20
+ * Build a human-readable action description matching backend's format_action_detail().
21
+ */
22
+ export declare function describeAction(action: LocalStepAction): string;
@@ -0,0 +1,379 @@
1
+ /**
2
+ * Action executor — resolves elements and executes Playwright actions.
3
+ *
4
+ * Resolution strategy:
5
+ * 1. CDP node resolution (using node_id from tree data)
6
+ * 2. Playwright locator fallback (using element_name + element_type)
7
+ * 3. Coordinate fallback (if returned by backend)
8
+ */
9
+ import { resolveNodeToBoundingBox } from "./browser.js";
10
+ import { isDebugEnabled } from "./debug.js";
11
+ // --- ARIA role → Playwright role mapping ---
12
+ const ELEMENT_TYPE_TO_ROLE = {
13
+ BUTTON: "button",
14
+ TEXT_INPUT: "input",
15
+ SELECT: "combobox",
16
+ LINK: "link",
17
+ CHECKBOX: "checkbox",
18
+ RADIO: "radio",
19
+ HEADING: "heading",
20
+ TAB: "tab",
21
+ MENU_ITEM: "menuitem",
22
+ SWITCH: "switch",
23
+ SLIDER: "slider",
24
+ IMAGE: "img",
25
+ };
26
+ /**
27
+ * Execute a single action on the page.
28
+ */
29
+ export async function executeAction(page, action, treeData, contextValues) {
30
+ try {
31
+ // Intercept "back button" taps — the LLM often tries to tap the browser
32
+ // back button which doesn't exist in the viewport. Convert to page.goBack().
33
+ const isBackAttempt = action.element_name?.toLowerCase().includes("back") &&
34
+ (action.element_description?.toLowerCase().includes("browser") ||
35
+ action.element_description?.toLowerCase().includes("navigate") ||
36
+ action.element_name?.toLowerCase().includes("browser back"));
37
+ if (isBackAttempt || action.type === "navigate_back") {
38
+ await page.goBack({ timeout: 10_000 }).catch(() => { });
39
+ return { success: true, elementName: action.element_name, coordinates: null };
40
+ }
41
+ let coordinates = null;
42
+ switch (action.type) {
43
+ case "tap":
44
+ coordinates = await executeTap(page, action, treeData);
45
+ break;
46
+ case "text_input":
47
+ coordinates = await executeTextInput(page, action, treeData, contextValues);
48
+ break;
49
+ case "scroll":
50
+ await executeScroll(page, action, treeData);
51
+ break;
52
+ case "swipe":
53
+ case "pull_to_refresh":
54
+ await executeSwipe(page, action.direction ?? "down");
55
+ break;
56
+ case "wait":
57
+ await page.waitForTimeout(action.duration_ms ?? 1000);
58
+ break;
59
+ case "navigate_back":
60
+ await page.goBack({ timeout: 10_000 }).catch(() => { });
61
+ break;
62
+ case "long_press":
63
+ coordinates = await executeLongPress(page, action, treeData);
64
+ break;
65
+ case "double_tap":
66
+ coordinates = await executeDoubleTap(page, action, treeData);
67
+ break;
68
+ case "drag":
69
+ // Drag requires coordinates — skip if unavailable
70
+ break;
71
+ case "think":
72
+ // No-op: model is reasoning without acting
73
+ break;
74
+ case "pinch_zoom":
75
+ case "rotate_device":
76
+ // Not supported in desktop browser
77
+ break;
78
+ }
79
+ return { success: true, elementName: action.element_name, coordinates };
80
+ }
81
+ catch (err) {
82
+ if (!isRecoverableError(err))
83
+ throw err;
84
+ return { success: false, elementName: action.element_name, coordinates: null };
85
+ }
86
+ }
87
+ // --- Element Resolution ---
88
+ /**
89
+ * Resolve an element: try CDP node_id first, then Playwright locator.
90
+ * Returns click coordinates { x, y } or null.
91
+ */
92
+ async function resolveElement(page, action, treeData) {
93
+ // Strategy 1: CDP node resolution
94
+ if (action.node_id) {
95
+ const box = await resolveNodeToBoundingBox(page, action.node_id, treeData);
96
+ if (box) {
97
+ if (isDebugEnabled())
98
+ console.error(` [resolve] CDP node ${action.node_id} → (${Math.round(box.x)}, ${Math.round(box.y)})`);
99
+ return { x: box.x, y: box.y };
100
+ }
101
+ if (isDebugEnabled())
102
+ console.error(` [resolve] CDP node ${action.node_id} → FAILED, trying Playwright fallback`);
103
+ }
104
+ // Strategy 2: Playwright locator (tries multiple strategies)
105
+ const locator = await findElement(page, action);
106
+ if (locator) {
107
+ const box = await locator.boundingBox();
108
+ if (box) {
109
+ const coords = { x: box.x + box.width / 2, y: box.y + box.height / 2 };
110
+ if (isDebugEnabled())
111
+ console.error(` [resolve] Playwright locator → (${Math.round(coords.x)}, ${Math.round(coords.y)})`);
112
+ return coords;
113
+ }
114
+ }
115
+ if (isDebugEnabled())
116
+ console.error(` [resolve] ALL strategies FAILED for "${action.element_name}"`);
117
+ return null;
118
+ }
119
+ /**
120
+ * Resolve to a Playwright Locator (for fill/type operations that need a Locator).
121
+ */
122
+ async function resolveLocator(page, action, treeData) {
123
+ return findElement(page, action);
124
+ }
125
+ /**
126
+ * Build a Playwright locator, trying multiple strategies in order:
127
+ * 1. getByRole with element_name
128
+ * 2. getByRole with element_description (often more accurate)
129
+ * 3. getByText with element_name
130
+ * 4. getByText with element_description
131
+ */
132
+ async function findElement(page, action) {
133
+ const role = action.element_type ? ELEMENT_TYPE_TO_ROLE[action.element_type] : null;
134
+ const name = action.element_name;
135
+ const desc = action.element_description;
136
+ const roleArg = role;
137
+ const candidates = [];
138
+ // Strategy 1: role + name
139
+ if (role && name) {
140
+ candidates.push(page.getByRole(roleArg, { name, exact: false }).first());
141
+ }
142
+ // Strategy 2: role + description
143
+ if (role && desc) {
144
+ candidates.push(page.getByRole(roleArg, { name: desc, exact: false }).first());
145
+ }
146
+ // Strategy 3: role only (if there's just one of that role)
147
+ if (role) {
148
+ candidates.push(page.getByRole(roleArg).first());
149
+ }
150
+ // Strategy 4: text search on name
151
+ if (name) {
152
+ candidates.push(page.getByText(name, { exact: false }).first());
153
+ }
154
+ // Strategy 5: text search on description
155
+ if (desc) {
156
+ candidates.push(page.getByText(desc, { exact: false }).first());
157
+ }
158
+ // Strategy 6: link by name (common case — LLM often calls links "buttons")
159
+ if (name) {
160
+ candidates.push(page.getByRole("link", { name, exact: false }).first());
161
+ }
162
+ if (desc) {
163
+ candidates.push(page.getByRole("link", { name: desc, exact: false }).first());
164
+ }
165
+ for (const locator of candidates) {
166
+ try {
167
+ await locator.waitFor({ state: "visible", timeout: 1500 });
168
+ return locator;
169
+ }
170
+ catch {
171
+ // Try next strategy
172
+ }
173
+ }
174
+ return null;
175
+ }
176
+ // --- Action Implementations ---
177
+ async function executeTap(page, action, treeData) {
178
+ const count = action.count ?? 1;
179
+ const coords = await resolveElement(page, action, treeData);
180
+ if (coords) {
181
+ for (let i = 0; i < count; i++) {
182
+ await page.mouse.click(coords.x, coords.y);
183
+ }
184
+ return coords;
185
+ }
186
+ else {
187
+ throw new Error(`Cannot locate element for tap: ${action.element_name ?? "unknown"}`);
188
+ }
189
+ }
190
+ async function executeTextInput(page, action, treeData, contextValues) {
191
+ // Resolve the actual text to type
192
+ const text = resolveTextValue(action, contextValues);
193
+ // Try to get a Playwright locator for fill operations
194
+ const locator = await resolveLocator(page, action, treeData);
195
+ if (locator) {
196
+ if (action.mode === "click_type") {
197
+ await locator.click({ timeout: 5000 });
198
+ await locator.fill("");
199
+ await locator.pressSequentially(text, { delay: 30 });
200
+ }
201
+ else {
202
+ await locator.fill(text);
203
+ }
204
+ if (action.submit) {
205
+ await locator.press("Enter");
206
+ }
207
+ // Extract coordinates from the locator for recording
208
+ const box = await locator.boundingBox().catch(() => null);
209
+ return box ? { x: box.x + box.width / 2, y: box.y + box.height / 2 } : null;
210
+ }
211
+ else {
212
+ // Coordinate-based fallback
213
+ const coords = await resolveElement(page, action, treeData);
214
+ if (coords) {
215
+ await page.mouse.click(coords.x, coords.y);
216
+ await page.waitForTimeout(200);
217
+ const selectAll = process.platform === "darwin" ? "Meta+a" : "Control+a";
218
+ await page.keyboard.press(selectAll);
219
+ await page.keyboard.type(text, { delay: 30 });
220
+ if (action.submit) {
221
+ await page.keyboard.press("Enter");
222
+ }
223
+ return coords;
224
+ }
225
+ else {
226
+ throw new Error(`Cannot locate element for text input: ${action.element_name ?? "unknown"}`);
227
+ }
228
+ }
229
+ }
230
+ async function executeScroll(page, action, treeData) {
231
+ const viewport = page.viewportSize() ?? { width: 1440, height: 900 };
232
+ const amountMap = {
233
+ small: 0.5, medium: 0.8, large: 1.5, extra_large: 3.0,
234
+ };
235
+ const fraction = amountMap[action.amount ?? "medium"] ?? 0.8;
236
+ const pixels = Math.round(viewport.height * fraction);
237
+ switch (action.direction) {
238
+ case "up":
239
+ await page.evaluate((px) => window.scrollBy(0, -px), pixels);
240
+ break;
241
+ case "down":
242
+ await page.evaluate((px) => window.scrollBy(0, px), pixels);
243
+ break;
244
+ case "to_top":
245
+ await page.evaluate(() => window.scrollTo(0, 0));
246
+ break;
247
+ case "to_bottom":
248
+ await page.evaluate(() => window.scrollTo(0, document.documentElement.scrollHeight));
249
+ break;
250
+ case "to_element": {
251
+ const locator = await findElement(page, action);
252
+ if (locator) {
253
+ await locator.scrollIntoViewIfNeeded({ timeout: 5000 }).catch(() => { });
254
+ }
255
+ else {
256
+ await page.evaluate((px) => window.scrollBy(0, px), pixels);
257
+ }
258
+ break;
259
+ }
260
+ default:
261
+ await page.evaluate((px) => window.scrollBy(0, px), pixels);
262
+ }
263
+ await page.waitForTimeout(300);
264
+ }
265
+ async function executeSwipe(page, direction) {
266
+ const viewport = page.viewportSize() ?? { width: 1440, height: 900 };
267
+ const cx = viewport.width / 2;
268
+ const cy = viewport.height / 2;
269
+ const d = viewport.height * 0.4;
270
+ let sx = cx, sy = cy, ex = cx, ey = cy;
271
+ switch (direction) {
272
+ case "up":
273
+ sy = cy + d / 2;
274
+ ey = cy - d / 2;
275
+ break;
276
+ case "down":
277
+ sy = cy - d / 2;
278
+ ey = cy + d / 2;
279
+ break;
280
+ case "left":
281
+ sx = cx + d / 2;
282
+ ex = cx - d / 2;
283
+ break;
284
+ case "right":
285
+ sx = cx - d / 2;
286
+ ex = cx + d / 2;
287
+ break;
288
+ }
289
+ await page.mouse.move(sx, sy);
290
+ await page.mouse.down();
291
+ await page.mouse.move(ex, ey, { steps: 10 });
292
+ await page.mouse.up();
293
+ }
294
+ async function executeLongPress(page, action, treeData) {
295
+ const coords = await resolveElement(page, action, treeData);
296
+ if (!coords)
297
+ throw new Error(`Cannot locate element for long press: ${action.element_name ?? "unknown"}`);
298
+ await page.mouse.move(coords.x, coords.y);
299
+ await page.mouse.down();
300
+ await page.waitForTimeout(action.duration_ms ?? 500);
301
+ await page.mouse.up();
302
+ return coords;
303
+ }
304
+ async function executeDoubleTap(page, action, treeData) {
305
+ const coords = await resolveElement(page, action, treeData);
306
+ if (coords) {
307
+ await page.mouse.dblclick(coords.x, coords.y);
308
+ return coords;
309
+ }
310
+ else {
311
+ throw new Error(`Cannot locate element for double tap: ${action.element_name ?? "unknown"}`);
312
+ }
313
+ }
314
+ // --- Helpers ---
315
+ /**
316
+ * Resolve the actual text to type from an action, handling var/secret value types.
317
+ */
318
+ function resolveTextValue(action, contextValues) {
319
+ if (action.value_type === "var" || action.value_type === "secret") {
320
+ const cv = contextValues.find(v => v.name === action.value);
321
+ if (cv?.value)
322
+ return cv.value;
323
+ // Fallback to the key name if resolution fails
324
+ return action.value ?? "";
325
+ }
326
+ return action.value ?? "";
327
+ }
328
+ function isRecoverableError(err) {
329
+ if (err instanceof Error && err.constructor.name === "TargetClosedError")
330
+ return false;
331
+ const msg = (err instanceof Error ? err.message : String(err)).toLowerCase();
332
+ const fatal = ["target page", "browser has been closed", "target closed", "browser disconnected"];
333
+ return !fatal.some((f) => msg.includes(f));
334
+ }
335
+ /**
336
+ * Compare two base64 screenshots to detect visible change.
337
+ */
338
+ export function detectNoVisibleChange(before, after) {
339
+ return before === after;
340
+ }
341
+ /**
342
+ * Build a human-readable action description matching backend's format_action_detail().
343
+ */
344
+ export function describeAction(action) {
345
+ const element = action.element_name || "element";
346
+ switch (action.type) {
347
+ case "tap":
348
+ return action.count && action.count > 1
349
+ ? `tap on '${element}' x${action.count}`
350
+ : `tap on '${element}'`;
351
+ case "text_input": {
352
+ const val = action.value_type === "secret" ? "***" : `"${(action.value ?? "").slice(0, 30)}"`;
353
+ const modeStr = action.mode ? ` (${action.mode}${action.submit ? ", submit" : ""})` : "";
354
+ return `text_input on '${element}' → ${val}${modeStr}`;
355
+ }
356
+ case "scroll":
357
+ return action.direction === "to_element"
358
+ ? `scroll to '${element}'`
359
+ : `scroll ${action.direction ?? "down"} (${action.amount ?? "medium"})`;
360
+ case "swipe":
361
+ return `swipe ${action.direction ?? "up"} on '${element}'`;
362
+ case "wait":
363
+ return `wait ${action.duration_ms ?? 1000}ms`;
364
+ case "navigate_back":
365
+ return "navigate back";
366
+ case "long_press":
367
+ return `long_press on '${element}'`;
368
+ case "double_tap":
369
+ return `double_tap on '${element}'`;
370
+ case "drag":
371
+ return `drag '${element}'`;
372
+ case "think":
373
+ return `think: "${(action.thoughts ?? "").slice(0, 50)}"`;
374
+ case "pull_to_refresh":
375
+ return "pull_to_refresh";
376
+ default:
377
+ return `${action.type} on '${element}'`;
378
+ }
379
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Playwright browser lifecycle, CDP tree extraction, and node resolution.
3
+ *
4
+ * Tree format matches backend's get_accessibility_tree():
5
+ * [frame_index:node_id] role "name"
6
+ * with single-space indentation per level.
7
+ */
8
+ import { type Browser, type BrowserContext, type Page } from "playwright-core";
9
+ import type { LocalSimBrowserOptions, TreeData } from "./types.js";
10
+ import "./install.js";
11
+ export interface BrowserSession {
12
+ browser: Browser;
13
+ context: BrowserContext;
14
+ page: Page;
15
+ }
16
+ /**
17
+ * Launch a shared browser process with a pre-configured context.
18
+ * Create tabs with createTab() — they appear as tabs in one window.
19
+ */
20
+ export declare function launchSharedBrowser(opts: LocalSimBrowserOptions): Promise<Browser>;
21
+ /**
22
+ * Create a new tab in the shared browser's default context.
23
+ * Tabs share cookies/storage — appears as tabs in one window in headed mode.
24
+ */
25
+ export declare function createTab(browser: Browser, opts: LocalSimBrowserOptions): Promise<BrowserSession>;
26
+ /**
27
+ * Launch a standalone browser session (single tester, owns the browser).
28
+ */
29
+ export declare function launchBrowser(opts: LocalSimBrowserOptions): Promise<BrowserSession>;
30
+ export interface ObservationData {
31
+ screenshot: string;
32
+ treeData: TreeData;
33
+ url: string;
34
+ viewportWidth: number;
35
+ viewportHeight: number;
36
+ scrollPosition: number;
37
+ documentHeight: number;
38
+ }
39
+ /**
40
+ * Capture a full observation from the current page state.
41
+ */
42
+ export declare function captureObservation(page: Page): Promise<ObservationData>;
43
+ /**
44
+ * Extract accessibility tree via CDP, matching backend's [nodeId] role "name" format.
45
+ */
46
+ export declare function extractAccessibilityTree(page: Page): Promise<TreeData>;
47
+ /**
48
+ * Resolve a composite node_id (e.g., "0:42") to bounding box coordinates.
49
+ */
50
+ export declare function resolveNodeToBoundingBox(page: Page, nodeId: string, treeData: TreeData): Promise<{
51
+ x: number;
52
+ y: number;
53
+ width: number;
54
+ height: number;
55
+ } | null>;
56
+ /**
57
+ * Resolve a composite node_id to an XPath selector.
58
+ */
59
+ export declare function resolveNodeToXPath(page: Page, nodeId: string, treeData: TreeData): Promise<string | null>;
60
+ export declare function takeScreenshot(page: Page): Promise<string>;
61
+ export declare function takeScreenshotJpeg(page: Page, quality?: number): Promise<Buffer>;
62
+ export declare function navigateWithRetry(page: Page, url: string, maxRetries?: number): Promise<void>;
63
+ export declare function closeBrowser(session: BrowserSession): Promise<void>;