@silvery/test 0.3.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.
@@ -0,0 +1,518 @@
1
+ /**
2
+ * Debug utilities for incremental render mismatch diagnostics.
3
+ *
4
+ * When SILVERY_STRICT detects a mismatch between incremental and fresh renders,
5
+ * these utilities help identify the root cause by providing:
6
+ * - Node attribution (which node owns the mismatched cell)
7
+ * - Dirty flag state (what flags were set before render)
8
+ * - Layout changes (prevLayout vs contentRect)
9
+ * - Scroll context (offset changes, hidden items)
10
+ */
11
+
12
+ import type { Cell } from "@silvery/term/buffer"
13
+ import type { BoxProps, TeaNode, Rect, TextProps } from "@silvery/tea/types"
14
+ import type { ContentPhaseStats } from "@silvery/term/pipeline/types"
15
+
16
+ // ============================================================================
17
+ // Types
18
+ // ============================================================================
19
+
20
+ /** Debug info about a node at a screen position */
21
+ export interface NodeDebugInfo {
22
+ /** Node ID (if set via props.id) */
23
+ id: string | undefined
24
+ /** Node type (silvery-box, silvery-text, silvery-root) */
25
+ type: string
26
+ /** Path from root to this node (IDs or indices) */
27
+ path: string
28
+ /** Index within parent's children array */
29
+ childIndex: number | null
30
+ /** Dirty flags at time of mismatch */
31
+ dirtyFlags: {
32
+ contentDirty: boolean
33
+ paintDirty: boolean
34
+ subtreeDirty: boolean
35
+ childrenDirty: boolean
36
+ layoutDirty: boolean
37
+ }
38
+ /** Layout info */
39
+ layout: {
40
+ prevLayout: Rect | null
41
+ contentRect: Rect | null
42
+ screenRect: Rect | null
43
+ layoutChanged: boolean
44
+ }
45
+ /** Scroll context (if this is a scroll container or inside one) */
46
+ scroll?: {
47
+ offset: number
48
+ prevOffset: number
49
+ offsetChanged: boolean
50
+ contentHeight: number
51
+ viewportHeight: number
52
+ hiddenAbove: number
53
+ hiddenBelow: number
54
+ firstVisibleChild: number
55
+ lastVisibleChild: number
56
+ }
57
+ /** Background color from props */
58
+ backgroundColor: string | undefined
59
+ /** Number of children */
60
+ childCount: number
61
+ /** Whether node is hidden (Suspense) */
62
+ hidden: boolean
63
+ }
64
+
65
+ /** Full mismatch debug context */
66
+ export interface MismatchDebugContext {
67
+ /** Screen position of the mismatch */
68
+ position: { x: number; y: number }
69
+ /** Cell values */
70
+ cells: {
71
+ incremental: Cell
72
+ fresh: Cell
73
+ }
74
+ /** Render number */
75
+ renderNum: number
76
+ /** Node that owns this screen position (innermost) */
77
+ node: NodeDebugInfo | null
78
+ /** Scroll container ancestry (if any) */
79
+ scrollAncestors: NodeDebugInfo[]
80
+ /** All nodes whose screenRect contains this position */
81
+ containingNodes: NodeDebugInfo[]
82
+ /** Fast-path analysis - why the node was likely skipped */
83
+ fastPathAnalysis: string[]
84
+ }
85
+
86
+ // ============================================================================
87
+ // Implementation
88
+ // ============================================================================
89
+
90
+ /**
91
+ * Find the innermost node at a screen position.
92
+ */
93
+ export function findNodeAtPosition(root: TeaNode, x: number, y: number): TeaNode | null {
94
+ let result: TeaNode | null = null
95
+
96
+ function visit(node: TeaNode): void {
97
+ const rect = node.screenRect
98
+ if (!rect) return
99
+
100
+ // Check if position is within this node's screenRect
101
+ if (x >= rect.x && x < rect.x + rect.width && y >= rect.y && y < rect.y + rect.height) {
102
+ result = node // This node contains the position
103
+
104
+ // Check children (later children render on top of earlier ones)
105
+ for (const child of node.children) {
106
+ visit(child)
107
+ }
108
+ }
109
+ }
110
+
111
+ visit(root)
112
+ return result
113
+ }
114
+
115
+ /**
116
+ * Find all nodes whose screenRect contains the given position.
117
+ * Returns nodes from root to innermost (outermost first).
118
+ */
119
+ export function findAllContainingNodes(root: TeaNode, x: number, y: number): TeaNode[] {
120
+ const result: TeaNode[] = []
121
+
122
+ function visit(node: TeaNode): void {
123
+ const rect = node.screenRect
124
+ if (!rect) return
125
+
126
+ if (x >= rect.x && x < rect.x + rect.width && y >= rect.y && y < rect.y + rect.height) {
127
+ result.push(node)
128
+ for (const child of node.children) {
129
+ visit(child)
130
+ }
131
+ }
132
+ }
133
+
134
+ visit(root)
135
+ return result
136
+ }
137
+
138
+ /**
139
+ * Get the path from root to a node (for identification).
140
+ */
141
+ function getNodePath(node: TeaNode): string {
142
+ const parts: string[] = []
143
+ let current: TeaNode | null = node
144
+
145
+ while (current) {
146
+ const props = current.props as BoxProps & TextProps
147
+ if (props.id) {
148
+ parts.unshift(`#${props.id}`)
149
+ } else if (current.parent) {
150
+ const idx = current.parent.children.indexOf(current)
151
+ parts.unshift(`[${idx}]`)
152
+ } else {
153
+ parts.unshift("root")
154
+ }
155
+ current = current.parent
156
+ }
157
+
158
+ return parts.join(" > ")
159
+ }
160
+
161
+ /**
162
+ * Check if a rect changed (position or size).
163
+ */
164
+ function rectChanged(a: Rect | null, b: Rect | null): boolean {
165
+ if (a === b) return false
166
+ if (!a || !b) return true
167
+ return a.x !== b.x || a.y !== b.y || a.width !== b.width || a.height !== b.height
168
+ }
169
+
170
+ /**
171
+ * Extract debug info from a node.
172
+ */
173
+ export function getNodeDebugInfo(node: TeaNode): NodeDebugInfo {
174
+ const props = node.props as BoxProps & TextProps
175
+
176
+ // Get child index within parent
177
+ let childIndex: number | null = null
178
+ if (node.parent) {
179
+ childIndex = node.parent.children.indexOf(node)
180
+ }
181
+
182
+ return {
183
+ id: props.id,
184
+ type: node.type,
185
+ path: getNodePath(node),
186
+ childIndex,
187
+ dirtyFlags: {
188
+ contentDirty: node.contentDirty,
189
+ paintDirty: node.paintDirty,
190
+ subtreeDirty: node.subtreeDirty,
191
+ childrenDirty: node.childrenDirty,
192
+ layoutDirty: node.layoutDirty,
193
+ },
194
+ layout: {
195
+ prevLayout: node.prevLayout,
196
+ contentRect: node.contentRect,
197
+ screenRect: node.screenRect,
198
+ layoutChanged: rectChanged(node.prevLayout, node.contentRect),
199
+ },
200
+ scroll: node.scrollState
201
+ ? {
202
+ offset: node.scrollState.offset,
203
+ prevOffset: node.scrollState.prevOffset,
204
+ offsetChanged: node.scrollState.offset !== node.scrollState.prevOffset,
205
+ contentHeight: node.scrollState.contentHeight,
206
+ viewportHeight: node.scrollState.viewportHeight,
207
+ hiddenAbove: node.scrollState.hiddenAbove,
208
+ hiddenBelow: node.scrollState.hiddenBelow,
209
+ firstVisibleChild: node.scrollState.firstVisibleChild,
210
+ lastVisibleChild: node.scrollState.lastVisibleChild,
211
+ }
212
+ : undefined,
213
+ backgroundColor: props.backgroundColor,
214
+ childCount: node.children.length,
215
+ hidden: node.hidden ?? false,
216
+ }
217
+ }
218
+
219
+ /**
220
+ * Find scroll container ancestors for a node.
221
+ */
222
+ function findScrollAncestors(node: TeaNode): TeaNode[] {
223
+ const result: TeaNode[] = []
224
+ let current = node.parent
225
+
226
+ while (current) {
227
+ if (current.scrollState) {
228
+ result.push(current)
229
+ }
230
+ current = current.parent
231
+ }
232
+
233
+ return result
234
+ }
235
+
236
+ /**
237
+ * Analyze why a node might have been incorrectly skipped by fast-path.
238
+ */
239
+ function analyzeFastPath(node: TeaNode | null, scrollAncestors: TeaNode[]): string[] {
240
+ const analysis: string[] = []
241
+
242
+ if (!node) {
243
+ analysis.push("⚠ No node found at mismatch position - possible virtualization issue")
244
+ return analysis
245
+ }
246
+
247
+ const flags = node
248
+ const allClean =
249
+ !flags.contentDirty && !flags.paintDirty && !flags.subtreeDirty && !flags.childrenDirty && !flags.layoutDirty
250
+
251
+ if (allClean) {
252
+ analysis.push("⚠ ALL DIRTY FLAGS FALSE - fast-path likely skipped this node")
253
+ }
254
+
255
+ // Check if node is in a scroll container
256
+ const scrollParent = scrollAncestors[0]
257
+ if (scrollParent?.scrollState) {
258
+ const ss = scrollParent.scrollState
259
+ const childIndex = node.parent ? node.parent.children.indexOf(node) : -1
260
+
261
+ // Check if this node SHOULD be in visible range
262
+ const inVisibleRange = childIndex >= ss.firstVisibleChild && childIndex <= ss.lastVisibleChild
263
+ if (!inVisibleRange && childIndex >= 0) {
264
+ analysis.push(
265
+ `⚠ Node index ${childIndex} is OUTSIDE visible range [${ss.firstVisibleChild}..${ss.lastVisibleChild}]`,
266
+ )
267
+ analysis.push(" → Node should have been skipped, but mismatch suggests it should render")
268
+ } else if (inVisibleRange) {
269
+ analysis.push(`✓ Node index ${childIndex} is in visible range [${ss.firstVisibleChild}..${ss.lastVisibleChild}]`)
270
+ }
271
+
272
+ // Check scroll offset
273
+ if (ss.offset === ss.prevOffset) {
274
+ analysis.push("✓ Scroll offset unchanged (fast-path enabled for children)")
275
+ } else {
276
+ analysis.push(`⚠ Scroll offset CHANGED: ${ss.prevOffset} → ${ss.offset}`)
277
+ }
278
+
279
+ // Check if visible range might have changed
280
+ if (ss.firstVisibleChild !== 0 || ss.lastVisibleChild !== scrollParent.children.length - 1) {
281
+ analysis.push(
282
+ ` Visible range is partial: [${ss.firstVisibleChild}..${ss.lastVisibleChild}] of ${scrollParent.children.length} children`,
283
+ )
284
+ analysis.push(" → If visible range changed, newly visible children need rendering")
285
+ }
286
+ }
287
+
288
+ // Check prevLayout
289
+ const layoutChanged = rectChanged(node.prevLayout, node.contentRect)
290
+ if (!layoutChanged && node.prevLayout) {
291
+ analysis.push("✓ Layout unchanged (prevLayout matches contentRect)")
292
+ } else if (!node.prevLayout) {
293
+ analysis.push("⚠ prevLayout is NULL - node may never have been rendered before")
294
+ } else {
295
+ analysis.push("⚠ Layout CHANGED but node still skipped - dirty flag not set?")
296
+ }
297
+
298
+ // Check for sibling child position changes
299
+ if (node.parent && node.parent.children.length > 1) {
300
+ let siblingMoved = false
301
+ for (const sibling of node.parent.children) {
302
+ if (sibling !== node && sibling.contentRect && sibling.prevLayout) {
303
+ if (sibling.contentRect.x !== sibling.prevLayout.x || sibling.contentRect.y !== sibling.prevLayout.y) {
304
+ siblingMoved = true
305
+ break
306
+ }
307
+ }
308
+ }
309
+ if (siblingMoved) {
310
+ analysis.push("⚠ SIBLING POSITION CHANGED - parent should have detected this")
311
+ }
312
+ }
313
+
314
+ // Check hidden state
315
+ if (node.hidden) {
316
+ analysis.push("⚠ Node is HIDDEN (Suspense) - should not be rendered")
317
+ }
318
+
319
+ return analysis
320
+ }
321
+
322
+ /**
323
+ * Build full mismatch debug context.
324
+ */
325
+ export function buildMismatchContext(
326
+ root: TeaNode,
327
+ x: number,
328
+ y: number,
329
+ incrementalCell: Cell,
330
+ freshCell: Cell,
331
+ renderNum: number,
332
+ ): MismatchDebugContext {
333
+ const innermost = findNodeAtPosition(root, x, y)
334
+ const containing = findAllContainingNodes(root, x, y)
335
+ const scrollAncestorNodes = innermost ? findScrollAncestors(innermost) : []
336
+
337
+ return {
338
+ position: { x, y },
339
+ cells: {
340
+ incremental: incrementalCell,
341
+ fresh: freshCell,
342
+ },
343
+ renderNum,
344
+ node: innermost ? getNodeDebugInfo(innermost) : null,
345
+ scrollAncestors: scrollAncestorNodes.map(getNodeDebugInfo),
346
+ containingNodes: containing.map(getNodeDebugInfo),
347
+ fastPathAnalysis: analyzeFastPath(innermost, scrollAncestorNodes),
348
+ }
349
+ }
350
+
351
+ /**
352
+ * Format mismatch context as a human-readable string.
353
+ *
354
+ * @param ctx - The mismatch debug context (node attribution, dirty flags, scroll, fast-path)
355
+ * @param contentPhaseStats - Optional content-phase instrumentation snapshot (auto-included by SILVERY_STRICT)
356
+ */
357
+ export function formatMismatchContext(ctx: MismatchDebugContext, contentPhaseStats?: ContentPhaseStats): string {
358
+ const lines: string[] = []
359
+
360
+ // Header
361
+ lines.push(
362
+ `SILVERY_CHECK_INCREMENTAL: MISMATCH at (${ctx.position.x}, ${ctx.position.y}) on render #${ctx.renderNum}`,
363
+ )
364
+ lines.push("")
365
+
366
+ // Cell values
367
+ const { incremental, fresh } = ctx.cells
368
+ lines.push("CELL VALUES:")
369
+ lines.push(
370
+ ` incremental: char=${JSON.stringify(incremental.char)} fg=${JSON.stringify(incremental.fg)} bg=${JSON.stringify(incremental.bg)} attrs=${JSON.stringify(incremental.attrs)}`,
371
+ )
372
+ lines.push(
373
+ ` fresh: char=${JSON.stringify(fresh.char)} fg=${JSON.stringify(fresh.fg)} bg=${JSON.stringify(fresh.bg)} attrs=${JSON.stringify(fresh.attrs)}`,
374
+ )
375
+ lines.push("")
376
+
377
+ // Node attribution
378
+ if (ctx.node) {
379
+ lines.push("INNERMOST NODE:")
380
+ lines.push(` path: ${ctx.node.path}`)
381
+ lines.push(` type: ${ctx.node.type}`)
382
+ if (ctx.node.backgroundColor) {
383
+ lines.push(` backgroundColor: ${ctx.node.backgroundColor}`)
384
+ }
385
+ lines.push("")
386
+
387
+ // Dirty flags
388
+ const flags = ctx.node.dirtyFlags
389
+ const activeFlags = Object.entries(flags)
390
+ .filter(([, v]) => v)
391
+ .map(([k]) => k)
392
+ lines.push("DIRTY FLAGS:")
393
+ if (activeFlags.length > 0) {
394
+ lines.push(` active: ${activeFlags.join(", ")}`)
395
+ } else {
396
+ lines.push(" active: (none - node was clean)")
397
+ }
398
+ lines.push(
399
+ ` all: contentDirty=${flags.contentDirty} paintDirty=${flags.paintDirty} subtreeDirty=${flags.subtreeDirty} childrenDirty=${flags.childrenDirty} layoutDirty=${flags.layoutDirty}`,
400
+ )
401
+ lines.push("")
402
+
403
+ // Layout info
404
+ const { layout } = ctx.node
405
+ lines.push("LAYOUT:")
406
+ if (layout.layoutChanged) {
407
+ lines.push(" ⚠ LAYOUT CHANGED:")
408
+ lines.push(` prevLayout: ${formatRect(layout.prevLayout)}`)
409
+ lines.push(` contentRect: ${formatRect(layout.contentRect)}`)
410
+ } else {
411
+ lines.push(` contentRect: ${formatRect(layout.contentRect)}`)
412
+ }
413
+ lines.push(` screenRect: ${formatRect(layout.screenRect)}`)
414
+ lines.push("")
415
+
416
+ // Scroll context
417
+ if (ctx.node.scroll) {
418
+ lines.push("SCROLL STATE (this node):")
419
+ formatScrollState(lines, ctx.node.scroll)
420
+ lines.push("")
421
+ }
422
+ } else {
423
+ lines.push("INNERMOST NODE: (none found at this position)")
424
+ lines.push("")
425
+ }
426
+
427
+ // Scroll ancestors
428
+ if (ctx.scrollAncestors.length > 0) {
429
+ lines.push("SCROLL ANCESTORS:")
430
+ for (const ancestor of ctx.scrollAncestors) {
431
+ lines.push(` ${ancestor.path}:`)
432
+ if (ancestor.scroll) {
433
+ formatScrollState(lines, ancestor.scroll, " ")
434
+ }
435
+ }
436
+ lines.push("")
437
+ }
438
+
439
+ // Containing nodes (for debugging layering issues)
440
+ if (ctx.containingNodes.length > 1) {
441
+ lines.push("ALL CONTAINING NODES (outermost to innermost):")
442
+ for (const node of ctx.containingNodes) {
443
+ const flags = Object.entries(node.dirtyFlags)
444
+ .filter(([, v]) => v)
445
+ .map(([k]) => k.replace("Dirty", ""))
446
+ .join(",")
447
+ const flagStr = flags ? ` [${flags}]` : " [clean]"
448
+ const bgStr = node.backgroundColor ? ` bg=${node.backgroundColor}` : ""
449
+ const childStr = node.childIndex !== null ? ` child[${node.childIndex}]` : ""
450
+ lines.push(` ${node.path}${flagStr}${bgStr}${childStr}`)
451
+ }
452
+ lines.push("")
453
+ }
454
+
455
+ // Fast-path analysis
456
+ if (ctx.fastPathAnalysis.length > 0) {
457
+ lines.push("FAST-PATH ANALYSIS:")
458
+ for (const line of ctx.fastPathAnalysis) {
459
+ lines.push(` ${line}`)
460
+ }
461
+ lines.push("")
462
+ }
463
+
464
+ // Content-phase instrumentation stats
465
+ if (contentPhaseStats) {
466
+ const s = contentPhaseStats
467
+ lines.push("CONTENT PHASE STATS:")
468
+ lines.push(` nodesVisited: ${s.nodesVisited} nodesRendered: ${s.nodesRendered} nodesSkipped: ${s.nodesSkipped}`)
469
+ lines.push(` textNodes: ${s.textNodes} boxNodes: ${s.boxNodes} clearOps: ${s.clearOps}`)
470
+ // Per-flag breakdown (why nodes weren't skipped)
471
+ const flagLines: string[] = []
472
+ if (s.noPrevBuffer) flagLines.push(`noPrevBuffer=${s.noPrevBuffer}`)
473
+ if (s.flagContentDirty) flagLines.push(`contentDirty=${s.flagContentDirty}`)
474
+ if (s.flagPaintDirty) flagLines.push(`paintDirty=${s.flagPaintDirty}`)
475
+ if (s.flagLayoutChanged) flagLines.push(`layoutChanged=${s.flagLayoutChanged}`)
476
+ if (s.flagSubtreeDirty) flagLines.push(`subtreeDirty=${s.flagSubtreeDirty}`)
477
+ if (s.flagChildrenDirty) flagLines.push(`childrenDirty=${s.flagChildrenDirty}`)
478
+ if (s.flagChildPositionChanged) flagLines.push(`childPositionChanged=${s.flagChildPositionChanged}`)
479
+ if (flagLines.length > 0) {
480
+ lines.push(` render reasons: ${flagLines.join(", ")}`)
481
+ }
482
+ // Scroll container diagnostics
483
+ if (s.scrollContainerCount > 0) {
484
+ lines.push(` scrollContainers: ${s.scrollContainerCount} viewportCleared: ${s.scrollViewportCleared}`)
485
+ if (s.scrollClearReason) lines.push(` scrollClearReason: ${s.scrollClearReason}`)
486
+ }
487
+ // Normal container diagnostics
488
+ if (s.normalChildrenRepaint > 0) {
489
+ lines.push(` normalChildrenRepaint: ${s.normalChildrenRepaint}`)
490
+ if (s.normalRepaintReason) lines.push(` normalRepaintReason: ${s.normalRepaintReason}`)
491
+ }
492
+ // Cascade diagnostics
493
+ if (s.cascadeMinDepth < 999) {
494
+ lines.push(` cascadeMinDepth: ${s.cascadeMinDepth}`)
495
+ if (s.cascadeNodes) lines.push(` cascadeNodes: ${s.cascadeNodes}`)
496
+ }
497
+ lines.push("")
498
+ }
499
+
500
+ return lines.join("\n")
501
+ }
502
+
503
+ function formatRect(rect: Rect | null): string {
504
+ if (!rect) return "(null)"
505
+ return `{x:${rect.x}, y:${rect.y}, w:${rect.width}, h:${rect.height}}`
506
+ }
507
+
508
+ function formatScrollState(lines: string[], scroll: NonNullable<NodeDebugInfo["scroll"]>, indent = " "): void {
509
+ if (scroll.offsetChanged) {
510
+ lines.push(`${indent}⚠ SCROLL CHANGED: offset ${scroll.prevOffset} → ${scroll.offset}`)
511
+ } else {
512
+ lines.push(`${indent}offset: ${scroll.offset}`)
513
+ }
514
+ lines.push(
515
+ `${indent}viewport: ${scroll.viewportHeight}/${scroll.contentHeight} (hidden: ▲${scroll.hiddenAbove} ▼${scroll.hiddenBelow})`,
516
+ )
517
+ lines.push(`${indent}visibleRange: [${scroll.firstVisibleChild}..${scroll.lastVisibleChild}]`)
518
+ }
package/src/debug.ts ADDED
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Debug Tree Inspection
3
+ *
4
+ * Pretty-prints SilveryNode trees for debugging TUI tests.
5
+ * Similar to React DevTools component tree or browser DOM inspection.
6
+ *
7
+ * @example
8
+ * ```tsx
9
+ * import { debugTree } from '@silvery/test'
10
+ *
11
+ * const { getContainer } = render(<MyComponent />)
12
+ * console.log(debugTree(getContainer()))
13
+ * // Output:
14
+ * // <silvery-root [0,0 80×24]>
15
+ * // <silvery-box testID="main" [0,0 80×24]>
16
+ * // <silvery-text "Hello World" [0,0 11×1]>
17
+ * ```
18
+ */
19
+
20
+ import type { TeaNode } from "@silvery/tea/types"
21
+
22
+ export interface DebugTreeOptions {
23
+ /** Maximum depth to traverse (default: unlimited) */
24
+ depth?: number
25
+ /** Include layout rectangles (default: true) */
26
+ showRects?: boolean
27
+ /** Include text content (default: true) */
28
+ showText?: boolean
29
+ }
30
+
31
+ /**
32
+ * Pretty-print SilveryNode tree for debugging.
33
+ *
34
+ * @param node - Root node to inspect
35
+ * @param options - Display options
36
+ * @returns Formatted tree string
37
+ */
38
+ export function debugTree(node: TeaNode, options: DebugTreeOptions = {}): string {
39
+ const { depth = Number.POSITIVE_INFINITY, showRects = true, showText = true } = options
40
+ const lines: string[] = []
41
+
42
+ // Safe JSON.stringify that handles cyclic references
43
+ function safeStringify(value: unknown): string {
44
+ try {
45
+ return JSON.stringify(value)
46
+ } catch {
47
+ // Handle cyclic structures or other stringify errors
48
+ if (typeof value === "object" && value !== null) {
49
+ return "[object]"
50
+ }
51
+ return String(value)
52
+ }
53
+ }
54
+
55
+ function walk(n: TeaNode, indent: number, currentDepth: number): void {
56
+ if (currentDepth > depth) return
57
+
58
+ // Build props string (exclude children and internal props)
59
+ const props = Object.entries(n.props ?? {})
60
+ .filter(([k]) => !["children"].includes(k))
61
+ .filter(([, v]) => v !== undefined && v !== null && v !== false)
62
+ .map(([k, v]) => {
63
+ if (typeof v === "string") return `${k}="${v}"`
64
+ if (typeof v === "boolean") return k
65
+ return `${k}=${safeStringify(v)}`
66
+ })
67
+ .join(" ")
68
+
69
+ // Build rect string
70
+ let rect = ""
71
+ if (showRects && n.screenRect) {
72
+ const { x, y, width, height } = n.screenRect
73
+ rect = ` [${x},${y} ${width}×${height}]`
74
+ }
75
+
76
+ // Build text content string
77
+ let text = ""
78
+ if (showText && n.textContent) {
79
+ // Truncate long text
80
+ const content = n.textContent.length > 40 ? n.textContent.slice(0, 37) + "..." : n.textContent
81
+ text = ` "${content}"`
82
+ }
83
+
84
+ // Format line
85
+ const propsStr = props ? " " + props : ""
86
+ lines.push(" ".repeat(indent) + `<${n.type}${propsStr}${text}${rect}>`)
87
+
88
+ // Recurse into children
89
+ for (const child of n.children) {
90
+ walk(child, indent + 1, currentDepth + 1)
91
+ }
92
+ }
93
+
94
+ walk(node, 0, 0)
95
+ return lines.join("\n")
96
+ }