@goliapkg/smix 1.0.26 → 2.0.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 (74) hide show
  1. package/README.md +53 -130
  2. package/dist/A11yNode.d.ts.map +1 -1
  3. package/dist/A11yNode.js +2 -3
  4. package/dist/A11yNode.js.map +1 -1
  5. package/dist/App.d.ts +37 -22
  6. package/dist/App.d.ts.map +1 -1
  7. package/dist/App.js +72 -66
  8. package/dist/App.js.map +1 -1
  9. package/dist/ExpectationFailure.d.ts +8 -1
  10. package/dist/ExpectationFailure.d.ts.map +1 -1
  11. package/dist/ExpectationFailure.js +7 -7
  12. package/dist/ExpectationFailure.js.map +1 -1
  13. package/dist/HttpRunner.d.ts +9 -31
  14. package/dist/HttpRunner.d.ts.map +1 -1
  15. package/dist/HttpRunner.js +14 -100
  16. package/dist/HttpRunner.js.map +1 -1
  17. package/dist/Locator.d.ts +7 -9
  18. package/dist/Locator.d.ts.map +1 -1
  19. package/dist/Locator.js +17 -18
  20. package/dist/Locator.js.map +1 -1
  21. package/dist/NodeDriver.d.ts +53 -0
  22. package/dist/NodeDriver.d.ts.map +1 -0
  23. package/dist/NodeDriver.js +70 -0
  24. package/dist/NodeDriver.js.map +1 -0
  25. package/dist/Pattern.js +2 -2
  26. package/dist/Pattern.js.map +1 -1
  27. package/dist/Selector.d.ts.map +1 -1
  28. package/dist/Selector.js +5 -8
  29. package/dist/Selector.js.map +1 -1
  30. package/dist/SelectorResolver.d.ts +4 -6
  31. package/dist/SelectorResolver.d.ts.map +1 -1
  32. package/dist/SelectorResolver.js +5 -8
  33. package/dist/SelectorResolver.js.map +1 -1
  34. package/dist/Smix.d.ts +12 -4
  35. package/dist/Smix.d.ts.map +1 -1
  36. package/dist/Smix.js +25 -23
  37. package/dist/Smix.js.map +1 -1
  38. package/dist/SystemPopup.d.ts +18 -0
  39. package/dist/SystemPopup.d.ts.map +1 -0
  40. package/dist/SystemPopup.js +7 -0
  41. package/dist/SystemPopup.js.map +1 -0
  42. package/dist/index.d.ts +3 -1
  43. package/dist/index.d.ts.map +1 -1
  44. package/dist/index.js +2 -1
  45. package/dist/index.js.map +1 -1
  46. package/dist/loadNodeDriver.d.ts +21 -0
  47. package/dist/loadNodeDriver.d.ts.map +1 -0
  48. package/dist/loadNodeDriver.js +33 -0
  49. package/dist/loadNodeDriver.js.map +1 -0
  50. package/package.json +5 -2
  51. package/src/A11yNode.ts +2 -3
  52. package/src/App.ts +75 -78
  53. package/src/ExpectationFailure.ts +23 -13
  54. package/src/HttpRunner.ts +15 -126
  55. package/src/Locator.ts +17 -18
  56. package/src/NodeDriver.ts +109 -0
  57. package/src/Pattern.ts +2 -2
  58. package/src/Selector.ts +5 -8
  59. package/src/SelectorResolver.ts +7 -11
  60. package/src/Smix.ts +30 -26
  61. package/src/SystemPopup.ts +24 -0
  62. package/src/__tests__/AppDriving.test.ts +106 -0
  63. package/src/__tests__/MvpApiShape.test.ts +78 -46
  64. package/src/__tests__/ReadmeSnippets.test.ts +132 -0
  65. package/src/__tests__/RealAddonLoopback.test.ts +81 -0
  66. package/src/__tests__/SelectorFullSchema.test.ts +2 -4
  67. package/src/index.ts +19 -8
  68. package/src/loadNodeDriver.ts +41 -0
  69. package/src/SimRuntime.ts +0 -145
  70. package/src/__tests__/AppFullApi.test.ts +0 -168
  71. package/src/__tests__/AppTapMock.test.ts +0 -122
  72. package/src/__tests__/HttpRunner.test.ts +0 -121
  73. package/src/__tests__/Locator.test.ts +0 -128
  74. package/src/__tests__/LocatorToHave.test.ts +0 -118
