@goliapkg/smix 1.0.1 → 1.0.3

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.1",
3
+ "version": "1.0.3",
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
@@ -62,6 +62,24 @@ export class HttpSimRuntime implements SmixSimRuntime {
62
62
  */
63
63
  readonly labelsResolver: LabelsResolver
64
64
 
65
+ /**
66
+ * v1.0.3 — session id sent as `Session-Id` header on every request
67
+ * when non-null. Set via {@link setSessionId}; when set, the runner
68
+ * short-circuits per-request `.activate()` and reuses the cached
69
+ * XCUIApplication binding. Typically managed by the {@link Session}
70
+ * class — direct callers rarely need to touch this.
71
+ */
72
+ private sessionId: string | null = null
73
+
74
+ /**
75
+ * v1.0.3 — public alias for the wrapped fetch implementation.
76
+ * Consumed by the {@link Session} class which drives `/session/*`
77
+ * routes through the same transport.
78
+ */
79
+ get fetch(): HttpFetch {
80
+ return this.fetchImpl
81
+ }
82
+
65
83
  constructor(
66
84
  public readonly baseUrl: string,
67
85
  public readonly fetchImpl: HttpFetch = globalThis.fetch as unknown as HttpFetch,
@@ -76,6 +94,15 @@ export class HttpSimRuntime implements SmixSimRuntime {
76
94
  }
77
95
  }
78
96
 
97
+ /**
98
+ * v1.0.3 — attach / clear the `Session-Id` header on every subsequent
99
+ * request. Called by {@link Session.open} / {@link Session.close};
100
+ * consumers who manage sessions manually can call this directly.
101
+ */
102
+ setSessionId(id: string | null): void {
103
+ this.sessionId = id
104
+ }
105
+
79
106
  /**
80
107
  * v7.7 c1 — direct match-count via dedicated FFI fn (avoid allocating
81
108
  * the full ids list when callers only need count). Mirrors Rust
@@ -155,9 +182,15 @@ export class HttpSimRuntime implements SmixSimRuntime {
155
182
 
156
183
  private async post(path: string, body: unknown): Promise<unknown> {
157
184
  const url = `${this.baseUrl}${path}`
185
+ const headers: Record<string, string> = {
186
+ 'content-type': 'application/json',
187
+ }
188
+ if (this.sessionId !== null) {
189
+ headers['session-id'] = this.sessionId
190
+ }
158
191
  const resp = await this.fetchImpl(url, {
159
192
  method: 'POST',
160
- headers: { 'content-type': 'application/json' },
193
+ headers,
161
194
  body: JSON.stringify(body),
162
195
  })
163
196
  if (!resp.ok) {
package/src/Session.ts ADDED
@@ -0,0 +1,134 @@
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
+ }
35
+
36
+ export interface SessionOpenOptions {
37
+ /** If true, runner calls `.activate()` on the target once at open time. */
38
+ activate?: boolean
39
+ }
40
+
41
+ /**
42
+ * Runner-side session guard. Use `Session.open()` to acquire, always
43
+ * pair with `session.close()` in a `finally` block.
44
+ *
45
+ * While the Session is open, every runner request from the same
46
+ * `HttpRunnerClient` carries a `Session-Id` header. The runner
47
+ * short-circuits per-request `.activate()` and reuses the session's
48
+ * cached binding.
49
+ */
50
+ export class Session {
51
+ private constructor(
52
+ private readonly runner: HttpRunnerLike,
53
+ public readonly sessionId: string,
54
+ public readonly activatedOnce: boolean,
55
+ public readonly serverTimeMs: number,
56
+ ) {}
57
+
58
+ static async open(
59
+ runner: HttpRunnerLike,
60
+ bundleId: string,
61
+ options: SessionOpenOptions = {},
62
+ ): Promise<Session> {
63
+ const activate = options.activate ?? false
64
+ const res = await runner.fetch(`${runner.baseUrl}/session/open`, {
65
+ method: 'POST',
66
+ headers: { 'Content-Type': 'application/json' },
67
+ body: JSON.stringify({ bundleId, activate }),
68
+ })
69
+ if (!res.ok) {
70
+ const body = await res.text()
71
+ throw new Error(`Session.open ${bundleId}: HTTP ${res.status}: ${body}`)
72
+ }
73
+ const json = (await res.json()) as {
74
+ sessionId?: string
75
+ activatedOnce?: boolean
76
+ serverTimeMs?: number
77
+ }
78
+ if (typeof json.sessionId !== 'string' || json.sessionId.length === 0) {
79
+ throw new Error(`Session.open ${bundleId}: response missing sessionId`)
80
+ }
81
+ runner.setSessionId(json.sessionId)
82
+ return new Session(
83
+ runner,
84
+ json.sessionId,
85
+ json.activatedOnce ?? false,
86
+ json.serverTimeMs ?? 0,
87
+ )
88
+ }
89
+
90
+ /**
91
+ * Ask the runner to re-issue `.activate()` on the session's cached
92
+ * binding. Subject to a runner-side 2 s rate limit per session; when
93
+ * rate-limited, returns `false` (no-op, session still healthy).
94
+ *
95
+ * Use when the client detects target-app drift (e.g. tree returned
96
+ * empty payload, foreground steal by system UI).
97
+ */
98
+ async renewActivation(): Promise<boolean> {
99
+ const res = await this.runner.fetch(
100
+ `${this.runner.baseUrl}/session/renew-activation`,
101
+ {
102
+ method: 'POST',
103
+ headers: { 'Content-Type': 'application/json' },
104
+ body: JSON.stringify({ sessionId: this.sessionId }),
105
+ },
106
+ )
107
+ if (!res.ok) {
108
+ const body = await res.text()
109
+ throw new Error(
110
+ `Session.renewActivation ${this.sessionId}: HTTP ${res.status}: ${body}`,
111
+ )
112
+ }
113
+ const json = (await res.json()) as { ok?: boolean; activated?: boolean }
114
+ return json.activated ?? false
115
+ }
116
+
117
+ /**
118
+ * Release the session. Sends `POST /session/close` (idempotent),
119
+ * clears the `Session-Id` header from the client. Subsequent client
120
+ * requests go via the legacy per-request rebind path (rate-limited).
121
+ */
122
+ async close(): Promise<void> {
123
+ try {
124
+ await this.runner.fetch(`${this.runner.baseUrl}/session/close`, {
125
+ method: 'POST',
126
+ headers: { 'Content-Type': 'application/json' },
127
+ body: JSON.stringify({ sessionId: this.sessionId }),
128
+ })
129
+ } finally {
130
+ // Clear the client header regardless of runner-side outcome.
131
+ this.runner.setSessionId(null)
132
+ }
133
+ }
134
+ }
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'