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/bin/serve-sim-bin +0 -0
- package/dist/middleware.js +11 -7
- package/dist/serve-sim.js +19 -15
- package/package.json +3 -1
- package/src/ax-shared.ts +26 -0
- package/src/ax.ts +223 -0
- package/src/middleware.ts +28 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "serve-sim",
|
|
3
|
-
"version": "0.1.
|
|
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"
|
package/src/ax-shared.ts
ADDED
|
@@ -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.
|