package/src/App.ts CHANGED
@@ -1,18 +1,21 @@
1
- // v7.5 c3 — App class full wire mirrors Kotlin v7.4 c5 / Swift v7.2.
2
-
3
- import { type A11yNode, findById, flatten } from './A11yNode.js'
1
+ // A handle to a running app. Live driving (tap/fill/swipe/...) and live
2
+ // sense (snapshotTree) are pending the napi axis: the TS SDK has no FFI
3
+ // path, so with the fictional HTTP wire deleted there is no way to reach a
4
+ // simulator from here yet. These methods throw SmixNotImplementedError
5
+ // rather than posting to a route the runner never served. What does work is
6
+ // the resolver seam: given a treeJson, `resolver` / `labelsResolver` resolve
7
+ // selectors against the runner's served /select/resolve* routes.
8
+
9
+ import { type A11yNode, flatten } from './A11yNode.js'
4
10
  import { ExpectationFailure } from './ExpectationFailure.js'
5
11
  import { Locator, SmixNotImplementedError } from './Locator.js'
6
- import {
7
- encodeSelectorJson,
8
- type Selector,
9
- } from './Selector.js'
10
- import {
11
- type KeyName,
12
- type SmixSimRuntime,
13
- type SwipeDirection,
14
- } from './SimRuntime.js'
12
+ import type { NodeDriver, NodeSession } from './NodeDriver.js'
13
+ import { type Selector, encodeSelectorJson } from './Selector.js'
15
14
  import type { LabelsResolver, SelectorResolver } from './SelectorResolver.js'
15
+ import type { SystemPopup } from './SystemPopup.js'
16
+
17
+ type KeyName = 'return' | 'delete' | 'space' | 'tab' | 'escape' | 'enter'
18
+ type SwipeDirection = 'up' | 'down' | 'left' | 'right'
16
19
 
17
20
  /**
18
21
  * A handle to a running app on the simulator / emulator.
@@ -20,13 +23,16 @@ import type { LabelsResolver, SelectorResolver } from './SelectorResolver.js'
20
23
  export class App {
21
24
  constructor(
22
25
  public readonly bundleId: string,
23
- public readonly runtime: SmixSimRuntime,
26
+ /** The napi driver (act + sense); a mock in tests, the real addon in prod. */
27
+ public readonly driver: NodeDriver,
28
+ /** The napi session this app was launched in (lifecycle verbs). */
29
+ public readonly session: NodeSession,
24
30
  public readonly resolver: SelectorResolver,
25
31
  /**
26
- * v7.6 c1 — wraps Rust `resolve_selector_labels` (each match's
27
- * `.label`). Used by Locator.toHaveCount + toHaveLabel. Defaults
28
- * to a stub that throws — tests + production should pass a real
29
- * impl (HttpSimRuntime.labelsResolver or MockLabelsResolver.resolve).
32
+ * Wraps `resolve_selector_labels`, backing toHaveCount and
33
+ * toHaveLabel. The default throws rather than guessing: there is no
34
+ * sensible answer without a resolver, and a silent empty list would
35
+ * read as "no matches".
30
36
  */
