@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.
- package/README.md +53 -130
- package/dist/A11yNode.d.ts.map +1 -1
- package/dist/A11yNode.js +2 -3
- package/dist/A11yNode.js.map +1 -1
- package/dist/App.d.ts +37 -22
- package/dist/App.d.ts.map +1 -1
- package/dist/App.js +72 -66
- package/dist/App.js.map +1 -1
- package/dist/ExpectationFailure.d.ts +8 -1
- package/dist/ExpectationFailure.d.ts.map +1 -1
- package/dist/ExpectationFailure.js +7 -7
- package/dist/ExpectationFailure.js.map +1 -1
- package/dist/HttpRunner.d.ts +9 -31
- package/dist/HttpRunner.d.ts.map +1 -1
- package/dist/HttpRunner.js +14 -100
- package/dist/HttpRunner.js.map +1 -1
- package/dist/Locator.d.ts +7 -9
- package/dist/Locator.d.ts.map +1 -1
- package/dist/Locator.js +17 -18
- package/dist/Locator.js.map +1 -1
- package/dist/NodeDriver.d.ts +53 -0
- package/dist/NodeDriver.d.ts.map +1 -0
- package/dist/NodeDriver.js +70 -0
- package/dist/NodeDriver.js.map +1 -0
- package/dist/Pattern.js +2 -2
- package/dist/Pattern.js.map +1 -1
- package/dist/Selector.d.ts.map +1 -1
- package/dist/Selector.js +5 -8
- package/dist/Selector.js.map +1 -1
- package/dist/SelectorResolver.d.ts +4 -6
- package/dist/SelectorResolver.d.ts.map +1 -1
- package/dist/SelectorResolver.js +5 -8
- package/dist/SelectorResolver.js.map +1 -1
- package/dist/Smix.d.ts +12 -4
- package/dist/Smix.d.ts.map +1 -1
- package/dist/Smix.js +25 -23
- package/dist/Smix.js.map +1 -1
- package/dist/SystemPopup.d.ts +18 -0
- package/dist/SystemPopup.d.ts.map +1 -0
- package/dist/SystemPopup.js +7 -0
- package/dist/SystemPopup.js.map +1 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/loadNodeDriver.d.ts +21 -0
- package/dist/loadNodeDriver.d.ts.map +1 -0
- package/dist/loadNodeDriver.js +33 -0
- package/dist/loadNodeDriver.js.map +1 -0
- package/package.json +5 -2
- package/src/A11yNode.ts +2 -3
- package/src/App.ts +75 -78
- package/src/ExpectationFailure.ts +23 -13
- package/src/HttpRunner.ts +15 -126
- package/src/Locator.ts +17 -18
- package/src/NodeDriver.ts +109 -0
- package/src/Pattern.ts +2 -2
- package/src/Selector.ts +5 -8
- package/src/SelectorResolver.ts +7 -11
- package/src/Smix.ts +30 -26
- package/src/SystemPopup.ts +24 -0
- package/src/__tests__/AppDriving.test.ts +106 -0
- package/src/__tests__/MvpApiShape.test.ts +78 -46
- package/src/__tests__/ReadmeSnippets.test.ts +132 -0
- package/src/__tests__/RealAddonLoopback.test.ts +81 -0
- package/src/__tests__/SelectorFullSchema.test.ts +2 -4
- package/src/index.ts +19 -8
- package/src/loadNodeDriver.ts +41 -0
- package/src/SimRuntime.ts +0 -145
- package/src/__tests__/AppFullApi.test.ts +0 -168
- package/src/__tests__/AppTapMock.test.ts +0 -122
- package/src/__tests__/HttpRunner.test.ts +0 -121
- package/src/__tests__/Locator.test.ts +0 -128
- package/src/__tests__/LocatorToHave.test.ts +0 -118
package/src/App.ts
CHANGED
|
@@ -1,18 +1,21 @@
|
|
|
1
|
-
//
|
|
2
|
-
|
|
3
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
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
|
-
|
|
39
|
-
|
|
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
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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: '
|
|
60
|
-
message:
|
|
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
|
-
|
|
71
|
-
|
|
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.
|
|
82
|
-
await this.
|
|
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.
|
|
78
|
+
await this.driver.pressKey(key === 'enter' ? 'return' : key)
|
|
87
79
|
}
|
|
88
80
|
|
|
89
81
|
async swipe(direction: SwipeDirection): Promise<void> {
|
|
90
|
-
await this.
|
|
82
|
+
await this.driver.swipe(direction)
|
|
91
83
|
}
|
|
92
84
|
|
|
93
85
|
async screenshot(): Promise<Uint8Array> {
|
|
94
|
-
|
|
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: '
|
|
105
|
-
message: `tapAtCoord
|
|
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.
|
|
99
|
+
await this.driver.tapAtCoord(nx, ny)
|
|
110
100
|
}
|
|
111
101
|
|
|
112
102
|
async terminate(): Promise<void> {
|
|
113
|
-
await this.
|
|
103
|
+
await this.session.terminateApp()
|
|
114
104
|
}
|
|
115
105
|
|
|
116
106
|
async relaunch(): Promise<void> {
|
|
117
|
-
await this.
|
|
118
|
-
await this.runtime.launch(this.bundleId)
|
|
107
|
+
await this.session.relaunchApp()
|
|
119
108
|
}
|
|
120
109
|
|
|
121
|
-
async launchFresh(
|
|
110
|
+
async launchFresh(_opts: {
|
|
122
111
|
clearState?: boolean
|
|
123
112
|
clearKeychain?: boolean
|
|
124
113
|
appPath?: string
|
|
125
114
|
} = {}): Promise<void> {
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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.
|
|
136
|
+
return this.snapshotTree()
|
|
142
137
|
}
|
|
143
138
|
|
|
144
|
-
async systemPopups(): Promise<
|
|
145
|
-
return this.
|
|
139
|
+
async systemPopups(): Promise<SystemPopup[]> {
|
|
140
|
+
return JSON.parse(await this.driver.systemPopups()) as SystemPopup[]
|
|
146
141
|
}
|
|
147
142
|
|
|
148
|
-
async openUrl(
|
|
149
|
-
|
|
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
|
-
//
|
|
2
|
-
// Kotlin v7.4 c1 + Rust smix-error.
|
|
1
|
+
// The wire form of `smix-error`'s failure surface.
|
|
3
2
|
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
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
|
-
| '
|
|
12
|
-
| '
|
|
13
|
-
| '
|
|
14
|
-
| '
|
|
15
|
-
| '
|
|
16
|
-
| '
|
|
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
|
-
'
|
|
20
|
-
'
|
|
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
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
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
|
-
*
|
|
60
|
-
*
|
|
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
|
|
32
|
+
export class HttpSimRuntime {
|
|
63
33
|
readonly resolver: SelectorResolver
|
|
64
34
|
/**
|
|
65
|
-
*
|
|
66
|
-
*
|
|
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
|
-
*
|
|
132
|
-
*
|
|
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
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
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
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
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.
|
|
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: '
|
|
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: '
|
|
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
|
|
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.
|
|
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: '
|
|
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
|
-
|
|
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: '
|
|
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
|
+
}
|