@goliapkg/smix 1.0.2 → 1.0.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goliapkg/smix",
3
- "version": "1.0.2",
3
+ "version": "1.0.4",
4
4
  "description": "Playwright-shape iOS Simulator / Android emulator UI automation SDK for React Native + Expo + Detox-compatible TypeScript test runners.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0 OR MIT",
package/src/HttpRunner.ts CHANGED
@@ -46,7 +46,14 @@ import type { LabelsResolver, SelectorResolver } from './SelectorResolver.js'
46
46
  export type HttpFetch = (
47
47
  input: string,
48
48
  init: { method: 'POST'; headers: Record<string, string>; body: string },
49
- ) => Promise<{ ok: boolean; status: number; json(): Promise<unknown>; text(): Promise<string> }>
49
+ ) => Promise<{
50
+ ok: boolean
51
+ status: number
52
+ json(): Promise<unknown>
53
+ text(): Promise<string>
54
+ /** v1.0.4 §D7 — optional; used to parse `X-Sim-Health` response header. */
55
+ headers?: { get(name: string): string | null }
56
+ }>
50
57
 
51
58
  /**
52
59
  * SmixSimRuntime + SelectorResolver wrapper around the smix-runner HTTP
@@ -62,6 +69,32 @@ export class HttpSimRuntime implements SmixSimRuntime {
62
69
  */
63
70
  readonly labelsResolver: LabelsResolver
64
71
 
72
+ /**
73
+ * v1.0.3 — session id sent as `Session-Id` header on every request
74
+ * when non-null. Set via {@link setSessionId}; when set, the runner
75
+ * short-circuits per-request `.activate()` and reuses the cached
76
+ * XCUIApplication binding. Typically managed by the {@link Session}
77
+ * class — direct callers rarely need to touch this.
78
+ */
79
+ private sessionId: string | null = null
80
+
81
+ /**
82
+ * v1.0.4 §D7 — setter installed by {@link Session.open} so this
83
+ * client can push `X-Sim-Health` header transitions back to the
84
+ * session's state machine. `null` when no session is open (legacy
85
+ * per-request path).
86
+ */
87
+ private sessionStateSetter: ((state: SessionState) => void) | null = null
88
+
89
+ /**
90
+ * v1.0.3 — public alias for the wrapped fetch implementation.
91
+ * Consumed by the {@link Session} class which drives `/session/*`
92
+ * routes through the same transport.
93
+ */
94
+ get fetch(): HttpFetch {
95
+ return this.fetchImpl
96
+ }
97
+
65
98
  constructor(
66
99
  public readonly baseUrl: string,
67
100
  public readonly fetchImpl: HttpFetch = globalThis.fetch as unknown as HttpFetch,
@@ -76,6 +109,24 @@ export class HttpSimRuntime implements SmixSimRuntime {
76
109
  }
77
110
  }
78
111
 
