@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 +1 -1
- package/src/HttpRunner.ts +77 -2
- package/src/Session.ts +204 -0
- package/src/index.ts +6 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goliapkg/smix",
|
|
3
|
-
"version": "1.0.
|
|
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<{
|
|
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
|
|
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
|
+
}
|