simframe 0.6.2 → 0.7.0

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.
@@ -0,0 +1,15 @@
1
+ // Host-side tools, shared by every backend.
2
+ //
3
+ // `resize` was on the Phase 8 list of eleven platform functions and does not
4
+ // belong there: it downscales a PNG file with sips and never touches a device.
5
+ // Behind the platform boundary it would have to be declared once per backend,
6
+ // identically, for no reason. Here both backends get it and neither owns it.
7
+ import { execFile } from 'node:child_process';
8
+ import { promisify } from 'node:util';
9
+
10
+ const run = promisify(execFile);
11
+
12
+ /** Resample with sips, which ships with macOS, so simframe needs no image deps. */
13
+ export async function resize(inFile, outFile, maxDim) {
14
+ await run('sips', ['-Z', String(maxDim), inFile, '--out', outFile], { timeout: 10_000 });
15
+ }
@@ -0,0 +1,248 @@
1
+ // The platform boundary.
2
+ //
3
+ // Above this line nothing knows what a simulator is. `src/index.js`,
4
+ // `daemon.js`, `actions.js`, `mcp.js` and `cli.js` used to import
5
+ // `src/simctl.js` directly — eleven functions and a constant, none of them
6
+ // conceptually iOS: list devices, resolve one, launch and terminate an app,
7
+ // open a URL, set the pasteboard, grant a permission, take a screenshot. Every
8
+ // one has an `adb` equivalent, which is exactly why the boundary had to exist
9
+ // before a second backend rather than beside it.
10
+ //
11
+ // The Swift half has had this since Phase 0: a 21-method `SimulatorPlatform`
12
+ // protocol in `PrivateAPI`, with a stub implementation proving the protocol is
13
+ // satisfiable by something that is not a simulator. `PLATFORM_SURFACE` below is
14
+ // the same idea for JavaScript, and `test/unit.test.mjs` holds every registered
15
+ // backend to it.
16
+ //
17
+ // **Dispatch is by device, not by a process-wide default.** A device is iOS or
18
+ // Android; nothing about the host decides which. So `listDevices` unions the
19
+ // backends and stamps each record with the platform it came from, and every
20
+ // function that takes a udid routes on that udid — `ownsUdid` answers that from
21
+ // the id's own shape, because the question is asked from inside a capture loop
22
+ // in a process that never listed anything.
23
+ import { platform as android } from './android.js';
24
+ import { platform as ios } from './ios.js';
25
+
26
+ export { resize } from './host.js';
27
+
28
+ /**
29
+ * @typedef {object} Device
30
+ * @property {string} udid
31
+ * @property {string} name
32
+ * @property {string} runtime
33
+ * @property {string} state 'Booted' when the device can be driven
34
+ * @property {string} platform stamped by the seam, never by the backend
35
+ */
36
+
37
+ /**
38
+ * @typedef {object} Platform
39
+ * @property {string} id
40
+ * @property {string} deviceNoun what to call a device in user-facing text
41
+ * @property {(opts?: object) => Promise<Device[]>} listDevices
42
+ * @property {(opts?: object) => Promise<Device[]>} bootedDevices
43
+ * @property {(query?: string, opts?: object) => Promise<Device>} resolveDevice
44
+ * @property {(udid: string) => boolean} isBootedSync
45
+ * @property {(udid: string) => boolean} ownsUdid sync, no I/O — see ios.js
46
+ * @property {Function} geometry the device's real point size, or null if this backend cannot say
47
+ * @property {Function} inputDriver this backend's own input path, or null if input comes from above
48
+ * @property {Function} screenshot
49
+ * @property {Function} launchApp
50
+ * @property {Function} terminateApp
51
+ * @property {Function} openUrl
52
+ * @property {Function} setPermission
53
+ * @property {Function} setPasteboard
54
+ * @property {() => string[]} permissionServices
55
+ * @property {() => {captureEngines: string[], input: object, ax: object}} capabilities
56
+ * @property {() => Array<{name: string, level: string, detail: string}>} toolchain
57
+ */
58
+
59
+ /** Every member a backend must provide. A backend missing one fails a test, not a user. */
60
+ export const PLATFORM_SURFACE = Object.freeze([
61
+ 'id', 'deviceNoun',
62
+ 'listDevices', 'bootedDevices', 'resolveDevice', 'isBootedSync', 'ownsUdid',
63
+ 'geometry', 'inputDriver',
64
+ 'screenshot', 'launchApp', 'terminateApp', 'openUrl',
65
+ 'setPermission', 'setPasteboard', 'permissionServices', 'capabilities', 'toolchain',
66
+ ]);
67
+
68
+ /** @type {Record<string, Platform>} */
69
+ export const PLATFORMS = Object.freeze({ ios, android });
70
+
71
+ /** @returns {Platform[]} in registration order, which is the order devices are listed in. */
72
+ export function backends() {
73
+ return Object.values(PLATFORMS);
74
+ }
75
+
76
+ // udid → platform id, learned from every listing and every resolve. A device
77
+ // cannot change platform, so this never goes stale and never needs clearing.
78
+ const learned = new Map();
79
+
80
+ /**
81
+ * Which backend owns this device.
82
+ *
83
+ * Synchronous by requirement: `isBootedSync` is called from the capture loop,
84
+ * in a daemon process that was handed a udid and never listed anything. So the
85
+ * answer comes from what a listing already taught us, or from the id's own
86
+ * shape, and never from the device.
87
+ *
88
+ * With one backend registered an unrecognised id still routes there, so a
89
+ * typo'd udid gets simctl's own error rather than one from the seam — which is
90
+ * what it got before the seam existed.
91
+ *
92
+ * @returns {Platform}
93
+ */
94
+ export function platformFor(udid) {
95
+ return chooseBackend(udid, backends());
96
+ }
97
+
98
+ /**
99
+ * The routing decision itself, over a given set of backends, so it can be
100
+ * tested against two of them before a second one exists. Everything here is a
101
+ * decision about *which* backend; nothing here talks to a device.
102
+ */
103
+ export function chooseBackend(udid, all) {
104
+ const remembered = learned.get(udid);
105
+ const known = all.find((b) => b.id === remembered);
106
+ if (known) return known;
107
+ const claimed = all.filter((b) => b.ownsUdid(udid));
108
+ if (claimed.length === 1) return claimed[0];
109
+ if (claimed.length === 0 && all.length === 1) return all[0];
110
+ throw new Error(
111
+ claimed.length > 1
112
+ ? `device id "${udid}" is claimed by ${claimed.map((b) => b.id).join(' and ')}`
113
+ : `no platform recognises the device id "${udid}" (tried ${all.map((b) => b.id).join(', ')})`,
114
+ );
115
+ }
116
+
117
+ /** Stamp the platform onto a record without mutating a backend's cached copy. */
118
+ function stamp(device, backend) {
119
+ learned.set(device.udid, backend.id);
120
+ return { ...device, platform: backend.id };
121
+ }
122
+
123
+ /** Every device every backend can see, in registration order. @returns {Promise<Device[]>} */
124
+ export async function listDevices(opts) {
125
+ const out = [];
126
+ for (const backend of backends()) {
127
+ for (const device of await backend.listDevices(opts)) out.push(stamp(device, backend));
128
+ }
129
+ return out;
130
+ }
131
+
132
+ /** @returns {Promise<Device[]>} */
133
+ export async function bootedDevices(opts) {
134
+ const out = [];
135
+ for (const backend of backends()) {
136
+ for (const device of await backend.bootedDevices(opts)) out.push(stamp(device, backend));
137
+ }
138
+ return out;
139
+ }
140
+
141
+ /**
142
+ * Resolve a device string against every backend.
143
+ *
144
+ * A backend that finds nothing is passed over. A backend that finds the query
145
+ * *ambiguous* is not: that error wins even when another backend matched
146
+ * exactly, because answering an ambiguous query with the other platform's
147
+ * device is the wrong-device bug wearing a different hat.
148
+ *
149
+ * Cross-platform ambiguity — one name matching a simulator and an emulator — is
150
+ * reported rather than guessed at. Which of them a bare query should prefer is
151
+ * a question for the step that adds the second backend, not one to invent an
152
+ * answer to here.
153
+ *
154
+ * @returns {Promise<Device>}
155
+ */
156
+ export async function resolveDevice(query, opts) {
157
+ return resolveAcross(query, opts, backends());
158
+ }
159
+
160
+ /** As above, over a given set of backends — the testable half. @returns {Promise<Device>} */
161
+ export async function resolveAcross(query, opts, all) {
162
+ const hits = [];
163
+ const misses = [];
164
+ for (const backend of all) {
165
+ try {
166
+ hits.push(stamp(await backend.resolveDevice(query, opts), backend));
167
+ } catch (err) {
168
+ if (err?.ambiguous) throw err;
169
+ misses.push(err);
170
+ }
171
+ }
172
+ if (hits.length === 1) return hits[0];
173
+ if (hits.length > 1) {
174
+ throw new Error(
175
+ `"${query}" matches a device on more than one platform: ` +
176
+ `${hits.map((d) => `${d.name} (${d.platform})`).join(', ')} — name one by its id`,
177
+ );
178
+ }
179
+ // One backend: its own message, unchanged. It is the better message, because
180
+ // it knows what it looked in.
181
+ if (misses.length === 1) throw misses[0];
182
+ throw new Error(misses.map((e) => e.message).join('; '));
183
+ }
184
+
185
+ // Device-keyed dispatch. The udid is passed through as well as routed on:
186
+ // backends keep the signatures they always had, and the seam decides which
187
+ // backend a call reaches, never what the call means.
188
+ export const isBootedSync = (udid, ...args) => platformFor(udid).isBootedSync(udid, ...args);
189
+ export const screenshot = (udid, ...args) => platformFor(udid).screenshot(udid, ...args);
190
+ export const launchApp = (udid, ...args) => platformFor(udid).launchApp(udid, ...args);
191
+ export const terminateApp = (udid, ...args) => platformFor(udid).terminateApp(udid, ...args);
192
+ export const openUrl = (udid, ...args) => platformFor(udid).openUrl(udid, ...args);
193
+ export const setPermission = (udid, ...args) => platformFor(udid).setPermission(udid, ...args);
194
+ export const setPasteboard = (udid, ...args) => platformFor(udid).setPasteboard(udid, ...args);
195
+
196
+ /**
197
+ * The permission services a device understands, or every service any backend
198
+ * understands when no device is named.
199
+ *
200
+ * simctl's list has no Android meaning and Android's has no iOS meaning, so the
201
+ * unioned form is only honest as a menu — `setPermission` validates against the
202
+ * backend that will actually run it. `docs/DEFERRED.md` has the open question:
203
+ * the two platforms do not have the same permissions, and the MCP tool
204
+ * description is written once, before any device is chosen.
205
+ */
206
+ export function permissionServices(udid) {
207
+ if (udid !== undefined) return platformFor(udid).permissionServices();
208
+ const all = [];
209
+ for (const backend of backends()) {
210
+ for (const service of backend.permissionServices()) if (!all.includes(service)) all.push(service);
211
+ }
212
+ return all;
213
+ }
214
+
215
+ /**
216
+ * The device's real point size and scale, or null when the backend cannot say
217
+ * and something above the boundary has to.
218
+ *
219
+ * Android's only source for this is the backend, and without it the geometry
220
+ * fell through to a guess derived from the capture image — an emulator reported
221
+ * "393x700pt", which is the ring size and not any coordinate space the device
222
+ * knows. Tap points computed from that are wrong, and nothing says so.
223
+ */
224
+ export const geometryFor = (udid) => platformFor(udid).geometry(udid);
225
+
226
+ /**
227
+ * The backend's own input path, or null when input comes from above the
228
+ * boundary. iOS is null: Indigo HID lives in the daemon, which is simframe's
229
+ * engine rather than the platform's. Android is the emulator console.
230
+ */
231
+ export const inputDriverFor = (udid) => platformFor(udid).inputDriver(udid);
232
+
233
+ /**
234
+ * What a device's platform can currently do: which capture engines it has, and
235
+ * whether input and the accessibility tree are implemented for it at all.
236
+ *
237
+ * This exists because doctor, asked about an Android emulator, reported "input
238
+ * driver: idb" and "accessibility tree: idb" — a claim about a tool that has
239
+ * never spoken to an Android device. A layer above the boundary must not
240
+ * describe a device in the other platform's terms, and the only way it can
241
+ * avoid that is to ask.
242
+ */
243
+ export const capabilitiesFor = (udid) => platformFor(udid).capabilities();
244
+
245
+ /** What `simframe doctor` should check: each registered backend's own toolchain. */
246
+ export function toolchainChecks() {
247
+ return backends().flatMap((backend) => backend.toolchain());
248
+ }
@@ -1,4 +1,9 @@
1
- // Everything that shells out to Xcode's command line tools.
1
+ // The iOS backend: everything that shells out to Xcode's command line tools.
2
+ //
3
+ // Nothing outside src/platform/ may import this file. Callers go through
4
+ // src/platform/index.js, which is why every function here is module-private
5
+ // and reachable only through the `platform` object at the bottom — the
6
+ // JavaScript counterpart of the `SimulatorPlatform` protocol in Swift.
2
7
  import { execFile, execFileSync } from 'node:child_process';