112
+ /**
113
+ * v1.0.3 — attach / clear the `Session-Id` header on every subsequent
114
+ * request. Called by {@link Session.open} / {@link Session.close};
115
+ * consumers who manage sessions manually can call this directly.
116
+ */
117
+ setSessionId(id: string | null): void {
118
+ this.sessionId = id
119
+ }
120
+
121
+ /**
122
+ * v1.0.4 §D7 — register a callback the client invokes on every
123
+ * response that carries `X-Sim-Health`. Session.open() wires this
124
+ * so consumers can subscribe via `session.on('state', ...)`.
125
+ */
126
+ attachSessionState(setter: (state: SessionState) => void): void {
127
+ this.sessionStateSetter = setter
128
+ }
129
+
79
130
  /**
80
131
  * v7.7 c1 — direct match-count via dedicated FFI fn (avoid allocating
81
132
  * the full ids list when callers only need count). Mirrors Rust
@@ -155,11 +206,32 @@ export class HttpSimRuntime implements SmixSimRuntime {
155
206
 
156
207
  private async post(path: string, body: unknown): Promise<unknown> {
157
208
  const url = `${this.baseUrl}${path}`
209
+ const headers: Record<string, string> = {
210
+ 'content-type': 'application/json',
211
+ }
212
+ if (this.sessionId !== null) {
213
+ headers['session-id'] = this.sessionId
214
+ }
158
215
  const resp = await this.fetchImpl(url, {
159
216
  method: 'POST',
160
- headers: { 'content-type': 'application/json' },
217
+ headers,
161
218
  body: JSON.stringify(body),
162
219
  })
220
+ // v1.0.4 §D7 — parse X-Sim-Health header and forward to the
221
+ // attached session state setter (if any). Runs BEFORE the
222
+ // ok-check so state transitions are visible on error paths too.
223
+ const simHealthHeader = resp.headers?.get?.('x-sim-health')
224
+ if (simHealthHeader && this.sessionStateSetter) {
225
+ const normalized = simHealthHeader.trim().toLowerCase()
226
+ if (
227
+ normalized === 'healthy' ||
228
+ normalized === 'degraded' ||
229
+ normalized === 'cycling' ||
230
+ normalized === 'dead'
231
+ ) {
232
+ this.sessionStateSetter(normalized as SessionState)
233
+ }
234
+ }
163
235
  if (!resp.ok) {
164
236
  const text = await resp.text()
165
237
  throw new Error(`smix-runner ${path} → HTTP ${resp.status}: ${text}`)
@@ -173,6 +245,9 @@ export class HttpSimRuntime implements SmixSimRuntime {
173
245
  }
174
246
  }
175
247
 
248
+ /** v1.0.4 §D7 — re-export from Session.ts so this file's use compiles. */
249
+ export type SessionState = 'healthy' | 'degraded' | 'cycling' | 'dead'
250
+
176
251
  function base64ToBytes(b64: string): Uint8Array {
177
252
  if (typeof globalThis.atob === 'function') {
178
253
  const bin = globalThis.atob(b64)
package/src/Session.ts ADDED
@@ -0,0 +1,204 @@
1
+ // v1.0.3 — Session lifecycle wrapper.
2
+ //
3
+ // Session lifecycle addresses the runner-side "activation storm": pre-
4
+ // v1.0.2 runners called `.activate()` on every request whose
5
+ // `App-Activate: true` header was set, exhausting XCTest process
6
+ // arbitration on long-running gates. Sessions replace that with a
7
+ // one-shot lifecycle — open once, every subsequent request carries
8
+ // the `Session-Id` header, runner reuses its cached XCUIApplication
9
+ // binding, no per-request activation.
10
+ //
11
+ // Consumer flow:
12
+ //
13
+ // ```ts
14
+ // import { Smix, HttpRunnerClient, HttpSimRuntime, HttpSelectorResolver } from '@goliapkg/smix'
15
+ //
16
+ // const runner = new HttpRunnerClient('http://127.0.0.1:22087')
17
+ // const runtime = new HttpSimRuntime(runner)
18
+ // const resolver = new HttpSelectorResolver(runner)
19
+ // const session = await Session.open(runner, 'com.example.app', { activate: true })
20
+ // try {
21
+ // const app = await Smix.launchApp(bundleId('com.example.app'), runtime, resolver)
22
+ // // ... use `app` normally; every request carries Session-Id ...
23
+ // } finally {
24
+ // await session.close()
25
+ // }
26
+ // ```
27
+
28
+ import type { HttpFetch } from './HttpRunner.js'
29
+
30
+ interface HttpRunnerLike {
31
+ readonly baseUrl: string
32
+ readonly fetch: HttpFetch
33
+ setSessionId(id: string | null): void
34
+ /** v1.0.4 §D7 — optional hook so the client can push state
35
+ * transitions parsed from `X-Sim-Health` headers back to the
36
+ * Session. Legacy runners omit the header; state stays `healthy`. */
37
+ attachSessionState?(setter: (state: SessionState) => void): void
38
+ }
39
+
40
+ export interface SessionOpenOptions {
41
+ /** If true, runner calls `.activate()` on the target once at open time. */
42
+ activate?: boolean
43
+ }
44
+
45
+ /**
46
+ * v1.0.4 §D7 — session-scoped sim health classification observed via
47
+ * the runner's `X-Sim-Health` response header. Consumers subscribe
48
+ * via {@link Session.on}.
49
+ */
50
+ export type SessionState = 'healthy' | 'degraded' | 'cycling' | 'dead'
51
+
52
+ type StateListener = (state: SessionState) => void
53
+
54
+ /**
55
+ * Runner-side session guard. Use `Session.open()` to acquire, always
56
+ * pair with `session.close()` in a `finally` block.
57
+ *
58
+ * While the Session is open, every runner request from the same
59
+ * `HttpRunnerClient` carries a `Session-Id` header. The runner
60
+ * short-circuits per-request `.activate()` and reuses the session's
61
+ * cached binding.
62
+ */
63
+ export class Session {
64
+ /** v1.0.4 §D7 — current classification; updated on every response
65
+ * that carries `X-Sim-Health`. */
66
+ private _state: SessionState = 'healthy'
67
+ private readonly listeners = new Set<StateListener>()
68
+
69
+ private constructor(
70
+ private readonly runner: HttpRunnerLike,
71
+ public readonly sessionId: string,
72
+ public readonly activatedOnce: boolean,
73
+ public readonly serverTimeMs: number,
74
+ ) {}
75
+
76
+ static async open(
77
+ runner: HttpRunnerLike,
78
+ bundleId: string,
79
+ options: SessionOpenOptions = {},
80
+ ): Promise<Session> {
81
+ const activate = options.activate ?? false
82
+ const res = await runner.fetch(`${runner.baseUrl}/session/open`, {
83
+ method: 'POST',
84
+ headers: { 'Content-Type': 'application/json' },
85
+ body: JSON.stringify({ bundleId, activate }),
86
+ })
87
+ if (!res.ok) {
88
+ const body = await res.text()
89
+ throw new Error(`Session.open ${bundleId}: HTTP ${res.status}: ${body}`)
90
+ }
91
+ const json = (await res.json()) as {
92
+ sessionId?: string
93
+ activatedOnce?: boolean
94
+ serverTimeMs?: number
95
+ }
96
+ if (typeof json.sessionId !== 'string' || json.sessionId.length === 0) {
97
+ throw new Error(`Session.open ${bundleId}: response missing sessionId`)
98
+ }
99
+ runner.setSessionId(json.sessionId)
100
+ const session = new Session(
101
+ runner,
102
+ json.sessionId,
103
+ json.activatedOnce ?? false,
104
+ json.serverTimeMs ?? 0,
105
+ )
106
+ // v1.0.4 §D7 — wire the runner's X-Sim-Health parse into this
107
+ // session's state machine. Legacy runner-clients without the
108
+ // attachSessionState hook keep working; the session's state stays
109
+ // 'healthy'.
110
+ runner.attachSessionState?.((next) => session.updateState(next))
111
+ return session
112
+ }
113
+
114
+ /** v1.0.4 §D7 — current sim-health classification. */
115
+ get state(): SessionState {
116
+ return this._state
117
+ }
118
+
119
+ /** v1.0.4 §D7 — subscribe to state transitions. Returns an
120
+ * unsubscribe function. */
121
+ on(_event: 'state', listener: StateListener): () => void {
122
+ this.listeners.add(listener)
123
+ return () => this.listeners.delete(listener)
124
+ }
125
+
126
+ /** v1.0.4 §D14 — instruct the runner to `terminate()` + `launch()`
127
+ * the session's cached `XCUIApplication` in place. Preserves the
128
+ * session id and XCUITest binding. Returns wall-clock ms. */
129
+ async relaunchApp(): Promise<number> {
130
+ const res = await this.runner.fetch(
131
+ `${this.runner.baseUrl}/session/relaunch-app`,
132
+ {
133
+ method: 'POST',
134
+ headers: { 'Content-Type': 'application/json' },
135
+ body: JSON.stringify({ sessionId: this.sessionId }),
136
+ },
137
+ )
138
+ if (!res.ok) {
139
+ const body = await res.text()
140
+ throw new Error(
141
+ `Session.relaunchApp ${this.sessionId}: HTTP ${res.status}: ${body}`,
142
+ )
143
+ }
144
+ const json = (await res.json()) as { ok?: boolean; wallMs?: number }
145
+ return json.wallMs ?? 0
146
+ }
147
+
148
+ private updateState(next: SessionState) {
149
+ if (next === this._state) return
150
+ this._state = next
151
+ for (const listener of this.listeners) {
152
+ try {
153
+ listener(next)
154
+ } catch {
155
+ // Consumer callbacks must not break the runner-response path.
156
+ }
157
+ }
158
+ }
159
+
160
+ /**
161
+ * Ask the runner to re-issue `.activate()` on the session's cached
162
+ * binding. Subject to a runner-side 2 s rate limit per session; when
163
+ * rate-limited, returns `false` (no-op, session still healthy).
164
+ *
165
+ * Use when the client detects target-app drift (e.g. tree returned
166
+ * empty payload, foreground steal by system UI).
167
+ */
168
+ async renewActivation(): Promise<boolean> {
169
+ const res = await this.runner.fetch(
170
+ `${this.runner.baseUrl}/session/renew-activation`,
171
+ {
172
+ method: 'POST',
173
+ headers: { 'Content-Type': 'application/json' },
174
+ body: JSON.stringify({ sessionId: this.sessionId }),
175
+ },
176
+ )
177
+ if (!res.ok) {
178
+ const body = await res.text()
179
+ throw new Error(
180
+ `Session.renewActivation ${this.sessionId}: HTTP ${res.status}: ${body}`,
181
+ )
182
+ }
183
+ const json = (await res.json()) as { ok?: boolean; activated?: boolean }
184
+ return json.activated ?? false
185
+ }
186
+
187
+ /**
188
+ * Release the session. Sends `POST /session/close` (idempotent),
189
+ * clears the `Session-Id` header from the client. Subsequent client
190
+ * requests go via the legacy per-request rebind path (rate-limited).
191
+ */
192
+ async close(): Promise<void> {
193
+ try {
194
+ await this.runner.fetch(`${this.runner.baseUrl}/session/close`, {
195
+ method: 'POST',
196
+ headers: { 'Content-Type': 'application/json' },
197
+ body: JSON.stringify({ sessionId: this.sessionId }),
198
+ })
199
+ } finally {
200
+ // Clear the client header regardless of runner-side outcome.
201
+ this.runner.setSessionId(null)
202
+ }
203
+ }
204
+ }
package/src/index.ts CHANGED
@@ -74,3 +74,9 @@ export {
74
74
  type A11yRole,
75
75
  type Rect,
76
76
  } from './A11yNode.js'
77
+
78
+ // v1.0.3 — session lifecycle. Wraps runner-side `/session/*` routes.
79
+ export {
80
+ Session,
81
+ type SessionOpenOptions,
82
+ } from './Session.js'