@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
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@silvery/test",
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "Testing utilities for silvery — virtual renderer, locators, assertions",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Bjørn Stabell <bjorn@stabell.org>",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "https://github.com/beorn/silvery.git",
|
|
10
|
+
"directory": "packages/test"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"src"
|
|
14
|
+
],
|
|
15
|
+
"type": "module",
|
|
16
|
+
"main": "src/index.tsx",
|
|
17
|
+
"types": "src/index.tsx",
|
|
18
|
+
"exports": {
|
|
19
|
+
".": {
|
|
20
|
+
"types": "./src/index.tsx",
|
|
21
|
+
"import": "./src/index.tsx"
|
|
22
|
+
},
|
|
23
|
+
"./*": {
|
|
24
|
+
"types": "./src/*.ts",
|
|
25
|
+
"import": "./src/*.ts"
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
"publishConfig": {
|
|
29
|
+
"access": "public"
|
|
30
|
+
},
|
|
31
|
+
"dependencies": {
|
|
32
|
+
"@silvery/react": "workspace:*",
|
|
33
|
+
"@silvery/tea": "workspace:*",
|
|
34
|
+
"@silvery/term": "workspace:*"
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AutoLocator - Self-refreshing Playwright-style locator (canonical implementation)
|
|
3
|
+
*
|
|
4
|
+
* This is the primary locator API. Prefer `App.locator()` / `App.getByTestId()` /
|
|
5
|
+
* `App.getByText()` which use AutoLocator internally.
|
|
6
|
+
*
|
|
7
|
+
* Unlike the static SilveryLocator in `testing/locator.ts` (legacy, deprecated),
|
|
8
|
+
* AutoLocator re-evaluates queries against the current tree on each access.
|
|
9
|
+
* This eliminates the stale locator problem in tests.
|
|
10
|
+
*
|
|
11
|
+
* @example
|
|
12
|
+
* ```tsx
|
|
13
|
+
* const app = render(<Board />)
|
|
14
|
+
* const cursor = app.locator('[data-cursor]')
|
|
15
|
+
*
|
|
16
|
+
* // Same locator, fresh result after state change
|
|
17
|
+
* expect(cursor.textContent()).toBe('item1')
|
|
18
|
+
* await app.press('j')
|
|
19
|
+
* expect(cursor.textContent()).toBe('item2') // Auto-refreshed!
|
|
20
|
+
* ```
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import type { TeaNode, Rect } from "@silvery/tea/types"
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Filter options for locator narrowing
|
|
27
|
+
*/
|
|
28
|
+
export interface FilterOptions {
|
|
29
|
+
/** Match nodes containing this text */
|
|
30
|
+
hasText?: string | RegExp
|
|
31
|
+
/** Match nodes with this testID */
|
|
32
|
+
hasTestId?: string
|
|
33
|
+
/** Match nodes with this attribute value */
|
|
34
|
+
has?: { attr: string; value?: string }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* AutoLocator interface - lazy, self-refreshing reference to nodes
|
|
39
|
+
*/
|
|
40
|
+
export interface AutoLocator {
|
|
41
|
+
// Core queries (return new AutoLocators)
|
|
42
|
+
getByText(text: string | RegExp): AutoLocator
|
|
43
|
+
getByTestId(id: string): AutoLocator
|
|
44
|
+
locator(selector: string): AutoLocator
|
|
45
|
+
|
|
46
|
+
// Filtering
|
|
47
|
+
filter(options: FilterOptions): AutoLocator
|
|
48
|
+
filter(predicate: (node: TeaNode) => boolean): AutoLocator
|
|
49
|
+
|
|
50
|
+
// Narrowing
|
|
51
|
+
first(): AutoLocator
|
|
52
|
+
last(): AutoLocator
|
|
53
|
+
nth(index: number): AutoLocator
|
|
54
|
+
|
|
55
|
+
// Resolution (actually finds nodes - re-evaluates on each call)
|
|
56
|
+
resolve(): TeaNode | null
|
|
57
|
+
resolveAll(): TeaNode[]
|
|
58
|
+
count(): number
|
|
59
|
+
|
|
60
|
+
// Utilities (resolve then read)
|
|
61
|
+
textContent(): string
|
|
62
|
+
getAttribute(name: string): string | undefined
|
|
63
|
+
boundingBox(): Rect | null
|
|
64
|
+
isVisible(): boolean
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Query predicate type
|
|
68
|
+
type NodePredicate = (node: TeaNode) => boolean
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Create an AutoLocator from a container getter function.
|
|
72
|
+
* The getter is called fresh on each resolution.
|
|
73
|
+
*/
|
|
74
|
+
export function createAutoLocator(getContainer: () => TeaNode): AutoLocator {
|
|
75
|
+
return new AutoLocatorImpl(getContainer, [])
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* AutoLocator implementation
|
|
80
|
+
*/
|
|
81
|
+
class AutoLocatorImpl implements AutoLocator {
|
|
82
|
+
constructor(
|
|
83
|
+
private getContainer: () => TeaNode,
|
|
84
|
+
private predicates: NodePredicate[],
|
|
85
|
+
private indexSelector?: { type: "first" | "last" | "nth"; index?: number },
|
|
86
|
+
) {}
|
|
87
|
+
|
|
88
|
+
getByText(text: string | RegExp): AutoLocator {
|
|
89
|
+
const predicate: NodePredicate = (node) => {
|
|
90
|
+
const content = getNodeTextContent(node)
|
|
91
|
+
if (!content) return false
|
|
92
|
+
|
|
93
|
+
// Only match silvery-text nodes (not containers)
|
|
94
|
+
if (node.type !== "silvery-text") {
|
|
95
|
+
return false
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Skip raw text nodes if their parent also matches
|
|
99
|
+
if (node.isRawText && node.parent?.type === "silvery-text") {
|
|
100
|
+
return false
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (typeof text === "string") {
|
|
104
|
+
return content.includes(text)
|
|
105
|
+
}
|
|
106
|
+
return text.test(content)
|
|
107
|
+
}
|
|
108
|
+
return new AutoLocatorImpl(this.getContainer, [...this.predicates, predicate])
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
getByTestId(id: string): AutoLocator {
|
|
112
|
+
const predicate: NodePredicate = (node) => {
|
|
113
|
+
return getNodeProp(node, "testID") === id
|
|
114
|
+
}
|
|
115
|
+
return new AutoLocatorImpl(this.getContainer, [...this.predicates, predicate])
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
locator(selector: string): AutoLocator {
|
|
119
|
+
const predicate = parseSelector(selector)
|
|
120
|
+
if (!predicate) {
|
|
121
|
+
// Invalid selector - return locator that matches nothing
|
|
122
|
+
return new AutoLocatorImpl(this.getContainer, [() => false])
|
|
123
|
+
}
|
|
124
|
+
return new AutoLocatorImpl(this.getContainer, [...this.predicates, predicate])
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
filter(optionsOrPredicate: FilterOptions | ((node: TeaNode) => boolean)): AutoLocator {
|
|
128
|
+
let predicate: NodePredicate
|
|
129
|
+
|
|
130
|
+
if (typeof optionsOrPredicate === "function") {
|
|
131
|
+
predicate = optionsOrPredicate
|
|
132
|
+
} else {
|
|
133
|
+
const opts = optionsOrPredicate
|
|
134
|
+
predicate = (node: TeaNode) => {
|
|
135
|
+
if (opts.hasText !== undefined) {
|
|
136
|
+
const content = getNodeTextContent(node)
|
|
137
|
+
if (typeof opts.hasText === "string") {
|
|
138
|
+
if (!content.includes(opts.hasText)) return false
|
|
139
|
+
} else {
|
|
140
|
+
if (!opts.hasText.test(content)) return false
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
if (opts.hasTestId !== undefined) {
|
|
144
|
+
if (getNodeProp(node, "testID") !== opts.hasTestId) return false
|
|
145
|
+
}
|
|
146
|
+
if (opts.has !== undefined) {
|
|
147
|
+
const value = getNodeProp(node, opts.has.attr)
|
|
148
|
+
if (opts.has.value !== undefined) {
|
|
149
|
+
if (value !== opts.has.value) return false
|
|
150
|
+
} else {
|
|
151
|
+
if (value === undefined) return false
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return true
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return new AutoLocatorImpl(this.getContainer, [...this.predicates, predicate])
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
first(): AutoLocator {
|
|
162
|
+
return new AutoLocatorImpl(this.getContainer, this.predicates, {
|
|
163
|
+
type: "first",
|
|
164
|
+
})
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
last(): AutoLocator {
|
|
168
|
+
return new AutoLocatorImpl(this.getContainer, this.predicates, {
|
|
169
|
+
type: "last",
|
|
170
|
+
})
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
nth(index: number): AutoLocator {
|
|
174
|
+
return new AutoLocatorImpl(this.getContainer, this.predicates, {
|
|
175
|
+
type: "nth",
|
|
176
|
+
index,
|
|
177
|
+
})
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
resolve(): TeaNode | null {
|
|
181
|
+
const nodes = this.resolveAll()
|
|
182
|
+
if (this.indexSelector) {
|
|
183
|
+
switch (this.indexSelector.type) {
|
|
184
|
+
case "first":
|
|
185
|
+
return nodes[0] ?? null
|
|
186
|
+
case "last":
|
|
187
|
+
return nodes[nodes.length - 1] ?? null
|
|
188
|
+
case "nth":
|
|
189
|
+
return nodes[this.indexSelector.index ?? 0] ?? null
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return nodes[0] ?? null
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
resolveAll(): TeaNode[] {
|
|
196
|
+
// Get fresh container on each resolution
|
|
197
|
+
const container = this.getContainer()
|
|
198
|
+
|
|
199
|
+
if (this.predicates.length === 0) {
|
|
200
|
+
return [container]
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const matches: TeaNode[] = []
|
|
204
|
+
walkTree(container, (node) => {
|
|
205
|
+
if (this.predicates.every((p) => p(node))) {
|
|
206
|
+
matches.push(node)
|
|
207
|
+
}
|
|
208
|
+
})
|
|
209
|
+
return matches
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
count(): number {
|
|
213
|
+
return this.resolveAll().length
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
textContent(): string {
|
|
217
|
+
const node = this.resolve()
|
|
218
|
+
if (!node) return ""
|
|
219
|
+
return getNodeTextContent(node)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
getAttribute(name: string): string | undefined {
|
|
223
|
+
const node = this.resolve()
|
|
224
|
+
if (!node) return undefined
|
|
225
|
+
return getNodeProp(node, name)
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
boundingBox(): Rect | null {
|
|
229
|
+
const node = this.resolve()
|
|
230
|
+
if (!node) return null
|
|
231
|
+
return node.screenRect ?? null
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
isVisible(): boolean {
|
|
235
|
+
const box = this.boundingBox()
|
|
236
|
+
if (!box) return false
|
|
237
|
+
return box.width > 0 && box.height > 0
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// ============================================================================
|
|
242
|
+
// Tree Walking Helpers
|
|
243
|
+
// ============================================================================
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Walk tree depth-first, calling visitor for each node
|
|
247
|
+
*/
|
|
248
|
+
function walkTree(node: TeaNode, visitor: (node: TeaNode) => void): void {
|
|
249
|
+
visitor(node)
|
|
250
|
+
for (const child of node.children) {
|
|
251
|
+
walkTree(child, visitor)
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Get text content of a node (concatenated from all text descendants)
|
|
257
|
+
*/
|
|
258
|
+
function getNodeTextContent(node: TeaNode): string {
|
|
259
|
+
if (node.textContent !== undefined) {
|
|
260
|
+
return node.textContent
|
|
261
|
+
}
|
|
262
|
+
return node.children.map(getNodeTextContent).join("")
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Get a prop value from node
|
|
267
|
+
*/
|
|
268
|
+
function getNodeProp(node: TeaNode, name: string): string | undefined {
|
|
269
|
+
const props = node.props as Record<string, unknown>
|
|
270
|
+
const value = props[name]
|
|
271
|
+
if (value === undefined || value === null) return undefined
|
|
272
|
+
return String(value)
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// ============================================================================
|
|
276
|
+
// Selector Parsing (from locator.ts)
|
|
277
|
+
// ============================================================================
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Parse CSS-like selector into predicate
|
|
281
|
+
*/
|
|
282
|
+
function parseSelector(selector: string): NodePredicate | null {
|
|
283
|
+
const trimmed = selector.trim()
|
|
284
|
+
|
|
285
|
+
// Check for combinators
|
|
286
|
+
if (trimmed.includes(">")) {
|
|
287
|
+
return parseChildCombinator(trimmed)
|
|
288
|
+
}
|
|
289
|
+
if (trimmed.includes("+")) {
|
|
290
|
+
return parseAdjacentSiblingCombinator(trimmed)
|
|
291
|
+
}
|
|
292
|
+
if (trimmed.includes(" ") && !trimmed.startsWith("[")) {
|
|
293
|
+
return parseDescendantCombinator(trimmed)
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
return parseSingleSelector(trimmed)
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Parse a single selector (no combinators)
|
|
301
|
+
*/
|
|
302
|
+
function parseSingleSelector(selector: string): NodePredicate | null {
|
|
303
|
+
const parts: NodePredicate[] = []
|
|
304
|
+
let remaining = selector
|
|
305
|
+
|
|
306
|
+
// Universal selector - matches all nodes
|
|
307
|
+
if (remaining === "*") {
|
|
308
|
+
return () => true
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// Extract ID if present
|
|
312
|
+
const idMatch = remaining.match(/^#([a-zA-Z0-9_-]+)/)
|
|
313
|
+
if (idMatch) {
|
|
314
|
+
const id = idMatch[1]!
|
|
315
|
+
parts.push((node: TeaNode) => getNodeProp(node, "id") === id)
|
|
316
|
+
remaining = remaining.slice(idMatch[0].length)
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// Extract all attribute selectors
|
|
320
|
+
const attrRegex = /\[([a-zA-Z_][a-zA-Z0-9_-]*)(?:([~^$*]?)=["']([^"']*)["'])?\]/g
|
|
321
|
+
for (const match of remaining.matchAll(attrRegex)) {
|
|
322
|
+
const [, attr, op, value] = match
|
|
323
|
+
if (!attr) continue
|
|
324
|
+
|
|
325
|
+
if (value === undefined) {
|
|
326
|
+
parts.push((node: TeaNode) => getNodeProp(node, attr) !== undefined)
|
|
327
|
+
} else {
|
|
328
|
+
parts.push((node: TeaNode) => {
|
|
329
|
+
const nodeValue = getNodeProp(node, attr)
|
|
330
|
+
if (nodeValue === undefined) return false
|
|
331
|
+
switch (op) {
|
|
332
|
+
case "":
|
|
333
|
+
return nodeValue === value
|
|
334
|
+
case "^":
|
|
335
|
+
return nodeValue.startsWith(value ?? "")
|
|
336
|
+
case "$":
|
|
337
|
+
return nodeValue.endsWith(value ?? "")
|
|
338
|
+
case "*":
|
|
339
|
+
return nodeValue.includes(value ?? "")
|
|
340
|
+
default:
|
|
341
|
+
return false
|
|
342
|
+
}
|
|
343
|
+
})
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
if (parts.length === 0) return null
|
|
348
|
+
|
|
349
|
+
return (node: TeaNode) => parts.every((pred) => pred(node))
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Parse child combinator: A > B
|
|
354
|
+
*/
|
|
355
|
+
function parseChildCombinator(selector: string): NodePredicate | null {
|
|
356
|
+
const parts = selector.split(">").map((s) => s.trim())
|
|
357
|
+
if (parts.length !== 2) return null
|
|
358
|
+
|
|
359
|
+
const [parentSel, childSel] = parts
|
|
360
|
+
const parentPred = parseSingleSelector(parentSel!)
|
|
361
|
+
const childPred = parseSingleSelector(childSel!)
|
|
362
|
+
|
|
363
|
+
if (!parentPred || !childPred) return null
|
|
364
|
+
|
|
365
|
+
return (node: TeaNode) => {
|
|
366
|
+
if (!childPred(node)) return false
|
|
367
|
+
return node.parent !== null && parentPred(node.parent)
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Parse adjacent sibling combinator: A + B
|
|
373
|
+
*/
|
|
374
|
+
function parseAdjacentSiblingCombinator(selector: string): NodePredicate | null {
|
|
375
|
+
const parts = selector.split("+").map((s) => s.trim())
|
|
376
|
+
if (parts.length !== 2) return null
|
|
377
|
+
|
|
378
|
+
const [prevSel, nextSel] = parts
|
|
379
|
+
const prevPred = parseSingleSelector(prevSel!)
|
|
380
|
+
const nextPred = parseSingleSelector(nextSel!)
|
|
381
|
+
|
|
382
|
+
if (!prevPred || !nextPred) return null
|
|
383
|
+
|
|
384
|
+
return (node: TeaNode) => {
|
|
385
|
+
if (!nextPred(node)) return false
|
|
386
|
+
if (!node.parent) return false
|
|
387
|
+
|
|
388
|
+
const siblings = node.parent.children
|
|
389
|
+
const index = siblings.indexOf(node)
|
|
390
|
+
if (index <= 0) return false
|
|
391
|
+
|
|
392
|
+
const prevSibling = siblings[index - 1]
|
|
393
|
+
return prevSibling !== undefined && prevPred(prevSibling)
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Parse descendant combinator: A B
|
|
399
|
+
*/
|
|
400
|
+
function parseDescendantCombinator(selector: string): NodePredicate | null {
|
|
401
|
+
const parts = selector.split(/\s+/).filter((s) => s.length > 0)
|
|
402
|
+
if (parts.length !== 2) return null
|
|
403
|
+
|
|
404
|
+
const [ancestorSel, descendantSel] = parts
|
|
405
|
+
const ancestorPred = parseSingleSelector(ancestorSel!)
|
|
406
|
+
const descendantPred = parseSingleSelector(descendantSel!)
|
|
407
|
+
|
|
408
|
+
if (!ancestorPred || !descendantPred) return null
|
|
409
|
+
|
|
410
|
+
return (node: TeaNode) => {
|
|
411
|
+
if (!descendantPred(node)) return false
|
|
412
|
+
|
|
413
|
+
let current = node.parent
|
|
414
|
+
while (current) {
|
|
415
|
+
if (ancestorPred(current)) return true
|
|
416
|
+
current = current.parent
|
|
417
|
+
}
|
|
418
|
+
return false
|
|
419
|
+
}
|
|
420
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Buffer comparison utility for differential rendering tests.
|
|
3
|
+
*
|
|
4
|
+
* Compares two terminal buffers cell-by-cell, returning the first
|
|
5
|
+
* mismatch found (or null if buffers are identical).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { type Cell, type TerminalBuffer, cellEquals } from "@silvery/term/buffer"
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* A single cell mismatch between two buffers.
|
|
12
|
+
*/
|
|
13
|
+
export interface BufferMismatch {
|
|
14
|
+
/** Column of the mismatched cell */
|
|
15
|
+
x: number
|
|
16
|
+
/** Row of the mismatched cell */
|
|
17
|
+
y: number
|
|
18
|
+
/** Cell from buffer A (e.g., incremental render) */
|
|
19
|
+
cellA: Cell
|
|
20
|
+
/** Cell from buffer B (e.g., fresh render) */
|
|
21
|
+
cellB: Cell
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Compare two terminal buffers cell-by-cell.
|
|
26
|
+
*
|
|
27
|
+
* @returns The first mismatch found, or null if buffers are identical.
|
|
28
|
+
*/
|
|
29
|
+
export function compareBuffers(a: TerminalBuffer, b: TerminalBuffer): BufferMismatch | null {
|
|
30
|
+
const width = Math.max(a.width, b.width)
|
|
31
|
+
const height = Math.max(a.height, b.height)
|
|
32
|
+
|
|
33
|
+
for (let y = 0; y < height; y++) {
|
|
34
|
+
for (let x = 0; x < width; x++) {
|
|
35
|
+
const cellA = a.inBounds(x, y)
|
|
36
|
+
? a.getCell(x, y)
|
|
37
|
+
: {
|
|
38
|
+
char: " ",
|
|
39
|
+
fg: null,
|
|
40
|
+
bg: null,
|
|
41
|
+
underlineColor: null,
|
|
42
|
+
attrs: {},
|
|
43
|
+
wide: false,
|
|
44
|
+
continuation: false,
|
|
45
|
+
}
|
|
46
|
+
const cellB = b.inBounds(x, y)
|
|
47
|
+
? b.getCell(x, y)
|
|
48
|
+
: {
|
|
49
|
+
char: " ",
|
|
50
|
+
fg: null,
|
|
51
|
+
bg: null,
|
|
52
|
+
underlineColor: null,
|
|
53
|
+
attrs: {},
|
|
54
|
+
wide: false,
|
|
55
|
+
continuation: false,
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (!cellEquals(cellA, cellB)) {
|
|
59
|
+
return { x, y, cellA, cellB }
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return null
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Format a buffer mismatch for human-readable error output.
|
|
69
|
+
*/
|
|
70
|
+
export function formatMismatch(
|
|
71
|
+
mismatch: BufferMismatch,
|
|
72
|
+
context?: {
|
|
73
|
+
incrementalText?: string
|
|
74
|
+
freshText?: string
|
|
75
|
+
seed?: number
|
|
76
|
+
iteration?: number
|
|
77
|
+
key?: string
|
|
78
|
+
},
|
|
79
|
+
): string {
|
|
80
|
+
const { x, y, cellA, cellB } = mismatch
|
|
81
|
+
const lines: string[] = [
|
|
82
|
+
`Buffer mismatch at (${x}, ${y})`,
|
|
83
|
+
` incremental: char=${JSON.stringify(cellA.char)} fg=${JSON.stringify(cellA.fg)} bg=${JSON.stringify(cellA.bg)} attrs=${JSON.stringify(cellA.attrs)}`,
|
|
84
|
+
` fresh: char=${JSON.stringify(cellB.char)} fg=${JSON.stringify(cellB.fg)} bg=${JSON.stringify(cellB.bg)} attrs=${JSON.stringify(cellB.attrs)}`,
|
|
85
|
+
]
|
|
86
|
+
|
|
87
|
+
if (context?.seed !== undefined) lines.push(` seed: ${context.seed}`)
|
|
88
|
+
if (context?.iteration !== undefined) {
|
|
89
|
+
lines.push(` iteration: ${context.iteration}`)
|
|
90
|
+
}
|
|
91
|
+
if (context?.key) lines.push(` key: ${JSON.stringify(context.key)}`)
|
|
92
|
+
|
|
93
|
+
if (context?.incrementalText) {
|
|
94
|
+
lines.push("", "--- incremental ---", context.incrementalText)
|
|
95
|
+
}
|
|
96
|
+
if (context?.freshText) {
|
|
97
|
+
lines.push("", "--- fresh ---", context.freshText)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return lines.join("\n")
|
|
101
|
+
}
|