@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
@@ -0,0 +1,11 @@
1
+ export { App, } from './App.js';
2
+ export { literal, patternFromJson, patternToJson, regex, type Pattern, } from './Pattern.js';
3
+ export { encodeSelectorJson, Selector, selectorFromJsonValue, selectorToJsonValue, type AnchorBox, type IndexModifiers, type Modifiers, type SelectorData, type SelectorKind, } from './Selector.js';
4
+ export { ExpectationFailure, FAILURE_CODES, type FailureCode, } from './ExpectationFailure.js';
5
+ export { Locator, SmixNotImplementedError, } from './Locator.js';
6
+ export { appPath, bundleId, Smix, type AppTarget, } from './Smix.js';
7
+ export { MockSimRuntime, type KeyName, type LaunchFreshCall, type SmixSimRuntime, type SwipeDirection, } from './SimRuntime.js';
8
+ export { MockLabelsResolver, MockSelectorResolver, type LabelsResolver, type SelectorResolver, } from './SelectorResolver.js';
9
+ export { HttpSimRuntime, type HttpFetch, } from './HttpRunner.js';
10
+ export { findById, flatten, rectCenter, type A11yNode, type A11yRole, type Rect, } from './A11yNode.js';
11
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAMA,OAAO,EACL,GAAG,GACJ,MAAM,UAAU,CAAA;AAEjB,OAAO,EACL,OAAO,EACP,eAAe,EACf,aAAa,EACb,KAAK,EACL,KAAK,OAAO,GACb,MAAM,cAAc,CAAA;AAErB,OAAO,EACL,kBAAkB,EAClB,QAAQ,EACR,qBAAqB,EACrB,mBAAmB,EACnB,KAAK,SAAS,EACd,KAAK,cAAc,EACnB,KAAK,SAAS,EACd,KAAK,YAAY,EACjB,KAAK,YAAY,GAClB,MAAM,eAAe,CAAA;AAEtB,OAAO,EACL,kBAAkB,EAClB,aAAa,EACb,KAAK,WAAW,GACjB,MAAM,yBAAyB,CAAA;AAEhC,OAAO,EACL,OAAO,EACP,uBAAuB,GACxB,MAAM,cAAc,CAAA;AAErB,OAAO,EACL,OAAO,EACP,QAAQ,EACR,IAAI,EACJ,KAAK,SAAS,GACf,MAAM,WAAW,CAAA;AAElB,OAAO,EACL,cAAc,EACd,KAAK,OAAO,EACZ,KAAK,eAAe,EACpB,KAAK,cAAc,EACnB,KAAK,cAAc,GACpB,MAAM,iBAAiB,CAAA;AAExB,OAAO,EACL,kBAAkB,EAClB,oBAAoB,EACpB,KAAK,cAAc,EACnB,KAAK,gBAAgB,GACtB,MAAM,uBAAuB,CAAA;AAE9B,OAAO,EACL,cAAc,EACd,KAAK,SAAS,GACf,MAAM,iBAAiB,CAAA;AAExB,OAAO,EACL,QAAQ,EACR,OAAO,EACP,UAAU,EACV,KAAK,QAAQ,EACb,KAAK,QAAQ,EACb,KAAK,IAAI,GACV,MAAM,eAAe,CAAA"}
package/dist/index.js ADDED
@@ -0,0 +1,16 @@
1
+ // v7.5 c1 — @smix/rn public API exports.
2
+ //
3
+ // Mirror Kotlin SDK + Swift SDK surface. Per iron rule §9 #13:
4
+ // this package only ships to test harnesses (vitest / jest / Detox-like
5
+ // runners) — never bundled into a production RN app.
6
+ export { App, } from './App.js';
7
+ export { literal, patternFromJson, patternToJson, regex, } from './Pattern.js';
8
+ export { encodeSelectorJson, Selector, selectorFromJsonValue, selectorToJsonValue, } from './Selector.js';
9
+ export { ExpectationFailure, FAILURE_CODES, } from './ExpectationFailure.js';
10
+ export { Locator, SmixNotImplementedError, } from './Locator.js';
11
+ export { appPath, bundleId, Smix, } from './Smix.js';
12
+ export { MockSimRuntime, } from './SimRuntime.js';
13
+ export { MockLabelsResolver, MockSelectorResolver, } from './SelectorResolver.js';
14
+ export { HttpSimRuntime, } from './HttpRunner.js';
15
+ export { findById, flatten, rectCenter, } from './A11yNode.js';
16
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,yCAAyC;AACzC,EAAE;AACF,+DAA+D;AAC/D,wEAAwE;AACxE,qDAAqD;AAErD,OAAO,EACL,GAAG,GACJ,MAAM,UAAU,CAAA;AAEjB,OAAO,EACL,OAAO,EACP,eAAe,EACf,aAAa,EACb,KAAK,GAEN,MAAM,cAAc,CAAA;AAErB,OAAO,EACL,kBAAkB,EAClB,QAAQ,EACR,qBAAqB,EACrB,mBAAmB,GAMpB,MAAM,eAAe,CAAA;AAEtB,OAAO,EACL,kBAAkB,EAClB,aAAa,GAEd,MAAM,yBAAyB,CAAA;AAEhC,OAAO,EACL,OAAO,EACP,uBAAuB,GACxB,MAAM,cAAc,CAAA;AAErB,OAAO,EACL,OAAO,EACP,QAAQ,EACR,IAAI,GAEL,MAAM,WAAW,CAAA;AAElB,OAAO,EACL,cAAc,GAKf,MAAM,iBAAiB,CAAA;AAExB,OAAO,EACL,kBAAkB,EAClB,oBAAoB,GAGrB,MAAM,uBAAuB,CAAA;AAE9B,OAAO,EACL,cAAc,GAEf,MAAM,iBAAiB,CAAA;AAExB,OAAO,EACL,QAAQ,EACR,OAAO,EACP,UAAU,GAIX,MAAM,eAAe,CAAA"}
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "@goliapkg/smix",
3
+ "version": "0.1.0",
4
+ "description": "Playwright-shape iOS Simulator / Android emulator UI automation SDK for React Native + Expo + Detox-compatible TypeScript test runners.",
5
+ "type": "module",
6
+ "license": "Apache-2.0 OR MIT",
7
+ "main": "./dist/index.js",
8
+ "module": "./dist/index.js",
9
+ "types": "./dist/index.d.ts",
10
+ "exports": {
11
+ ".": {
12
+ "types": "./dist/index.d.ts",
13
+ "import": "./dist/index.js"
14
+ }
15
+ },
16
+ "files": [
17
+ "dist",
18
+ "src",
19
+ "README.md"
20
+ ],
21
+ "scripts": {
22
+ "build": "tsc -p tsconfig.json",
23
+ "test": "vitest run",
24
+ "test:watch": "vitest",
25
+ "typecheck": "tsc --noEmit -p tsconfig.json"
26
+ },
27
+ "devDependencies": {
28
+ "@types/node": "^20.14.0",
29
+ "typescript": "^5.5.0",
30
+ "vitest": "^2.0.0"
31
+ },
32
+ "engines": {
33
+ "node": ">=20"
34
+ },
35
+ "homepage": "https://github.com/goliajp/smix",
36
+ "repository": {
37
+ "type": "git",
38
+ "url": "https://github.com/goliajp/smix.git",
39
+ "directory": "npm/smix-rn"
40
+ },
41
+ "publishConfig": {
42
+ "access": "public"
43
+ }
44
+ }
@@ -0,0 +1,62 @@
1
+ // v7.5 c1 — A11yNode + Rect mirror Rust smix-screen A11yNode +
2
+ // Swift v7.2 + Kotlin v7.4 A11yNode. CamelCase wire keys match
3
+ // Rust `#[serde(rename_all = "camelCase")]`.
4
+
5
+ export interface Rect {
6
+ x: number
7
+ y: number
8
+ w: number
9
+ h: number
10
+ }
11
+
12
+ export function rectCenter(r: Rect): { x: number; y: number } {
13
+ return { x: r.x + r.w / 2, y: r.y + r.h / 2 }
14
+ }
15
+
16
+ export type A11yRole =
17
+ | 'button' | 'link' | 'textField' | 'secureTextField' | 'searchField'
18
+ | 'switch' | 'toggle' | 'checkBox' | 'radio' | 'image' | 'staticText'
19
+ | 'tab' | 'tabBar' | 'navigationBar' | 'cell' | 'alert' | 'dialog'
20
+ | 'slider' | 'progressBar' | 'picker' | 'menu' | 'menuItem'
21
+ | 'scrollView' | 'segmentedControl' | 'table' | 'collectionView'
22
+ | 'webView' | 'keyboard'
23
+
24
+ export interface A11yNode {
25
+ rawType: string
26
+ role?: A11yRole | undefined
27
+ identifier?: string | undefined
28
+ label?: string | undefined
29
+ title?: string | undefined
30
+ placeholderValue?: string | undefined
31
+ value?: string | undefined
32
+ text?: string | undefined
33
+ bounds: Rect
34
+ enabled?: boolean | undefined
35
+ selected?: boolean | undefined
36
+ hasFocus?: boolean | undefined
37
+ visible?: boolean | undefined
38
+ children?: A11yNode[] | undefined
39
+ }
40
+
41
+ /**
42
+ * Recursively find the first node whose [identifier] matches [id].
43
+ */
44
+ export function findById(node: A11yNode, id: string): A11yNode | null {
45
+ if (node.identifier === id) return node
46
+ for (const child of node.children ?? []) {
47
+ const hit = findById(child, id)
48
+ if (hit !== null) return hit
49
+ }
50
+ return null
51
+ }
52
+
53
+ /**
54
+ * DFS pre-order flatten — self + all descendants in a single list.
55
+ */
56
+ export function flatten(node: A11yNode): A11yNode[] {
57
+ const out: A11yNode[] = [node]
58
+ for (const child of node.children ?? []) {
59
+ out.push(...flatten(child))
60
+ }
61
+ return out
62
+ }
package/src/App.ts ADDED
@@ -0,0 +1,153 @@
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'
4
+ import { ExpectationFailure } from './ExpectationFailure.js'
5
+ 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'
15
+ import type { LabelsResolver, SelectorResolver } from './SelectorResolver.js'
16
+
17
+ /**
18
+ * A handle to a running app on the simulator / emulator.
19
+ */
20
+ export class App {
21
+ constructor(
22
+ public readonly bundleId: string,
23
+ public readonly runtime: SmixSimRuntime,
24
+ public readonly resolver: SelectorResolver,
25
+ /**
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).
30
+ */
31
+ public readonly labelsResolver: LabelsResolver = async () => {
32
+ throw new Error('labelsResolver not configured; pass one to Smix.launchApp or App constructor')
33
+ },
34
+ ) {}
35
+
36
+ // ---- act methods --------------------------------------------------
37
+
38
+ async tap(selector: Selector, _opts: { timeoutMs?: number } = {}): Promise<void> {
39
+ const tree = await this.runtime.snapshotTree()
40
+ 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) {
58
+ throw new ExpectationFailure({
59
+ code: 'notFound',
60
+ message: 'no candidates after spatial + index filters',
61
+ selectorJson,
62
+ 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
+ })
68
+ }
69
+
70
+ await this.runtime.synthesizeTap(
71
+ node.bounds.x + node.bounds.w / 2,
72
+ node.bounds.y + node.bounds.h / 2,
73
+ )
74
+ }
75
+
76
+ /**
77
+ * Resolve [selector], tap to focus, then type [text]. Mirror
78
+ * Kotlin App.fill from v7.4 c3.
79
+ */
80
+ async fill(selector: Selector, text: string): Promise<void> {
81
+ await this.tap(selector)
82
+ await this.runtime.sendString(text)
83
+ }
84
+
85
+ async pressKey(key: KeyName): Promise<void> {
86
+ await this.runtime.pressKey(key)
87
+ }
88
+
89
+ async swipe(direction: SwipeDirection): Promise<void> {
90
+ await this.runtime.swipe(direction)
91
+ }
92
+
93
+ async screenshot(): Promise<Uint8Array> {
94
+ return this.runtime.screenshot()
95
+ }
96
+
97
+ /**
98
+ * Tap at a normalized coordinate (0..1) — escape hatch per §9 #3.
99
+ * Out-of-range values throw ExpectationFailure code=wrongState.
100
+ */
101
+ async tapAtCoord(nx: number, ny: number): Promise<void> {
102
+ if (nx < 0 || nx > 1 || ny < 0 || ny > 1) {
103
+ 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'],
107
+ })
108
+ }
109
+ await this.runtime.synthesizeTapAtNormalized(nx, ny)
110
+ }
111
+
112
+ async terminate(): Promise<void> {
113
+ await this.runtime.terminate(this.bundleId)
114
+ }
115
+
116
+ async relaunch(): Promise<void> {
117
+ await this.runtime.terminate(this.bundleId)
118
+ await this.runtime.launch(this.bundleId)
119
+ }
120
+
121
+ async launchFresh(opts: {
122
+ clearState?: boolean
123
+ clearKeychain?: boolean
124
+ appPath?: string
125
+ } = {}): 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
+ })
132
+ }
133
+
134
+ // ---- sense methods ------------------------------------------------
135
+
136
+ find(selector: Selector): Locator {
137
+ return new Locator(this, selector)
138
+ }
139
+
140
+ async tree(): Promise<A11yNode> {
141
+ return this.runtime.snapshotTree()
142
+ }
143
+
144
+ async systemPopups(): Promise<A11yNode[]> {
145
+ return this.runtime.systemPopups()
146
+ }
147
+
148
+ async openUrl(url: string): Promise<void> {
149
+ await this.runtime.openUrl(url)
150
+ }
151
+ }
152
+
153
+ export { SmixNotImplementedError }
@@ -0,0 +1,67 @@
1
+ // v7.5 c1 — ExpectationFailure + FailureCode mirror Swift v7.2 c4 +
2
+ // Kotlin v7.4 c1 + Rust smix-error.
3
+ //
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).
7
+
8
+ import type { A11yNode } from './A11yNode.js'
9
+
10
+ export type FailureCode =
11
+ | 'notFound'
12
+ | 'ambiguous'
13
+ | 'notInteractable'
14
+ | 'timeout'
15
+ | 'wrongState'
16
+ | 'unknown'
17
+
18
+ export const FAILURE_CODES: readonly FailureCode[] = [
19
+ 'notFound', 'ambiguous', 'notInteractable',
20
+ 'timeout', 'wrongState', 'unknown',
21
+ ] as const
22
+
23
+ /**
24
+ * Structured failure thrown by SDK resolver paths (App.tap, Locator
25
+ * assertions) when matching fails. Wire format matches Swift +
26
+ * Kotlin + Rust 1:1.
27
+ */
28
+ export class ExpectationFailure extends Error {
29
+ readonly code: FailureCode
30
+ readonly selectorJson: string | null
31
+ readonly visibleElements: readonly A11yNode[]
32
+ readonly suggestions: readonly string[]
33
+ readonly timestamp: number
34
+
35
+ constructor(opts: {
36
+ code: FailureCode
37
+ message: string
38
+ selectorJson?: string | undefined
39
+ visibleElements?: readonly A11yNode[] | undefined
40
+ suggestions?: readonly string[] | undefined
41
+ timestamp?: number | undefined
42
+ }) {
43
+ super(opts.message)
44
+ this.name = 'ExpectationFailure'
45
+ this.code = opts.code
46
+ this.selectorJson = opts.selectorJson ?? null
47
+ this.visibleElements = opts.visibleElements ?? []
48
+ this.suggestions = opts.suggestions ?? []
49
+ this.timestamp = opts.timestamp ?? Date.now()
50
+ }
51
+
52
+ /**
53
+ * AI-readable JSON dump — what Claude Code sees when the test fails.
54
+ * Sorted keys, single line. Mirror Swift errorDescription / Kotlin
55
+ * errorJson() byte-shape.
56
+ */
57
+ toJson(): string {
58
+ return JSON.stringify({
59
+ code: this.code,
60
+ message: this.message,
61
+ selector: this.selectorJson,
62
+ suggestions: this.suggestions,
63
+ timestamp: this.timestamp,
64
+ visibleElements: this.visibleElements,
65
+ })
66
+ }
67
+ }
@@ -0,0 +1,185 @@
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.
32
+
33
+ import { type A11yNode } from './A11yNode.js'
34
+ import type {
35
+ KeyName,
36
+ LaunchFreshCall,
37
+ SmixSimRuntime,
38
+ SwipeDirection,
39
+ } from './SimRuntime.js'
40
+ import type { LabelsResolver, SelectorResolver } from './SelectorResolver.js'
41
+
42
+ /**
43
+ * Minimal HTTP client surface — overridable for testing. fetch-compatible
44
+ * signature so consumers can pass globalThis.fetch directly.
45
+ */
46
+ export type HttpFetch = (
47
+ input: string,
48
+ init: { method: 'POST'; headers: Record<string, string>; body: string },
49
+ ) => Promise<{ ok: boolean; status: number; json(): Promise<unknown>; text(): Promise<string> }>
50
+
51
+ /**
52
+ * SmixSimRuntime + SelectorResolver wrapper around the smix-runner HTTP
53
+ * server. Used in production RN/Expo test targets via fetch.
54
+ */
55
+ export class HttpSimRuntime implements SmixSimRuntime {
56
+ readonly resolver: SelectorResolver
57
+ /**
58
+ * v7.7 c1 — labels resolver wrapping Rust `resolve_selector_labels`
59
+ * via smix-runner-server `/select/resolve-labels` endpoint. Used by
60
+ * `Locator.toHaveCount` (count = labels.length) + Locator.toHaveLabel.
61
+ * Pass directly to App constructor or Smix.launchApp.
62
+ */
63
+ readonly labelsResolver: LabelsResolver
64
+
65
+ constructor(
66
+ public readonly baseUrl: string,
67
+ public readonly fetchImpl: HttpFetch = globalThis.fetch as unknown as HttpFetch,
68
+ ) {
69
+ this.resolver = async (treeJson, selectorJson) => {
70
+ const r = await this.post('/select/resolve', { treeJson, selectorJson })
71
+ return (r as { ids: readonly string[] }).ids
72
+ }
73
+ this.labelsResolver = async (treeJson, selectorJson) => {
74
+ const r = await this.post('/select/resolve-labels', { treeJson, selectorJson })
75
+ return (r as { labels: readonly string[] }).labels
76
+ }
77
+ }
78
+
79
+ /**
80
+ * v7.7 c1 — direct match-count via dedicated FFI fn (avoid allocating
81
+ * the full ids list when callers only need count). Mirrors Rust
82
+ * `resolve_selector_count`. Locator.toHaveCount can prefer this over
83
+ * `labelsResolver` if it needs only count, not the labels themselves.
84
+ */
85
+ async resolveCount(treeJson: string, selectorJson: string): Promise<number> {
86
+ const r = await this.post('/select/resolve-count', { treeJson, selectorJson })
87
+ return (r as { count: number }).count
88
+ }
89
+
90
+ async launch(bundleId: string): Promise<void> {
91
+ await this.post('/sim/launch', { bundleId })
92
+ }
93
+
94
+ async terminate(bundleId: string): Promise<void> {
95
+ await this.post('/sim/terminate', { bundleId })
96
+ }
97
+
98
+ async snapshotTree(): Promise<A11yNode> {
99
+ const r = await this.post('/a11y/snapshot', {})
100
+ return (r as { tree: A11yNode }).tree
101
+ }
102
+
103
+ async synthesizeTap(x: number, y: number): Promise<void> {
104
+ await this.post('/input/tap', { x, y })
105
+ }
106
+
107
+ async sendString(text: string): Promise<void> {
108
+ await this.post('/input/send-string', { text })
109
+ }
110
+
111
+ async pressKey(key: KeyName): Promise<void> {
112
+ await this.post('/input/press-key', { key })
113
+ }
114
+
115
+ async swipe(direction: SwipeDirection): Promise<void> {
116
+ await this.post('/input/swipe', { direction })
117
+ }
118
+
119
+ async screenshot(): Promise<Uint8Array> {
120
+ const r = await this.post('/sim/screenshot', {}) as { png: string }
121
+ return base64ToBytes(r.png)
122
+ }
123
+
124
+ async systemPopups(): Promise<A11yNode[]> {
125
+ const r = await this.post('/sim/system-popups', {}) as { nodes: A11yNode[] }
126
+ return r.nodes
127
+ }
128
+
129
+ async openUrl(url: string): Promise<void> {
130
+ await this.post('/sim/open-url', { url })
131
+ }
132
+
133
+ async launchFresh(opts: {
134
+ bundleId: string
135
+ clearState: boolean
136
+ clearKeychain: boolean
137
+ appPath?: string | undefined
138
+ }): Promise<void> {
139
+ const payload: LaunchFreshCall = {
140
+ bundleId: opts.bundleId,
141
+ clearState: opts.clearState,
142
+ clearKeychain: opts.clearKeychain,
143
+ appPath: opts.appPath,
144
+ }
145
+ await this.post('/sim/launch-fresh', payload)
146
+ }
147
+
148
+ async launchFromPath(appPath: string): Promise<void> {
149
+ await this.post('/sim/launch-from-path', { appPath })
150
+ }
151
+
152
+ async synthesizeTapAtNormalized(nx: number, ny: number): Promise<void> {
153
+ await this.post('/input/tap-normalized', { nx, ny })
154
+ }
155
+
156
+ private async post(path: string, body: unknown): Promise<unknown> {
157
+ const url = `${this.baseUrl}${path}`
158
+ const resp = await this.fetchImpl(url, {
159
+ method: 'POST',
160
+ headers: { 'content-type': 'application/json' },
161
+ body: JSON.stringify(body),
162
+ })
163
+ if (!resp.ok) {
164
+ const text = await resp.text()
165
+ throw new Error(`smix-runner ${path} → HTTP ${resp.status}: ${text}`)
166
+ }
167
+ // 200 with no body returns undefined; tolerate empty response
168
+ try {
169
+ return await resp.json()
170
+ } catch {
171
+ return undefined
172
+ }
173
+ }
174
+ }
175
+
176
+ function base64ToBytes(b64: string): Uint8Array {
177
+ if (typeof globalThis.atob === 'function') {
178
+ const bin = globalThis.atob(b64)
179
+ const out = new Uint8Array(bin.length)
180
+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i)
181
+ return out
182
+ }
183
+ // Node fallback
184
+ return Uint8Array.from(Buffer.from(b64, 'base64'))
185
+ }