@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
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// v7.5 c1 — SmixSimRuntime interface + MockSimRuntime impl.
|
|
2
|
+
//
|
|
3
|
+
// Mirror Swift v7.2 + Kotlin v7.4 SimRuntime architecture. TS SDK
|
|
4
|
+
// is HTTP-runner-friendly (per R-7.5-B decision):
|
|
5
|
+
// - HttpSimRuntime (real, lives in src/HttpRunner.ts, v7.5 c3):
|
|
6
|
+
// wraps smix-runner HTTP server via fetch (cross-platform iOS+Android).
|
|
7
|
+
// - MockSimRuntime (here): in-memory tree + event log for unit tests.
|
|
8
|
+
//
|
|
9
|
+
// Per iron rule §9 #13: this module ships only to test targets.
|
|
10
|
+
|
|
11
|
+
import type { A11yNode } from './A11yNode.js'
|
|
12
|
+
|
|
13
|
+
export type KeyName = 'return' | 'delete' | 'space' | 'tab' | 'escape' | 'enter'
|
|
14
|
+
export type SwipeDirection = 'up' | 'down' | 'left' | 'right'
|
|
15
|
+
|
|
16
|
+
/** Low-level emulator runtime — captures + injects events. */
|
|
17
|
+
export interface SmixSimRuntime {
|
|
18
|
+
launch(bundleId: string): Promise<void>
|
|
19
|
+
terminate(bundleId: string): Promise<void>
|
|
20
|
+
snapshotTree(): Promise<A11yNode>
|
|
21
|
+
synthesizeTap(x: number, y: number): Promise<void>
|
|
22
|
+
sendString(text: string): Promise<void>
|
|
23
|
+
pressKey(key: KeyName): Promise<void>
|
|
24
|
+
swipe(direction: SwipeDirection): Promise<void>
|
|
25
|
+
screenshot(): Promise<Uint8Array>
|
|
26
|
+
systemPopups(): Promise<A11yNode[]>
|
|
27
|
+
openUrl(url: string): Promise<void>
|
|
28
|
+
launchFresh(opts: {
|
|
29
|
+
bundleId: string
|
|
30
|
+
clearState: boolean
|
|
31
|
+
clearKeychain: boolean
|
|
32
|
+
appPath?: string | undefined
|
|
33
|
+
}): Promise<void>
|
|
34
|
+
launchFromPath(appPath: string): Promise<void>
|
|
35
|
+
synthesizeTapAtNormalized(nx: number, ny: number): Promise<void>
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface LaunchFreshCall {
|
|
39
|
+
bundleId: string
|
|
40
|
+
clearState: boolean
|
|
41
|
+
clearKeychain: boolean
|
|
42
|
+
appPath: string | undefined
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const EMPTY_DEFAULT: A11yNode = {
|
|
46
|
+
rawType: 'other',
|
|
47
|
+
bounds: { x: 0, y: 0, w: 393, h: 852 },
|
|
48
|
+
enabled: true,
|
|
49
|
+
visible: true,
|
|
50
|
+
children: [],
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* In-memory mock runtime — used by vitest unit tests for deterministic
|
|
55
|
+
* wire verification without booting an emulator.
|
|
56
|
+
*/
|
|
57
|
+
export class MockSimRuntime implements SmixSimRuntime {
|
|
58
|
+
snapshotResult: A11yNode = EMPTY_DEFAULT
|
|
59
|
+
readonly tapCalls: { x: number; y: number }[] = []
|
|
60
|
+
readonly launchCalls: string[] = []
|
|
61
|
+
readonly terminateCalls: string[] = []
|
|
62
|
+
readonly sendStringCalls: string[] = []
|
|
63
|
+
readonly pressKeyCalls: KeyName[] = []
|
|
64
|
+
readonly swipeCalls: SwipeDirection[] = []
|
|
65
|
+
screenshotCalls = 0
|
|
66
|
+
screenshotResult: Uint8Array = new Uint8Array(0)
|
|
67
|
+
systemPopupsResult: A11yNode[] = []
|
|
68
|
+
readonly openUrlCalls: string[] = []
|
|
69
|
+
readonly launchFreshCalls: LaunchFreshCall[] = []
|
|
70
|
+
readonly launchFromPathCalls: string[] = []
|
|
71
|
+
readonly tapAtNormalizedCalls: { nx: number; ny: number }[] = []
|
|
72
|
+
|
|
73
|
+
/** Hook invoked just before [snapshotTree] returns. Mirrors Swift/Kotlin. */
|
|
74
|
+
afterSnapshot: ((callIndex: number) => void) | null = null
|
|
75
|
+
private snapshotCallCount = 0
|
|
76
|
+
|
|
77
|
+
constructor(opts: { snapshotResult?: A11yNode } = {}) {
|
|
78
|
+
if (opts.snapshotResult !== undefined) this.snapshotResult = opts.snapshotResult
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async launch(bundleId: string): Promise<void> {
|
|
82
|
+
this.launchCalls.push(bundleId)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async terminate(bundleId: string): Promise<void> {
|
|
86
|
+
this.terminateCalls.push(bundleId)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async snapshotTree(): Promise<A11yNode> {
|
|
90
|
+
const idx = this.snapshotCallCount++
|
|
91
|
+
this.afterSnapshot?.(idx)
|
|
92
|
+
return this.snapshotResult
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async synthesizeTap(x: number, y: number): Promise<void> {
|
|
96
|
+
this.tapCalls.push({ x, y })
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async sendString(text: string): Promise<void> {
|
|
100
|
+
this.sendStringCalls.push(text)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async pressKey(key: KeyName): Promise<void> {
|
|
104
|
+
this.pressKeyCalls.push(key)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async swipe(direction: SwipeDirection): Promise<void> {
|
|
108
|
+
this.swipeCalls.push(direction)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async screenshot(): Promise<Uint8Array> {
|
|
112
|
+
this.screenshotCalls++
|
|
113
|
+
return this.screenshotResult
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async systemPopups(): Promise<A11yNode[]> {
|
|
117
|
+
return this.systemPopupsResult
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async openUrl(url: string): Promise<void> {
|
|
121
|
+
this.openUrlCalls.push(url)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async launchFresh(opts: {
|
|
125
|
+
bundleId: string
|
|
126
|
+
clearState: boolean
|
|
127
|
+
clearKeychain: boolean
|
|
128
|
+
appPath?: string | undefined
|
|
129
|
+
}): Promise<void> {
|
|
130
|
+
this.launchFreshCalls.push({
|
|
131
|
+
bundleId: opts.bundleId,
|
|
132
|
+
clearState: opts.clearState,
|
|
133
|
+
clearKeychain: opts.clearKeychain,
|
|
134
|
+
appPath: opts.appPath,
|
|
135
|
+
})
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async launchFromPath(appPath: string): Promise<void> {
|
|
139
|
+
this.launchFromPathCalls.push(appPath)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async synthesizeTapAtNormalized(nx: number, ny: number): Promise<void> {
|
|
143
|
+
this.tapAtNormalizedCalls.push({ nx, ny })
|
|
144
|
+
}
|
|
145
|
+
}
|
package/src/Smix.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// v7.5 c1 — Smix top-level entry. Mirror Kotlin / Swift Smix.launchApp.
|
|
2
|
+
//
|
|
3
|
+
// Per R-7.5-B HTTP-runtime decision, the default SelectorResolver for
|
|
4
|
+
// production points at the smix-runner HTTP server (lands v7.5 c3 in
|
|
5
|
+
// src/HttpRunner.ts). For c1 MVP, callers must pass an explicit
|
|
6
|
+
// SelectorResolver (e.g. MockSelectorResolver in tests; HttpSelectorResolver
|
|
7
|
+
// in c3 production).
|
|
8
|
+
|
|
9
|
+
import { App } from './App.js'
|
|
10
|
+
import { SmixNotImplementedError } from './Locator.js'
|
|
11
|
+
import type { LabelsResolver, SelectorResolver } from './SelectorResolver.js'
|
|
12
|
+
import type { SmixSimRuntime } from './SimRuntime.js'
|
|
13
|
+
|
|
14
|
+
export type AppTarget =
|
|
15
|
+
| { kind: 'bundleId'; value: string }
|
|
16
|
+
| { kind: 'appPath'; path: string }
|
|
17
|
+
|
|
18
|
+
export const bundleId = (value: string): AppTarget => ({ kind: 'bundleId', value })
|
|
19
|
+
export const appPath = (path: string): AppTarget => ({ kind: 'appPath', path })
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Top-level entry. Launches the target app via [runtime] and returns
|
|
23
|
+
* an [App] handle wired with the given [resolver] for selector queries.
|
|
24
|
+
*/
|
|
25
|
+
export const Smix = {
|
|
26
|
+
async launchApp(
|
|
27
|
+
target: AppTarget,
|
|
28
|
+
runtime: SmixSimRuntime,
|
|
29
|
+
resolver: SelectorResolver,
|
|
30
|
+
labelsResolver?: LabelsResolver,
|
|
31
|
+
): Promise<App> {
|
|
32
|
+
switch (target.kind) {
|
|
33
|
+
case 'bundleId': {
|
|
34
|
+
await runtime.launch(target.value)
|
|
35
|
+
return labelsResolver !== undefined
|
|
36
|
+
? new App(target.value, runtime, resolver, labelsResolver)
|
|
37
|
+
: new App(target.value, runtime, resolver)
|
|
38
|
+
}
|
|
39
|
+
case 'appPath': {
|
|
40
|
+
await runtime.launchFromPath(target.path)
|
|
41
|
+
return labelsResolver !== undefined
|
|
42
|
+
? new App(target.path, runtime, resolver, labelsResolver)
|
|
43
|
+
: new App(target.path, runtime, resolver)
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
} as const
|
|
48
|
+
|
|
49
|
+
export { SmixNotImplementedError }
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
// v7.5 c3 — App act/sense full API wire tests, mirror Kotlin
|
|
2
|
+
// AppActSenseExtMockTest + AppFillPressKeyMockTest + Swift v7.2 c1/c2/c5.
|
|
3
|
+
|
|
4
|
+
import { describe, expect, test } from 'vitest'
|
|
5
|
+
import {
|
|
6
|
+
bundleId,
|
|
7
|
+
ExpectationFailure,
|
|
8
|
+
literal,
|
|
9
|
+
MockSelectorResolver,
|
|
10
|
+
MockSimRuntime,
|
|
11
|
+
Selector,
|
|
12
|
+
Smix,
|
|
13
|
+
type A11yNode,
|
|
14
|
+
} from '../index.js'
|
|
15
|
+
|
|
16
|
+
describe('App.fill / pressKey', () => {
|
|
17
|
+
test('fill = tap-to-focus + sendString', async () => {
|
|
18
|
+
const field: A11yNode = {
|
|
19
|
+
rawType: 'textField',
|
|
20
|
+
identifier: 'input-username',
|
|
21
|
+
label: 'Username',
|
|
22
|
+
bounds: { x: 50, y: 200, w: 200, h: 40 },
|
|
23
|
+
enabled: true,
|
|
24
|
+
visible: true,
|
|
25
|
+
}
|
|
26
|
+
const tree: A11yNode = {
|
|
27
|
+
rawType: 'other',
|
|
28
|
+
bounds: { x: 0, y: 0, w: 393, h: 852 },
|
|
29
|
+
visible: true,
|
|
30
|
+
children: [field],
|
|
31
|
+
}
|
|
32
|
+
const runtime = new MockSimRuntime({ snapshotResult: tree })
|
|
33
|
+
const resolver = new MockSelectorResolver()
|
|
34
|
+
resolver.registerHit('{"id":"input-username"}', 'input-username')
|
|
35
|
+
const app = await Smix.launchApp(bundleId('dev.smix.fixture'), runtime, resolver.resolve)
|
|
36
|
+
|
|
37
|
+
await app.fill(Selector.id('input-username'), 'alice')
|
|
38
|
+
|
|
39
|
+
expect(runtime.tapCalls.length).toBe(1)
|
|
40
|
+
expect(runtime.tapCalls[0]?.x).toBeCloseTo(150, 1)
|
|
41
|
+
expect(runtime.tapCalls[0]?.y).toBeCloseTo(220, 1)
|
|
42
|
+
expect(runtime.sendStringCalls).toEqual(['alice'])
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
test('pressKey delegates to runtime', async () => {
|
|
46
|
+
const runtime = new MockSimRuntime()
|
|
47
|
+
const app = await Smix.launchApp(bundleId('dev.smix.fixture'), runtime, new MockSelectorResolver().resolve)
|
|
48
|
+
await app.pressKey('return')
|
|
49
|
+
await app.pressKey('delete')
|
|
50
|
+
expect(runtime.pressKeyCalls).toEqual(['return', 'delete'])
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
test('fill failure surfaces ExpectationFailure (no sendString fired)', async () => {
|
|
54
|
+
const runtime = new MockSimRuntime()
|
|
55
|
+
const app = await Smix.launchApp(bundleId('dev.smix.fixture'), runtime, new MockSelectorResolver().resolve)
|
|
56
|
+
await expect(app.fill(Selector.id('missing'), 'text')).rejects.toBeInstanceOf(ExpectationFailure)
|
|
57
|
+
expect(runtime.sendStringCalls).toEqual([])
|
|
58
|
+
})
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
describe('App.swipe / screenshot / systemPopups / openUrl', () => {
|
|
62
|
+
test('swipe delegates with 4 directions', async () => {
|
|
63
|
+
const runtime = new MockSimRuntime()
|
|
64
|
+
const app = await Smix.launchApp(bundleId('dev.smix.fixture'), runtime, new MockSelectorResolver().resolve)
|
|
65
|
+
await app.swipe('down')
|
|
66
|
+
await app.swipe('up')
|
|
67
|
+
await app.swipe('left')
|
|
68
|
+
await app.swipe('right')
|
|
69
|
+
expect(runtime.swipeCalls).toEqual(['down', 'up', 'left', 'right'])
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
test('screenshot returns runtime bytes', async () => {
|
|
73
|
+
const expected = new Uint8Array([0x89, 0x50, 0x4e, 0x47])
|
|
74
|
+
const runtime = new MockSimRuntime()
|
|
75
|
+
runtime.screenshotResult = expected
|
|
76
|
+
const app = await Smix.launchApp(bundleId('dev.smix.fixture'), runtime, new MockSelectorResolver().resolve)
|
|
77
|
+
const out = await app.screenshot()
|
|
78
|
+
expect(out).toEqual(expected)
|
|
79
|
+
expect(runtime.screenshotCalls).toBe(1)
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
test('systemPopups returns runtime list', async () => {
|
|
83
|
+
const alert: A11yNode = {
|
|
84
|
+
rawType: 'alert',
|
|
85
|
+
identifier: 'alert-permission',
|
|
86
|
+
bounds: { x: 0, y: 0, w: 200, h: 100 },
|
|
87
|
+
}
|
|
88
|
+
const runtime = new MockSimRuntime()
|
|
89
|
+
runtime.systemPopupsResult = [alert]
|
|
90
|
+
const app = await Smix.launchApp(bundleId('dev.smix.fixture'), runtime, new MockSelectorResolver().resolve)
|
|
91
|
+
const popups = await app.systemPopups()
|
|
92
|
+
expect(popups.length).toBe(1)
|
|
93
|
+
expect(popups[0]?.identifier).toBe('alert-permission')
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
test('openUrl forwards to runtime', async () => {
|
|
97
|
+
const runtime = new MockSimRuntime()
|
|
98
|
+
const app = await Smix.launchApp(bundleId('dev.smix.fixture'), runtime, new MockSelectorResolver().resolve)
|
|
99
|
+
await app.openUrl('smix://deep/link?id=42')
|
|
100
|
+
expect(runtime.openUrlCalls).toEqual(['smix://deep/link?id=42'])
|
|
101
|
+
})
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
describe('App.launchFresh', () => {
|
|
105
|
+
test('clearState + clearKeychain', async () => {
|
|
106
|
+
const runtime = new MockSimRuntime()
|
|
107
|
+
const app = await Smix.launchApp(bundleId('dev.smix.fixture'), runtime, new MockSelectorResolver().resolve)
|
|
108
|
+
await app.launchFresh({ clearState: true, clearKeychain: true })
|
|
109
|
+
expect(runtime.launchFreshCalls.length).toBe(1)
|
|
110
|
+
const call = runtime.launchFreshCalls[0]!
|
|
111
|
+
expect(call.bundleId).toBe('dev.smix.fixture')
|
|
112
|
+
expect(call.clearState).toBe(true)
|
|
113
|
+
expect(call.clearKeychain).toBe(true)
|
|
114
|
+
expect(call.appPath).toBeUndefined()
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
test('with appPath override', async () => {
|
|
118
|
+
const runtime = new MockSimRuntime()
|
|
119
|
+
const app = await Smix.launchApp(bundleId('dev.smix.fixture'), runtime, new MockSelectorResolver().resolve)
|
|
120
|
+
await app.launchFresh({ appPath: '/tmp/Reinstall.apk' })
|
|
121
|
+
expect(runtime.launchFreshCalls[0]?.appPath).toBe('/tmp/Reinstall.apk')
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
test('defaults are false', async () => {
|
|
125
|
+
const runtime = new MockSimRuntime()
|
|
126
|
+
const app = await Smix.launchApp(bundleId('dev.smix.fixture'), runtime, new MockSelectorResolver().resolve)
|
|
127
|
+
await app.launchFresh()
|
|
128
|
+
const call = runtime.launchFreshCalls[0]!
|
|
129
|
+
expect(call.clearState).toBe(false)
|
|
130
|
+
expect(call.clearKeychain).toBe(false)
|
|
131
|
+
})
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
describe('App.tapAtCoord', () => {
|
|
135
|
+
test('valid range', async () => {
|
|
136
|
+
const runtime = new MockSimRuntime()
|
|
137
|
+
const app = await Smix.launchApp(bundleId('dev.smix.fixture'), runtime, new MockSelectorResolver().resolve)
|
|
138
|
+
await app.tapAtCoord(0.5, 0.5)
|
|
139
|
+
expect(runtime.tapAtNormalizedCalls.length).toBe(1)
|
|
140
|
+
expect(runtime.tapAtNormalizedCalls[0]).toEqual({ nx: 0.5, ny: 0.5 })
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
test('edge values 0,0 and 1,1', async () => {
|
|
144
|
+
const runtime = new MockSimRuntime()
|
|
145
|
+
const app = await Smix.launchApp(bundleId('dev.smix.fixture'), runtime, new MockSelectorResolver().resolve)
|
|
146
|
+
await app.tapAtCoord(0, 0)
|
|
147
|
+
await app.tapAtCoord(1, 1)
|
|
148
|
+
expect(runtime.tapAtNormalizedCalls.length).toBe(2)
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
test('out of range nx throws wrongState', async () => {
|
|
152
|
+
const runtime = new MockSimRuntime()
|
|
153
|
+
const app = await Smix.launchApp(bundleId('dev.smix.fixture'), runtime, new MockSelectorResolver().resolve)
|
|
154
|
+
await expect(app.tapAtCoord(1.5, 0.5)).rejects.toMatchObject({
|
|
155
|
+
name: 'ExpectationFailure',
|
|
156
|
+
code: 'wrongState',
|
|
157
|
+
})
|
|
158
|
+
expect(runtime.tapAtNormalizedCalls).toEqual([])
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
test('negative ny throws wrongState', async () => {
|
|
162
|
+
const runtime = new MockSimRuntime()
|
|
163
|
+
const app = await Smix.launchApp(bundleId('dev.smix.fixture'), runtime, new MockSelectorResolver().resolve)
|
|
164
|
+
await expect(app.tapAtCoord(0.5, -0.1)).rejects.toMatchObject({
|
|
165
|
+
code: 'wrongState',
|
|
166
|
+
})
|
|
167
|
+
})
|
|
168
|
+
})
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// v7.5 c1 — App.tap mock-based unit tests.
|
|
2
|
+
//
|
|
3
|
+
// Mirror Kotlin AppTapMockTest + Swift AppTapMockTests. Verifies wire
|
|
4
|
+
// pipeline (snapshot → SelectorResolver → tap synthesize) end-to-end
|
|
5
|
+
// against MockSimRuntime + MockSelectorResolver.
|
|
6
|
+
|
|
7
|
+
import { describe, expect, test } from 'vitest'
|
|
8
|
+
import {
|
|
9
|
+
bundleId,
|
|
10
|
+
ExpectationFailure,
|
|
11
|
+
literal,
|
|
12
|
+
MockSelectorResolver,
|
|
13
|
+
MockSimRuntime,
|
|
14
|
+
Selector,
|
|
15
|
+
Smix,
|
|
16
|
+
type A11yNode,
|
|
17
|
+
} from '../index.js'
|
|
18
|
+
|
|
19
|
+
describe('App.tap notFound path', () => {
|
|
20
|
+
test('empty resolver result yields notFound', async () => {
|
|
21
|
+
const runtime = new MockSimRuntime()
|
|
22
|
+
const resolver = new MockSelectorResolver()
|
|
23
|
+
const app = await Smix.launchApp(bundleId('dev.smix.fixture'), runtime, resolver.resolve)
|
|
24
|
+
await expect(app.tap(Selector.id('btn-missing'))).rejects.toMatchObject({
|
|
25
|
+
name: 'ExpectationFailure',
|
|
26
|
+
code: 'notFound',
|
|
27
|
+
})
|
|
28
|
+
})
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
describe('App.tap hit path', () => {
|
|
32
|
+
test('id hit synthesizes at bounds center', async () => {
|
|
33
|
+
const button: A11yNode = {
|
|
34
|
+
rawType: 'button',
|
|
35
|
+
identifier: 'btn-login',
|
|
36
|
+
label: 'Sign In',
|
|
37
|
+
bounds: { x: 100, y: 200, w: 80, h: 40 },
|
|
38
|
+
enabled: true,
|
|
39
|
+
visible: true,
|
|
40
|
+
}
|
|
41
|
+
const root: A11yNode = {
|
|
42
|
+
rawType: 'other',
|
|
43
|
+
bounds: { x: 0, y: 0, w: 393, h: 852 },
|
|
44
|
+
enabled: true,
|
|
45
|
+
visible: true,
|
|
46
|
+
children: [button],
|
|
47
|
+
}
|
|
48
|
+
const runtime = new MockSimRuntime({ snapshotResult: root })
|
|
49
|
+
const resolver = new MockSelectorResolver()
|
|
50
|
+
resolver.registerHit('{"id":"btn-login"}', 'btn-login')
|
|
51
|
+
const app = await Smix.launchApp(bundleId('dev.smix.fixture'), runtime, resolver.resolve)
|
|
52
|
+
|
|
53
|
+
await app.tap(Selector.id('btn-login'))
|
|
54
|
+
|
|
55
|
+
expect(runtime.tapCalls.length).toBe(1)
|
|
56
|
+
expect(runtime.tapCalls[0]?.x).toBeCloseTo(140, 1)
|
|
57
|
+
expect(runtime.tapCalls[0]?.y).toBeCloseTo(220, 1)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
test('label hit synthesizes at center', async () => {
|
|
61
|
+
const button: A11yNode = {
|
|
62
|
+
rawType: 'button',
|
|
63
|
+
identifier: 'btn-foo',
|
|
64
|
+
label: 'Submit',
|
|
65
|
+
bounds: { x: 50, y: 600, w: 100, h: 50 },
|
|
66
|
+
visible: true,
|
|
67
|
+
}
|
|
68
|
+
const root: A11yNode = {
|
|
69
|
+
rawType: 'other',
|
|
70
|
+
bounds: { x: 0, y: 0, w: 393, h: 852 },
|
|
71
|
+
visible: true,
|
|
72
|
+
children: [button],
|
|
73
|
+
}
|
|
74
|
+
const runtime = new MockSimRuntime({ snapshotResult: root })
|
|
75
|
+
const resolver = new MockSelectorResolver()
|
|
76
|
+
resolver.registerHit('{"label":"Submit"}', 'btn-foo')
|
|
77
|
+
const app = await Smix.launchApp(bundleId('dev.smix.fixture'), runtime, resolver.resolve)
|
|
78
|
+
|
|
79
|
+
await app.tap(Selector.label('Submit'))
|
|
80
|
+
expect(runtime.tapCalls.length).toBe(1)
|
|
81
|
+
expect(runtime.tapCalls[0]?.x).toBeCloseTo(100, 1)
|
|
82
|
+
})
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
describe('App.tap passes correct selectorJson', () => {
|
|
86
|
+
test('Rust-compatible untagged shape', async () => {
|
|
87
|
+
const root: A11yNode = {
|
|
88
|
+
rawType: 'other',
|
|
89
|
+
bounds: { x: 0, y: 0, w: 393, h: 852 },
|
|
90
|
+
visible: true,
|
|
91
|
+
children: [],
|
|
92
|
+
}
|
|
93
|
+
const runtime = new MockSimRuntime({ snapshotResult: root })
|
|
94
|
+
const resolver = new MockSelectorResolver()
|
|
95
|
+
const app = await Smix.launchApp(bundleId('dev.smix.fixture'), runtime, resolver.resolve)
|
|
96
|
+
|
|
97
|
+
await expect(app.tap(Selector.id('btn-x'))).rejects.toBeInstanceOf(ExpectationFailure)
|
|
98
|
+
|
|
99
|
+
expect(resolver.calls.length).toBe(1)
|
|
100
|
+
expect(resolver.calls[0]?.selectorJson).toBe('{"id":"btn-x"}')
|
|
101
|
+
})
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
describe('App.tap resolver error surfaces as ExpectationFailure', () => {
|
|
105
|
+
test('throws unknown when resolver raises', async () => {
|
|
106
|
+
const root: A11yNode = {
|
|
107
|
+
rawType: 'other',
|
|
108
|
+
bounds: { x: 0, y: 0, w: 393, h: 852 },
|
|
109
|
+
visible: true,
|
|
110
|
+
children: [],
|
|
111
|
+
}
|
|
112
|
+
const runtime = new MockSimRuntime({ snapshotResult: root })
|
|
113
|
+
const resolver = new MockSelectorResolver()
|
|
114
|
+
resolver.throwOnNext = new Error('simulated FFI parse error')
|
|
115
|
+
const app = await Smix.launchApp(bundleId('dev.smix.fixture'), runtime, resolver.resolve)
|
|
116
|
+
|
|
117
|
+
await expect(app.tap(Selector.id('anything'))).rejects.toMatchObject({
|
|
118
|
+
name: 'ExpectationFailure',
|
|
119
|
+
code: 'unknown',
|
|
120
|
+
})
|
|
121
|
+
})
|
|
122
|
+
})
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// v7.7 c1 — HttpSimRuntime fetch wire integration tests via injected mock.
|
|
2
|
+
//
|
|
3
|
+
// Verifies:
|
|
4
|
+
// - resolver POSTs to /select/resolve with treeJson + selectorJson + decodes ids
|
|
5
|
+
// - labelsResolver POSTs to /select/resolve-labels + decodes labels
|
|
6
|
+
// - resolveCount POSTs to /select/resolve-count + decodes count
|
|
7
|
+
// - Wire body shape matches Rust `{treeJson, selectorJson}` contract
|
|
8
|
+
// - Errors propagate cleanly
|
|
9
|
+
|
|
10
|
+
import { describe, expect, test } from 'vitest'
|
|
11
|
+
import { HttpSimRuntime, type HttpFetch } from '../HttpRunner.js'
|
|
12
|
+
|
|
13
|
+
type CapturedRequest = {
|
|
14
|
+
url: string
|
|
15
|
+
body: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Build a mock fetch that captures requests and returns a stub JSON body. */
|
|
19
|
+
function mockFetch(stub: unknown, captured: CapturedRequest[]): HttpFetch {
|
|
20
|
+
return async (url, init) => {
|
|
21
|
+
captured.push({ url, body: init.body })
|
|
22
|
+
return {
|
|
23
|
+
ok: true,
|
|
24
|
+
status: 200,
|
|
25
|
+
json: async () => stub,
|
|
26
|
+
text: async () => JSON.stringify(stub),
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function failingFetch(): HttpFetch {
|
|
32
|
+
return async (url) => ({
|
|
33
|
+
ok: false,
|
|
34
|
+
status: 500,
|
|
35
|
+
json: async () => ({}),
|
|
36
|
+
text: async () => `server error at ${url}`,
|
|
37
|
+
})
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe('HttpSimRuntime.resolver', () => {
|
|
41
|
+
test('POSTs to /select/resolve with treeJson + selectorJson', async () => {
|
|
42
|
+
const captured: CapturedRequest[] = []
|
|
43
|
+
const runtime = new HttpSimRuntime('http://localhost:14101', mockFetch({ ids: ['btn-a', 'btn-b'] }, captured))
|
|
44
|
+
|
|
45
|
+
const ids = await runtime.resolver('{"rawType":"other"}', '{"id":"btn"}')
|
|
46
|
+
|
|
47
|
+
expect(ids).toEqual(['btn-a', 'btn-b'])
|
|
48
|
+
expect(captured.length).toBe(1)
|
|
49
|
+
expect(captured[0]?.url).toBe('http://localhost:14101/select/resolve')
|
|
50
|
+
expect(JSON.parse(captured[0]?.body ?? '')).toEqual({
|
|
51
|
+
treeJson: '{"rawType":"other"}',
|
|
52
|
+
selectorJson: '{"id":"btn"}',
|
|
53
|
+
})
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
test('throws on 500', async () => {
|
|
57
|
+
const runtime = new HttpSimRuntime('http://localhost:14101', failingFetch())
|
|
58
|
+
await expect(runtime.resolver('{}', '{}')).rejects.toMatchObject({
|
|
59
|
+
message: expect.stringContaining('HTTP 500'),
|
|
60
|
+
})
|
|
61
|
+
})
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
describe('HttpSimRuntime.labelsResolver (v7.7 c1)', () => {
|
|
65
|
+
test('POSTs to /select/resolve-labels with treeJson + selectorJson', async () => {
|
|
66
|
+
const captured: CapturedRequest[] = []
|
|
67
|
+
const runtime = new HttpSimRuntime(
|
|
68
|
+
'http://localhost:14101',
|
|
69
|
+
mockFetch({ labels: ['Item 1', 'Item 2', 'Item 3'] }, captured),
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
const labels = await runtime.labelsResolver('{"rawType":"other"}', '{"label":"Item"}')
|
|
73
|
+
|
|
74
|
+
expect(labels).toEqual(['Item 1', 'Item 2', 'Item 3'])
|
|
75
|
+
expect(captured[0]?.url).toBe('http://localhost:14101/select/resolve-labels')
|
|
76
|
+
expect(JSON.parse(captured[0]?.body ?? '')).toEqual({
|
|
77
|
+
treeJson: '{"rawType":"other"}',
|
|
78
|
+
selectorJson: '{"label":"Item"}',
|
|
79
|
+
})
|
|
80
|
+
})
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
describe('HttpSimRuntime.resolveCount (v7.7 c1)', () => {
|
|
84
|
+
test('POSTs to /select/resolve-count and decodes count', async () => {
|
|
85
|
+
const captured: CapturedRequest[] = []
|
|
86
|
+
const runtime = new HttpSimRuntime(
|
|
87
|
+
'http://localhost:14101',
|
|
88
|
+
mockFetch({ count: 7 }, captured),
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
const n = await runtime.resolveCount('{}', '{"role":"button"}')
|
|
92
|
+
|
|
93
|
+
expect(n).toBe(7)
|
|
94
|
+
expect(captured[0]?.url).toBe('http://localhost:14101/select/resolve-count')
|
|
95
|
+
})
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
describe('HttpSimRuntime runtime methods wire shape', () => {
|
|
99
|
+
test('synthesizeTap → POST /input/tap with {x,y}', async () => {
|
|
100
|
+
const captured: CapturedRequest[] = []
|
|
101
|
+
const runtime = new HttpSimRuntime('http://localhost:14101', mockFetch({}, captured))
|
|
102
|
+
await runtime.synthesizeTap(140.5, 220.0)
|
|
103
|
+
expect(captured[0]?.url).toBe('http://localhost:14101/input/tap')
|
|
104
|
+
expect(JSON.parse(captured[0]?.body ?? '')).toEqual({ x: 140.5, y: 220.0 })
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
test('launch → POST /sim/launch with {bundleId}', async () => {
|
|
108
|
+
const captured: CapturedRequest[] = []
|
|
109
|
+
const runtime = new HttpSimRuntime('http://localhost:14101', mockFetch({}, captured))
|
|
110
|
+
await runtime.launch('dev.smix.MyApp')
|
|
111
|
+
expect(captured[0]?.url).toBe('http://localhost:14101/sim/launch')
|
|
112
|
+
expect(JSON.parse(captured[0]?.body ?? '')).toEqual({ bundleId: 'dev.smix.MyApp' })
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
test('synthesizeTapAtNormalized → POST /input/tap-normalized', async () => {
|
|
116
|
+
const captured: CapturedRequest[] = []
|
|
117
|
+
const runtime = new HttpSimRuntime('http://localhost:14101', mockFetch({}, captured))
|
|
118
|
+
await runtime.synthesizeTapAtNormalized(0.5, 0.5)
|
|
119
|
+
expect(captured[0]?.url).toBe('http://localhost:14101/input/tap-normalized')
|
|
120
|
+
})
|
|
121
|
+
})
|