@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.
Files changed (74) hide show
  1. package/README.md +53 -130
  2. package/dist/A11yNode.d.ts.map +1 -1
  3. package/dist/A11yNode.js +2 -3
  4. package/dist/A11yNode.js.map +1 -1
  5. package/dist/App.d.ts +37 -22
  6. package/dist/App.d.ts.map +1 -1
  7. package/dist/App.js +72 -66
  8. package/dist/App.js.map +1 -1
  9. package/dist/ExpectationFailure.d.ts +8 -1
  10. package/dist/ExpectationFailure.d.ts.map +1 -1
  11. package/dist/ExpectationFailure.js +7 -7
  12. package/dist/ExpectationFailure.js.map +1 -1
  13. package/dist/HttpRunner.d.ts +9 -31
  14. package/dist/HttpRunner.d.ts.map +1 -1
  15. package/dist/HttpRunner.js +14 -100
  16. package/dist/HttpRunner.js.map +1 -1
  17. package/dist/Locator.d.ts +7 -9
  18. package/dist/Locator.d.ts.map +1 -1
  19. package/dist/Locator.js +17 -18
  20. package/dist/Locator.js.map +1 -1
  21. package/dist/NodeDriver.d.ts +53 -0
  22. package/dist/NodeDriver.d.ts.map +1 -0
  23. package/dist/NodeDriver.js +70 -0
  24. package/dist/NodeDriver.js.map +1 -0
  25. package/dist/Pattern.js +2 -2
  26. package/dist/Pattern.js.map +1 -1
  27. package/dist/Selector.d.ts.map +1 -1
  28. package/dist/Selector.js +5 -8
  29. package/dist/Selector.js.map +1 -1
  30. package/dist/SelectorResolver.d.ts +4 -6
  31. package/dist/SelectorResolver.d.ts.map +1 -1
  32. package/dist/SelectorResolver.js +5 -8
  33. package/dist/SelectorResolver.js.map +1 -1
  34. package/dist/Smix.d.ts +12 -4
  35. package/dist/Smix.d.ts.map +1 -1
  36. package/dist/Smix.js +25 -23
  37. package/dist/Smix.js.map +1 -1
  38. package/dist/SystemPopup.d.ts +18 -0
  39. package/dist/SystemPopup.d.ts.map +1 -0
  40. package/dist/SystemPopup.js +7 -0
  41. package/dist/SystemPopup.js.map +1 -0
  42. package/dist/index.d.ts +3 -1
  43. package/dist/index.d.ts.map +1 -1
  44. package/dist/index.js +2 -1
  45. package/dist/index.js.map +1 -1
  46. package/dist/loadNodeDriver.d.ts +21 -0
  47. package/dist/loadNodeDriver.d.ts.map +1 -0
  48. package/dist/loadNodeDriver.js +33 -0
  49. package/dist/loadNodeDriver.js.map +1 -0
  50. package/package.json +5 -2
  51. package/src/A11yNode.ts +2 -3
  52. package/src/App.ts +75 -78
  53. package/src/ExpectationFailure.ts +23 -13
  54. package/src/HttpRunner.ts +15 -126
  55. package/src/Locator.ts +17 -18
  56. package/src/NodeDriver.ts +109 -0
  57. package/src/Pattern.ts +2 -2
  58. package/src/Selector.ts +5 -8
  59. package/src/SelectorResolver.ts +7 -11
  60. package/src/Smix.ts +30 -26
  61. package/src/SystemPopup.ts +24 -0
  62. package/src/__tests__/AppDriving.test.ts +106 -0
  63. package/src/__tests__/MvpApiShape.test.ts +78 -46
  64. package/src/__tests__/ReadmeSnippets.test.ts +132 -0
  65. package/src/__tests__/RealAddonLoopback.test.ts +81 -0
  66. package/src/__tests__/SelectorFullSchema.test.ts +2 -4
  67. package/src/index.ts +19 -8
  68. package/src/loadNodeDriver.ts +41 -0
  69. package/src/SimRuntime.ts +0 -145
  70. package/src/__tests__/AppFullApi.test.ts +0 -168
  71. package/src/__tests__/AppTapMock.test.ts +0 -122
  72. package/src/__tests__/HttpRunner.test.ts +0 -121
  73. package/src/__tests__/Locator.test.ts +0 -128
  74. package/src/__tests__/LocatorToHave.test.ts +0 -118
