@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.
- package/package.json +36 -0
- package/src/auto-locator.ts +420 -0
- package/src/compare-buffers.ts +101 -0
- package/src/debug-mismatch.ts +518 -0
- package/src/debug.ts +96 -0
- package/src/index.tsx +210 -0
- package/src/locator.ts +429 -0
|
@@ -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
|
+
}
|