3
8
  import { promisify } from 'node:util';
4
9
 
@@ -10,7 +15,7 @@ const DEVICE_CACHE_MS = 4000;
10
15
  let deviceCache = { at: 0, devices: null, inflight: null };
11
16
 
12
17
  /** @returns {Promise<Array<{udid: string, name: string, runtime: string, state: string}>>} */
13
- export async function listDevices({ maxAgeMs = DEVICE_CACHE_MS } = {}) {
18
+ async function listDevices({ maxAgeMs = DEVICE_CACHE_MS } = {}) {
14
19
  if (deviceCache.devices && Date.now() - deviceCache.at <= maxAgeMs) return deviceCache.devices;
15
20
  if (deviceCache.inflight) return deviceCache.inflight;
16
21
  deviceCache.inflight = fetchDevices()
@@ -47,7 +52,7 @@ async function fetchDevices() {
47
52
  return out;
48
53
  }
49
54
 
50
- export async function bootedDevices(opts) {
55
+ async function bootedDevices(opts) {
51
56
  return (await listDevices(opts)).filter((d) => d.state === 'Booted');
52
57
  }
53
58
 
@@ -56,7 +61,7 @@ export async function bootedDevices(opts) {
56
61
  * booted device. Prefers booted devices; falls back to a clear error listing
57
62
  * what is actually available.
58
63
  */
59
- export async function resolveDevice(query, opts) {
64
+ async function resolveDevice(query, opts) {
60
65
  const all = await listDevices(opts);
61
66
  const booted = all.filter((d) => d.state === 'Booted');
62
67
  if (!query) {
@@ -71,13 +76,19 @@ export async function resolveDevice(query, opts) {
71
76
  const partial = pool.filter((d) => d.name.toLowerCase().includes(q));
72
77
  if (partial.length === 1) return partial[0];
73
78
  if (partial.length > 1) {
74
- throw new Error(`"${query}" matches ${partial.length} devices: ${partial.map((d) => d.name).join(', ')}`);
79
+ // Marked, because the seam asks every backend to resolve and must not
80
+ // answer with an Android device when the query was ambiguous here. A
81
+ // plain "no match" may be passed over; an ambiguity may not.
82
+ throw Object.assign(
83
+ new Error(`"${query}" matches ${partial.length} devices: ${partial.map((d) => d.name).join(', ')}`),
84
+ { ambiguous: true },
85
+ );
75
86
  }
76
87
  }
77
88
  throw new Error(`no simulator matches "${query}"; booted: ${booted.map((d) => d.name).join(', ') || 'none'}`);
78
89
  }
79
90
 
80
- export function isBootedSync(udid) {
91
+ function isBootedSync(udid) {
81
92
  try {
82
93
  const out = execFileSync('xcrun', ['simctl', 'list', 'devices', '--json'], {
83
94
  maxBuffer: 8 << 20,
@@ -95,7 +106,7 @@ export function isBootedSync(udid) {
95
106
  return false;
96
107
  }
97
108
 
98
- export async function screenshot(udid, outFile, { mask = 'ignored' } = {}) {
109
+ async function screenshot(udid, outFile, { mask = 'ignored' } = {}) {
99
110
  try {
100
111
  await run('xcrun', ['simctl', 'io', udid, 'screenshot', '--type=png', `--mask=${mask}`, outFile], {
101
112
  timeout: 10_000,
@@ -109,11 +120,6 @@ export async function screenshot(udid, outFile, { mask = 'ignored' } = {}) {
109
120
  }
110
121
  }
111
122
 
112
- /** Resample with sips, which ships with macOS, so simframe needs no image deps. */
113
- export async function resize(inFile, outFile, maxDim) {
114
- await run('sips', ['-Z', String(maxDim), inFile, '--out', outFile], { timeout: 10_000 });
115
- }
116
-
117
123
  /**
118
124
  * Launch, optionally with arguments and environment.
119
125
  *
@@ -121,7 +127,7 @@ export async function resize(inFile, outFile, maxDim) {
121
127
  * `SIMCTL_CHILD_`-prefixed variables of its own process — which is why env has
122
128
  * to be set on the child rather than passed as flags.
123
129
  */
124
- export async function launchApp(udid, bundleId, { args = [], env = {}, terminateFirst = false } = {}) {
130
+ async function launchApp(udid, bundleId, { args = [], env = {}, terminateFirst = false } = {}) {
125
131
  if (terminateFirst) {
126
132
  // A launch against an already-running app is a no-op that reports success,
127
133
  // which is how a flow "relaunched" an app and tested the screen it was
@@ -148,15 +154,15 @@ export async function launchApp(udid, bundleId, { args = [], env = {}, terminate
148
154
  }
149
155
  }
150
156
 
151
- export async function terminateApp(udid, bundleId) {
157
+ async function terminateApp(udid, bundleId) {
152
158
  await run('xcrun', ['simctl', 'terminate', udid, bundleId], { timeout: 20_000 });
153
159
  }
154
160
 
155
- export async function openUrl(udid, url) {
161
+ async function openUrl(udid, url) {
156
162
  await run('xcrun', ['simctl', 'openurl', udid, url], { timeout: 20_000 });
157
163
  }
158
164
 
159
- export const PERMISSION_SERVICES = [
165
+ const PERMISSION_SERVICES = [
160
166
  'all', 'calendar', 'contacts-limited', 'contacts', 'location', 'location-always',
161
167
  'photos-add', 'photos', 'media-library', 'microphone', 'motion', 'reminders', 'siri',
162
168
  ];
@@ -168,7 +174,7 @@ export const PERMISSION_SERVICES = [
168
174
  * tapping a system alert, and a system alert is not part of the app under test:
169
175
  * its buttons move between iOS versions and its appearance is a race.
170
176
  */
171
- export async function setPermission(udid, action, service, bundleId) {
177
+ async function setPermission(udid, action, service, bundleId) {
172
178
  const verb = String(action).toLowerCase();
173
179
  if (!['grant', 'revoke', 'reset'].includes(verb)) {
174
180
  throw new Error(`permission action must be grant, revoke or reset (got "${action}")`);
@@ -188,7 +194,7 @@ export async function setPermission(udid, action, service, bundleId) {
188
194
  }
189
195
 
190
196
  /** Put text on the device pasteboard — far faster than typing a long string. */
191
- export async function setPasteboard(udid, value) {
197
+ async function setPasteboard(udid, value) {
192
198
  const child = execFile('xcrun', ['simctl', 'pbcopy', udid], { timeout: 10_000 });
193
199
  child.stdin.end(value);
194
200
  await new Promise((resolve, reject) => {
@@ -196,3 +202,90 @@ export async function setPasteboard(udid, value) {
196
202
  child.on('close', (code) => (code === 0 ? resolve() : reject(new Error(`pbcopy exited ${code}`))));
197
203
  });
198
204
  }
205
+
206
+ /**
207
+ * Does this backend own that device id, judged without touching the device?
208
+ *
209
+ * The routing question is asked from inside a capture loop and from a daemon
210
+ * process that never resolved the device itself, so it has to be answered
211
+ * synchronously and for free. A simulator udid is a UUID; an emulator serial
212
+ * (`emulator-5554`) is not, and cannot be mistaken for one.
213
+ */
214
+ function ownsUdid(udid) {
215
+ return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(String(udid ?? ''));
216
+ }
217
+
218
+ /**
219
+ * The prerequisites `simframe doctor` reports for this backend. Returned rather
220
+ * than printed so doctor stays one renderer: a backend says what it needs, and
221
+ * a machine missing it is told which tool, not which platform.
222
+ */
223
+ function toolchain() {
224
+ try {
225
+ const version = execFileSync('xcrun', ['--version'], { encoding: 'utf8' }).trim().split('\n')[0];
226
+ return [{ name: 'xcrun', level: 'ok', detail: version }];
227
+ } catch (err) {
228
+ return [{ name: 'xcrun', level: 'fail', detail: err.message }];
229
+ }
230
+ }
231
+
232
+ /**
233
+ * iOS geometry does not come from here.
234
+ *
235
+ * The daemon holds the device's own point size and scale and is both
236
+ * authoritative and free, and idb can answer when the daemon cannot. Both sit
237
+ * above this boundary, so this backend has nothing to add — and returning null
238
+ * says that, where a guess would have been believed.
239
+ */
240
+ function geometry() {
241
+ return null;
242
+ }
243
+
244
+ /**
245
+ * Nor does iOS input.
246
+ *
247
+ * It is Indigo HID inside `simframed`, reached over the control socket: that is
248
+ * simframe's own engine, not something the platform provides. Android's input
249
+ * *is* the platform's — the emulator console — which is why this is a question
250
+ * a backend gets asked at all.
251
+ */
252
+ function inputDriver() {
253
+ return null;
254
+ }
255
+
256
+ /**
257
+ * What this backend can currently do, so nothing above the boundary has to
258
+ * assume. iOS has both capture engines, input through Indigo HID and the
259
+ * accessibility tree through AXPTranslator — which is to say, everything, and
260
+ * that is exactly why the shape of this was invisible until a second backend
261
+ * turned up without it.
262
+ */
263
+ function capabilities() {
264
+ return {
265
+ captureEngines: ['simframed', 'screenshot'],
266
+ input: { supported: true, via: 'daemon' },
267
+ ax: { supported: true },
268
+ };
269
+ }
270
+
271
+ /** @type {import('./index.js').Platform} */
272
+ export const platform = {
273
+ id: 'ios',
274
+ deviceNoun: 'simulator',
275
+ listDevices,
276
+ bootedDevices,
277
+ resolveDevice,
278
+ isBootedSync,
279
+ ownsUdid,
280
+ geometry,
281
+ inputDriver,
282
+ screenshot,
283
+ launchApp,
284
+ terminateApp,
285
+ openUrl,
286
+ setPermission,
287
+ setPasteboard,
288
+ permissionServices: () => PERMISSION_SERVICES,
289
+ capabilities,
290
+ toolchain,
291
+ };
package/src/store.js CHANGED
@@ -30,9 +30,41 @@ export function paths(udid) {
30
30
  heartbeat: path.join(dir, 'heartbeat'),
31
31
  log: path.join(dir, 'daemon.log'),
32
32
  lock: path.join(dir, 'daemon.lock'),
33
+ // Written by whichever capture loop is running, and only when capture is
34
+ // wedged. It cannot ride in state.json: that is written when a frame is
35
+ // recorded, and a stall is the absence of frames.
36
+ captureHealth: path.join(dir, 'capture-health.json'),
33
37
  };
34
38
  }
35
39
 
40
+ /** What the capture loop last said about its own health, or null if it has no complaint. */
41
+ export function captureHealth(udid) {
42
+ return readJson(paths(udid).captureHealth);
43
+ }
44
+
45
+ /**
46
+ * Publish a complaint, or clear it with `null`.
47
+ *
48
+ * The directory is created rather than assumed. A capture loop has always made
49
+ * it already, so the first version of this left it out and swallowed the
50
+ * failure — which meant the write silently did nothing for every other caller,
51
+ * and the test that caught it was the first thing to ask.
52
+ */
53
+ export function writeCaptureHealth(udid, health) {
54
+ const p = paths(udid);
55
+ try {
56
+ if (health) {
57
+ fs.mkdirSync(p.dir, { recursive: true });
58
+ writeAtomic(p.captureHealth, JSON.stringify(health));
59
+ } else {
60
+ fs.rmSync(p.captureHealth, { force: true });
61
+ }
62
+ } catch {
63
+ // Never let bookkeeping stop capture. Anything that reaches here has
64
+ // already failed to make a directory, which capture itself will report.
65
+ }
66
+ }
67
+
36
68
  export function ensureDirs(udid) {
37
69
  const p = paths(udid);
38
70
  fs.mkdirSync(p.ring, { recursive: true });