31
37
  public readonly labelsResolver: LabelsResolver = async () => {
32
38
  throw new Error('labelsResolver not configured; pass one to Smix.launchApp or App constructor')
@@ -35,100 +41,81 @@ export class App {
35
41
 
36
42
  // ---- act methods --------------------------------------------------
37
43
 
38
- async tap(selector: Selector, _opts: { timeoutMs?: number } = {}): Promise<void> {
39
- const tree = await this.runtime.snapshotTree()
44
+ /**
45
+ * Snapshot the tree, resolve `selector` against it, and return the first
46
+ * matching id. Selectors resolve to an id on this side; only the id crosses
47
+ * the napi boundary. No match throws ELEMENT_NOT_FOUND with the visible
48
+ * elements so the failure is AI-readable.
49
+ */
50
+ private async resolveFirstOrThrow(selector: Selector): Promise<string> {
51
+ const treeJson = await this.driver.snapshotTree()
40
52
  const selectorJson = encodeSelectorJson(selector)
41
- const treeJson = JSON.stringify(tree)
42
-
43
- let matches: readonly string[]
44
- try {
45
- matches = await this.resolver(treeJson, selectorJson)
46
- } catch (e) {
47
- const msg = e instanceof Error ? e.message : String(e)
48
- throw new ExpectationFailure({
49
- code: 'unknown',
50
- message: `FFI resolveSelector raised: ${msg}`,
51
- selectorJson,
52
- })
53
- }
54
-
55
- const firstId = matches[0]
56
- const node = firstId !== undefined ? findById(tree, firstId) : null
57
- if (node === null) {
53
+ const ids = await this.resolver(treeJson, selectorJson)
54
+ const first = ids[0]
55
+ if (first === undefined) {
56
+ const tree = JSON.parse(treeJson) as A11yNode
58
57
  throw new ExpectationFailure({
59
- code: 'notFound',
60
- message: 'no candidates after spatial + index filters',
58
+ code: 'ELEMENT_NOT_FOUND',
59
+ message: `no element matched selector ${selectorJson}`,
61
60
  selectorJson,
62
61
  visibleElements: flatten(tree).slice(0, 20),
63
- suggestions: [
64
- 'check `accessibilityIdentifier` is set on the target view',
65
- 'consider relaxing to text(...) if the label is more stable than the id',
66
- ],
67
62
  })
68
63
  }
64
+ return first
65
+ }
69
66
 
70
- await this.runtime.synthesizeTap(
71
- node.bounds.x + node.bounds.w / 2,
72
- node.bounds.y + node.bounds.h / 2,
73
- )
67
+ async tap(selector: Selector, _opts: { timeoutMs?: number } = {}): Promise<void> {
68
+ await this.driver.tapById(await this.resolveFirstOrThrow(selector))
74
69
  }
75
70
 
76
- /**
77
- * Resolve [selector], tap to focus, then type [text]. Mirror
78
- * Kotlin App.fill from v7.4 c3.
79
- */
80
71
  async fill(selector: Selector, text: string): Promise<void> {
81
- await this.tap(selector)
82
- await this.runtime.sendString(text)
72
+ const id = await this.resolveFirstOrThrow(selector)
73
+ await this.driver.tapById(id)
74
+ await this.driver.inputText(text)
83
75
  }
84
76
 
85
77
  async pressKey(key: KeyName): Promise<void> {
86
- await this.runtime.pressKey(key)
78
+ await this.driver.pressKey(key === 'enter' ? 'return' : key)
87
79
  }
88
80
 
89
81
  async swipe(direction: SwipeDirection): Promise<void> {
90
- await this.runtime.swipe(direction)
82
+ await this.driver.swipe(direction)
91
83
  }
92
84
 
93
85
  async screenshot(): Promise<Uint8Array> {
94
- return this.runtime.screenshot()
86
+ // No runner wire route yet — singled out to a later checkpoint (v2.md
87
+ // decision, 2026-07-24). The blocker is the wire, not napi.
88
+ throw new SmixNotImplementedError('wire', 'App.screenshot')
95
89
  }
96
90
 
97
- /**
98
- * Tap at a normalized coordinate (0..1) — escape hatch per §9 #3.
99
- * Out-of-range values throw ExpectationFailure code=wrongState.
100
- */
91
+ /** Tap at a normalized coordinate (0..1) — escape hatch per §9 #3. */
101
92
  async tapAtCoord(nx: number, ny: number): Promise<void> {
102
93
  if (nx < 0 || nx > 1 || ny < 0 || ny > 1) {
103
94
  throw new ExpectationFailure({
104
- code: 'wrongState',
105
- message: `tapAtCoord arguments out of [0,1] range: nx=${nx}, ny=${ny}`,
106
- suggestions: ['use normalized 0..1 — for raw points, divide by viewport size'],
95
+ code: 'ASSERTION_FAILED',
96
+ message: `tapAtCoord expects normalized 0..1 coordinates, got (${nx}, ${ny})`,
107
97
  })
108
98
  }
109
- await this.runtime.synthesizeTapAtNormalized(nx, ny)
99
+ await this.driver.tapAtCoord(nx, ny)
110
100
  }
111
101
 
112
102
  async terminate(): Promise<void> {
113
- await this.runtime.terminate(this.bundleId)
103
+ await this.session.terminateApp()
114
104
  }
115
105
 
116
106
  async relaunch(): Promise<void> {
117
- await this.runtime.terminate(this.bundleId)
118
- await this.runtime.launch(this.bundleId)
107
+ await this.session.relaunchApp()
119
108
  }
120
109
 
121
- async launchFresh(opts: {
110
+ async launchFresh(_opts: {
122
111
  clearState?: boolean
123
112
  clearKeychain?: boolean
124
113
  appPath?: string
125
114
  } = {}): Promise<void> {
126
- await this.runtime.launchFresh({
127
- bundleId: this.bundleId,
128
- clearState: opts.clearState ?? false,
129
- clearKeychain: opts.clearKeychain ?? false,
130
- appPath: opts.appPath,
131
- })
115
+ // Clearing state / installing an appPath is host-side (simctl), with no
116
+ // runner wire — singled out to a later checkpoint. The blocker is the
117
+ // host, not napi.
118
+ throw new SmixNotImplementedError('host', 'App.launchFresh')
132
119
  }
133
120
 
134
121
  // ---- sense methods ------------------------------------------------
@@ -137,16 +124,26 @@ export class App {
137
124
  return new Locator(this, selector)
138
125
  }
139
126
 
127
+ /**
128
+ * The tree seam Locator polls. Pending napi: without an FFI path there is
129
+ * no live simulator to snapshot. Lands with the napi axis.
130
+ */
131
+ async snapshotTree(): Promise<A11yNode> {
132
+ return JSON.parse(await this.driver.snapshotTree()) as A11yNode
133
+ }
134
+
140
135
  async tree(): Promise<A11yNode> {
141
- return this.runtime.snapshotTree()
136
+ return this.snapshotTree()
142
137
  }
143
138
 
144
- async systemPopups(): Promise<A11yNode[]> {
145
- return this.runtime.systemPopups()
139
+ async systemPopups(): Promise<SystemPopup[]> {
140
+ return JSON.parse(await this.driver.systemPopups()) as SystemPopup[]
146
141
  }
147
142
 
148
- async openUrl(url: string): Promise<void> {
149
- await this.runtime.openUrl(url)
143
+ async openUrl(_url: string): Promise<void> {
144
+ // No runner wire route yet — singled out to a later checkpoint. The
145
+ // blocker is the wire, not napi.
146
+ throw new SmixNotImplementedError('wire', 'App.openUrl')
150
147
  }
151
148
  }
152
149
 
@@ -1,23 +1,33 @@
1
- // v7.5 c1 — ExpectationFailure + FailureCode mirror Swift v7.2 c4 +
2
- // Kotlin v7.4 c1 + Rust smix-error.
1
+ // The wire form of `smix-error`'s failure surface.
3
2
  //
4
- // AI-readable JSON contract: toJson() emits sorted-keys, ISO-8601
5
- // timestamp single-line JSON matching Swift errorDescription contract
6
- // (per design.md §9 #11).
3
+ // toJson() emits sorted keys and an ISO-8601 timestamp on one line. Every
4
+ // SDK emits the same bytes for the same failure, so a reader — human or
5
+ // model — sees one format whichever language raised it.
7
6
 
8
7
  import type { A11yNode } from './A11yNode.js'
9
8
 
9
+ /**
10
+ * Rust `smix_error::FailureCode`'s wire strings verbatim —
11
+ * `crates/smix-error/tests/sdk_failure_code_parity.rs` reads this
12
+ * declaration and fails if the two sets ever diverge.
13
+ */
10
14
  export type FailureCode =
11
- | 'notFound'
12
- | 'ambiguous'
13
- | 'notInteractable'
14
- | 'timeout'
15
- | 'wrongState'
16
- | 'unknown'
15
+ | 'ELEMENT_NOT_FOUND'
16
+ | 'NOT_VISIBLE'
17
+ | 'NOT_ENABLED'
18
+ | 'AMBIGUOUS'
19
+ | 'TIMEOUT'
20
+ | 'ASSERTION_FAILED'
21
+ | 'APP_NOT_RUNNING'
22
+ | 'SIMULATOR_NOT_BOOTED'
23
+ /** The touch was synthesised, and it did not land inside the element the selector matched. Distinct from element-not-found: not-found means fix the selector, missed means the element was there and the touch went elsewhere. */
24
+ | 'TAP_MISSED'
25
+ | 'DRIVER_ERROR'
17
26
 
18
27
  export const FAILURE_CODES: readonly FailureCode[] = [
19
- 'notFound', 'ambiguous', 'notInteractable',
20
- 'timeout', 'wrongState', 'unknown',
28
+ 'ELEMENT_NOT_FOUND', 'NOT_VISIBLE', 'NOT_ENABLED',
29
+ 'AMBIGUOUS', 'TIMEOUT', 'ASSERTION_FAILED',
30
+ 'APP_NOT_RUNNING', 'SIMULATOR_NOT_BOOTED', 'TAP_MISSED', 'DRIVER_ERROR',
21
31
  ] as const
22
32
 
23
33
  /**
package/src/HttpRunner.ts CHANGED
@@ -1,42 +1,10 @@
1
- // v7.5 c3 — HTTP-based SmixSimRuntime + SelectorResolver implementations.
2
- //
3
- // Per R-7.5-B HTTP-runtime decision: TS SDK communicates with smix-runner
4
- // via fetch JSON-RPC-ish API (one POST per call). Default endpoint
5
- // matches existing smix-runner-client conventions; consumers override
6
- // via base URL constructor param.
7
- //
8
- // Wire contract (mirrors smix-runner-client v3+):
9
- // POST /sim/launch { bundleId } → 200
10
- // POST /sim/terminate { bundleId } → 200
11
- // POST /sim/launch-fresh { bundleId, clearState, clearKeychain, appPath? } → 200
12
- // POST /sim/launch-from-path { appPath } → 200
13
- // POST /sim/screenshot {} → 200 { png: base64 }
14
- // POST /sim/system-popups {} → 200 { nodes: A11yNode[] }
15
- // POST /sim/open-url { url } → 200
16
- // POST /a11y/snapshot {} → 200 { tree: A11yNode }
17
- // POST /input/tap { x, y } → 200
18
- // POST /input/tap-normalized { nx, ny } → 200
19
- // POST /input/swipe { direction } → 200
20
- // POST /input/send-string { text } → 200
21
- // POST /input/press-key { key } → 200
22
- // POST /select/resolve { treeJson, selectorJson } → 200 { ids: string[] }
23
- //
24
- // v7.7 c1 contract additions (server impl deferred):
25
- // POST /select/resolve-count { treeJson, selectorJson } → 200 { count: u32 }
26
- // POST /select/resolve-labels { treeJson, selectorJson } → 200 { labels: string[] }
27
- //
28
- // The exact endpoint shapes are negotiated with smix-runner-server in
29
- // follow-up Swift work; for v7.5-v7.7 c1 we wire the TS-side fetch +
30
- // decode and a MockHttpRunnerClient stub that lets vitest exercise the
31
- // runtime without booting a real server.
1
+ // The HTTP-backed SelectorResolver + session client: one POST per call
2
+ // against a running smix-runner. Every route it sends is one the runner
3
+ // serves — /select/resolve{,-count,-labels} for resolution and /session/*
4
+ // (driven by the Session class through this client's fetch + Session-Id
5
+ // plumbing). Live driving and sense (launch / tap / snapshot) are not here;
6
+ // they are pending the napi axis.
32
7
 
33
- import { type A11yNode } from './A11yNode.js'
34
- import type {
35
- KeyName,
36
- LaunchFreshCall,
37
- SmixSimRuntime,
38
- SwipeDirection,
39
- } from './SimRuntime.js'
40
8
  import type { LabelsResolver, SelectorResolver } from './SelectorResolver.js'
41
9
 
42
10
  /**
@@ -56,16 +24,16 @@ export type HttpFetch = (
56
24
  }>
57
25
 
58
26
  /**
59
- * SmixSimRuntime + SelectorResolver wrapper around the smix-runner HTTP
60
- * server. Used in production RN/Expo test targets via fetch.
27
+ * SelectorResolver + session client over the smix-runner HTTP server.
28
+ * Resolves selectors against a treeJson supplied by the caller and carries
29
+ * the `Session-Id` header for the {@link Session} lifecycle. Used in
30
+ * production RN/Expo test targets via fetch.
61
31
  */
62
- export class HttpSimRuntime implements SmixSimRuntime {
32
+ export class HttpSimRuntime {
63
33
  readonly resolver: SelectorResolver
64
34
  /**
65
- * v7.7 c1 — labels resolver wrapping Rust `resolve_selector_labels`
66
- * via smix-runner-server `/select/resolve-labels` endpoint. Used by
67
- * `Locator.toHaveCount` (count = labels.length) + Locator.toHaveLabel.
68
- * Pass directly to App constructor or Smix.launchApp.
35
+ * Backs toHaveCount (count = labels.length) and toHaveLabel. Pass it to
36
+ * the App constructor or to Smix.launchApp.
69
37
  */
70
38
  readonly labelsResolver: LabelsResolver
71
39
 
@@ -128,82 +96,14 @@ export class HttpSimRuntime implements SmixSimRuntime {
128
96
  }
129
97
 
130
98
  /**
131
- * v7.7 c1 — direct match-count via dedicated FFI fn (avoid allocating
132
- * the full ids list when callers only need count). Mirrors Rust
133
- * `resolve_selector_count`. Locator.toHaveCount can prefer this over
134
- * `labelsResolver` if it needs only count, not the labels themselves.
99
+ * Counts matches without building the id list, for callers that want a
100
+ * count and nothing else.
135
101
  */
136
102
  async resolveCount(treeJson: string, selectorJson: string): Promise<number> {
137
103
  const r = await this.post('/select/resolve-count', { treeJson, selectorJson })
138
104
  return (r as { count: number }).count
139
105
  }
140
106
 
141
- async launch(bundleId: string): Promise<void> {
142
- await this.post('/sim/launch', { bundleId })
143
- }
144
-
145
- async terminate(bundleId: string): Promise<void> {
146
- await this.post('/sim/terminate', { bundleId })
147
- }
148
-
149
- async snapshotTree(): Promise<A11yNode> {
150
- const r = await this.post('/a11y/snapshot', {})
151
- return (r as { tree: A11yNode }).tree
152
- }
153
-
154
- async synthesizeTap(x: number, y: number): Promise<void> {
155
- await this.post('/input/tap', { x, y })
156
- }
157
-
158
- async sendString(text: string): Promise<void> {
159
- await this.post('/input/send-string', { text })
160
- }
161
-
162
- async pressKey(key: KeyName): Promise<void> {
163
- await this.post('/input/press-key', { key })
164
- }
165
-
166
- async swipe(direction: SwipeDirection): Promise<void> {
167
- await this.post('/input/swipe', { direction })
168
- }
169
-
170
- async screenshot(): Promise<Uint8Array> {
171
- const r = await this.post('/sim/screenshot', {}) as { png: string }
172
- return base64ToBytes(r.png)
173
- }
174
-
175
- async systemPopups(): Promise<A11yNode[]> {
176
- const r = await this.post('/sim/system-popups', {}) as { nodes: A11yNode[] }
177
- return r.nodes
178
- }
179
-
180
- async openUrl(url: string): Promise<void> {
181
- await this.post('/sim/open-url', { url })
182
- }
183
-
184
- async launchFresh(opts: {
185
- bundleId: string
186
- clearState: boolean
187
- clearKeychain: boolean
188
- appPath?: string | undefined
189
- }): Promise<void> {
190
- const payload: LaunchFreshCall = {
191
- bundleId: opts.bundleId,
192
- clearState: opts.clearState,
193
- clearKeychain: opts.clearKeychain,
194
- appPath: opts.appPath,
195
- }
196
- await this.post('/sim/launch-fresh', payload)
197
- }
198
-
199
- async launchFromPath(appPath: string): Promise<void> {
200
- await this.post('/sim/launch-from-path', { appPath })
201
- }
202
-
203
- async synthesizeTapAtNormalized(nx: number, ny: number): Promise<void> {
204
- await this.post('/input/tap-normalized', { nx, ny })
205
- }
206
-
207
107
  private async post(path: string, body: unknown): Promise<unknown> {
208
108
  const url = `${this.baseUrl}${path}`
209
109
  const headers: Record<string, string> = {
@@ -247,14 +147,3 @@ export class HttpSimRuntime implements SmixSimRuntime {
247
147
 
248
148
  /** v1.0.4 §D7 — re-export from Session.ts so this file's use compiles. */
249
149
  export type SessionState = 'healthy' | 'degraded' | 'cycling' | 'dead'
250
-
251
- function base64ToBytes(b64: string): Uint8Array {
252
- if (typeof globalThis.atob === 'function') {
253
- const bin = globalThis.atob(b64)
254
- const out = new Uint8Array(bin.length)
255
- for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i)
256
- return out
257
- }
258
- // Node fallback
259
- return Uint8Array.from(Buffer.from(b64, 'base64'))
260
- }
package/src/Locator.ts CHANGED
@@ -1,5 +1,3 @@
1
- // v7.5 c3 — Locator poll loop, mirror Kotlin v7.4 c3 + Swift v7.1 c3.
2
- //
3
1
  // 250ms tick over a millisecond deadline. On timeout, throws
4
2
  // ExpectationFailure with the last tree's first-20 visible elements
5
3
  // populated for AI agent diagnosis.
@@ -53,10 +51,9 @@ export class Locator {
53
51
  }
54
52
 
55
53
  /**
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).
54
+ * Resolve [selector] until a match exists and its label equals
55
+ * [label]. Reads the label off the snapshot node rather than the
56
+ * labels resolver, which answers about the whole match set.
60
57
  */
61
58
  async toHaveLabel(label: string, opts: { timeoutMs?: number } = {}): Promise<void> {
62
59
  return this.pollUntil({
@@ -68,10 +65,9 @@ export class Locator {
68
65
  }
69
66
 
70
67
  /**
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.
68
+ * Resolve [selector] until the labels resolver returns exactly [n]
69
+ * matches. Goes through the labels resolver so the count ignores any
70
+ * index modifier — counting asks about all matches, as in Playwright.
75
71
  */
76
72
  async toHaveCount(n: number, opts: { timeoutMs?: number } = {}): Promise<void> {
77
73
  const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS
@@ -80,7 +76,7 @@ export class Locator {
80
76
  let lastTree: A11yNode | null = null
81
77
  const selectorJson = encodeSelectorJson(this.selector)
82
78
  while (true) {
83
- const tree = await this.app.runtime.snapshotTree()
79
+ const tree = await this.app.snapshotTree()
84
80
  lastTree = tree
85
81
  let labels: readonly string[]
86
82
  try {
@@ -88,7 +84,7 @@ export class Locator {
88
84
  } catch (e) {
89
85
  const msg = e instanceof Error ? e.message : String(e)
90
86
  throw new ExpectationFailure({
91
- code: 'unknown',
87
+ code: 'DRIVER_ERROR',
92
88
  message: `FFI resolveSelectorLabels raised during poll: ${msg}`,
93
89
  selectorJson,
94
90
  })
@@ -99,7 +95,7 @@ export class Locator {
99
95
  await delay(POLL_INTERVAL_MS)
100
96
  }
101
97
  throw new ExpectationFailure({
102
- code: 'wrongState',
98
+ code: 'ASSERTION_FAILED',
103
99
  message: `expected ${n} matches, last poll saw ${lastCount}`,
104
100
  selectorJson,
105
101
  visibleElements: lastTree !== null ? flatten(lastTree).slice(0, 20) : [],
@@ -115,7 +111,7 @@ export class Locator {
115
111
  * app's runtime + resolver, then check predicates.
116
112
  *
117
113
  * - [successPredicate] returns true → return immediately
118
- * - [wrongStatePredicate] returns true → throw wrongState
114
+ * - [wrongStatePredicate] returns true → throw NOT_VISIBLE
119
115
  * - Otherwise, continue polling.
120
116
  *
121
117
  * On timeout, throws timeout with the last tree's first-20 nodes.
@@ -130,7 +126,7 @@ export class Locator {
130
126
  let lastTree: A11yNode | null = null
131
127
 
132
128
  while (true) {
133
- const tree = await this.app.runtime.snapshotTree()
129
+ const tree = await this.app.snapshotTree()
134
130
  lastTree = tree
135
131
  const selectorJson = encodeSelectorJson(this.selector)
136
132
  let matches: readonly string[]
@@ -139,7 +135,7 @@ export class Locator {
139
135
  } catch (e) {
140
136
  const msg = e instanceof Error ? e.message : String(e)
141
137
  throw new ExpectationFailure({
142
- code: 'unknown',
138
+ code: 'DRIVER_ERROR',
143
139
  message: `FFI resolveSelector raised during poll: ${msg}`,
144
140
  selectorJson,
145
141
  })
@@ -149,7 +145,10 @@ export class Locator {
149
145
 
150
146
  if (opts.wrongStatePredicate(node)) {
151
147
  throw new ExpectationFailure({
152
- code: 'wrongState',
148
+ // toBeVisible is the only caller that supplies a firing
149
+ // wrongStatePredicate, so this branch always means
150
+ // "matched, but not visible".
151
+ code: 'NOT_VISIBLE',
153
152
  message: 'element matched but predicate failed (e.g. not visible)',
154
153
  selectorJson,
155
154
  visibleElements: node !== null ? [node, ...flatten(tree).slice(0, 19)] : flatten(tree).slice(0, 20),
@@ -162,7 +161,7 @@ export class Locator {
162
161
  }
163
162
 
164
163
  throw new ExpectationFailure({
165
- code: 'timeout',
164
+ code: 'TIMEOUT',
166
165
  message: opts.timeoutMessage,
167
166
  selectorJson: encodeSelectorJson(this.selector),
168
167
  visibleElements: lastTree !== null ? flatten(lastTree).slice(0, 20) : [],
@@ -0,0 +1,109 @@
1
+ // The seam where live driving is supplied. The real driver is the napi
2
+ // addon (`@goliapkg/smix-node`, wired in with the prebuild matrix — the C4
3
+ // axis); its `SmixNodeDriver` / `SmixNodeSession` classes satisfy these
4
+ // interfaces structurally. The mocks answer from memory so the App's
5
+ // assembly logic (snapshot -> resolve -> tapById, range checks, enter ->
6
+ // return) is unit-tested without the native addon and without a device —
7
+ // the same seam-injection pattern as SelectorResolver.
8
+ //
9
+ // Every method returns what `crates/smix-node/index.d.ts` returns; the tree
10
+ // and popups cross as JSON strings, parsed on this side.
11
+
12
+ /** A live driver: the stateless act + sense verbs, plus session opening. */
13
+ export interface NodeDriver {
14
+ tapById(id: string): Promise<boolean>
15
+ inputText(text: string): Promise<void>
16
+ pressKey(key: string): Promise<void>
17
+ swipe(direction: string): Promise<void>
18
+ tapAtCoord(nx: number, ny: number): Promise<string>
19
+ snapshotTree(): Promise<string>
20
+ systemPopups(): Promise<string>
21
+ openSession(bundleId: string): Promise<NodeSession>
22
+ }
23
+
24
+ /** A handle to an open session: the lifecycle verbs. */
25
+ export interface NodeSession {
26
+ launchApp(): Promise<void>
27
+ terminateApp(): Promise<void>
28
+ relaunchApp(): Promise<void>
29
+ }
30
+
31
+ /** A recorded call: the verb name and the arguments it was given. */
32
+ export interface RecordedCall {
33
+ readonly verb: string
34
+ readonly args: readonly unknown[]
35
+ }
36
+
37
+ /**
38
+ * Mock session for vitest. Records every lifecycle call; resolves them all.
39
+ */
40
+ export class MockNodeSession implements NodeSession {
41
+ readonly calls: RecordedCall[] = []
42
+
43
+ async launchApp(): Promise<void> {
44
+ this.calls.push({ verb: 'launchApp', args: [] })
45
+ }
46
+
47
+ async terminateApp(): Promise<void> {
48
+ this.calls.push({ verb: 'terminateApp', args: [] })
49
+ }
50
+
51
+ async relaunchApp(): Promise<void> {
52
+ this.calls.push({ verb: 'relaunchApp', args: [] })
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Mock driver for vitest. Pre-set the tree / popups JSON it returns; records
58
+ * every call; `tapById` reports a hit by default. `openSession` hands back a
59
+ * fresh (or supplied) MockNodeSession so lifecycle calls are inspectable.
60
+ */
61
+ export class MockNodeDriver implements NodeDriver {
62
+ readonly calls: RecordedCall[] = []
63
+ treeJson =
64
+ '{"rawType":"application","enabled":true,"selected":false,"hasFocus":false,"visible":true,"bounds":{"x":0,"y":0,"w":0,"h":0}}'
65
+ popupsJson = '[]'
66
+ tapByIdResult = true
67
+ readonly session: MockNodeSession
68
+
69
+ constructor(session: MockNodeSession = new MockNodeSession()) {
70
+ this.session = session
71
+ }
72
+
73
+ async tapById(id: string): Promise<boolean> {
74
+ this.calls.push({ verb: 'tapById', args: [id] })
75
+ return this.tapByIdResult
76
+ }
77
+
78
+ async inputText(text: string): Promise<void> {
79
+ this.calls.push({ verb: 'inputText', args: [text] })
80
+ }
81
+
82
+ async pressKey(key: string): Promise<void> {
83
+ this.calls.push({ verb: 'pressKey', args: [key] })
84
+ }
85
+
86
+ async swipe(direction: string): Promise<void> {
87
+ this.calls.push({ verb: 'swipe', args: [direction] })
88
+ }
89
+
90
+ async tapAtCoord(nx: number, ny: number): Promise<string> {
91
+ this.calls.push({ verb: 'tapAtCoord', args: [nx, ny] })
92
+ return '{"chain":[]}'
93
+ }
94
+
95
+ async snapshotTree(): Promise<string> {
96
+ this.calls.push({ verb: 'snapshotTree', args: [] })
97
+ return this.treeJson
98
+ }
99
+
100
+ async systemPopups(): Promise<string> {
101
+ this.calls.push({ verb: 'systemPopups', args: [] })
102
+ return this.popupsJson
103
+ }
104
+
105
+ async openSession(bundleId: string): Promise<NodeSession> {
106
+ this.calls.push({ verb: 'openSession', args: [bundleId] })
107
+ return this.session
108
+ }
109
+ }