@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.
Files changed (64) hide show
  1. package/README.md +193 -0
  2. package/dist/A11yNode.d.ts +36 -0
  3. package/dist/A11yNode.d.ts.map +1 -0
  4. package/dist/A11yNode.js +30 -0
  5. package/dist/A11yNode.js.map +1 -0
  6. package/dist/App.d.ts +57 -0
  7. package/dist/App.d.ts.map +1 -0
  8. package/dist/App.js +123 -0
  9. package/dist/App.js.map +1 -0
  10. package/dist/ExpectationFailure.d.ts +30 -0
  11. package/dist/ExpectationFailure.d.ts.map +1 -0
  12. package/dist/ExpectationFailure.js +47 -0
  13. package/dist/ExpectationFailure.js.map +1 -0
  14. package/dist/HttpRunner.d.ts +61 -0
  15. package/dist/HttpRunner.d.ts.map +1 -0
  16. package/dist/HttpRunner.js +148 -0
  17. package/dist/HttpRunner.js.map +1 -0
  18. package/dist/Locator.d.ts +60 -0
  19. package/dist/Locator.d.ts.map +1 -0
  20. package/dist/Locator.js +177 -0
  21. package/dist/Locator.js.map +1 -0
  22. package/dist/Pattern.d.ts +21 -0
  23. package/dist/Pattern.d.ts.map +1 -0
  24. package/dist/Pattern.js +44 -0
  25. package/dist/Pattern.js.map +1 -0
  26. package/dist/Selector.d.ts +96 -0
  27. package/dist/Selector.d.ts.map +1 -0
  28. package/dist/Selector.js +276 -0
  29. package/dist/Selector.js.map +1 -0
  30. package/dist/SelectorResolver.d.ts +41 -0
  31. package/dist/SelectorResolver.d.ts.map +1 -0
  32. package/dist/SelectorResolver.js +55 -0
  33. package/dist/SelectorResolver.js.map +1 -0
  34. package/dist/SimRuntime.d.ts +81 -0
  35. package/dist/SimRuntime.d.ts.map +1 -0
  36. package/dist/SimRuntime.js +91 -0
  37. package/dist/SimRuntime.js.map +1 -0
  38. package/dist/Smix.d.ts +22 -0
  39. package/dist/Smix.d.ts.map +1 -0
  40. package/dist/Smix.js +35 -0
  41. package/dist/Smix.js.map +1 -0
  42. package/dist/index.d.ts +11 -0
  43. package/dist/index.d.ts.map +1 -0
  44. package/dist/index.js +16 -0
  45. package/dist/index.js.map +1 -0
  46. package/package.json +44 -0
  47. package/src/A11yNode.ts +62 -0
  48. package/src/App.ts +153 -0
  49. package/src/ExpectationFailure.ts +67 -0
  50. package/src/HttpRunner.ts +185 -0
  51. package/src/Locator.ts +190 -0
  52. package/src/Pattern.ts +51 -0
  53. package/src/Selector.ts +296 -0
  54. package/src/SelectorResolver.ts +76 -0
  55. package/src/SimRuntime.ts +145 -0
  56. package/src/Smix.ts +49 -0
  57. package/src/__tests__/AppFullApi.test.ts +168 -0
  58. package/src/__tests__/AppTapMock.test.ts +122 -0
  59. package/src/__tests__/HttpRunner.test.ts +121 -0
  60. package/src/__tests__/Locator.test.ts +128 -0
  61. package/src/__tests__/LocatorToHave.test.ts +118 -0
  62. package/src/__tests__/MvpApiShape.test.ts +117 -0
  63. package/src/__tests__/SelectorFullSchema.test.ts +161 -0
  64. 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
+ }
@@ -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
+ }