@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/src/index.tsx ADDED
@@ -0,0 +1,210 @@
1
+ /**
2
+ * Silvery Testing Library
3
+ *
4
+ * Unified App-based API for testing Silvery components.
5
+ * Uses the actual silvery render pipeline for accurate ANSI output.
6
+ *
7
+ * ## Import Syntax
8
+ *
9
+ * ```tsx
10
+ * import { createRenderer, bufferToText, stripAnsi } from '@silvery/test';
11
+ * ```
12
+ *
13
+ * ## Auto-cleanup
14
+ *
15
+ * Each render() call from createRenderer automatically unmounts the previous render,
16
+ * so you don't need explicit cleanup.
17
+ *
18
+ * ## Basic Testing
19
+ *
20
+ * @example
21
+ * ```tsx
22
+ * import { createRenderer } from '@silvery/test';
23
+ * import { Text, Box } from '@silvery/react';
24
+ *
25
+ * const render = createRenderer({ cols: 80, rows: 24 });
26
+ *
27
+ * test('renders text', () => {
28
+ * const app = render(<Text>Hello</Text>);
29
+ *
30
+ * // Plain text (no ANSI)
31
+ * expect(app.text).toContain('Hello');
32
+ *
33
+ * // Auto-refreshing locators
34
+ * expect(app.getByText('Hello').count()).toBe(1);
35
+ * });
36
+ * ```
37
+ *
38
+ * ## Keyboard Input Testing
39
+ *
40
+ * @example
41
+ * ```tsx
42
+ * test('handles keyboard', () => {
43
+ * const app = render(<MyComponent />);
44
+ *
45
+ * await app.press('j'); // Letter key
46
+ * await app.press('ArrowUp'); // Arrow keys
47
+ * await app.press('Escape'); // Special keys
48
+ * await app.press('Enter'); // Enter
49
+ *
50
+ * expect(app.text).toContain('expected result');
51
+ * });
52
+ * ```
53
+ *
54
+ * ## Auto-refreshing Locators
55
+ *
56
+ * @example
57
+ * ```tsx
58
+ * test('locators auto-refresh', () => {
59
+ * const app = render(<Board />);
60
+ * const cursor = app.locator('[data-cursor]');
61
+ *
62
+ * expect(cursor.textContent()).toBe('item1');
63
+ * await app.press('j');
64
+ * expect(cursor.textContent()).toBe('item2'); // Same locator, fresh result!
65
+ * });
66
+ * ```
67
+ *
68
+ * ## Querying by ID
69
+ *
70
+ * Two equivalent approaches for identifying components:
71
+ *
72
+ * @example
73
+ * ```tsx
74
+ * // Option 1: id prop with #id selector (CSS-style, preferred)
75
+ * const app = render(<Box id="sidebar">Content</Box>);
76
+ * expect(app.locator('#sidebar').textContent()).toBe('Content');
77
+ *
78
+ * // Option 2: testID prop with getByTestId (React Testing Library style)
79
+ * const app = render(<Box testID="sidebar">Content</Box>);
80
+ * expect(app.getByTestId('sidebar').textContent()).toBe('Content');
81
+ * ```
82
+ */
83
+
84
+ import { ensureDefaultLayoutEngine } from "@silvery/term/layout-engine"
85
+
86
+ // Re-export App for type usage
87
+ export type { App } from "@silvery/term/app"
88
+ export { createAutoLocator, type AutoLocator, type FilterOptions } from "./auto-locator"
89
+ export type { BoundTerm } from "@silvery/term/bound-term"
90
+
91
+ // Re-export buffer utilities for testing convenience
92
+ export { bufferToText, bufferToStyledText, bufferToHTML } from "@silvery/term/buffer"
93
+ export type { TerminalBuffer } from "@silvery/term/buffer"
94
+
95
+ // Re-export locator API for DOM queries (legacy, prefer App.locator())
96
+ export { createLocator, type SilveryLocator } from "./locator"
97
+ export type { Rect } from "@silvery/tea/types"
98
+
99
+ // Re-export keyboard utilities
100
+ export { keyToAnsi, keyToKittyAnsi, CODE_TO_KEY } from "@silvery/tea/keys"
101
+
102
+ // Re-export debug utilities
103
+ export { debugTree, type DebugTreeOptions } from "./debug"
104
+
105
+ // Re-export buffer comparison utilities
106
+ export { compareBuffers, formatMismatch, type BufferMismatch } from "./compare-buffers"
107
+
108
+ // Re-export render API
109
+ export {
110
+ render,
111
+ createRenderer,
112
+ createStore,
113
+ run,
114
+ ensureEngine,
115
+ getActiveRenderCount,
116
+ type RenderOptions,
117
+ type PerRenderOptions,
118
+ type Store,
119
+ type StoreOptions,
120
+ } from "@silvery/term/renderer"
121
+
122
+ // ============================================================================
123
+ // Module Initialization
124
+ // ============================================================================
125
+
126
+ // Configure React to recognize this as a testing environment for act() support
127
+ // This suppresses the "testing environment not configured" warning
128
+ // @ts-expect-error - React internal flag for testing environments
129
+ globalThis.IS_REACT_ACT_ENVIRONMENT = true
130
+
131
+ // Initialize default layout engine via top-level await.
132
+ // This ensures render()/createRenderer() work immediately after import.
133
+ await ensureDefaultLayoutEngine()
134
+
135
+ // ============================================================================
136
+ // Termless — in-process terminal emulation for full ANSI testing
137
+ // ============================================================================
138
+
139
+ import { createTerm, type Term } from "@silvery/term"
140
+
141
+ /**
142
+ * Create a Term backed by a termless xterm.js emulator for full ANSI testing.
143
+ *
144
+ * Convenience wrapper around `createTerm(createXtermBackend(), dims)` that
145
+ * handles the xterm.js backend import. Use with `run()` to render components
146
+ * into a real terminal emulator in-process — no PTY, no timing issues.
147
+ *
148
+ * @example
149
+ * ```tsx
150
+ * import { createTermless } from "@silvery/test"
151
+ * import { run } from "@silvery/term/runtime"
152
+ * import "@termless/test/matchers"
153
+ *
154
+ * test("renders correctly", async () => {
155
+ * using term = createTermless({ cols: 80, rows: 24 })
156
+ * const handle = await run(<App />, term)
157
+ *
158
+ * expect(term.screen).toContainText("Hello")
159
+ * await handle.press("j")
160
+ * expect(term.screen).toContainText("Count: 1")
161
+ *
162
+ * handle.unmount()
163
+ * })
164
+ * ```
165
+ */
166
+ export function createTermless(dims: { cols: number; rows: number } = { cols: 80, rows: 24 }): Term {
167
+ // Lazy import — only loads xterm.js when createTermless is called
168
+ const { createXtermBackend } = require("@termless/xtermjs") as {
169
+ createXtermBackend: () => import("@silvery/term").TermEmulatorBackend
170
+ }
171
+ return createTerm(createXtermBackend(), dims)
172
+ }
173
+
174
+ // ============================================================================
175
+ // Utility Functions
176
+ // ============================================================================
177
+
178
+ // Re-export stripAnsi from unicode.ts (canonical implementation)
179
+ import { stripAnsi } from "@silvery/term/unicode"
180
+ export { stripAnsi } from "@silvery/term/unicode"
181
+
182
+ /**
183
+ * Normalize frame output for comparison.
184
+ * - Strips ANSI codes
185
+ * - Trims trailing whitespace from lines
186
+ * - Removes empty trailing lines
187
+ */
188
+ export function normalizeFrame(frame: string): string {
189
+ return stripAnsi(frame)
190
+ .split("\n")
191
+ .map((line) => line.trimEnd())
192
+ .join("\n")
193
+ .trimEnd()
194
+ }
195
+
196
+ /**
197
+ * Wait for a condition to be true, polling at intervals.
198
+ * Useful for waiting for async state updates.
199
+ */
200
+ export async function waitFor(condition: () => boolean, { timeout = 1000, interval = 10 } = {}): Promise<void> {
201
+ const start = Date.now()
202
+ while (!condition()) {
203
+ if (Date.now() - start > timeout) {
204
+ throw new Error(`waitFor timed out after ${timeout}ms`)
205
+ }
206
+ await new Promise<void>((resolve) => {
207
+ setTimeout(resolve, interval)
208
+ })
209
+ }
210
+ }
package/src/locator.ts ADDED
@@ -0,0 +1,429 @@
1
+ /**
2
+ * @deprecated Use `App.locator()` instead (from `auto-locator.ts`). This module will be removed
3
+ * in a future version. The auto-locator provides the same API with auto-refreshing queries that
4
+ * re-evaluate against the current tree on each access, eliminating stale reference bugs.
5
+ *
6
+ * SilveryLocator - Playwright-inspired DOM queries for SilveryNode tree
7
+ *
8
+ * Provides lazy query evaluation - queries don't resolve until you call
9
+ * count(), resolve(), resolveAll(), textContent(), or boundingBox().
10
+ *
11
+ * @example
12
+ * ```typescript
13
+ * const render = createRenderer({ cols: 80, rows: 24 });
14
+ * const { getContainer } = render(<MyComponent />);
15
+ *
16
+ * // Query by text content
17
+ * const task = createLocator(getContainer()).getByText("Task 1");
18
+ * expect(task.count()).toBe(1);
19
+ *
20
+ * // Query by testID prop
21
+ * const sidebar = createLocator(getContainer()).getByTestId("sidebar");
22
+ * expect(sidebar.boundingBox()?.width).toBe(20);
23
+ *
24
+ * // Attribute selectors
25
+ * const selected = createLocator(getContainer()).locator('[data-selected="true"]');
26
+ * expect(selected.count()).toBe(1);
27
+ * ```
28
+ */
29
+
30
+ import type { TeaNode, Rect } from "@silvery/tea/types"
31
+
32
+ /**
33
+ * Locator interface - lazy reference to nodes matching a query
34
+ */
35
+ export interface SilveryLocator {
36
+ // Core queries (return new Locators)
37
+ getByText(text: string | RegExp): SilveryLocator
38
+ getByTestId(id: string): SilveryLocator
39
+
40
+ // Attribute selector (CSS-like: '[data-selected="true"]')
41
+ locator(selector: string): SilveryLocator
42
+
43
+ // Narrowing
44
+ first(): SilveryLocator
45
+ last(): SilveryLocator
46
+ nth(index: number): SilveryLocator
47
+
48
+ // Resolution (actually finds nodes)
49
+ resolve(): TeaNode | null
50
+ resolveAll(): TeaNode[]
51
+ count(): number
52
+
53
+ // Utilities
54
+ textContent(): string
55
+ getAttribute(name: string): string | undefined
56
+ boundingBox(): Rect | null
57
+ isVisible(): boolean
58
+ }
59
+
60
+ // Query predicate type
61
+ type NodePredicate = (node: TeaNode) => boolean
62
+
63
+ /**
64
+ * Create a locator rooted at the given container node
65
+ */
66
+ export function createLocator(root: TeaNode): SilveryLocator {
67
+ return new LocatorImpl(root, [])
68
+ }
69
+
70
+ /**
71
+ * Internal locator implementation
72
+ */
73
+ class LocatorImpl implements SilveryLocator {
74
+ constructor(
75
+ private root: TeaNode,
76
+ private predicates: NodePredicate[],
77
+ private indexSelector?: { type: "first" | "last" | "nth"; index?: number },
78
+ ) {}
79
+
80
+ getByText(text: string | RegExp): SilveryLocator {
81
+ const predicate: NodePredicate = (node) => {
82
+ // Match nodes that have text content directly (raw text nodes)
83
+ // OR Text nodes that contain text (but not their parent containers)
84
+ const content = getNodeTextContent(node)
85
+ if (!content) return false
86
+
87
+ // Only match if this node directly contains text or is an silvery-text
88
+ // Skip silvery-box and silvery-root which contain text via children
89
+ if (node.type !== "silvery-text") {
90
+ return false
91
+ }
92
+
93
+ // Skip raw text nodes if their parent also matches (match parent Text instead)
94
+ // This prevents matching both the Text component AND its raw text child
95
+ if (node.isRawText && node.parent?.type === "silvery-text") {
96
+ return false
97
+ }
98
+
99
+ if (typeof text === "string") {
100
+ return content.includes(text)
101
+ }
102
+ return text.test(content)
103
+ }
104
+ return new LocatorImpl(this.root, [...this.predicates, predicate])
105
+ }
106
+
107
+ getByTestId(id: string): SilveryLocator {
108
+ const predicate: NodePredicate = (node) => {
109
+ return getNodeProp(node, "testID") === id
110
+ }
111
+ return new LocatorImpl(this.root, [...this.predicates, predicate])
112
+ }
113
+
114
+ locator(selector: string): SilveryLocator {
115
+ const predicate = parseSelector(selector)
116
+ if (!predicate) {
117
+ // Invalid selector - return locator that matches nothing
118
+ return new LocatorImpl(this.root, [() => false])
119
+ }
120
+ return new LocatorImpl(this.root, [...this.predicates, predicate])
121
+ }
122
+
123
+ first(): SilveryLocator {
124
+ return new LocatorImpl(this.root, this.predicates, { type: "first" })
125
+ }
126
+
127
+ last(): SilveryLocator {
128
+ return new LocatorImpl(this.root, this.predicates, { type: "last" })
129
+ }
130
+
131
+ nth(index: number): SilveryLocator {
132
+ return new LocatorImpl(this.root, this.predicates, {
133
+ type: "nth",
134
+ index,
135
+ })
136
+ }
137
+
138
+ resolve(): TeaNode | null {
139
+ const nodes = this.resolveAll()
140
+ if (this.indexSelector) {
141
+ switch (this.indexSelector.type) {
142
+ case "first":
143
+ return nodes[0] ?? null
144
+ case "last":
145
+ return nodes[nodes.length - 1] ?? null
146
+ case "nth":
147
+ return nodes[this.indexSelector.index ?? 0] ?? null
148
+ }
149
+ }
150
+ return nodes[0] ?? null
151
+ }
152
+
153
+ resolveAll(): TeaNode[] {
154
+ if (this.predicates.length === 0) {
155
+ // No predicates - return root's children (or root if querying root)
156
+ return [this.root]
157
+ }
158
+
159
+ const matches: TeaNode[] = []
160
+ walkTree(this.root, (node) => {
161
+ if (this.predicates.every((p) => p(node))) {
162
+ matches.push(node)
163
+ }
164
+ })
165
+ return matches
166
+ }
167
+
168
+ count(): number {
169
+ return this.resolveAll().length
170
+ }
171
+
172
+ textContent(): string {
173
+ const node = this.resolve()
174
+ if (!node) return ""
175
+ return getNodeTextContent(node)
176
+ }
177
+
178
+ getAttribute(name: string): string | undefined {
179
+ const node = this.resolve()
180
+ if (!node) return undefined
181
+ return getNodeProp(node, name)
182
+ }
183
+
184
+ boundingBox(): Rect | null {
185
+ const node = this.resolve()
186
+ if (!node) return null
187
+ return node.screenRect ?? null
188
+ }
189
+
190
+ isVisible(): boolean {
191
+ const box = this.boundingBox()
192
+ if (!box) return false
193
+ // Check if any part of the node is within viewport
194
+ // Note: We don't have viewport bounds here, so just check if it has dimensions
195
+ return box.width > 0 && box.height > 0
196
+ }
197
+ }
198
+
199
+ /**
200
+ * Walk tree depth-first, calling visitor for each node
201
+ */
202
+ function walkTree(node: TeaNode, visitor: (node: TeaNode) => void): void {
203
+ visitor(node)
204
+ for (const child of node.children) {
205
+ walkTree(child, visitor)
206
+ }
207
+ }
208
+
209
+ /**
210
+ * Get text content of a node (concatenated from all text descendants)
211
+ */
212
+ function getNodeTextContent(node: TeaNode): string {
213
+ // Raw text nodes have textContent set directly
214
+ if (node.textContent !== undefined) {
215
+ return node.textContent
216
+ }
217
+ // Concatenate children's text content
218
+ return node.children.map(getNodeTextContent).join("")
219
+ }
220
+
221
+ /**
222
+ * Get a prop value from node
223
+ */
224
+ function getNodeProp(node: TeaNode, name: string): string | undefined {
225
+ const props = node.props as Record<string, unknown>
226
+ const value = props[name]
227
+ if (value === undefined || value === null) return undefined
228
+ return String(value)
229
+ }
230
+
231
+ /**
232
+ * Parse CSS-like selector into predicate
233
+ * Supports:
234
+ * - ID selectors: #id
235
+ * - Attribute selectors: [attr], [attr="value"], [attr^="prefix"], [attr$="suffix"], [attr*="contains"]
236
+ * - Combinators: > (child), + (adjacent sibling), space (descendant)
237
+ */
238
+ function parseSelector(selector: string): NodePredicate | null {
239
+ const trimmed = selector.trim()
240
+
241
+ // Detect unsupported selectors and throw helpful errors
242
+ detectUnsupportedSelectors(trimmed)
243
+
244
+ // Check for combinators
245
+ if (trimmed.includes(">")) {
246
+ return parseChildCombinator(trimmed)
247
+ }
248
+ if (trimmed.includes("+")) {
249
+ return parseAdjacentSiblingCombinator(trimmed)
250
+ }
251
+ if (trimmed.includes(" ") && !trimmed.startsWith("[")) {
252
+ return parseDescendantCombinator(trimmed)
253
+ }
254
+
255
+ // Single selector
256
+ return parseSingleSelector(trimmed)
257
+ }
258
+
259
+ /**
260
+ * Detect unsupported CSS selector patterns and throw informative errors
261
+ */
262
+ function detectUnsupportedSelectors(selector: string): void {
263
+ // Pseudo-elements (::before, ::after) - check BEFORE pseudo-classes
264
+ if (selector.includes("::")) {
265
+ throw new Error(
266
+ `Unsupported selector: pseudo-elements like "${selector}" are not supported.\nThe custom selector engine only supports: #id, [attr], [attr="value"], and basic combinators (>, +, space).\nIf you need pseudo-element support, see bead km-silvery-css-select for discussion about switching to css-select library.`,
267
+ )
268
+ }
269
+
270
+ // Pseudo-classes (:hover, :nth-child, :not, etc.)
271
+ if (selector.includes(":")) {
272
+ throw new Error(
273
+ `Unsupported selector: pseudo-classes like "${selector}" are not supported.\nThe custom selector engine only supports: #id, [attr], [attr="value"], and basic combinators (>, +, space).\nIf you need pseudo-class support, see bead km-silvery-css-select for discussion about switching to css-select library.`,
274
+ )
275
+ }
276
+
277
+ // Class selectors (.class)
278
+ if (/\.[a-zA-Z]/.test(selector)) {
279
+ throw new Error(
280
+ `Unsupported selector: class selectors like "${selector}" are not supported.\nThe custom selector engine only supports: #id, [attr], [attr="value"], and basic combinators (>, +, space).\nTip: Use [class="myclass"] or [class*="myclass"] instead, or see bead km-silvery-css-select for css-select library.`,
281
+ )
282
+ }
283
+
284
+ // Tag/type selectors (div, span, etc.)
285
+ // Allow single character selectors (might be valid IDs or edge cases)
286
+ if (/^[a-z][a-z0-9-]*$/i.test(selector) && selector.length > 1) {
287
+ throw new Error(
288
+ `Unsupported selector: tag/type selectors like "${selector}" are not supported.\nThe custom selector engine only supports: #id, [attr], [attr="value"], and basic combinators (>, +, space).\nTip: Use [data-view="${selector}"] or similar attribute selector, or see bead km-silvery-css-select for css-select library.`,
289
+ )
290
+ }
291
+
292
+ // Universal selector (*)
293
+ if (selector.trim() === "*") {
294
+ throw new Error(
295
+ `Unsupported selector: universal selector "*" is not supported.\n` +
296
+ `The custom selector engine only supports: #id, [attr], [attr="value"], and basic combinators (>, +, space).\n` +
297
+ "If you need universal selector support, see bead km-silvery-css-select for css-select library.",
298
+ )
299
+ }
300
+ }
301
+
302
+ /**
303
+ * Parse a single selector (no combinators)
304
+ * Supports compound selectors: #id[attr="value"][attr2]
305
+ */
306
+ function parseSingleSelector(selector: string): NodePredicate | null {
307
+ // Parse compound selector into parts
308
+ const parts: NodePredicate[] = []
309
+ let remaining = selector
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
+ // Remove ID from selector string
317
+ remaining = remaining.slice(idMatch[0].length)
318
+ }
319
+
320
+ // Extract all attribute selectors
321
+ const attrRegex = /\[([a-zA-Z_][a-zA-Z0-9_-]*)(?:([~^$*]?)=["']([^"']*)["'])?\]/g
322
+ for (const match of remaining.matchAll(attrRegex)) {
323
+ const [, attr, op, value] = match
324
+ if (!attr) continue
325
+
326
+ if (value === undefined) {
327
+ // Presence check [attr] - value group didn't match
328
+ parts.push((node: TeaNode) => getNodeProp(node, attr) !== undefined)
329
+ } else {
330
+ // Value check [attr="value"] - value group matched
331
+ parts.push((node: TeaNode) => {
332
+ const nodeValue = getNodeProp(node, attr)
333
+ if (nodeValue === undefined) return false
334
+ switch (op) {
335
+ case "":
336
+ return nodeValue === value
337
+ case "^":
338
+ return nodeValue.startsWith(value ?? "")
339
+ case "$":
340
+ return nodeValue.endsWith(value ?? "")
341
+ case "*":
342
+ return nodeValue.includes(value ?? "")
343
+ default:
344
+ return false
345
+ }
346
+ })
347
+ }
348
+ }
349
+
350
+ // If no parts matched, invalid selector
351
+ if (parts.length === 0) return null
352
+
353
+ // Compound selector - all parts must match
354
+ return (node: TeaNode) => parts.every((pred) => pred(node))
355
+ }
356
+
357
+ /**
358
+ * Parse child combinator: A > B (B is direct child of A)
359
+ */
360
+ function parseChildCombinator(selector: string): NodePredicate | null {
361
+ const parts = selector.split(">").map((s) => s.trim())
362
+ if (parts.length !== 2) return null
363
+
364
+ const [parentSel, childSel] = parts
365
+ const parentPred = parseSingleSelector(parentSel!)
366
+ const childPred = parseSingleSelector(childSel!)
367
+
368
+ if (!parentPred || !childPred) return null
369
+
370
+ return (node: TeaNode) => {
371
+ if (!childPred(node)) return false
372
+ // Check if parent matches
373
+ return node.parent !== null && parentPred(node.parent)
374
+ }
375
+ }
376
+
377
+ /**
378
+ * Parse adjacent sibling combinator: A + B (B immediately follows A)
379
+ */
380
+ function parseAdjacentSiblingCombinator(selector: string): NodePredicate | null {
381
+ const parts = selector.split("+").map((s) => s.trim())
382
+ if (parts.length !== 2) return null
383
+
384
+ const [prevSel, nextSel] = parts
385
+ const prevPred = parseSingleSelector(prevSel!)
386
+ const nextPred = parseSingleSelector(nextSel!)
387
+
388
+ if (!prevPred || !nextPred) return null
389
+
390
+ return (node: TeaNode) => {
391
+ if (!nextPred(node)) return false
392
+ if (!node.parent) return false
393
+
394
+ // Find this node's index in parent's children
395
+ const siblings = node.parent.children
396
+ const index = siblings.indexOf(node)
397
+ if (index <= 0) return false
398
+
399
+ // Check if previous sibling matches
400
+ const prevSibling = siblings[index - 1]
401
+ return prevSibling !== undefined && prevPred(prevSibling)
402
+ }
403
+ }
404
+
405
+ /**
406
+ * Parse descendant combinator: A B (B is descendant of A)
407
+ */
408
+ function parseDescendantCombinator(selector: string): NodePredicate | null {
409
+ const parts = selector.split(/\s+/).filter((s) => s.length > 0)
410
+ if (parts.length !== 2) return null
411
+
412
+ const [ancestorSel, descendantSel] = parts
413
+ const ancestorPred = parseSingleSelector(ancestorSel!)
414
+ const descendantPred = parseSingleSelector(descendantSel!)
415
+
416
+ if (!ancestorPred || !descendantPred) return null
417
+
418
+ return (node: TeaNode) => {
419
+ if (!descendantPred(node)) return false
420
+
421
+ // Walk up the tree to find ancestor
422
+ let current = node.parent
423
+ while (current) {
424
+ if (ancestorPred(current)) return true
425
+ current = current.parent
426
+ }
427
+ return false
428
+ }
429
+ }