@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 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
+ }