@goliapkg/smix 0.1.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/README.md +193 -0
- package/dist/A11yNode.d.ts +36 -0
- package/dist/A11yNode.d.ts.map +1 -0
- package/dist/A11yNode.js +30 -0
- package/dist/A11yNode.js.map +1 -0
- package/dist/App.d.ts +57 -0
- package/dist/App.d.ts.map +1 -0
- package/dist/App.js +123 -0
- package/dist/App.js.map +1 -0
- package/dist/ExpectationFailure.d.ts +30 -0
- package/dist/ExpectationFailure.d.ts.map +1 -0
- package/dist/ExpectationFailure.js +47 -0
- package/dist/ExpectationFailure.js.map +1 -0
- package/dist/HttpRunner.d.ts +61 -0
- package/dist/HttpRunner.d.ts.map +1 -0
- package/dist/HttpRunner.js +148 -0
- package/dist/HttpRunner.js.map +1 -0
- package/dist/Locator.d.ts +60 -0
- package/dist/Locator.d.ts.map +1 -0
- package/dist/Locator.js +177 -0
- package/dist/Locator.js.map +1 -0
- package/dist/Pattern.d.ts +21 -0
- package/dist/Pattern.d.ts.map +1 -0
- package/dist/Pattern.js +44 -0
- package/dist/Pattern.js.map +1 -0
- package/dist/Selector.d.ts +96 -0
- package/dist/Selector.d.ts.map +1 -0
- package/dist/Selector.js +276 -0
- package/dist/Selector.js.map +1 -0
- package/dist/SelectorResolver.d.ts +41 -0
- package/dist/SelectorResolver.d.ts.map +1 -0
- package/dist/SelectorResolver.js +55 -0
- package/dist/SelectorResolver.js.map +1 -0
- package/dist/SimRuntime.d.ts +81 -0
- package/dist/SimRuntime.d.ts.map +1 -0
- package/dist/SimRuntime.js +91 -0
- package/dist/SimRuntime.js.map +1 -0
- package/dist/Smix.d.ts +22 -0
- package/dist/Smix.d.ts.map +1 -0
- package/dist/Smix.js +35 -0
- package/dist/Smix.js.map +1 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +16 -0
- package/dist/index.js.map +1 -0
- package/package.json +44 -0
- package/src/A11yNode.ts +62 -0
- package/src/App.ts +153 -0
- package/src/ExpectationFailure.ts +67 -0
- package/src/HttpRunner.ts +185 -0
- package/src/Locator.ts +190 -0
- package/src/Pattern.ts +51 -0
- package/src/Selector.ts +296 -0
- package/src/SelectorResolver.ts +76 -0
- package/src/SimRuntime.ts +145 -0
- package/src/Smix.ts +49 -0
- package/src/__tests__/AppFullApi.test.ts +168 -0
- package/src/__tests__/AppTapMock.test.ts +122 -0
- package/src/__tests__/HttpRunner.test.ts +121 -0
- package/src/__tests__/Locator.test.ts +128 -0
- package/src/__tests__/LocatorToHave.test.ts +118 -0
- package/src/__tests__/MvpApiShape.test.ts +117 -0
- package/src/__tests__/SelectorFullSchema.test.ts +161 -0
- package/src/index.ts +76 -0
package/src/Locator.ts
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
// v7.5 c3 — Locator poll loop, mirror Kotlin v7.4 c3 + Swift v7.1 c3.
|
|
2
|
+
//
|
|
3
|
+
// 250ms tick over a millisecond deadline. On timeout, throws
|
|
4
|
+
// ExpectationFailure with the last tree's first-20 visible elements
|
|
5
|
+
// populated for AI agent diagnosis.
|
|
6
|
+
|
|
7
|
+
import { findById, flatten, type A11yNode } from './A11yNode.js'
|
|
8
|
+
import type { App } from './App.js'
|
|
9
|
+
import { ExpectationFailure } from './ExpectationFailure.js'
|
|
10
|
+
import {
|
|
11
|
+
encodeSelectorJson,
|
|
12
|
+
type Selector,
|
|
13
|
+
} from './Selector.js'
|
|
14
|
+
|
|
15
|
+
const POLL_INTERVAL_MS = 250
|
|
16
|
+
const DEFAULT_TIMEOUT_MS = 5_000
|
|
17
|
+
|
|
18
|
+
export class Locator {
|
|
19
|
+
constructor(
|
|
20
|
+
public readonly app: App,
|
|
21
|
+
public readonly selector: Selector,
|
|
22
|
+
) {}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Resolve [selector] until at least one match exists AND that match
|
|
26
|
+
* is `visible=true`. Polls every 250ms until [timeoutMs] expires.
|
|
27
|
+
*/
|
|
28
|
+
async toBeVisible(opts: { timeoutMs?: number } = {}): Promise<void> {
|
|
29
|
+
return this.pollUntil({
|
|
30
|
+
timeoutMs: opts.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
|
31
|
+
wrongStatePredicate: node => node !== null && node.visible !== true,
|
|
32
|
+
successPredicate: node => node !== null && node.visible === true,
|
|
33
|
+
timeoutMessage: 'element never became visible',
|
|
34
|
+
})
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Resolve [selector] until a match exists AND `text.includes(needle)`
|
|
39
|
+
* on label/title/text fields.
|
|
40
|
+
*/
|
|
41
|
+
async toContainText(text: string, opts: { timeoutMs?: number } = {}): Promise<void> {
|
|
42
|
+
return this.pollUntil({
|
|
43
|
+
timeoutMs: opts.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
|
44
|
+
wrongStatePredicate: () => false,
|
|
45
|
+
successPredicate: node =>
|
|
46
|
+
node !== null && (
|
|
47
|
+
node.label?.includes(text) === true ||
|
|
48
|
+
node.title?.includes(text) === true ||
|
|
49
|
+
node.text?.includes(text) === true
|
|
50
|
+
),
|
|
51
|
+
timeoutMessage: `element text never contained "${text}"`,
|
|
52
|
+
})
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* v7.6 c1 — Resolve [selector] until at least one match exists AND
|
|
57
|
+
* that match's label equals [label]. Polls via standard resolver +
|
|
58
|
+
* findById path (label is on the snapshot node, not on FFI's labels
|
|
59
|
+
* resolver which is more useful for count-based assertions).
|
|
60
|
+
*/
|
|
61
|
+
async toHaveLabel(label: string, opts: { timeoutMs?: number } = {}): Promise<void> {
|
|
62
|
+
return this.pollUntil({
|
|
63
|
+
timeoutMs: opts.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
|
64
|
+
wrongStatePredicate: () => false,
|
|
65
|
+
successPredicate: node => node !== null && node.label === label,
|
|
66
|
+
timeoutMessage: `element label never equaled "${label}"`,
|
|
67
|
+
})
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* v7.6 c1 — Resolve [selector] until the labels resolver returns
|
|
72
|
+
* exactly [n] matches. Uses dedicated `app.labelsResolver` (wraps
|
|
73
|
+
* Rust `resolve_selector_labels`) so count semantics skip index
|
|
74
|
+
* modifier per Playwright "match all" behavior.
|
|
75
|
+
*/
|
|
76
|
+
async toHaveCount(n: number, opts: { timeoutMs?: number } = {}): Promise<void> {
|
|
77
|
+
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS
|
|
78
|
+
const deadline = Date.now() + timeoutMs
|
|
79
|
+
let lastCount = 0
|
|
80
|
+
let lastTree: A11yNode | null = null
|
|
81
|
+
const selectorJson = encodeSelectorJson(this.selector)
|
|
82
|
+
while (true) {
|
|
83
|
+
const tree = await this.app.runtime.snapshotTree()
|
|
84
|
+
lastTree = tree
|
|
85
|
+
let labels: readonly string[]
|
|
86
|
+
try {
|
|
87
|
+
labels = await this.app.labelsResolver(JSON.stringify(tree), selectorJson)
|
|
88
|
+
} catch (e) {
|
|
89
|
+
const msg = e instanceof Error ? e.message : String(e)
|
|
90
|
+
throw new ExpectationFailure({
|
|
91
|
+
code: 'unknown',
|
|
92
|
+
message: `FFI resolveSelectorLabels raised during poll: ${msg}`,
|
|
93
|
+
selectorJson,
|
|
94
|
+
})
|
|
95
|
+
}
|
|
96
|
+
lastCount = labels.length
|
|
97
|
+
if (lastCount === n) return
|
|
98
|
+
if (Date.now() >= deadline) break
|
|
99
|
+
await delay(POLL_INTERVAL_MS)
|
|
100
|
+
}
|
|
101
|
+
throw new ExpectationFailure({
|
|
102
|
+
code: 'wrongState',
|
|
103
|
+
message: `expected ${n} matches, last poll saw ${lastCount}`,
|
|
104
|
+
selectorJson,
|
|
105
|
+
visibleElements: lastTree !== null ? flatten(lastTree).slice(0, 20) : [],
|
|
106
|
+
suggestions: [
|
|
107
|
+
'verify the selector matches the expected count in the current tree',
|
|
108
|
+
'consider extending the timeout if render is slow',
|
|
109
|
+
],
|
|
110
|
+
})
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Generic 250ms poll loop. On each tick, resolve [selector] via the
|
|
115
|
+
* app's runtime + resolver, then check predicates.
|
|
116
|
+
*
|
|
117
|
+
* - [successPredicate] returns true → return immediately
|
|
118
|
+
* - [wrongStatePredicate] returns true → throw wrongState
|
|
119
|
+
* - Otherwise, continue polling.
|
|
120
|
+
*
|
|
121
|
+
* On timeout, throws timeout with the last tree's first-20 nodes.
|
|
122
|
+
*/
|
|
123
|
+
private async pollUntil(opts: {
|
|
124
|
+
timeoutMs: number
|
|
125
|
+
wrongStatePredicate: (node: A11yNode | null) => boolean
|
|
126
|
+
successPredicate: (node: A11yNode | null) => boolean
|
|
127
|
+
timeoutMessage: string
|
|
128
|
+
}): Promise<void> {
|
|
129
|
+
const deadline = Date.now() + opts.timeoutMs
|
|
130
|
+
let lastTree: A11yNode | null = null
|
|
131
|
+
|
|
132
|
+
while (true) {
|
|
133
|
+
const tree = await this.app.runtime.snapshotTree()
|
|
134
|
+
lastTree = tree
|
|
135
|
+
const selectorJson = encodeSelectorJson(this.selector)
|
|
136
|
+
let matches: readonly string[]
|
|
137
|
+
try {
|
|
138
|
+
matches = await this.app.resolver(JSON.stringify(tree), selectorJson)
|
|
139
|
+
} catch (e) {
|
|
140
|
+
const msg = e instanceof Error ? e.message : String(e)
|
|
141
|
+
throw new ExpectationFailure({
|
|
142
|
+
code: 'unknown',
|
|
143
|
+
message: `FFI resolveSelector raised during poll: ${msg}`,
|
|
144
|
+
selectorJson,
|
|
145
|
+
})
|
|
146
|
+
}
|
|
147
|
+
const firstId = matches[0]
|
|
148
|
+
const node = firstId !== undefined ? findById(tree, firstId) : null
|
|
149
|
+
|
|
150
|
+
if (opts.wrongStatePredicate(node)) {
|
|
151
|
+
throw new ExpectationFailure({
|
|
152
|
+
code: 'wrongState',
|
|
153
|
+
message: 'element matched but predicate failed (e.g. not visible)',
|
|
154
|
+
selectorJson,
|
|
155
|
+
visibleElements: node !== null ? [node, ...flatten(tree).slice(0, 19)] : flatten(tree).slice(0, 20),
|
|
156
|
+
})
|
|
157
|
+
}
|
|
158
|
+
if (opts.successPredicate(node)) return
|
|
159
|
+
|
|
160
|
+
if (Date.now() >= deadline) break
|
|
161
|
+
await delay(POLL_INTERVAL_MS)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
throw new ExpectationFailure({
|
|
165
|
+
code: 'timeout',
|
|
166
|
+
message: opts.timeoutMessage,
|
|
167
|
+
selectorJson: encodeSelectorJson(this.selector),
|
|
168
|
+
visibleElements: lastTree !== null ? flatten(lastTree).slice(0, 20) : [],
|
|
169
|
+
suggestions: [
|
|
170
|
+
'consider extending the timeout if the UI takes longer to settle',
|
|
171
|
+
'verify the selector matches when manually inspected (use app.tree())',
|
|
172
|
+
],
|
|
173
|
+
})
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function delay(ms: number): Promise<void> {
|
|
178
|
+
return new Promise(resolve => setTimeout(resolve, ms))
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Compile-time-known unimplemented surface (distinct from
|
|
183
|
+
* ExpectationFailure which is runtime resolution failure).
|
|
184
|
+
*/
|
|
185
|
+
export class SmixNotImplementedError extends Error {
|
|
186
|
+
constructor(public readonly stage: string, public readonly api: string) {
|
|
187
|
+
super(`${api} not implemented yet (lands ${stage})`)
|
|
188
|
+
this.name = 'SmixNotImplementedError'
|
|
189
|
+
}
|
|
190
|
+
}
|
package/src/Pattern.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// v7.5 c1 — Pattern (literal vs regex) mirror Swift v7.2 c3 + Kotlin v7.4 c4
|
|
2
|
+
// + Rust smix-selector `#[serde(untagged)] enum Pattern`.
|
|
3
|
+
//
|
|
4
|
+
// Wire JSON forms (untagged):
|
|
5
|
+
// Literal: bare string e.g. "hello"
|
|
6
|
+
// Regex: {"regex":"...","flags":"i"} (flags default "i")
|
|
7
|
+
|
|
8
|
+
export type Pattern =
|
|
9
|
+
| { kind: 'literal'; value: string }
|
|
10
|
+
| { kind: 'regex'; regex: string; flags: string }
|
|
11
|
+
|
|
12
|
+
export function literal(value: string): Pattern {
|
|
13
|
+
return { kind: 'literal', value }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function regex(pat: string, flags = 'i'): Pattern {
|
|
17
|
+
return { kind: 'regex', regex: pat, flags }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Encode a Pattern to its Rust-compatible untagged JSON form
|
|
22
|
+
* (bare string for literal / `{regex, flags}` object for regex).
|
|
23
|
+
* Returns a JSON-encodable value (not a JSON string).
|
|
24
|
+
*/
|
|
25
|
+
export function patternToJson(p: Pattern): unknown {
|
|
26
|
+
switch (p.kind) {
|
|
27
|
+
case 'literal':
|
|
28
|
+
return p.value
|
|
29
|
+
case 'regex':
|
|
30
|
+
return { regex: p.regex, flags: p.flags }
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Decode a JSON value into a Pattern. Throws on unrecognized shape.
|
|
36
|
+
*/
|
|
37
|
+
export function patternFromJson(value: unknown): Pattern {
|
|
38
|
+
if (typeof value === 'string') {
|
|
39
|
+
return { kind: 'literal', value }
|
|
40
|
+
}
|
|
41
|
+
if (typeof value === 'object' && value !== null && 'regex' in value) {
|
|
42
|
+
const obj = value as Record<string, unknown>
|
|
43
|
+
const rx = obj.regex
|
|
44
|
+
if (typeof rx !== 'string') {
|
|
45
|
+
throw new Error(`Pattern.regex must be string, got: ${typeof rx}`)
|
|
46
|
+
}
|
|
47
|
+
const flags = typeof obj.flags === 'string' ? obj.flags : 'i'
|
|
48
|
+
return { kind: 'regex', regex: rx, flags }
|
|
49
|
+
}
|
|
50
|
+
throw new Error(`Pattern must be string or {regex, flags?} object, got: ${JSON.stringify(value)}`)
|
|
51
|
+
}
|
package/src/Selector.ts
ADDED
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
// v7.5 c1 — Selector full 7-case + Modifiers flatten + Pattern + fluent.
|
|
2
|
+
//
|
|
3
|
+
// Mirror Swift v7.2 c3 + Kotlin v7.4 c4 + Rust smix-selector
|
|
4
|
+
// `#[serde(untagged)] enum Selector` with full `Modifiers` flatten on
|
|
5
|
+
// Text/Id/Label/Role/LocalizedText bases. Focused has no modifiers.
|
|
6
|
+
// Anchor uses AnchorBox + IndexModifiers.
|
|
7
|
+
//
|
|
8
|
+
// Class-based API (mirrors Kotlin sealed interface 1:1):
|
|
9
|
+
// sel.id('btn-login')
|
|
10
|
+
// .below(sel.text(literal('Sign In')))
|
|
11
|
+
// .nth(0)
|
|
12
|
+
//
|
|
13
|
+
// Each fluent method returns a NEW Selector (immutable copy semantics).
|
|
14
|
+
|
|
15
|
+
import { type Pattern, patternFromJson, patternToJson } from './Pattern.js'
|
|
16
|
+
|
|
17
|
+
/** All-optional modifier set stacked onto a base Selector. */
|
|
18
|
+
export interface Modifiers {
|
|
19
|
+
near?: Selector | undefined
|
|
20
|
+
below?: Selector | undefined
|
|
21
|
+
above?: Selector | undefined
|
|
22
|
+
leftOf?: Selector | undefined
|
|
23
|
+
rightOf?: Selector | undefined
|
|
24
|
+
inside?: Selector | undefined
|
|
25
|
+
ancestor?: Selector | undefined
|
|
26
|
+
nth?: number | undefined
|
|
27
|
+
first?: boolean | undefined
|
|
28
|
+
last?: boolean | undefined
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Spatial-only anchor box (no index, no base). Used by `anchor()`. */
|
|
32
|
+
export interface AnchorBox {
|
|
33
|
+
near?: Selector | undefined
|
|
34
|
+
below?: Selector | undefined
|
|
35
|
+
above?: Selector | undefined
|
|
36
|
+
leftOf?: Selector | undefined
|
|
37
|
+
rightOf?: Selector | undefined
|
|
38
|
+
inside?: Selector | undefined
|
|
39
|
+
ancestor?: Selector | undefined
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Index-only modifier subset used by `anchor()`. */
|
|
43
|
+
export interface IndexModifiers {
|
|
44
|
+
nth?: number | undefined
|
|
45
|
+
first?: boolean | undefined
|
|
46
|
+
last?: boolean | undefined
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export type SelectorKind =
|
|
50
|
+
| 'id' | 'text' | 'label' | 'role'
|
|
51
|
+
| 'focused' | 'anchor' | 'localizedText'
|
|
52
|
+
|
|
53
|
+
/** Discriminated union (kind tag for narrowing; wire-shape is untagged). */
|
|
54
|
+
export type SelectorData =
|
|
55
|
+
| { kind: 'id'; id: string; modifiers: Modifiers }
|
|
56
|
+
| { kind: 'text'; text: Pattern; modifiers: Modifiers }
|
|
57
|
+
| { kind: 'label'; label: string; modifiers: Modifiers }
|
|
58
|
+
| { kind: 'role'; role: string; name?: Pattern | undefined; modifiers: Modifiers }
|
|
59
|
+
| { kind: 'focused' }
|
|
60
|
+
| { kind: 'anchor'; box: AnchorBox; index: IndexModifiers }
|
|
61
|
+
| { kind: 'localizedText'; map: Readonly<Record<string, string>>; modifiers: Modifiers }
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* A11y selector — identifies a node in the tree.
|
|
65
|
+
*
|
|
66
|
+
* Wire JSON shape matches Rust smix-selector `#[serde(untagged)]`:
|
|
67
|
+
* discriminator is which key is present. Modifiers are flattened
|
|
68
|
+
* into the same JSON body. Implemented via [selectorToJson].
|
|
69
|
+
*/
|
|
70
|
+
export class Selector {
|
|
71
|
+
constructor(public readonly data: SelectorData) {}
|
|
72
|
+
|
|
73
|
+
// ---- Constructors -----------------------------------------------------
|
|
74
|
+
|
|
75
|
+
static id(value: string): Selector {
|
|
76
|
+
return new Selector({ kind: 'id', id: value, modifiers: {} })
|
|
77
|
+
}
|
|
78
|
+
static text(p: Pattern): Selector {
|
|
79
|
+
return new Selector({ kind: 'text', text: p, modifiers: {} })
|
|
80
|
+
}
|
|
81
|
+
static label(value: string): Selector {
|
|
82
|
+
return new Selector({ kind: 'label', label: value, modifiers: {} })
|
|
83
|
+
}
|
|
84
|
+
static role(value: string, name?: Pattern): Selector {
|
|
85
|
+
return new Selector({
|
|
86
|
+
kind: 'role',
|
|
87
|
+
role: value,
|
|
88
|
+
...(name !== undefined ? { name } : {}),
|
|
89
|
+
modifiers: {},
|
|
90
|
+
})
|
|
91
|
+
}
|
|
92
|
+
static focused(): Selector {
|
|
93
|
+
return new Selector({ kind: 'focused' })
|
|
94
|
+
}
|
|
95
|
+
static anchor(box: AnchorBox, index: IndexModifiers = {}): Selector {
|
|
96
|
+
return new Selector({ kind: 'anchor', box, index })
|
|
97
|
+
}
|
|
98
|
+
static localizedText(map: Record<string, string>): Selector {
|
|
99
|
+
return new Selector({ kind: 'localizedText', map, modifiers: {} })
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ---- Fluent chaining --------------------------------------------------
|
|
103
|
+
|
|
104
|
+
below(anchor: Selector): Selector { return this.withMods(m => ({ ...m, below: anchor })) }
|
|
105
|
+
above(anchor: Selector): Selector { return this.withMods(m => ({ ...m, above: anchor })) }
|
|
106
|
+
leftOf(anchor: Selector): Selector { return this.withMods(m => ({ ...m, leftOf: anchor })) }
|
|
107
|
+
rightOf(anchor: Selector): Selector { return this.withMods(m => ({ ...m, rightOf: anchor })) }
|
|
108
|
+
near(anchor: Selector): Selector { return this.withMods(m => ({ ...m, near: anchor })) }
|
|
109
|
+
inside(anchor: Selector): Selector { return this.withMods(m => ({ ...m, inside: anchor })) }
|
|
110
|
+
ancestor(anchor: Selector): Selector { return this.withMods(m => ({ ...m, ancestor: anchor })) }
|
|
111
|
+
nth(i: number): Selector { return this.withMods(m => ({ ...m, nth: i })) }
|
|
112
|
+
first(): Selector { return this.withMods(m => ({ ...m, first: true })) }
|
|
113
|
+
last(): Selector { return this.withMods(m => ({ ...m, last: true })) }
|
|
114
|
+
|
|
115
|
+
private withMods(mutate: (m: Modifiers) => Modifiers): Selector {
|
|
116
|
+
const d = this.data
|
|
117
|
+
switch (d.kind) {
|
|
118
|
+
case 'id':
|
|
119
|
+
return new Selector({ ...d, modifiers: mutate(d.modifiers) })
|
|
120
|
+
case 'text':
|
|
121
|
+
return new Selector({ ...d, modifiers: mutate(d.modifiers) })
|
|
122
|
+
case 'label':
|
|
123
|
+
return new Selector({ ...d, modifiers: mutate(d.modifiers) })
|
|
124
|
+
case 'role':
|
|
125
|
+
return new Selector({ ...d, modifiers: mutate(d.modifiers) })
|
|
126
|
+
case 'localizedText':
|
|
127
|
+
return new Selector({ ...d, modifiers: mutate(d.modifiers) })
|
|
128
|
+
case 'focused':
|
|
129
|
+
return this // no modifiers — no-op
|
|
130
|
+
case 'anchor': {
|
|
131
|
+
const proxy = mutate({})
|
|
132
|
+
const newIdx: IndexModifiers = {
|
|
133
|
+
...(proxy.nth !== undefined ? { nth: proxy.nth } : d.index.nth !== undefined ? { nth: d.index.nth } : {}),
|
|
134
|
+
...(proxy.first !== undefined ? { first: proxy.first } : d.index.first !== undefined ? { first: d.index.first } : {}),
|
|
135
|
+
...(proxy.last !== undefined ? { last: proxy.last } : d.index.last !== undefined ? { last: d.index.last } : {}),
|
|
136
|
+
}
|
|
137
|
+
return new Selector({ ...d, index: newIdx })
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ---- JSON encoding (Rust-compatible untagged + flatten) -----------------
|
|
144
|
+
|
|
145
|
+
function addModifiers(out: Record<string, unknown>, m: Modifiers): void {
|
|
146
|
+
if (m.near !== undefined) out.near = selectorToJsonValue(m.near)
|
|
147
|
+
if (m.below !== undefined) out.below = selectorToJsonValue(m.below)
|
|
148
|
+
if (m.above !== undefined) out.above = selectorToJsonValue(m.above)
|
|
149
|
+
if (m.leftOf !== undefined) out.leftOf = selectorToJsonValue(m.leftOf)
|
|
150
|
+
if (m.rightOf !== undefined) out.rightOf = selectorToJsonValue(m.rightOf)
|
|
151
|
+
if (m.inside !== undefined) out.inside = selectorToJsonValue(m.inside)
|
|
152
|
+
if (m.ancestor !== undefined) out.ancestor = selectorToJsonValue(m.ancestor)
|
|
153
|
+
if (m.nth !== undefined) out.nth = m.nth
|
|
154
|
+
if (m.first !== undefined) out.first = m.first
|
|
155
|
+
if (m.last !== undefined) out.last = m.last
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function addAnchorBox(out: Record<string, unknown>, b: AnchorBox): void {
|
|
159
|
+
if (b.near !== undefined) out.near = selectorToJsonValue(b.near)
|
|
160
|
+
if (b.below !== undefined) out.below = selectorToJsonValue(b.below)
|
|
161
|
+
if (b.above !== undefined) out.above = selectorToJsonValue(b.above)
|
|
162
|
+
if (b.leftOf !== undefined) out.leftOf = selectorToJsonValue(b.leftOf)
|
|
163
|
+
if (b.rightOf !== undefined) out.rightOf = selectorToJsonValue(b.rightOf)
|
|
164
|
+
if (b.inside !== undefined) out.inside = selectorToJsonValue(b.inside)
|
|
165
|
+
if (b.ancestor !== undefined) out.ancestor = selectorToJsonValue(b.ancestor)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function addIndex(out: Record<string, unknown>, i: IndexModifiers): void {
|
|
169
|
+
if (i.nth !== undefined) out.nth = i.nth
|
|
170
|
+
if (i.first !== undefined) out.first = i.first
|
|
171
|
+
if (i.last !== undefined) out.last = i.last
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Encode a Selector to its Rust-compatible untagged JSON value. */
|
|
175
|
+
export function selectorToJsonValue(s: Selector): unknown {
|
|
176
|
+
const out: Record<string, unknown> = {}
|
|
177
|
+
const d = s.data
|
|
178
|
+
switch (d.kind) {
|
|
179
|
+
case 'id':
|
|
180
|
+
out.id = d.id
|
|
181
|
+
addModifiers(out, d.modifiers)
|
|
182
|
+
break
|
|
183
|
+
case 'text':
|
|
184
|
+
out.text = patternToJson(d.text)
|
|
185
|
+
addModifiers(out, d.modifiers)
|
|
186
|
+
break
|
|
187
|
+
case 'label':
|
|
188
|
+
out.label = d.label
|
|
189
|
+
addModifiers(out, d.modifiers)
|
|
190
|
+
break
|
|
191
|
+
case 'role':
|
|
192
|
+
out.role = d.role
|
|
193
|
+
if (d.name !== undefined) out.name = patternToJson(d.name)
|
|
194
|
+
addModifiers(out, d.modifiers)
|
|
195
|
+
break
|
|
196
|
+
case 'focused':
|
|
197
|
+
out.focused = true
|
|
198
|
+
break
|
|
199
|
+
case 'anchor': {
|
|
200
|
+
const box: Record<string, unknown> = {}
|
|
201
|
+
addAnchorBox(box, d.box)
|
|
202
|
+
out.anchor = box
|
|
203
|
+
addIndex(out, d.index)
|
|
204
|
+
break
|
|
205
|
+
}
|
|
206
|
+
case 'localizedText':
|
|
207
|
+
out.localizedText = d.map
|
|
208
|
+
addModifiers(out, d.modifiers)
|
|
209
|
+
break
|
|
210
|
+
}
|
|
211
|
+
return out
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Encode a Selector to its Rust-compatible untagged JSON string. */
|
|
215
|
+
export function encodeSelectorJson(s: Selector): string {
|
|
216
|
+
return JSON.stringify(selectorToJsonValue(s))
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Decode a Rust-compatible untagged JSON value into a Selector. */
|
|
220
|
+
export function selectorFromJsonValue(value: unknown): Selector {
|
|
221
|
+
if (typeof value !== 'object' || value === null) {
|
|
222
|
+
throw new Error(`Selector must be a JSON object, got: ${JSON.stringify(value)}`)
|
|
223
|
+
}
|
|
224
|
+
const obj = value as Record<string, unknown>
|
|
225
|
+
|
|
226
|
+
// Focused: explicit boolean field, highest-priority discriminator.
|
|
227
|
+
if (obj.focused === true) return Selector.focused()
|
|
228
|
+
|
|
229
|
+
// Anchor: nested box discriminator.
|
|
230
|
+
if (obj.anchor !== undefined && typeof obj.anchor === 'object' && obj.anchor !== null) {
|
|
231
|
+
const box = decodeAnchorBox(obj.anchor as Record<string, unknown>)
|
|
232
|
+
return Selector.anchor(box, decodeIndex(obj))
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (typeof obj.id === 'string') {
|
|
236
|
+
return new Selector({ kind: 'id', id: obj.id, modifiers: decodeModifiers(obj) })
|
|
237
|
+
}
|
|
238
|
+
if (obj.text !== undefined) {
|
|
239
|
+
return new Selector({ kind: 'text', text: patternFromJson(obj.text), modifiers: decodeModifiers(obj) })
|
|
240
|
+
}
|
|
241
|
+
if (typeof obj.label === 'string') {
|
|
242
|
+
return new Selector({ kind: 'label', label: obj.label, modifiers: decodeModifiers(obj) })
|
|
243
|
+
}
|
|
244
|
+
if (typeof obj.role === 'string') {
|
|
245
|
+
const name = obj.name !== undefined ? patternFromJson(obj.name) : undefined
|
|
246
|
+
return new Selector({
|
|
247
|
+
kind: 'role',
|
|
248
|
+
role: obj.role,
|
|
249
|
+
...(name !== undefined ? { name } : {}),
|
|
250
|
+
modifiers: decodeModifiers(obj),
|
|
251
|
+
})
|
|
252
|
+
}
|
|
253
|
+
if (obj.localizedText !== undefined && typeof obj.localizedText === 'object' && obj.localizedText !== null) {
|
|
254
|
+
return new Selector({
|
|
255
|
+
kind: 'localizedText',
|
|
256
|
+
map: obj.localizedText as Record<string, string>,
|
|
257
|
+
modifiers: decodeModifiers(obj),
|
|
258
|
+
})
|
|
259
|
+
}
|
|
260
|
+
throw new Error(`Selector JSON missing recognized discriminator: ${JSON.stringify(obj)}`)
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function decodeModifiers(obj: Record<string, unknown>): Modifiers {
|
|
264
|
+
const m: Modifiers = {}
|
|
265
|
+
if (obj.near !== undefined) m.near = selectorFromJsonValue(obj.near)
|
|
266
|
+
if (obj.below !== undefined) m.below = selectorFromJsonValue(obj.below)
|
|
267
|
+
if (obj.above !== undefined) m.above = selectorFromJsonValue(obj.above)
|
|
268
|
+
if (obj.leftOf !== undefined) m.leftOf = selectorFromJsonValue(obj.leftOf)
|
|
269
|
+
if (obj.rightOf !== undefined) m.rightOf = selectorFromJsonValue(obj.rightOf)
|
|
270
|
+
if (obj.inside !== undefined) m.inside = selectorFromJsonValue(obj.inside)
|
|
271
|
+
if (obj.ancestor !== undefined) m.ancestor = selectorFromJsonValue(obj.ancestor)
|
|
272
|
+
if (typeof obj.nth === 'number') m.nth = obj.nth
|
|
273
|
+
if (typeof obj.first === 'boolean') m.first = obj.first
|
|
274
|
+
if (typeof obj.last === 'boolean') m.last = obj.last
|
|
275
|
+
return m
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function decodeAnchorBox(obj: Record<string, unknown>): AnchorBox {
|
|
279
|
+
const b: AnchorBox = {}
|
|
280
|
+
if (obj.near !== undefined) b.near = selectorFromJsonValue(obj.near)
|
|
281
|
+
if (obj.below !== undefined) b.below = selectorFromJsonValue(obj.below)
|
|
282
|
+
if (obj.above !== undefined) b.above = selectorFromJsonValue(obj.above)
|
|
283
|
+
if (obj.leftOf !== undefined) b.leftOf = selectorFromJsonValue(obj.leftOf)
|
|
284
|
+
if (obj.rightOf !== undefined) b.rightOf = selectorFromJsonValue(obj.rightOf)
|
|
285
|
+
if (obj.inside !== undefined) b.inside = selectorFromJsonValue(obj.inside)
|
|
286
|
+
if (obj.ancestor !== undefined) b.ancestor = selectorFromJsonValue(obj.ancestor)
|
|
287
|
+
return b
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function decodeIndex(obj: Record<string, unknown>): IndexModifiers {
|
|
291
|
+
const i: IndexModifiers = {}
|
|
292
|
+
if (typeof obj.nth === 'number') i.nth = obj.nth
|
|
293
|
+
if (typeof obj.first === 'boolean') i.first = obj.first
|
|
294
|
+
if (typeof obj.last === 'boolean') i.last = obj.last
|
|
295
|
+
return i
|
|
296
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// v7.5 c1 — SelectorResolver injection boundary.
|
|
2
|
+
//
|
|
3
|
+
// Mirror Kotlin v7.4 c2 R-Kotlin-A: real impl wraps Rust FFI core
|
|
4
|
+
// (in TS via HTTP runner or future ffi-napi binding); mock impl for
|
|
5
|
+
// vitest. Lambda-style fn interface keeps the abstraction minimal.
|
|
6
|
+
|
|
7
|
+
export type SelectorResolver = (
|
|
8
|
+
treeJson: string,
|
|
9
|
+
selectorJson: string,
|
|
10
|
+
) => Promise<readonly string[]>
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* v7.6 c1 — labels resolver wrapping Rust `resolve_selector_labels`
|
|
14
|
+
* (each match's `.label`; empty string when None). Used by
|
|
15
|
+
* Locator.toHaveCount + Locator.toHaveLabel.
|
|
16
|
+
*/
|
|
17
|
+
export type LabelsResolver = (
|
|
18
|
+
treeJson: string,
|
|
19
|
+
selectorJson: string,
|
|
20
|
+
) => Promise<readonly string[]>
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Mock resolver for vitest unit tests. Pre-registered selectorJson →
|
|
24
|
+
* id list mappings; falls back to empty list for unknown selectors.
|
|
25
|
+
* Mirrors Kotlin MockSelectorResolver from v7.4 c2.
|
|
26
|
+
*/
|
|
27
|
+
export class MockSelectorResolver {
|
|
28
|
+
readonly returnMap = new Map<string, readonly string[]>()
|
|
29
|
+
throwOnNext: Error | null = null
|
|
30
|
+
readonly calls: { treeJson: string; selectorJson: string }[] = []
|
|
31
|
+
|
|
32
|
+
resolve: SelectorResolver = async (treeJson, selectorJson) => {
|
|
33
|
+
this.calls.push({ treeJson, selectorJson })
|
|
34
|
+
const t = this.throwOnNext
|
|
35
|
+
if (t !== null) {
|
|
36
|
+
this.throwOnNext = null
|
|
37
|
+
throw t
|
|
38
|
+
}
|
|
39
|
+
return this.returnMap.get(selectorJson) ?? []
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Register a single-hit response for the given selector JSON key. */
|
|
43
|
+
registerHit(selectorJson: string, id: string): void {
|
|
44
|
+
this.returnMap.set(selectorJson, [id])
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Register a multi-hit response. */
|
|
48
|
+
registerHits(selectorJson: string, ids: readonly string[]): void {
|
|
49
|
+
this.returnMap.set(selectorJson, ids)
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* v7.6 c1 — Mock labels resolver for vitest tests. Pre-registered
|
|
55
|
+
* selectorJson → label list mappings. Mirrors Kotlin MockLabelsResolver.
|
|
56
|
+
*/
|
|
57
|
+
export class MockLabelsResolver {
|
|
58
|
+
readonly returnMap = new Map<string, readonly string[]>()
|
|
59
|
+
throwOnNext: Error | null = null
|
|
60
|
+
readonly calls: { treeJson: string; selectorJson: string }[] = []
|
|
61
|
+
|
|
62
|
+
resolve: LabelsResolver = async (treeJson, selectorJson) => {
|
|
63
|
+
this.calls.push({ treeJson, selectorJson })
|
|
64
|
+
const t = this.throwOnNext
|
|
65
|
+
if (t !== null) {
|
|
66
|
+
this.throwOnNext = null
|
|
67
|
+
throw t
|
|
68
|
+
}
|
|
69
|
+
return this.returnMap.get(selectorJson) ?? []
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Register the matched-labels list for a selector. */
|
|
73
|
+
registerLabels(selectorJson: string, labels: readonly string[]): void {
|
|
74
|
+
this.returnMap.set(selectorJson, labels)
|
|
75
|
+
}
|
|
76
|
+
}
|