serve-sim 0.1.16 → 0.1.18
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/README.md +1 -1
- package/bin/serve-sim-bin +0 -0
- package/dist/middleware.js +23 -23
- package/dist/serve-sim.js +28 -28
- package/package.json +1 -1
- package/src/ax-shared.ts +1 -2
- package/src/ax.ts +79 -45
- package/src/middleware.ts +1 -1
package/package.json
CHANGED
package/src/ax-shared.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
export const
|
|
2
|
-
export const AXE_NOT_INSTALLED_ERROR = `AXe is not installed. Install it from ${AXE_INSTALL_URL}.`;
|
|
1
|
+
export const AX_UNAVAILABLE_ERROR = "Accessibility unavailable on this simulator.";
|
|
3
2
|
|
|
4
3
|
export interface AxRect {
|
|
5
4
|
x: number;
|
package/src/ax.ts
CHANGED
|
@@ -1,6 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { promisify } from "util";
|
|
3
|
-
import { AXE_NOT_INSTALLED_ERROR } from "./ax-shared";
|
|
1
|
+
import { AX_UNAVAILABLE_ERROR } from "./ax-shared";
|
|
4
2
|
import type { AxElement, AxRect, AxSnapshot } from "./ax-shared";
|
|
5
3
|
|
|
6
4
|
export type { AxElement, AxRect, AxSnapshot } from "./ax-shared";
|
|
@@ -10,7 +8,6 @@ const MAX_ELEMENTS = 500;
|
|
|
10
8
|
const POLL_INTERVAL_MS = 500;
|
|
11
9
|
const MAX_POLL_INTERVAL_MS = 2000;
|
|
12
10
|
const UNAVAILABLE_RETRY_INTERVAL_MS = 15_000;
|
|
13
|
-
const execFileAsync = promisify(execFile);
|
|
14
11
|
|
|
15
12
|
interface RawAxeNode {
|
|
16
13
|
AXUniqueId: string | null;
|
|
@@ -23,14 +20,6 @@ interface RawAxeNode {
|
|
|
23
20
|
children: RawAxeNode[];
|
|
24
21
|
}
|
|
25
22
|
|
|
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
23
|
function chooseScreenFrame(roots: RawAxeNode[]) {
|
|
35
24
|
return roots[0]?.frame ?? {
|
|
36
25
|
x: 0,
|
|
@@ -90,17 +79,32 @@ function normalizeAxTree(roots: RawAxeNode[]): AxSnapshot {
|
|
|
90
79
|
};
|
|
91
80
|
}
|
|
92
81
|
|
|
93
|
-
async function
|
|
94
|
-
const
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
82
|
+
async function snapshotFromHelper(port: number): Promise<AxSnapshot> {
|
|
83
|
+
const controller = new AbortController();
|
|
84
|
+
const timer = setTimeout(() => controller.abort(), SNAPSHOT_TIMEOUT_MS);
|
|
85
|
+
try {
|
|
86
|
+
const res = await fetch(`http://127.0.0.1:${port}/ax`, { signal: controller.signal });
|
|
87
|
+
if (res.status === 503) {
|
|
88
|
+
// Helper is up but the simulator can't satisfy accessibility right
|
|
89
|
+
// now (framework missing, SpringBoard restarting, etc). Surface as
|
|
90
|
+
// the standard "unavailable" error so the streamer backs off.
|
|
91
|
+
return {
|
|
92
|
+
screen: { width: 1, height: 1 },
|
|
93
|
+
elements: [],
|
|
94
|
+
errors: [AX_UNAVAILABLE_ERROR],
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
if (!res.ok) {
|
|
98
|
+
throw new Error(`HTTP ${res.status} ${res.statusText}`);
|
|
99
|
+
}
|
|
100
|
+
return normalizeAxTree(await res.json() as RawAxeNode[]);
|
|
101
|
+
} finally {
|
|
102
|
+
clearTimeout(timer);
|
|
103
|
+
}
|
|
100
104
|
}
|
|
101
105
|
|
|
102
|
-
function
|
|
103
|
-
return snapshot?.errors?.includes(
|
|
106
|
+
function isAxUnavailableSnapshot(snapshot: AxSnapshot | null) {
|
|
107
|
+
return snapshot?.errors?.includes(AX_UNAVAILABLE_ERROR) ?? false;
|
|
104
108
|
}
|
|
105
109
|
|
|
106
110
|
function isUsableAxSnapshot(snapshot: AxSnapshot) {
|
|
@@ -111,14 +115,15 @@ function isUsableAxSnapshot(snapshot: AxSnapshot) {
|
|
|
111
115
|
);
|
|
112
116
|
}
|
|
113
117
|
|
|
114
|
-
async function collectAxSnapshot(
|
|
118
|
+
async function collectAxSnapshot(port: number) {
|
|
115
119
|
const errors: string[] = [];
|
|
116
120
|
|
|
117
121
|
try {
|
|
118
|
-
const snapshot = await
|
|
122
|
+
const snapshot = await snapshotFromHelper(port);
|
|
123
|
+
if (snapshot.errors?.length) return snapshot;
|
|
119
124
|
if (!isUsableAxSnapshot(snapshot)) {
|
|
120
125
|
throw new Error(
|
|
121
|
-
`
|
|
126
|
+
`helper returned ${snapshot.elements.length} elements in ${snapshot.screen.width}x${snapshot.screen.height} AX space`,
|
|
122
127
|
);
|
|
123
128
|
}
|
|
124
129
|
return {
|
|
@@ -126,12 +131,18 @@ async function collectAxSnapshot(udid: string) {
|
|
|
126
131
|
errors,
|
|
127
132
|
};
|
|
128
133
|
} catch (error) {
|
|
129
|
-
const err = error as Error & {
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
134
|
+
const err = error as Error & { cause?: { code?: string }; code?: string };
|
|
135
|
+
const code = err.cause?.code ?? err.code;
|
|
136
|
+
const message = err.message || String(error);
|
|
137
|
+
// Helper not yet up (or just restarted). Node sets cause.code; Bun/undici
|
|
138
|
+
// surface it as a free-text "Unable to connect" message. Either way,
|
|
139
|
+
// treat as unavailable so the SSE consumer renders a friendly state
|
|
140
|
+
// rather than churning per-poll error stacks.
|
|
141
|
+
const isConnectFailure =
|
|
142
|
+
code === "ECONNREFUSED" ||
|
|
143
|
+
code === "ECONNRESET" ||
|
|
144
|
+
/unable to connect|fetch failed|ECONNREFUSED/i.test(message);
|
|
145
|
+
errors.push(isConnectFailure ? AX_UNAVAILABLE_ERROR : message);
|
|
135
146
|
}
|
|
136
147
|
|
|
137
148
|
return {
|
|
@@ -145,16 +156,18 @@ function sseMessage(payload: unknown) {
|
|
|
145
156
|
return `data: ${JSON.stringify(payload)}\n\n`;
|
|
146
157
|
}
|
|
147
158
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
159
|
+
interface AxStreamer {
|
|
160
|
+
addClient(res: { write(chunk: string): void }): () => void;
|
|
161
|
+
setPort(port: number): void;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function createAxStreamer({ port }: { port: number }): AxStreamer {
|
|
153
165
|
const clients = new Set<{ write(chunk: string): void }>();
|
|
154
166
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
155
167
|
let latestMessage: string | null = null;
|
|
156
168
|
let pollIntervalMs = POLL_INTERVAL_MS;
|
|
157
169
|
let polling = false;
|
|
170
|
+
let currentPort = port;
|
|
158
171
|
|
|
159
172
|
const schedule = () => {
|
|
160
173
|
if (clients.size === 0 || timer) return;
|
|
@@ -170,7 +183,7 @@ function createAxStreamer({
|
|
|
170
183
|
|
|
171
184
|
polling = true;
|
|
172
185
|
try {
|
|
173
|
-
const next = await collectAxSnapshot(
|
|
186
|
+
const next = await collectAxSnapshot(currentPort);
|
|
174
187
|
const nextMessage = sseMessage(next);
|
|
175
188
|
if (nextMessage !== latestMessage) {
|
|
176
189
|
for (const client of clients) client.write(nextMessage);
|
|
@@ -179,10 +192,10 @@ function createAxStreamer({
|
|
|
179
192
|
pollIntervalMs = Math.min(pollIntervalMs * 2, MAX_POLL_INTERVAL_MS);
|
|
180
193
|
}
|
|
181
194
|
latestMessage = nextMessage;
|
|
182
|
-
//
|
|
183
|
-
//
|
|
184
|
-
//
|
|
185
|
-
if (
|
|
195
|
+
// If the helper says AX is unavailable (framework missing, sim
|
|
196
|
+
// booting), keep polling but back off so we recover automatically
|
|
197
|
+
// without spamming requests.
|
|
198
|
+
if (isAxUnavailableSnapshot(next)) {
|
|
186
199
|
pollIntervalMs = UNAVAILABLE_RETRY_INTERVAL_MS;
|
|
187
200
|
}
|
|
188
201
|
} finally {
|
|
@@ -192,7 +205,20 @@ function createAxStreamer({
|
|
|
192
205
|
};
|
|
193
206
|
|
|
194
207
|
return {
|
|
195
|
-
|
|
208
|
+
setPort(nextPort: number) {
|
|
209
|
+
if (nextPort === currentPort) return;
|
|
210
|
+
currentPort = nextPort;
|
|
211
|
+
latestMessage = null;
|
|
212
|
+
// Avoid sitting on the unavailable-backoff interval (15s) when the
|
|
213
|
+
// helper has just come up on a new port.
|
|
214
|
+
pollIntervalMs = POLL_INTERVAL_MS;
|
|
215
|
+
if (timer) {
|
|
216
|
+
clearTimeout(timer);
|
|
217
|
+
timer = null;
|
|
218
|
+
}
|
|
219
|
+
void poll();
|
|
220
|
+
},
|
|
221
|
+
addClient(res) {
|
|
196
222
|
clients.add(res);
|
|
197
223
|
if (latestMessage) res.write(latestMessage);
|
|
198
224
|
void poll();
|
|
@@ -208,14 +234,22 @@ function createAxStreamer({
|
|
|
208
234
|
}
|
|
209
235
|
|
|
210
236
|
export function createAxStreamerCache() {
|
|
211
|
-
const streamers = new Map<string,
|
|
237
|
+
const streamers = new Map<string, AxStreamer>();
|
|
212
238
|
|
|
213
239
|
return {
|
|
214
|
-
|
|
240
|
+
/**
|
|
241
|
+
* Get (or create) a streamer for the given simulator. The port is
|
|
242
|
+
* the helper's HTTP port — if the helper restarts on a different
|
|
243
|
+
* port, pass the new value and the cached streamer will retarget.
|
|
244
|
+
*/
|
|
245
|
+
get(udid: string, port: number) {
|
|
215
246
|
const existing = streamers.get(udid);
|
|
216
|
-
if (existing)
|
|
247
|
+
if (existing) {
|
|
248
|
+
existing.setPort(port);
|
|
249
|
+
return existing;
|
|
250
|
+
}
|
|
217
251
|
|
|
218
|
-
const streamer = createAxStreamer({
|
|
252
|
+
const streamer = createAxStreamer({ port });
|
|
219
253
|
streamers.set(udid, streamer);
|
|
220
254
|
return streamer;
|
|
221
255
|
},
|
package/src/middleware.ts
CHANGED
|
@@ -532,7 +532,7 @@ export function simMiddleware(options?: SimMiddlewareOptions) {
|
|
|
532
532
|
"X-Accel-Buffering": "no",
|
|
533
533
|
});
|
|
534
534
|
res.write(":\n\n");
|
|
535
|
-
const ax = axStreamerCache.get(state.device);
|
|
535
|
+
const ax = axStreamerCache.get(state.device, state.port);
|
|
536
536
|
const removeClient = ax.addClient(res);
|
|
537
537
|
req.on("close", removeClient);
|
|
538
538
|
return;
|