@goliapkg/smix 1.0.27 → 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/Pattern.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
1
|
+
// The wire form of `smix-selector`'s untagged `Pattern` enum. Untagged
|
|
2
|
+
// means the shape alone says which case it is, so these forms are exact:
|
|
3
3
|
//
|
|
4
4
|
// Wire JSON forms (untagged):
|
|
5
5
|
// Literal: bare string e.g. "hello"
|
package/src/Selector.ts
CHANGED
|
@@ -1,16 +1,13 @@
|
|
|
1
|
-
//
|
|
1
|
+
// The wire form of `smix-selector`'s untagged `Selector` enum, with
|
|
2
|
+
// `Modifiers` flattened onto the Text/Id/Label/Role/LocalizedText bases.
|
|
3
|
+
// Focused takes no modifiers; Anchor carries AnchorBox + IndexModifiers.
|
|
2
4
|
//
|
|
3
|
-
// Mirror Swift v7.2 c3 + Kotlin v7.4 c4 + Rust smix-selector
|
|
4
|
-
// `#[serde(untagged)] enum Selector` with full `Modifiers` flatten on
|
|
5
|
-
// Text/Id/Label/Role/LocalizedText bases. Focused has no modifiers.
|
|
6
|
-
// Anchor uses AnchorBox + IndexModifiers.
|
|
7
|
-
//
|
|
8
|
-
// Class-based API (mirrors Kotlin sealed interface 1:1):
|
|
9
5
|
// sel.id('btn-login')
|
|
10
6
|
// .below(sel.text(literal('Sign In')))
|
|
11
7
|
// .nth(0)
|
|
12
8
|
//
|
|
13
|
-
// Each fluent method returns a NEW Selector
|
|
9
|
+
// Each fluent method returns a NEW Selector, so a partly-built selector
|
|
10
|
+
// can be shared and specialised without the branches affecting each other.
|
|
14
11
|
|
|
15
12
|
import { type Pattern, patternFromJson, patternToJson } from './Pattern.js'
|
|
16
13
|
|
package/src/SelectorResolver.ts
CHANGED
|
@@ -1,8 +1,6 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
// (in TS via HTTP runner or future ffi-napi binding); mock impl for
|
|
5
|
-
// vitest. Lambda-style fn interface keeps the abstraction minimal.
|
|
1
|
+
// The seam where selector resolution is supplied: the real one wraps the
|
|
2
|
+
// Rust core over the HTTP runner, the mock answers from a table. A bare
|
|
3
|
+
// function type keeps that seam as small as it can be.
|
|
6
4
|
|
|
7
5
|
export type SelectorResolver = (
|
|
8
6
|
treeJson: string,
|
|
@@ -10,9 +8,8 @@ export type SelectorResolver = (
|
|
|
10
8
|
) => Promise<readonly string[]>
|
|
11
9
|
|
|
12
10
|
/**
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* Locator.toHaveCount + Locator.toHaveLabel.
|
|
11
|
+
* Wraps `resolve_selector_labels`: each match's label, empty string where
|
|
12
|
+
* it has none.
|
|
16
13
|
*/
|
|
17
14
|
export type LabelsResolver = (
|
|
18
15
|
treeJson: string,
|
|
@@ -22,7 +19,6 @@ export type LabelsResolver = (
|
|
|
22
19
|
/**
|
|
23
20
|
* Mock resolver for vitest unit tests. Pre-registered selectorJson →
|
|
24
21
|
* id list mappings; falls back to empty list for unknown selectors.
|
|
25
|
-
* Mirrors Kotlin MockSelectorResolver from v7.4 c2.
|
|
26
22
|
*/
|
|
27
23
|
export class MockSelectorResolver {
|
|
28
24
|
readonly returnMap = new Map<string, readonly string[]>()
|
|
@@ -51,8 +47,8 @@ export class MockSelectorResolver {
|
|
|
51
47
|
}
|
|
52
48
|
|
|
53
49
|
/**
|
|
54
|
-
*
|
|
55
|
-
*
|
|
50
|
+
* Mock labels resolver for unit tests. Pre-registered selectorJson →
|
|
51
|
+
* label list mappings.
|
|
56
52
|
*/
|
|
57
53
|
export class MockLabelsResolver {
|
|
58
54
|
readonly returnMap = new Map<string, readonly string[]>()
|
package/src/Smix.ts
CHANGED
|
@@ -1,15 +1,13 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
// src/HttpRunner.ts). For c1 MVP, callers must pass an explicit
|
|
6
|
-
// SelectorResolver (e.g. MockSelectorResolver in tests; HttpSelectorResolver
|
|
7
|
-
// in c3 production).
|
|
1
|
+
// The entry point. Launching an app opens a napi session on the driver,
|
|
2
|
+
// launches the app in it, and hands back a wired App. `appPath` targets need
|
|
3
|
+
// a host-side install (no runner wire), so they throw a clear host-side error
|
|
4
|
+
// rather than pretending; the bundleId path is the golden path.
|
|
8
5
|
|
|
9
6
|
import { App } from './App.js'
|
|
7
|
+
import { loadNodeDriver, loadNodeResolver } from './loadNodeDriver.js'
|
|
10
8
|
import { SmixNotImplementedError } from './Locator.js'
|
|
9
|
+
import type { NodeDriver } from './NodeDriver.js'
|
|
11
10
|
import type { LabelsResolver, SelectorResolver } from './SelectorResolver.js'
|
|
12
|
-
import type { SmixSimRuntime } from './SimRuntime.js'
|
|
13
11
|
|
|
14
12
|
export type AppTarget =
|
|
15
13
|
| { kind: 'bundleId'; value: string }
|
|
@@ -19,30 +17,36 @@ export const bundleId = (value: string): AppTarget => ({ kind: 'bundleId', value
|
|
|
19
17
|
export const appPath = (path: string): AppTarget => ({ kind: 'appPath', path })
|
|
20
18
|
|
|
21
19
|
/**
|
|
22
|
-
* Top-level entry.
|
|
23
|
-
* an [App] handle
|
|
20
|
+
* Top-level entry. Opens a session bound to the target's bundle id, launches
|
|
21
|
+
* its app, and returns an [App] handle. Everything defaults to the real napi
|
|
22
|
+
* addon: the driver drives through it and the resolver resolves host-side
|
|
23
|
+
* through it (like the Swift / Kotlin SDKs through UniFFI). Inject `driver`,
|
|
24
|
+
* `resolver`, or `labelsResolver` for tests or a custom transport —
|
|
25
|
+
* `smix.launchApp(bundleId('com.acme.app'))` alone is the golden path.
|
|
24
26
|
*/
|
|
25
27
|
export const Smix = {
|
|
26
28
|
async launchApp(
|
|
27
29
|
target: AppTarget,
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
30
|
+
options?: {
|
|
31
|
+
driver?: NodeDriver
|
|
32
|
+
resolver?: SelectorResolver
|
|
33
|
+
labelsResolver?: LabelsResolver
|
|
34
|
+
},
|
|
31
35
|
): Promise<App> {
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
}
|
|
36
|
+
if (target.kind !== 'bundleId') {
|
|
37
|
+
throw new SmixNotImplementedError('host', 'Smix.launchApp(appPath)')
|
|
45
38
|
}
|
|
39
|
+
const driver = options?.driver ?? (await loadNodeDriver())
|
|
40
|
+
let resolver = options?.resolver
|
|
41
|
+
let labelsResolver = options?.labelsResolver
|
|
42
|
+
if (resolver === undefined || labelsResolver === undefined) {
|
|
43
|
+
const node = await loadNodeResolver()
|
|
44
|
+
resolver = resolver ?? node.resolver
|
|
45
|
+
labelsResolver = labelsResolver ?? node.labelsResolver
|
|
46
|
+
}
|
|
47
|
+
const session = await driver.openSession(target.value)
|
|
48
|
+
await session.launchApp()
|
|
49
|
+
return new App(target.value, driver, session, resolver, labelsResolver)
|
|
46
50
|
},
|
|
47
51
|
} as const
|
|
48
52
|
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// A system popup as the runner reports it — the TS mirror of
|
|
2
|
+
// `smix_runner_wire::SystemPopup`. `App.systemPopups()` returns these
|
|
3
|
+
// verbatim from the wire; it is NOT an A11yNode (a popup has `id` / `type` /
|
|
4
|
+
// `source`, not accessibility geometry), so it carries its own type rather
|
|
5
|
+
// than being cast to one.
|
|
6
|
+
|
|
7
|
+
export interface SystemPopupButton {
|
|
8
|
+
readonly id: string
|
|
9
|
+
readonly label: string
|
|
10
|
+
/** Semantic role, e.g. "cancel" / "destructive" / "default". */
|
|
11
|
+
readonly role: string
|
|
12
|
+
readonly destructive: boolean
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface SystemPopup {
|
|
16
|
+
readonly id: string
|
|
17
|
+
/** Discriminator, e.g. "alert" / "sheet" / "banner". */
|
|
18
|
+
readonly type: string
|
|
19
|
+
/** Originator, e.g. a bundle id or system framework name. */
|
|
20
|
+
readonly source: string
|
|
21
|
+
readonly title: string
|
|
22
|
+
readonly body: string
|
|
23
|
+
readonly buttons: readonly SystemPopupButton[]
|
|
24
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { App } from '../App.js'
|
|
3
|
+
import { MockNodeDriver, MockNodeSession } from '../NodeDriver.js'
|
|
4
|
+
import { MockSelectorResolver } from '../SelectorResolver.js'
|
|
5
|
+
import { Selector, encodeSelectorJson } from '../Selector.js'
|
|
6
|
+
import { Smix, bundleId } from '../Smix.js'
|
|
7
|
+
import { ExpectationFailure } from '../ExpectationFailure.js'
|
|
8
|
+
|
|
9
|
+
function makeApp(driver = new MockNodeDriver(), resolver = new MockSelectorResolver()) {
|
|
10
|
+
return {
|
|
11
|
+
driver,
|
|
12
|
+
resolver,
|
|
13
|
+
app: new App('bid', driver, driver.session, resolver.resolve),
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe('App live sense (C3)', () => {
|
|
18
|
+
it('snapshotTree returns the parsed tree from the driver', async () => {
|
|
19
|
+
const driver = new MockNodeDriver()
|
|
20
|
+
driver.treeJson = '{"rawType":"application","identifier":"root","enabled":true,"selected":false,"hasFocus":false,"visible":true,"bounds":{"x":0,"y":0,"w":0,"h":0}}'
|
|
21
|
+
const { app } = makeApp(driver)
|
|
22
|
+
const tree = await app.snapshotTree()
|
|
23
|
+
expect(tree.identifier).toBe('root')
|
|
24
|
+
expect((await app.tree()).identifier).toBe('root')
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('systemPopups returns the wire SystemPopup list', async () => {
|
|
28
|
+
const driver = new MockNodeDriver()
|
|
29
|
+
driver.popupsJson = '[{"id":"p1","type":"alert","source":"SpringBoard","title":"","body":"","buttons":[]}]'
|
|
30
|
+
const { app } = makeApp(driver)
|
|
31
|
+
const popups = await app.systemPopups()
|
|
32
|
+
expect(popups).toHaveLength(1)
|
|
33
|
+
expect(popups[0]?.id).toBe('p1')
|
|
34
|
+
expect(popups[0]?.type).toBe('alert')
|
|
35
|
+
})
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
describe('App act verbs (C3)', () => {
|
|
39
|
+
it('tap resolves the selector to an id and taps it', async () => {
|
|
40
|
+
const { app, driver, resolver } = makeApp()
|
|
41
|
+
resolver.registerHit(encodeSelectorJson(Selector.id('btn-ok')), 'btn-ok')
|
|
42
|
+
await app.tap(Selector.id('btn-ok'))
|
|
43
|
+
expect(driver.calls.find((c) => c.verb === 'tapById')?.args[0]).toBe('btn-ok')
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('tap throws ELEMENT_NOT_FOUND with visibleElements when nothing resolves', async () => {
|
|
47
|
+
const { app } = makeApp()
|
|
48
|
+
await expect(app.tap(Selector.id('missing'))).rejects.toSatisfy(
|
|
49
|
+
(e: unknown) =>
|
|
50
|
+
e instanceof ExpectationFailure &&
|
|
51
|
+
e.code === 'ELEMENT_NOT_FOUND' &&
|
|
52
|
+
e.visibleElements.length > 0,
|
|
53
|
+
)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('fill focuses then types', async () => {
|
|
57
|
+
const { app, driver, resolver } = makeApp()
|
|
58
|
+
resolver.registerHit(encodeSelectorJson(Selector.id('inp')), 'inp')
|
|
59
|
+
await app.fill(Selector.id('inp'), 'hello')
|
|
60
|
+
const verbs = driver.calls.map((c) => c.verb)
|
|
61
|
+
expect(verbs).toEqual(expect.arrayContaining(['tapById', 'inputText']))
|
|
62
|
+
expect(driver.calls.find((c) => c.verb === 'inputText')?.args[0]).toBe('hello')
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('pressKey maps enter to return and passes others through', async () => {
|
|
66
|
+
const { app, driver } = makeApp()
|
|
67
|
+
await app.pressKey('enter')
|
|
68
|
+
await app.pressKey('escape')
|
|
69
|
+
const keys = driver.calls.filter((c) => c.verb === 'pressKey').map((c) => c.args[0])
|
|
70
|
+
expect(keys).toEqual(['return', 'escape'])
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('swipe passes the direction through', async () => {
|
|
74
|
+
const { app, driver } = makeApp()
|
|
75
|
+
await app.swipe('up')
|
|
76
|
+
expect(driver.calls.find((c) => c.verb === 'swipe')?.args[0]).toBe('up')
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('tapAtCoord range-checks then taps', async () => {
|
|
80
|
+
const { app, driver } = makeApp()
|
|
81
|
+
await expect(app.tapAtCoord(1.5, 0.5)).rejects.toSatisfy(
|
|
82
|
+
(e: unknown) => e instanceof ExpectationFailure && e.code === 'ASSERTION_FAILED',
|
|
83
|
+
)
|
|
84
|
+
await app.tapAtCoord(0.5, 0.5)
|
|
85
|
+
expect(driver.calls.find((c) => c.verb === 'tapAtCoord')?.args).toEqual([0.5, 0.5])
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('terminate and relaunch fire on the session', async () => {
|
|
89
|
+
const { app, driver } = makeApp()
|
|
90
|
+
await app.terminate()
|
|
91
|
+
await app.relaunch()
|
|
92
|
+
const verbs = driver.session.calls.map((c) => c.verb)
|
|
93
|
+
expect(verbs).toEqual(['terminateApp', 'relaunchApp'])
|
|
94
|
+
})
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
describe('Smix.launchApp (C3 entry)', () => {
|
|
98
|
+
it('opens a session, launches, and returns a wired App', async () => {
|
|
99
|
+
const driver = new MockNodeDriver()
|
|
100
|
+
const resolver = new MockSelectorResolver()
|
|
101
|
+
const app = await Smix.launchApp(bundleId('com.acme.app'), { driver, resolver: resolver.resolve })
|
|
102
|
+
expect(app).toBeInstanceOf(App)
|
|
103
|
+
expect(driver.calls.find((c) => c.verb === 'openSession')?.args[0]).toBe('com.acme.app')
|
|
104
|
+
expect(driver.session.calls.filter((c) => c.verb === 'launchApp')).toHaveLength(1)
|
|
105
|
+
})
|
|
106
|
+
})
|
|
@@ -1,37 +1,40 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
// stub surface throws SmixNotImplementedError with explicit stage
|
|
6
|
-
// markers.
|
|
1
|
+
// API-shape tests. Verifies the SDK exposes the right TypeScript types +
|
|
2
|
+
// constructors, and that the live driving / sense surface — pending the
|
|
3
|
+
// napi axis — throws SmixNotImplementedError rather than posting to a
|
|
4
|
+
// route the runner never served.
|
|
7
5
|
|
|
8
6
|
import { describe, expect, test } from 'vitest'
|
|
9
7
|
import {
|
|
10
8
|
App,
|
|
9
|
+
encodeSelectorJson,
|
|
11
10
|
ExpectationFailure,
|
|
12
11
|
FAILURE_CODES,
|
|
13
12
|
literal,
|
|
13
|
+
MockNodeDriver,
|
|
14
14
|
MockSelectorResolver,
|
|
15
|
-
MockSimRuntime,
|
|
16
15
|
Selector,
|
|
17
16
|
Smix,
|
|
18
17
|
SmixNotImplementedError,
|
|
19
|
-
appPath,
|
|
20
18
|
bundleId,
|
|
21
19
|
type A11yRole,
|
|
22
20
|
type FailureCode,
|
|
23
21
|
} from '../index.js'
|
|
24
22
|
|
|
25
23
|
describe('Selector base cases', () => {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
Selector.
|
|
32
|
-
Selector.
|
|
24
|
+
// Each constructor is asserted by the wire JSON it encodes to — a
|
|
25
|
+
// previous version built the five selectors and asserted the array
|
|
26
|
+
// had five elements, which a broken encoder passes.
|
|
27
|
+
test('every base constructor encodes its wire discriminator', () => {
|
|
28
|
+
const byWire: Array<[Selector, Record<string, unknown>]> = [
|
|
29
|
+
[Selector.id('btn-login'), { id: 'btn-login' }],
|
|
30
|
+
[Selector.text(literal('Sign In')), { text: 'Sign In' }],
|
|
31
|
+
[Selector.label('Settings'), { label: 'Settings' }],
|
|
32
|
+
[Selector.role('button'), { role: 'button' }],
|
|
33
|
+
[Selector.role('button', literal('Submit')), { role: 'button', name: 'Submit' }],
|
|
33
34
|
]
|
|
34
|
-
|
|
35
|
+
for (const [sel, wire] of byWire) {
|
|
36
|
+
expect(JSON.parse(encodeSelectorJson(sel))).toEqual(wire)
|
|
37
|
+
}
|
|
35
38
|
})
|
|
36
39
|
|
|
37
40
|
test('Selector.id encodes as {"id":...}', () => {
|
|
@@ -51,23 +54,34 @@ describe('A11yRole', () => {
|
|
|
51
54
|
'scrollView', 'segmentedControl', 'table', 'collectionView',
|
|
52
55
|
'webView', 'keyboard',
|
|
53
56
|
]
|
|
54
|
-
|
|
57
|
+
// The list's value is the type annotation above — each string must
|
|
58
|
+
// typecheck as A11yRole. The runtime assertion is only that the
|
|
59
|
+
// fixture has no duplicate entries; asserting its length proved
|
|
60
|
+
// nothing (the old `toBe(28)` passed with 28 wrong strings).
|
|
61
|
+
expect(new Set(roles).size).toBe(roles.length)
|
|
55
62
|
})
|
|
56
63
|
})
|
|
57
64
|
|
|
58
65
|
describe('FailureCode', () => {
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
+
// The vocabulary is Rust's — smix_error::FailureCode. Rust is the
|
|
67
|
+
// source; crates/smix-error/tests/sdk_failure_code_parity.rs reads
|
|
68
|
+
// this SDK's declaration and fails if the two ever diverge.
|
|
69
|
+
// The count is not hard-coded here. It used to be, and adding
|
|
70
|
+
// TAP_MISSED to the vocabulary left this asserting 9 against 10 — a
|
|
71
|
+
// second copy of the list that Rust's sdk_failure_code_parity gate
|
|
72
|
+
// could not see, because that gate reads FAILURE_CODES, not this
|
|
73
|
+
// test. So this checks the shape of FAILURE_CODES (no duplicates, a
|
|
74
|
+
// couple of anchors present) and leaves the count to parity.
|
|
75
|
+
test('the vocabulary is a set and carries its anchors', () => {
|
|
76
|
+
expect(new Set(FAILURE_CODES).size).toBe(FAILURE_CODES.length)
|
|
77
|
+
for (const code of ['ELEMENT_NOT_FOUND', 'TAP_MISSED', 'DRIVER_ERROR'] as FailureCode[]) {
|
|
78
|
+
expect(FAILURE_CODES).toContain(code)
|
|
79
|
+
}
|
|
66
80
|
})
|
|
67
81
|
|
|
68
82
|
test('toJson() emits stable keys', () => {
|
|
69
83
|
const failure = new ExpectationFailure({
|
|
70
|
-
code: '
|
|
84
|
+
code: 'ELEMENT_NOT_FOUND',
|
|
71
85
|
message: 'no candidates',
|
|
72
86
|
selectorJson: '{"id":"btn"}',
|
|
73
87
|
suggestions: ['check id'],
|
|
@@ -80,7 +94,7 @@ describe('FailureCode', () => {
|
|
|
80
94
|
}
|
|
81
95
|
})
|
|
82
96
|
|
|
83
|
-
test('toJson()
|
|
97
|
+
test('toJson() emits the Rust wire FailureCode value', () => {
|
|
84
98
|
for (const code of FAILURE_CODES) {
|
|
85
99
|
const f = new ExpectationFailure({ code, message: '' })
|
|
86
100
|
expect(f.toJson()).toContain(`"${code}"`)
|
|
@@ -88,30 +102,48 @@ describe('FailureCode', () => {
|
|
|
88
102
|
})
|
|
89
103
|
})
|
|
90
104
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
105
|
+
describe('the driving surface works through the node seam; only host/wire gaps throw', () => {
|
|
106
|
+
const makeApp = () => {
|
|
107
|
+
const driver = new MockNodeDriver()
|
|
108
|
+
return new App('dev.smix.target', driver, driver.session, new MockSelectorResolver().resolve)
|
|
109
|
+
}
|
|
94
110
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
111
|
+
test('Smix.launchApp drives through the node driver and returns an App', async () => {
|
|
112
|
+
const app = await Smix.launchApp(bundleId('dev.smix.target'), {
|
|
113
|
+
driver: new MockNodeDriver(),
|
|
114
|
+
resolver: new MockSelectorResolver().resolve,
|
|
115
|
+
})
|
|
116
|
+
expect(app).toBeInstanceOf(App)
|
|
100
117
|
})
|
|
101
118
|
|
|
102
|
-
test('
|
|
103
|
-
const
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
expect(
|
|
107
|
-
expect(app.
|
|
119
|
+
test('retired act + sense methods no longer throw SmixNotImplementedError', async () => {
|
|
120
|
+
const app = makeApp()
|
|
121
|
+
// tap with an empty resolver fails ELEMENT_NOT_FOUND — a real failure,
|
|
122
|
+
// NOT SmixNotImplementedError. That distinction is the whole point.
|
|
123
|
+
await expect(app.tap(Selector.id('x'))).rejects.not.toBeInstanceOf(SmixNotImplementedError)
|
|
124
|
+
await expect(app.snapshotTree()).resolves.toBeDefined()
|
|
125
|
+
await expect(app.tree()).resolves.toBeDefined()
|
|
126
|
+
await expect(app.systemPopups()).resolves.toBeDefined()
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
test('screenshot / openUrl / launchFresh still throw, re-labeled off napi', async () => {
|
|
130
|
+
const app = makeApp()
|
|
131
|
+
const gaps: Array<[() => Promise<unknown>, string]> = [
|
|
132
|
+
[() => app.screenshot(), 'wire'],
|
|
133
|
+
[() => app.openUrl('https://x'), 'wire'],
|
|
134
|
+
[() => app.launchFresh(), 'host'],
|
|
135
|
+
]
|
|
136
|
+
for (const [call, stage] of gaps) {
|
|
137
|
+
await expect(call()).rejects.toSatisfy(
|
|
138
|
+
(e: unknown) => e instanceof SmixNotImplementedError && e.stage === stage,
|
|
139
|
+
)
|
|
140
|
+
}
|
|
108
141
|
})
|
|
109
142
|
|
|
110
|
-
test('
|
|
111
|
-
const
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
expect(runtime.terminateCalls).toEqual(['dev.smix.target'])
|
|
143
|
+
test('Locator polls the real tree seam rather than throwing napi', async () => {
|
|
144
|
+
const app = makeApp()
|
|
145
|
+
await expect(
|
|
146
|
+
app.find(Selector.id('x')).toBeVisible({ timeoutMs: 50 }),
|
|
147
|
+
).rejects.not.toBeInstanceOf(SmixNotImplementedError)
|
|
116
148
|
})
|
|
117
149
|
})
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// The package README's code, type-checked and run.
|
|
2
|
+
//
|
|
3
|
+
// Nothing compiled these: the repo README's Rust snippet is now pinned
|
|
4
|
+
// by a compiled copy, but this package's four blocks — the ones an npm
|
|
5
|
+
// visitor reads first — had no such tie. The blocks below are the
|
|
6
|
+
// README's verbatim; the string test proves the README still says what
|
|
7
|
+
// this file compiles, and `tsc` proves this file matches the source.
|
|
8
|
+
|
|
9
|
+
import { describe, expect, it } from 'vitest'
|
|
10
|
+
import { readFileSync } from 'node:fs'
|
|
11
|
+
import { join } from 'node:path'
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
App,
|
|
15
|
+
ExpectationFailure,
|
|
16
|
+
HttpSimRuntime,
|
|
17
|
+
MockNodeDriver,
|
|
18
|
+
MockSelectorResolver,
|
|
19
|
+
Selector,
|
|
20
|
+
Session,
|
|
21
|
+
SmixNotImplementedError,
|
|
22
|
+
literal,
|
|
23
|
+
regex,
|
|
24
|
+
} from '../index'
|
|
25
|
+
|
|
26
|
+
const README = readFileSync(join(__dirname, '..', '..', 'README.md'), 'utf8')
|
|
27
|
+
|
|
28
|
+
/** The README's "Selector" block, verbatim. */
|
|
29
|
+
function selectorBlock(): void {
|
|
30
|
+
Selector.id('btn-login')
|
|
31
|
+
Selector.text(literal('Sign In'))
|
|
32
|
+
Selector.text(regex('^Sub', 'i'))
|
|
33
|
+
Selector.label('Settings')
|
|
34
|
+
Selector.role('button', literal('Submit'))
|
|
35
|
+
Selector.localizedText({ en: 'Submit', ja: '送信' })
|
|
36
|
+
|
|
37
|
+
// Fluent modifier chaining (returns a new Selector)
|
|
38
|
+
Selector.id('btn').below(Selector.text(literal('Address'))).nth(0)
|
|
39
|
+
Selector.role('button').near(Selector.text(literal('Confirm')))
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The README's "Session lifecycle" block, verbatim. */
|
|
43
|
+
async function sessionBlock(): Promise<void> {
|
|
44
|
+
const runtime = new HttpSimRuntime('http://127.0.0.1:22087')
|
|
45
|
+
const session = await Session.open(runtime, 'com.example.app')
|
|
46
|
+
await session.relaunchApp()
|
|
47
|
+
await session.close()
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
describe('the README compiles', () => {
|
|
51
|
+
it('has the selector block this file type-checks', () => {
|
|
52
|
+
// Referencing them keeps the compiler honest about the block being
|
|
53
|
+
// real code rather than dead text; neither needs a runner to exist.
|
|
54
|
+
expect(typeof selectorBlock).toBe('function')
|
|
55
|
+
expect(typeof sessionBlock).toBe('function')
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('every non-comment line of the README code appears in this file', () => {
|
|
59
|
+
const thisFile = readFileSync(__filename, 'utf8')
|
|
60
|
+
const blocks = [...README.matchAll(/```typescript\n([\s\S]*?)```/g)].map(
|
|
61
|
+
(m) => m[1] ?? '',
|
|
62
|
+
)
|
|
63
|
+
expect(blocks.length).toBeGreaterThanOrEqual(3)
|
|
64
|
+
const missing: string[] = []
|
|
65
|
+
for (const block of blocks) {
|
|
66
|
+
for (const line of block.split('\n')) {
|
|
67
|
+
const t = line.trim()
|
|
68
|
+
// Skip blank lines, imports (rewritten to a relative path
|
|
69
|
+
// here), and the ExpectationFailure block's illustrative body.
|
|
70
|
+
if (!t || t.startsWith('import') || t.startsWith('//')) continue
|
|
71
|
+
if (t.startsWith('e.') || t === 'try {' || t === '} catch (e) {') continue
|
|
72
|
+
if (t.startsWith('if (e instanceof') || t === '}') continue
|
|
73
|
+
if (!thisFile.includes(t)) missing.push(t)
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
expect(missing, 'README lines with no compiled counterpart').toEqual([])
|
|
77
|
+
})
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
describe('the README\'s claims about this package', () => {
|
|
81
|
+
it('exports ExpectationFailure with the documented shape', () => {
|
|
82
|
+
const f = new ExpectationFailure({
|
|
83
|
+
code: 'ELEMENT_NOT_FOUND',
|
|
84
|
+
message: 'no such element',
|
|
85
|
+
})
|
|
86
|
+
// The README tells readers to reach for exactly these.
|
|
87
|
+
expect(f.code).toBe('ELEMENT_NOT_FOUND')
|
|
88
|
+
expect(Array.isArray(f.visibleElements)).toBe(true)
|
|
89
|
+
expect(Array.isArray(f.suggestions)).toBe(true)
|
|
90
|
+
expect(typeof f.toJson()).toBe('string')
|
|
91
|
+
expect(f.toJson()).not.toContain('\n')
|
|
92
|
+
})
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
describe('the README\'s throw-warning is true', () => {
|
|
96
|
+
// The warning is the first thing an npm visitor reads. It must not lie in
|
|
97
|
+
// either direction: a method it says still throws must throw, and a method
|
|
98
|
+
// it shows driving must no longer throw SmixNotImplementedError.
|
|
99
|
+
const makeApp = () => {
|
|
100
|
+
const driver = new MockNodeDriver()
|
|
101
|
+
return new App('com.example.app', driver, driver.session, new MockSelectorResolver().resolve)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
it('names exactly the three surfaces that still throw, and they do', async () => {
|
|
105
|
+
const gaps = new Set(
|
|
106
|
+
[...README.matchAll(/`App\.(screenshot|openUrl|launchFresh)`/g)].map((m) => m[1]),
|
|
107
|
+
)
|
|
108
|
+
expect(gaps, 'README must name screenshot, openUrl, launchFresh as still-throwing').toEqual(
|
|
109
|
+
new Set(['screenshot', 'openUrl', 'launchFresh']),
|
|
110
|
+
)
|
|
111
|
+
const app = makeApp()
|
|
112
|
+
const calls: Array<() => Promise<unknown>> = [
|
|
113
|
+
() => app.screenshot(),
|
|
114
|
+
() => app.openUrl('https://x'),
|
|
115
|
+
() => app.launchFresh(),
|
|
116
|
+
]
|
|
117
|
+
for (const call of calls) {
|
|
118
|
+
await expect(call()).rejects.toBeInstanceOf(SmixNotImplementedError)
|
|
119
|
+
}
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it('the driving methods the README shows working no longer throw napi', async () => {
|
|
123
|
+
const app = makeApp()
|
|
124
|
+
// Empty resolver -> tap fails ELEMENT_NOT_FOUND, a real failure, NOT
|
|
125
|
+
// SmixNotImplementedError; snapshotTree drives and resolves.
|
|
126
|
+
await expect(app.tap(Selector.id('x'))).rejects.not.toBeInstanceOf(SmixNotImplementedError)
|
|
127
|
+
await expect(app.fill(Selector.id('x'), 't')).rejects.not.toBeInstanceOf(
|
|
128
|
+
SmixNotImplementedError,
|
|
129
|
+
)
|
|
130
|
+
await expect(app.snapshotTree()).resolves.toBeDefined()
|
|
131
|
+
})
|
|
132
|
+
})
|