package/src/Pattern.ts CHANGED
@@ -1,5 +1,5 @@
1
- // v7.5 c1 — Pattern (literal vs regex) mirror Swift v7.2 c3 + Kotlin v7.4 c4
2
- // + Rust smix-selector `#[serde(untagged)] enum Pattern`.
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
- // v7.5 c1 — Selector full 7-case + Modifiers flatten + Pattern + fluent.
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 (immutable copy semantics).
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
 
@@ -1,8 +1,6 @@
1
- // v7.5 c1 — SelectorResolver injection boundary.
2
- //
3
- // Mirror Kotlin v7.4 c2 R-Kotlin-A: real impl wraps Rust FFI core
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
- * v7.6 c1 — labels resolver wrapping Rust `resolve_selector_labels`
14
- * (each match's `.label`; empty string when None). Used by
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
- * v7.6 c1 — Mock labels resolver for vitest tests. Pre-registered
55
- * selectorJson → label list mappings. Mirrors Kotlin MockLabelsResolver.
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
- // 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).
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. Launches the target app via [runtime] and returns
23
- * an [App] handle wired with the given [resolver] for selector queries.
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
- runtime: SmixSimRuntime,
29
- resolver: SelectorResolver,
30
- labelsResolver?: LabelsResolver,
30
+ options?: {
31
+ driver?: NodeDriver
32
+ resolver?: SelectorResolver
33
+ labelsResolver?: LabelsResolver
34
+ },
31
35
  ): 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
- }
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
- // v7.5 c1 — MVP shape JVM vitest tests.
2
- //
3
- // Mirror Kotlin MvpApiShapeTest + Swift MvpApiShapeTests. Verifies
4
- // the SDK exposes the right TypeScript types + constructors and that
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
- test('selector base cases exhaustive', () => {
27
- const cases: Selector[] = [
28
- Selector.id('btn-login'),
29
- Selector.text(literal('Sign In')),
30
- Selector.label('Settings'),
31
- Selector.role('button'),
32
- Selector.role('button', literal('Submit')),
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
- expect(cases.length).toBe(5)
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
- expect(roles.length).toBe(28)
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
- test('6 cases exposed', () => {
60
- expect(FAILURE_CODES.length).toBe(6)
61
- const expected = new Set<FailureCode>([
62
- 'notFound', 'ambiguous', 'notInteractable',
63
- 'timeout', 'wrongState', 'unknown',
64
- ])
65
- expect(new Set(FAILURE_CODES)).toEqual(expected)
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: 'notFound',
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() camelCase FailureCode raw value', () => {
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
- // v7.6 c1 update: toHaveLabel + toHaveCount stubs replaced with real
92
- // wires via dedicated FFI resolve_selector_count / resolve_selector_labels.
93
- // All SDK API stubs from v7.5 c1 are now wired.
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
- describe('Smix.launchApp wire (v7.5 c1)', () => {
96
- test('launchApp(.bundleId) calls runtime.launch', async () => {
97
- const runtime = new MockSimRuntime()
98
- await Smix.launchApp(bundleId('dev.smix.target'), runtime, new MockSelectorResolver().resolve)
99
- expect(runtime.launchCalls).toEqual(['dev.smix.target'])
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('launchApp(.appPath) dispatches to launchFromPath (mirror Swift v7.2 c5)', async () => {
103
- const runtime = new MockSimRuntime()
104
- const app = await Smix.launchApp(appPath('/tmp/Foo.apk'), runtime, new MockSelectorResolver().resolve)
105
- expect(runtime.launchFromPathCalls).toEqual(['/tmp/Foo.apk'])
106
- expect(runtime.launchCalls).toEqual([])
107
- expect(app.bundleId).toBe('/tmp/Foo.apk')
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('relaunch issues terminate + launch', async () => {
111
- const runtime = new MockSimRuntime()
112
- const app = await Smix.launchApp(bundleId('dev.smix.target'), runtime, new MockSelectorResolver().resolve)
113
- await app.relaunch()
114
- expect(runtime.launchCalls).toEqual(['dev.smix.target', 'dev.smix.target'])
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
+ })