serve-sim 0.1.9 → 0.1.11

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": "serve-sim",
3
- "version": "0.1.9",
3
+ "version": "0.1.11",
4
4
  "type": "module",
5
5
  "author": {
6
6
  "name": "Evan Bacon",
@@ -27,6 +27,8 @@
27
27
  "dist/serve-sim.js",
28
28
  "dist/middleware.js",
29
29
  "dist/middleware.cjs",
30
+ "src/ax-shared.ts",
31
+ "src/ax.ts",
30
32
  "src/middleware.ts",
31
33
  "src/state.ts",
32
34
  "bin/serve-sim-bin"
@@ -0,0 +1,26 @@
1
+ export const AXE_INSTALL_URL = "https://github.com/cameroncooke/AXe";
2
+ export const AXE_NOT_INSTALLED_ERROR = `AXe is not installed. Install it from ${AXE_INSTALL_URL}.`;
3
+
4
+ export interface AxRect {
5
+ x: number;
6
+ y: number;
7
+ width: number;
8
+ height: number;
9
+ }
10
+
11
+ export interface AxElement {
12
+ id: string;
13
+ path: string;
14
+ label: string;
15
+ value: string;
16
+ role: string;
17
+ type: string;
18
+ enabled: boolean;
19
+ frame: AxRect;
20
+ }
21
+
22
+ export interface AxSnapshot {
23
+ screen: { width: number; height: number };
24
+ elements: AxElement[];
25
+ errors?: string[];
26
+ }
package/src/ax.ts ADDED
@@ -0,0 +1,223 @@
1
+ import { execFile } from "child_process";
2
+ import { promisify } from "util";
3
+ import { AXE_NOT_INSTALLED_ERROR } from "./ax-shared";
4
+ import type { AxElement, AxRect, AxSnapshot } from "./ax-shared";
5
+
6
+ export type { AxElement, AxRect, AxSnapshot } from "./ax-shared";
7
+
8
+ const SNAPSHOT_TIMEOUT_MS = 3500;
9
+ const MAX_ELEMENTS = 500;
10
+ const POLL_INTERVAL_MS = 500;
11
+ const MAX_POLL_INTERVAL_MS = 2000;
12
+ const UNAVAILABLE_RETRY_INTERVAL_MS = 15_000;
13
+ const execFileAsync = promisify(execFile);
14
+
15
+ interface RawAxeNode {
16
+ AXUniqueId: string | null;
17
+ AXLabel: string | null;
18
+ AXValue: string | null;
19
+ enabled: boolean;
20
+ frame: AxRect;
21
+ role_description: string;
22
+ type: string;
23
+ children: RawAxeNode[];
24
+ }
25
+
26
+ async function execFileText(command: string, args: string[]) {
27
+ const { stdout } = await execFileAsync(command, args, {
28
+ timeout: SNAPSHOT_TIMEOUT_MS,
29
+ maxBuffer: 8 * 1024 * 1024,
30
+ });
31
+ return stdout.toString();
32
+ }
33
+
34
+ function chooseScreenFrame(roots: RawAxeNode[]) {
35
+ return roots[0]?.frame ?? {
36
+ x: 0,
37
+ y: 0,
38
+ width: 1,
39
+ height: 1,
40
+ };
41
+ }
42
+
43
+ function sameRect(a: AxRect, b: AxRect) {
44
+ return (
45
+ Math.abs(a.x - b.x) < 0.5 &&
46
+ Math.abs(a.y - b.y) < 0.5 &&
47
+ Math.abs(a.width - b.width) < 0.5 &&
48
+ Math.abs(a.height - b.height) < 0.5
49
+ );
50
+ }
51
+
52
+ function normalizeAxTree(roots: RawAxeNode[]): AxSnapshot {
53
+ const screen = chooseScreenFrame(roots);
54
+ const elements: AxElement[] = [];
55
+
56
+ const visit = (node: RawAxeNode, path: string) => {
57
+ if (elements.length >= MAX_ELEMENTS) return;
58
+
59
+ const frame = node.frame;
60
+ const isScreenSized = sameRect(frame, screen);
61
+
62
+ if (!isScreenSized) {
63
+ elements.push({
64
+ id: node.AXUniqueId ?? path,
65
+ path,
66
+ label: node.AXLabel ?? "",
67
+ value: node.AXValue ?? "",
68
+ role: node.role_description,
69
+ type: node.type,
70
+ enabled: node.enabled !== false,
71
+ frame,
72
+ });
73
+ }
74
+
75
+ for (let index = 0; index < node.children.length && elements.length < MAX_ELEMENTS; index++) {
76
+ visit(node.children[index], `${path}.${index}`);
77
+ }
78
+ };
79
+
80
+ for (let index = 0; index < roots.length && elements.length < MAX_ELEMENTS; index++) {
81
+ visit(roots[index], String(index));
82
+ }
83
+
84
+ return {
85
+ screen: {
86
+ width: screen.width,
87
+ height: screen.height,
88
+ },
89
+ elements,
90
+ };
91
+ }
92
+
93
+ async function snapshotWithAxe(udid: string) {
94
+ const output = await execFileText("axe", ["describe-ui", "--udid", udid]);
95
+ return normalizeAxTree(JSON.parse(output) as RawAxeNode[]);
96
+ }
97
+
98
+ function isAxeNotInstalledError(error: unknown) {
99
+ return (error as NodeJS.ErrnoException | undefined)?.code === "ENOENT";
100
+ }
101
+
102
+ function isAxeUnavailableSnapshot(snapshot: AxSnapshot | null) {
103
+ return snapshot?.errors?.includes(AXE_NOT_INSTALLED_ERROR) ?? false;
104
+ }
105
+
106
+ function isUsableAxSnapshot(snapshot: AxSnapshot) {
107
+ return (
108
+ snapshot.elements.length > 0 &&
109
+ snapshot.screen.width > 1 &&
110
+ snapshot.screen.height > 1
111
+ );
112
+ }
113
+
114
+ async function collectAxSnapshot(udid: string) {
115
+ const errors: string[] = [];
116
+
117
+ try {
118
+ const snapshot = await snapshotWithAxe(udid);
119
+ if (!isUsableAxSnapshot(snapshot)) {
120
+ throw new Error(
121
+ `axe returned ${snapshot.elements.length} elements in ${snapshot.screen.width}x${snapshot.screen.height} AX space`,
122
+ );
123
+ }
124
+ return {
125
+ ...snapshot,
126
+ errors,
127
+ };
128
+ } catch (error) {
129
+ const err = error as Error & { stderr?: string };
130
+ errors.push(
131
+ isAxeNotInstalledError(error)
132
+ ? AXE_NOT_INSTALLED_ERROR
133
+ : err.stderr || err.message || String(error),
134
+ );
135
+ }
136
+
137
+ return {
138
+ screen: { width: 1, height: 1 },
139
+ elements: [],
140
+ errors,
141
+ };
142
+ }
143
+
144
+ function sseMessage(payload: unknown) {
145
+ return `data: ${JSON.stringify(payload)}\n\n`;
146
+ }
147
+
148
+ function createAxStreamer({
149
+ udid,
150
+ }: {
151
+ udid: string;
152
+ }) {
153
+ const clients = new Set<{ write(chunk: string): void }>();
154
+ let timer: ReturnType<typeof setTimeout> | null = null;
155
+ let latestMessage: string | null = null;
156
+ let pollIntervalMs = POLL_INTERVAL_MS;
157
+ let polling = false;
158
+
159
+ const schedule = () => {
160
+ if (clients.size === 0 || timer) return;
161
+ timer = setTimeout(poll, pollIntervalMs);
162
+ };
163
+
164
+ const poll = async () => {
165
+ timer = null;
166
+ if (polling || clients.size === 0) {
167
+ schedule();
168
+ return;
169
+ }
170
+
171
+ polling = true;
172
+ try {
173
+ const next = await collectAxSnapshot(udid);
174
+ const nextMessage = sseMessage(next);
175
+ if (nextMessage !== latestMessage) {
176
+ for (const client of clients) client.write(nextMessage);
177
+ pollIntervalMs = POLL_INTERVAL_MS;
178
+ } else {
179
+ pollIntervalMs = Math.min(pollIntervalMs * 2, MAX_POLL_INTERVAL_MS);
180
+ }
181
+ latestMessage = nextMessage;
182
+ // Back off aggressively while axe is missing so we don't spawn a
183
+ // subprocess every 2s, but keep polling so we recover automatically
184
+ // once the user installs it.
185
+ if (isAxeUnavailableSnapshot(next)) {
186
+ pollIntervalMs = UNAVAILABLE_RETRY_INTERVAL_MS;
187
+ }
188
+ } finally {
189
+ polling = false;
190
+ schedule();
191
+ }
192
+ };
193
+
194
+ return {
195
+ addClient(res: { write(chunk: string): void }) {
196
+ clients.add(res);
197
+ if (latestMessage) res.write(latestMessage);
198
+ void poll();
199
+ return () => {
200
+ clients.delete(res);
201
+ if (clients.size === 0 && timer) {
202
+ clearTimeout(timer);
203
+ timer = null;
204
+ }
205
+ };
206
+ },
207
+ };
208
+ }
209
+
210
+ export function createAxStreamerCache() {
211
+ const streamers = new Map<string, ReturnType<typeof createAxStreamer>>();
212
+
213
+ return {
214
+ get(udid: string) {
215
+ const existing = streamers.get(udid);
216
+ if (existing) return existing;
217
+
218
+ const streamer = createAxStreamer({ udid });
219
+ streamers.set(udid, streamer);
220
+ return streamer;
221
+ },
222
+ };
223
+ }
package/src/middleware.ts CHANGED
@@ -2,6 +2,7 @@ import { readdirSync, readFileSync, existsSync, unlinkSync } from "fs";
2
2
  import { execSync, spawn, exec, execFile, type ChildProcess } from "child_process";
3
3
  import { tmpdir } from "os";
4
4
  import { join } from "path";
5
+ import { createAxStreamerCache } from "./ax";
5
6
 
6
7
  // Injected at build time as a base64-encoded string via `define`
7
8
  declare const __PREVIEW_HTML_B64__: string;
@@ -16,6 +17,8 @@ export interface ServeSimState {
16
17
  wsUrl: string;
17
18
  }
18
19
 
20
+ const axStreamerCache = createAxStreamerCache();
21
+
19
22
  // Known bundle IDs that are always React Native shells (used as a fallback
20
23
  // before the app-container path resolves, since simctl can lag after launch).
21
24
  const RN_BUNDLE_IDS = new Set<string>([
@@ -168,6 +171,7 @@ export interface SimMiddlewareOptions {
168
171
  * GET {basePath} — the preview HTML page
169
172
  * GET {basePath}/api — serve-sim state JSON
170
173
  * GET {basePath}/logs — SSE stream of simctl logs
174
+ * GET {basePath}/ax — SSE stream of normalized accessibility snapshots
171
175
  */
172
176
  export function simMiddleware(options?: SimMiddlewareOptions) {
173
177
  const base = (options?.basePath ?? "/.sim").replace(/\/+$/, "");
@@ -190,8 +194,10 @@ export function simMiddleware(options?: SimMiddlewareOptions) {
190
194
  // and connects to the WS directly (WS has no CORS).
191
195
  const config = JSON.stringify({
192
196
  ...state,
197
+ basePath: base,
193
198
  logsEndpoint: endpoint(base, "/logs", state.device),
194
199
  appStateEndpoint: endpoint(base, "/appstate", state.device),
200
+ axEndpoint: endpoint(base, "/ax", state.device),
195
201
  });
196
202
  const configScript = `<script>window.__SIM_PREVIEW__=${config}</script>`;
197
203
  html = html.replace("<!--__SIM_PREVIEW_CONFIG__-->", configScript);
@@ -217,6 +223,28 @@ export function simMiddleware(options?: SimMiddlewareOptions) {
217
223
  return;
218
224
  }
219
225
 
226
+ // SSE: normalized accessibility snapshot stream
227
+ if (url === base + "/ax") {
228
+ const states = readServeSimStates();
229
+ const state = selectServeSimState(states, selectedDevice);
230
+ if (!state) {
231
+ res.writeHead(404);
232
+ res.end("No serve-sim device");
233
+ return;
234
+ }
235
+ res.writeHead(200, {
236
+ "Content-Type": "text/event-stream",
237
+ "Cache-Control": "no-cache",
238
+ Connection: "keep-alive",
239
+ "X-Accel-Buffering": "no",
240
+ });
241
+ res.write(":\n\n");
242
+ const ax = axStreamerCache.get(state.device);
243
+ const removeClient = ax.addClient(res);
244
+ req.on("close", removeClient);
245
+ return;
246
+ }
247
+
220
248
  // POST /exec — run a shell command on the host. The preview server binds
221
249
  // to localhost only and is meant for local dev, so we shell through
222
250
  // /bin/sh and return stdout/stderr/exitCode.