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