@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/dist/index.d.ts
ADDED
|
@@ -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
|
+
}
|
package/src/A11yNode.ts
ADDED
|
@@ -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
|
+
}
|