@thoughtflow/browser 0.2.0 → 0.3.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.
@@ -1,176 +1,375 @@
1
1
  import { z } from "zod";
2
- import { ToolBase, ToolContext, ToolExecutionResult } from "@thoughtflow/core";
2
+ import { ToolBase, ToolContext, ToolExecutionResult, LlmAdapter } from "@thoughtflow/core";
3
+ import { browserPool } from "./browser-session-pool";
4
+ import { buildUserVisionMessage } from "@thoughtflow/core";
3
5
 
4
6
  const BROWSER_ACTIONS = [
7
+ "open",
5
8
  "navigate",
9
+ "snapshot",
6
10
  "get_text",
7
- "screenshot",
8
11
  "get_dom",
9
- "extract",
12
+ "screenshot",
10
13
  "click",
11
14
  "type",
12
15
  "select",
16
+ "scroll",
17
+ "console",
18
+ "network",
19
+ "vision",
20
+ "new_page",
21
+ "switch_page",
22
+ "list_pages",
23
+ "list",
24
+ "close_page",
13
25
  "close",
26
+ "close_all",
14
27
  ] as const;
15
28
 
16
29
  type BrowserAction = (typeof BROWSER_ACTIONS)[number];
17
30
 
18
- const READ_ONLY_BROWSER_ACTIONS = new Set<BrowserAction>([
19
- "navigate",
20
- "get_text",
21
- "screenshot",
22
- "get_dom",
23
- "extract",
31
+ const READ_ONLY_ACTIONS = new Set<BrowserAction>([
32
+ "snapshot", "get_text", "get_dom", "screenshot",
33
+ "console", "network", "list_pages", "list", "vision",
34
+ ]);
35
+
36
+ const DESTRUCTIVE_ACTIONS = new Set<BrowserAction>([
37
+ "close", "close_page", "close_all",
24
38
  ]);
25
39
 
26
40
  /**
27
- * BrowserTool — Playwright-backed browser automation (Faza 8 stub).
41
+ * BrowserTool — stateful headless browser with session support.
28
42
  *
29
- * Playwright is loaded lazily on the first run() call so that registering
30
- * this tool does NOT require Playwright to be installed. If Playwright is
31
- * missing, run() returns `success: false` with a helpful install message.
43
+ * Sessions persist across calls via sessionId. Each session can have
44
+ * multiple tabs. Console and network activity is auto-captured.
32
45
  *
33
- * Note: Full implementation planned in Faza 8. Currently returns an
34
- * informative "not yet implemented" error for all actions.
46
+ * Use `open` to start, then navigate/interact/screenshot, finally `close`.
47
+ * Omit sessionId to use a default single session.
35
48
  */
36
49
  export class BrowserTool extends ToolBase {
37
50
  readonly name = "browser";
51
+ /** Static vision adapter — set once before use. Used by the `vision` action. */
52
+ static visionAdapter: LlmAdapter | null = null
53
+ static visionModel: string = "gemma3:12b"
38
54
  readonly description =
39
- "Headless browser automation with Playwright (Chromium). " +
40
- "Read: navigate, get_text, screenshot (vision only), get_dom, extract. " +
41
- "Write: click, type, select, close. " +
42
- "Playwright is loaded on first use no install required at import time.";
55
+ "Stateful headless browser (Playwright/Chromium). " +
56
+ "Sessions persist across calls open a session, navigate pages, " +
57
+ "interact, inspect, then close. Each session has its own browser process " +
58
+ "(isolated cookies/storage). Console and network auto-captured. " +
59
+ "Actions: open, navigate, snapshot (accessibility tree), get_text, " +
60
+ "get_dom (raw HTML), screenshot, click, type, select, scroll, " +
61
+ "console (browser logs), network (requests/responses), " +
62
+ "vision (visual AI analysis of current page), " +
63
+ "new_page, switch_page, list_pages, list (all sessions), " +
64
+ "close_page, close, close_all. " +
65
+ "Use snapshot/click with [ref=eN] selectors for reliable interaction. " +
66
+ "Sessions auto-expire after 5 minutes of inactivity.\n\n" +
67
+ "CANONICAL FLOW — follow this exactly:\n" +
68
+ "1. browser({action:\"open\"}) → save sessionId\n" +
69
+ "2. browser({action:\"navigate\", sessionId, url:\"...\"})\n" +
70
+ "3. browser({action:\"snapshot\", sessionId}) → find [ref=eN]\n" +
71
+ "4. browser({action:\"click\", sessionId, selector:\"e4\"})\n" +
72
+ "5. browser({action:\"vision\", sessionId, prompt:\"Is the button visible?\"})\n" +
73
+ "6. browser({action:\"console\", sessionId}) to check for JS errors\n" +
74
+ "7. browser({action:\"close\", sessionId}) when done\n" +
75
+ "ALWAYS reuse sessionId — NEVER call open again for the same browsing task."
43
76
 
44
- /** Read-only actions: navigate/get_text/screenshot/get_dom/extract */
45
77
  override isReadOnly(input: Record<string, unknown>): boolean {
46
- return READ_ONLY_BROWSER_ACTIONS.has((input["action"] as BrowserAction) ?? "navigate");
78
+ return READ_ONLY_ACTIONS.has((input["action"] as BrowserAction) ?? "navigate");
79
+ }
80
+
81
+ override isDestructive(input: Record<string, unknown>): boolean {
82
+ return DESTRUCTIVE_ACTIONS.has((input["action"] as BrowserAction) ?? "navigate");
47
83
  }
48
84
 
49
85
  constructor() {
50
86
  super(
51
87
  z.object({
52
- action: z
53
- .enum(BROWSER_ACTIONS)
54
- .describe("Browser action to perform"),
55
- url: z.string().optional().describe("URL to navigate to (for navigate action)"),
56
- selector: z.string().optional().describe("CSS selector (for click/type/select/extract/get_dom)"),
57
- text: z.string().optional().describe("Text to type (for type action)"),
58
- value: z.string().optional().describe("Option value to select (for select action)"),
59
- fields: z.array(z.string()).optional().describe("Fields to extract from rows (for extract action)"),
60
- waitFor: z.number().optional().describe("Max ms to wait for page load (default: 5000)"),
88
+ action: z.enum(BROWSER_ACTIONS).describe("Browser action to perform"),
89
+ sessionId: z.string().optional()
90
+ .describe("Session ID from `open`. Omit for default single session."),
91
+ url: z.string().optional().describe("URL to navigate to"),
92
+ selector: z.string().optional()
93
+ .describe("CSS selector or [ref=eN] from snapshot"),
94
+ text: z.string().optional().describe("Text to type"),
95
+ value: z.string().optional().describe("Value to select or scroll amount"),
96
+ pageId: z.string().optional()
97
+ .describe("Tab/page ID for switch_page/close_page"),
98
+ clear: z.boolean().optional().default(false)
99
+ .describe("Clear console/network logs after reading"),
100
+ fullPage: z.boolean().optional().default(false)
101
+ .describe("Screenshot full page (not just viewport)"),
102
+ isolated: z.boolean().optional().default(true)
103
+ .describe("Use separate browser process for cookie/storage isolation (default: true)"),
104
+ prompt: z.string().optional()
105
+ .describe("Vision analysis prompt — what to check visually on the page"),
106
+ model: z.string().optional()
107
+ .describe("Vision model override (default: gemma3:12b)"),
108
+ useCached: z.boolean().optional().default(false)
109
+ .describe("Re-analyze the cached screenshot instead of taking a new one"),
61
110
  })
62
111
  );
63
112
  }
64
113
 
65
- // Shared browser instance (reused across calls)
66
- private browser: import("playwright").Browser | null = null;
67
- private page: import("playwright").Page | null = null;
68
-
69
- private async getPage(): Promise<import("playwright").Page> {
70
- // eslint-disable-next-line @typescript-eslint/no-require-imports
71
- const { chromium } = require("playwright") as typeof import("playwright");
72
- if (!this.browser || !this.browser.isConnected()) {
73
- this.browser = await chromium.launch({ headless: true });
74
- }
75
- if (!this.page || this.page.isClosed()) {
76
- this.page = await this.browser.newPage();
77
- }
78
- return this.page;
79
- }
80
-
81
114
  async run(input: Record<string, unknown>, _ctx: ToolContext): Promise<ToolExecutionResult> {
82
- // Lazy-load Playwright on first use (optional peer dependency)
115
+ // Lazy-load Playwright
83
116
  try {
84
- // eslint-disable-next-line @typescript-eslint/no-require-imports
85
117
  require("playwright");
86
118
  } catch {
87
119
  return {
88
120
  success: false,
89
- error:
90
- "Playwright is not installed. Run: npm install playwright && npx playwright install chromium",
121
+ error: "Playwright is not installed. Run: npm install playwright && npx playwright install chromium",
91
122
  };
92
123
  }
93
124
 
94
125
  const action = (input["action"] as BrowserAction) ?? "navigate";
95
- const waitFor = (input["waitFor"] as number | undefined) ?? 5000;
126
+ const sessionId = input["sessionId"] as string | undefined;
127
+ const clear = (input["clear"] as boolean) ?? false;
96
128
 
97
129
  try {
98
- const page = await this.getPage();
99
-
100
130
  switch (action) {
131
+ case "open": {
132
+ const { sessionId: sid, page } = await browserPool.getOrCreate(sessionId);
133
+ return {
134
+ success: true,
135
+ output: JSON.stringify({
136
+ sessionId: sid,
137
+ message: "Browser session created. Use this sessionId for all subsequent calls.",
138
+ }),
139
+ };
140
+ }
141
+
101
142
  case "navigate": {
143
+ const page = await this.requirePage(sessionId);
144
+ if (!page) return this.noSession();
102
145
  const url = input["url"] as string;
103
- if (!url) return { success: false, error: "url is required for navigate" };
104
- await page.goto(url, { waitUntil: "domcontentloaded", timeout: waitFor });
146
+ if (!url) return { success: false, error: "url is required" };
147
+ await page.goto(url, { waitUntil: "domcontentloaded", timeout: 15000 });
105
148
  const title = await page.title();
149
+ if (sessionId) browserPool.setInitUrl(sessionId, url);
106
150
  return { success: true, output: JSON.stringify({ title, url: page.url() }) };
107
151
  }
108
152
 
153
+ case "snapshot": {
154
+ const page = await this.requirePage(sessionId);
155
+ if (!page) return this.noSession();
156
+ const tree = await this.getAccessibilityTree(page);
157
+ return { success: true, output: tree };
158
+ }
159
+
109
160
  case "get_text": {
110
- const selector = (input["selector"] as string | undefined) ?? "body";
111
- const text = await page.locator(selector).first().innerText({ timeout: waitFor });
161
+ const page = await this.requirePage(sessionId);
162
+ if (!page) return this.noSession();
163
+ const sel = (input["selector"] as string) ?? "body";
164
+ const text = await page.locator(sel).first().innerText({ timeout: 5000 });
112
165
  return { success: true, output: text.trim() };
113
166
  }
114
167
 
115
168
  case "get_dom": {
116
- const selector = (input["selector"] as string | undefined) ?? "body";
117
- const html = await page.locator(selector).first().innerHTML({ timeout: waitFor });
169
+ const page = await this.requirePage(sessionId);
170
+ if (!page) return this.noSession();
171
+ const sel = (input["selector"] as string) ?? "body";
172
+ const html = await page.locator(sel).first().innerHTML({ timeout: 5000 });
118
173
  return { success: true, output: html };
119
174
  }
120
175
 
121
- case "extract": {
122
- const selector = input["selector"] as string;
123
- const fields = (input["fields"] as string[] | undefined) ?? [];
124
- if (!selector) return { success: false, error: "selector is required for extract" };
125
- const rows = await page.locator(selector).all();
126
- const results: Record<string, string>[] = [];
127
- for (const row of rows) {
128
- if (fields.length === 0) {
129
- results.push({ text: (await row.innerText()).trim() });
130
- } else {
131
- const obj: Record<string, string> = {};
132
- for (const field of fields) {
133
- const el = row.locator(`[class*="${field}"], [data-${field}], .${field}`).first();
134
- obj[field] = (await el.innerText().catch(() => "")).trim();
135
- }
136
- results.push(obj);
137
- }
138
- }
139
- return { success: true, output: JSON.stringify(results) };
176
+ case "screenshot": {
177
+ const page = await this.requirePage(sessionId);
178
+ if (!page) return this.noSession();
179
+ const buf = await page.screenshot({
180
+ type: "png",
181
+ fullPage: (input["fullPage"] as boolean) ?? false,
182
+ });
183
+ const b64 = buf.toString("base64");
184
+ // Cache for vision action
185
+ if (sessionId) browserPool.cacheScreenshot(sessionId, b64);
186
+ return { success: true, output: `screenshot:base64:${b64}` };
140
187
  }
141
188
 
142
189
  case "click": {
143
- const selector = input["selector"] as string;
144
- if (!selector) return { success: false, error: "selector is required for click" };
145
- await page.locator(selector).first().click({ timeout: waitFor });
146
- return { success: true, output: `Clicked: ${selector}` };
190
+ const page = await this.requirePage(sessionId);
191
+ if (!page) return this.noSession();
192
+ const sel = input["selector"] as string;
193
+ if (!sel) return { success: false, error: "selector is required" };
194
+ const locator = this.resolveSelector(page, sel);
195
+ await locator.first().click({ timeout: 5000 });
196
+ return { success: true, output: `Clicked: ${sel}` };
147
197
  }
148
198
 
149
199
  case "type": {
150
- const selector = input["selector"] as string;
200
+ const page = await this.requirePage(sessionId);
201
+ if (!page) return this.noSession();
202
+ const sel = input["selector"] as string;
151
203
  const text = input["text"] as string;
152
- if (!selector || text === undefined) return { success: false, error: "selector and text required for type" };
153
- await page.locator(selector).first().fill(text, { timeout: waitFor });
154
- return { success: true, output: `Typed into ${selector}` };
204
+ if (!sel || text === undefined)
205
+ return { success: false, error: "selector and text required" };
206
+ const locator = this.resolveSelector(page, sel);
207
+ await locator.first().fill(text, { timeout: 5000 });
208
+ return { success: true, output: `Typed into ${sel}` };
155
209
  }
156
210
 
157
211
  case "select": {
158
- const selector = input["selector"] as string;
212
+ const page = await this.requirePage(sessionId);
213
+ if (!page) return this.noSession();
214
+ const sel = input["selector"] as string;
159
215
  const value = input["value"] as string;
160
- if (!selector || !value) return { success: false, error: "selector and value required for select" };
161
- await page.locator(selector).first().selectOption(value, { timeout: waitFor });
162
- return { success: true, output: `Selected ${value} in ${selector}` };
216
+ if (!sel || !value)
217
+ return { success: false, error: "selector and value required" };
218
+ const locator = this.resolveSelector(page, sel);
219
+ await locator.first().selectOption(value, { timeout: 5000 });
220
+ return { success: true, output: `Selected ${value} in ${sel}` };
163
221
  }
164
222
 
165
- case "screenshot": {
166
- const buf = await page.screenshot({ type: "png" });
167
- return { success: true, output: `screenshot:base64:${buf.toString("base64")}` };
223
+ case "scroll": {
224
+ const page = await this.requirePage(sessionId);
225
+ if (!page) return this.noSession();
226
+ const amount = parseInt(input["value"] as string ?? "") || 500;
227
+ await page.evaluate((px) => window.scrollBy(0, px), amount);
228
+ return { success: true, output: `Scrolled ${amount}px` };
229
+ }
230
+
231
+ case "vision": {
232
+ if (!BrowserTool.visionAdapter) {
233
+ return { success: false, error: "Vision adapter not configured. Set BrowserTool.visionAdapter before use." };
234
+ }
235
+ const prompt = input["prompt"] as string;
236
+ if (!prompt) return { success: false, error: "prompt is required for vision analysis" };
237
+ const useCached = (input["useCached"] as boolean) ?? false;
238
+ const model = input["model"] as string | undefined;
239
+
240
+ let base64Data: string | null = null;
241
+ if (useCached && sessionId) {
242
+ base64Data = browserPool.getCachedScreenshot(sessionId);
243
+ }
244
+ if (!base64Data) {
245
+ const page = await this.requirePage(sessionId);
246
+ if (!page) return this.noSession();
247
+ const buf = await page.screenshot({
248
+ type: "png",
249
+ fullPage: (input["fullPage"] as boolean) ?? false,
250
+ });
251
+ base64Data = buf.toString("base64");
252
+ if (sessionId) browserPool.cacheScreenshot(sessionId, base64Data);
253
+ }
254
+
255
+ const visionMessage = buildUserVisionMessage(
256
+ `You are a senior UI/UX designer and graphic design specialist. ` +
257
+ `Analyze this screenshot with extreme attention to visual detail:\n` +
258
+ `- Colors: exact hex/rgb values of backgrounds, text, borders, buttons\n` +
259
+ `- Borders: presence, color, width (px), radius, style (solid/dashed)\n` +
260
+ `- Spacing: padding, margins, gaps between elements (estimate px)\n` +
261
+ `- Typography: font sizes, weights, alignment, line heights\n` +
262
+ `- Layout: element positions, centering, whitespace balance\n` +
263
+ `- Visual bugs: overflow, misalignment, overlapping, cut-off text\n` +
264
+ `\n` +
265
+ `User request: ${prompt}\n` +
266
+ `\n` +
267
+ `Prioritize the user's request above these defaults. ` +
268
+ `If the user asks about something specific, focus on that ` +
269
+ `while still noting any obvious visual issues.`,
270
+ base64Data
271
+ );
272
+ const response = await BrowserTool.visionAdapter.chat({
273
+ model: model || BrowserTool.visionModel,
274
+ messages: [visionMessage],
275
+ tools: [],
276
+ });
277
+
278
+ return { success: true, output: response.message.content ?? "(no vision analysis)" };
279
+ }
280
+
281
+ case "console": {
282
+ if (!sessionId) return this.noSession();
283
+ const entries = browserPool.getConsole(sessionId);
284
+ if (clear) browserPool.clearConsole(sessionId);
285
+ if (entries.length === 0) return { success: true, output: "(no console output)" };
286
+ const lines = entries.map((e) => {
287
+ const tag = e.type === "error" ? "error" : e.type === "warning" ? "warn " : e.type;
288
+ const loc = e.location ? ` — ${e.location}` : "";
289
+ return `[${tag}] ${e.text.slice(0, 500)}${loc}`;
290
+ });
291
+ return { success: true, output: `Console (${entries.length} entries):\n${lines.join("\n")}` };
292
+ }
293
+
294
+ case "network": {
295
+ if (!sessionId) return this.noSession();
296
+ const entries = browserPool.getNetwork(sessionId);
297
+ if (clear) browserPool.clearNetwork(sessionId);
298
+ if (entries.length === 0) return { success: true, output: "(no network activity)" };
299
+ const sorted = [...entries].sort((a, b) => a.idx - b.idx);
300
+ const lines = sorted.map((e) => {
301
+ const status = e.error ? "FAILED" : `${e.status} ${e.statusText ?? ""}`;
302
+ const dur = e.duration != null ? ` (${e.duration}ms)` : "";
303
+ return `${e.error ? "✗" : "✓"} ${e.method} ${e.url.slice(0, 120)} → ${status}${dur}`;
304
+ });
305
+ return {
306
+ success: true,
307
+ output: `Network (${entries.length} requests):\n${lines.join("\n")}`,
308
+ };
309
+ }
310
+
311
+ case "new_page": {
312
+ if (!sessionId) return { success: false, error: "sessionId required — use `open` first" };
313
+ const { page } = await browserPool.newPage(sessionId);
314
+ return {
315
+ success: true,
316
+ output: JSON.stringify({ pageId: `pg_active`, url: page.url(), message: "New tab created" }),
317
+ };
318
+ }
319
+
320
+ case "switch_page": {
321
+ if (!sessionId) return this.noSession();
322
+ const pageId = input["pageId"] as string;
323
+ if (!pageId) return { success: false, error: "pageId required" };
324
+ const ok = browserPool.switchPage(sessionId, pageId);
325
+ return { success: ok, output: ok ? `Switched to ${pageId}` : `Page ${pageId} not found` };
326
+ }
327
+
328
+ case "list_pages": {
329
+ if (!sessionId) return this.noSession();
330
+ const pages = browserPool.listPages(sessionId);
331
+ const session = (browserPool as any).sessions?.get(sessionId);
332
+ if (session) {
333
+ for (const p of pages) {
334
+ const bp = session.pages?.find((bp2: any) => bp2.id === p.id && !bp2.page.isClosed());
335
+ if (bp) {
336
+ try { p.title = await bp.page.title(); } catch {}
337
+ }
338
+ }
339
+ }
340
+ return {
341
+ success: true,
342
+ output: JSON.stringify(pages.map(p => ({
343
+ id: p.id,
344
+ url: p.url,
345
+ title: p.title,
346
+ active: (p as any).active,
347
+ }))),
348
+ };
349
+ }
350
+
351
+ case "list": {
352
+ const sessions = browserPool.list();
353
+ if (sessions.length === 0) return { success: true, output: "(no active browser sessions)" };
354
+ return { success: true, output: JSON.stringify(sessions, null, 2) };
355
+ }
356
+
357
+ case "close_page": {
358
+ if (!sessionId) return this.noSession();
359
+ const pageId = input["pageId"] as string | undefined;
360
+ await browserPool.closePage(sessionId, pageId);
361
+ return { success: true, output: "Page closed" };
168
362
  }
169
363
 
170
364
  case "close": {
171
- await this.page?.close();
172
- this.page = null;
173
- return { success: true, output: "Browser page closed" };
365
+ if (!sessionId) return { success: false, error: "sessionId required" };
366
+ await browserPool.destroy(sessionId);
367
+ return { success: true, output: `Session ${sessionId} closed` };
368
+ }
369
+
370
+ case "close_all": {
371
+ const count = await browserPool.destroyAll();
372
+ return { success: true, output: `Closed ${count} browser session(s)` };
174
373
  }
175
374
 
176
375
  default:
@@ -180,4 +379,70 @@ export class BrowserTool extends ToolBase {
180
379
  return { success: false, error: String(err instanceof Error ? err.message : err) };
181
380
  }
182
381
  }
183
- }
382
+
383
+ // ── Private helpers ────────────────────────────────────────────────────────
384
+
385
+ private async requirePage(sessionId?: string): Promise<any> {
386
+ const { page } = await browserPool.getOrCreate(sessionId);
387
+ return page;
388
+ }
389
+
390
+ private noSession(): ToolExecutionResult {
391
+ return { success: false, error: "No active session. Use action: 'open' first." };
392
+ }
393
+
394
+ /** Resolve [ref=eN] selectors from snapshot, or pass through CSS selectors. */
395
+ private resolveSelector(page: any, sel: string) {
396
+ const refMatch = sel.match(/^e(\d+)$/);
397
+ if (refMatch) {
398
+ return page.locator(`[data-ref="e${refMatch[1]}"]`);
399
+ }
400
+ const bracketMatch = sel.match(/^\[ref=e(\d+)\]$/);
401
+ if (bracketMatch) {
402
+ return page.locator(`[data-ref="e${bracketMatch[1]}"]`);
403
+ }
404
+ return page.locator(sel);
405
+ }
406
+
407
+ /** Build a simplified accessibility tree with [ref=eN] markers. */
408
+ private async getAccessibilityTree(page: any): Promise<string> {
409
+ const lines: string[] = [];
410
+
411
+ // Inject data-ref attributes into interactive elements
412
+ await page.evaluate(() => {
413
+ let counter = 0;
414
+ const interactive = document.querySelectorAll(
415
+ 'a, button, input, select, textarea, [role="button"], [role="link"], [role="textbox"], [role="combobox"], [role="checkbox"], [role="radio"], [role="tab"], [role="menuitem"], [role="option"], [role="switch"], [onclick], summary, details'
416
+ );
417
+ interactive.forEach((el) => {
418
+ (el as HTMLElement).setAttribute("data-ref", `e${++counter}`);
419
+ });
420
+ });
421
+
422
+ // Use Playwright's accessibility snapshot
423
+ try {
424
+ const snapshot = await (page as any).accessibility.snapshot({ interestingOnly: false });
425
+ if (snapshot) {
426
+ this.flattenA11yTree(snapshot, lines, 0);
427
+ }
428
+ } catch {
429
+ const text = await page.locator("body").innerText();
430
+ lines.push(text.slice(0, 5000));
431
+ }
432
+
433
+ return lines.join("\n") || "(empty page)";
434
+ }
435
+
436
+ private flattenA11yTree(node: any, lines: string[], depth: number): void {
437
+ const indent = " ".repeat(Math.min(depth, 6));
438
+ const role = node.role || "unknown";
439
+ const name = node.name ? ` "${node.name.slice(0, 80)}"` : "";
440
+ lines.push(`${indent}- ${role}${name}`);
441
+
442
+ if (node.children) {
443
+ for (const child of node.children) {
444
+ this.flattenA11yTree(child, lines, depth + 1);
445
+ }
446
+ }
447
+ }
448
+ }