@sprid/cli 0.1.2
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/CHANGELOG.md +12 -0
- package/LICENSE +14 -0
- package/README-post.md +615 -0
- package/README.md +249 -0
- package/SECURITY.md +18 -0
- package/bin.mjs +12 -0
- package/package.json +47 -0
- package/src/args.mjs +134 -0
- package/src/browser.mjs +28 -0
- package/src/cli.mjs +257 -0
- package/src/commands/auth.mjs +192 -0
- package/src/commands/completion.mjs +90 -0
- package/src/commands/connect.mjs +586 -0
- package/src/commands/docs.mjs +53 -0
- package/src/commands/family.mjs +96 -0
- package/src/commands/init.mjs +137 -0
- package/src/commands/local-tools.mjs +31 -0
- package/src/commands/marketing-review.mjs +106 -0
- package/src/commands/mcp.mjs +53 -0
- package/src/commands/misc.mjs +94 -0
- package/src/commands/pinterest.mjs +120 -0
- package/src/commands/plan.mjs +230 -0
- package/src/commands/post.mjs +41 -0
- package/src/commands/reviews.mjs +149 -0
- package/src/commands/setup.mjs +220 -0
- package/src/commands/status.mjs +217 -0
- package/src/commands/studio.mjs +69 -0
- package/src/commands/update.mjs +69 -0
- package/src/creds.mjs +126 -0
- package/src/docs/commands.mjs +152 -0
- package/src/docs/guides.generated.mjs +1178 -0
- package/src/docs/help.mjs +77 -0
- package/src/docs/index.d.mts +18 -0
- package/src/docs/index.mjs +45 -0
- package/src/docs/queries.d.mts +11 -0
- package/src/docs/queries.mjs +77 -0
- package/src/endpoint.mjs +17 -0
- package/src/evidence.mjs +15 -0
- package/src/format.mjs +71 -0
- package/src/http.mjs +117 -0
- package/src/pending.mjs +27 -0
- package/src/post/cli.mjs +2285 -0
- package/src/post/json-worker.mjs +12 -0
- package/src/post/preview-server.mjs +58 -0
- package/src/post/preview.mjs +660 -0
- package/src/post/recipes/screen.mjs +290 -0
- package/src/post/recipes/stills.mjs +146 -0
- package/src/post/rules/platform-rules.d.mts +27 -0
- package/src/post/rules/platform-rules.mjs +164 -0
- package/src/post/screen/captions.mjs +131 -0
- package/src/post/screen/compose.mjs +284 -0
- package/src/post/screen/input.mjs +163 -0
- package/src/post/screen/sim.mjs +443 -0
- package/src/post/screen/simkit.swift +328 -0
- package/src/profiles.mjs +61 -0
- package/src/release.ts +2 -0
- package/src/screenshots.ts +1 -0
- package/src/updates.mjs +152 -0
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
// The simulator half of the `screen` recipe: boot it, dress it, drive it,
|
|
2
|
+
// record it, and write down when everything happened.
|
|
3
|
+
//
|
|
4
|
+
// **The timeline is the output that matters**, not the .mov. A caption is
|
|
5
|
+
// written against a MARK in the script ("when the log opens"), never against a
|
|
6
|
+
// second, because the same script run in seven languages produces seven
|
|
7
|
+
// different sets of timings and only the marks survive that. `compose` reads
|
|
8
|
+
// the timeline back and does the arithmetic.
|
|
9
|
+
|
|
10
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
11
|
+
import { existsSync, mkdirSync, rmSync, statSync, writeFileSync, readFileSync } from "node:fs";
|
|
12
|
+
import { dirname, join, resolve } from "node:path";
|
|
13
|
+
import { tmpdir } from "node:os";
|
|
14
|
+
|
|
15
|
+
const sh = (cmd, args, opts = {}) => spawnSync(cmd, args, { encoding: "utf8", ...opts });
|
|
16
|
+
export const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
17
|
+
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
// simkit, compiled on demand
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The Swift helper is compiled into a cache keyed by the source's mtime, so a
|
|
24
|
+
* checkout costs one 3-second build and never thinks about it again. Shipping a
|
|
25
|
+
* binary in the package would mean shipping an unsigned Mach-O through npm,
|
|
26
|
+
* which is somebody's security review; shipping 400 lines of Swift does not.
|
|
27
|
+
*/
|
|
28
|
+
export function simkit() {
|
|
29
|
+
const src = new URL("./simkit.swift", import.meta.url).pathname;
|
|
30
|
+
const stamp = String(Math.floor(statSync(src).mtimeMs));
|
|
31
|
+
const out = join(tmpdir(), `sprid-simkit-${stamp}`);
|
|
32
|
+
if (!existsSync(out)) {
|
|
33
|
+
const r = sh("swiftc", ["-O", "-o", out, src]);
|
|
34
|
+
if (r.status !== 0) throw new Error(`Could not build simkit:\n${r.stderr}`);
|
|
35
|
+
}
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function kit(args) {
|
|
40
|
+
const r = sh(simkit(), args);
|
|
41
|
+
if (r.status !== 0) throw new Error(`simkit ${args[0]} failed: ${r.stderr.trim()}`);
|
|
42
|
+
return r.stdout.trim();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
// the device
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* A name resolves to a UDID, and **a booted device wins.**
|
|
51
|
+
*
|
|
52
|
+
* Xcode keeps one simulator per name per runtime, so "iPhone 17" matched three
|
|
53
|
+
* devices here and the first in the list was an iOS 26.3 one with nothing
|
|
54
|
+
* installed on it. Taking the first match is how a run spends four minutes
|
|
55
|
+
* driving an empty springboard. Preferring the booted device makes the
|
|
56
|
+
* behaviour match what the person watching the screen expects; failing that,
|
|
57
|
+
* the newest runtime.
|
|
58
|
+
*/
|
|
59
|
+
export function resolveDevice(name) {
|
|
60
|
+
if (/^[0-9A-F-]{36}$/i.test(name)) return name;
|
|
61
|
+
const lines = sh("xcrun", ["simctl", "list", "devices", "available"]).stdout.split("\n");
|
|
62
|
+
const esc = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
63
|
+
const re = new RegExp(`^\\s+${esc} \\(([0-9A-F-]{36})\\)(.*)$`);
|
|
64
|
+
const hits = lines.map((l) => re.exec(l)).filter(Boolean);
|
|
65
|
+
if (!hits.length) throw new Error(`No simulator called "${name}".`);
|
|
66
|
+
return (hits.find((m) => m[2].includes("Booted")) ?? hits[hits.length - 1])[1];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* A killed run leaves the host recorder registered with CoreSimulator, and the
|
|
71
|
+
* next `recordVideo` gets *"Resource busy … Host recording is already in
|
|
72
|
+
* progress"* and writes nothing. No process is left to kill - the state lives
|
|
73
|
+
* in the service - so the only way out is to cycle the device.
|
|
74
|
+
*/
|
|
75
|
+
export async function clearStuckRecorder(udid) {
|
|
76
|
+
sh("xcrun", ["simctl", "shutdown", udid]);
|
|
77
|
+
await sleep(2500);
|
|
78
|
+
sh("xcrun", ["simctl", "boot", udid]);
|
|
79
|
+
sh("xcrun", ["simctl", "bootstatus", udid, "-b"]);
|
|
80
|
+
await sleep(3000);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function boot(udid) {
|
|
84
|
+
const booted = sh("xcrun", ["simctl", "list", "devices"]).stdout
|
|
85
|
+
.split("\n").some((l) => l.includes(udid) && l.includes("Booted"));
|
|
86
|
+
if (!booted) {
|
|
87
|
+
sh("xcrun", ["simctl", "boot", udid]);
|
|
88
|
+
sh("xcrun", ["simctl", "bootstatus", udid, "-b"]);
|
|
89
|
+
}
|
|
90
|
+
sh("open", ["-a", "Simulator"]);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The status bar, dressed.
|
|
95
|
+
*
|
|
96
|
+
* A real 22:47 with 61% battery and no signal is the single clearest tell that
|
|
97
|
+
* a demo was filmed on a machine rather than a phone, and it costs one command
|
|
98
|
+
* to remove.
|
|
99
|
+
*/
|
|
100
|
+
export function dressStatusBar(udid, { time = "now", battery = { level: 76, state: "discharging" } } = {}) {
|
|
101
|
+
// **9:41 is the STORE convention and it is wrong for a reel.** A screenshot
|
|
102
|
+
// is an advertisement and 9:41 reads as one; a reel is meant to look like
|
|
103
|
+
// somebody's phone, and the app's own content gives the real time away
|
|
104
|
+
// anyway - a decision logged during the recording carries the wall clock, so
|
|
105
|
+
// a 9:41 status bar over a row stamped 23:20 is a tell. Default to the host
|
|
106
|
+
// clock; pass a literal "HH:MM" to pin one.
|
|
107
|
+
const clock = time === "now"
|
|
108
|
+
? new Date().toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" })
|
|
109
|
+
: time;
|
|
110
|
+
sh("xcrun", ["simctl", "status_bar", udid, "override",
|
|
111
|
+
"--time", clock,
|
|
112
|
+
"--dataNetwork", "wifi", "--wifiMode", "active", "--wifiBars", "3",
|
|
113
|
+
"--cellularMode", "active", "--cellularBars", "4",
|
|
114
|
+
// A phone at 100% and charging is the store screenshot. Something in the
|
|
115
|
+
// seventies, discharging, is what a phone in a hand looks like.
|
|
116
|
+
"--batteryState", battery.state, "--batteryLevel", String(battery.level)]);
|
|
117
|
+
}
|
|
118
|
+
export const clearStatusBar = (udid) => sh("xcrun", ["simctl", "status_bar", udid, "clear"]);
|
|
119
|
+
|
|
120
|
+
export function screenshot(udid, path, { type = "png" } = {}) {
|
|
121
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
122
|
+
sh("xcrun", ["simctl", "io", udid, "screenshot", `--type=${type}`, path]);
|
|
123
|
+
return path;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* A cheap frame, for comparing against another cheap frame.
|
|
128
|
+
*
|
|
129
|
+
* **Nothing reads these probes but a PSNR**, so they run at a quarter scale
|
|
130
|
+
* through baguette: 302x656 and 49KB against `simctl`'s 1206x2622 and 464KB,
|
|
131
|
+
* which is a twentieth of the pixels for ffmpeg to decode twice a second while
|
|
132
|
+
* the recorder is trying to keep up. The recorder's own clock is the thing that
|
|
133
|
+
* suffers when it cannot - a run that dropped 29% of its elapsed time came back
|
|
134
|
+
* playing 1.4x fast, on top of the speed-up it was asked for.
|
|
135
|
+
*/
|
|
136
|
+
export function probe(udid, path) {
|
|
137
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
138
|
+
if (probe.baguette ?? (probe.baguette = spawnSync("baguette", ["--version"]).status === 0)) {
|
|
139
|
+
const r = sh("baguette", ["screenshot", "--udid", udid, "--scale", "4", "--format", "jpeg", "-o", path]);
|
|
140
|
+
if (r.status === 0) return path;
|
|
141
|
+
}
|
|
142
|
+
return screenshot(udid, path, { type: "jpeg" });
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function deviceSize(udid, tmp) {
|
|
146
|
+
const p = join(tmp, "probe.png");
|
|
147
|
+
screenshot(udid, p);
|
|
148
|
+
const r = sh("sips", ["-g", "pixelWidth", "-g", "pixelHeight", p]).stdout;
|
|
149
|
+
return { w: Number(/pixelWidth: (\d+)/.exec(r)?.[1]), h: Number(/pixelHeight: (\d+)/.exec(r)?.[1]) };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ---------------------------------------------------------------------------
|
|
153
|
+
// where the screen is on the desktop
|
|
154
|
+
// ---------------------------------------------------------------------------
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* **Calibrate rather than assume.** The first version of this read the device
|
|
158
|
+
* as 874 points tall and was wrong: a single-distance drag test cannot tell a
|
|
159
|
+
* scale error from UIScrollView's pan slop, because a constant loss fits any
|
|
160
|
+
* scale. Three distances separate them - see the note in simkit.swift - and the
|
|
161
|
+
* numbers came out 910 points of screen and 9.2 points of slop.
|
|
162
|
+
*
|
|
163
|
+
* The default model (aspect-fit below a 21-point title bar) reproduces that on
|
|
164
|
+
* this machine, so calibration is a check rather than a step. It is worth
|
|
165
|
+
* re-running whenever the window is resized or the Simulator is updated.
|
|
166
|
+
*/
|
|
167
|
+
export function deviceRect(aspect) {
|
|
168
|
+
const out = kit(["window", "--aspect", String(aspect), "--no-activate"]);
|
|
169
|
+
const line = out.split("\n").find((l) => l.startsWith("device"));
|
|
170
|
+
const [, x, y, w, h] = line.split(/\s+/).map(Number);
|
|
171
|
+
return { x, y, w, h };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ---------------------------------------------------------------------------
|
|
175
|
+
// recording
|
|
176
|
+
// ---------------------------------------------------------------------------
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* `simctl io recordVideo` writes a VARIABLE frame rate .mov and stops on
|
|
180
|
+
* SIGINT. Both facts bite later: SIGKILL leaves a file with no moov atom and
|
|
181
|
+
* nothing can read it, and a VFR source dropped into a filtergraph without a
|
|
182
|
+
* `-r` drifts against the caption times by whole seconds over a 20-second reel.
|
|
183
|
+
*/
|
|
184
|
+
export function startRecording(udid, path) {
|
|
185
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
186
|
+
rmSync(path, { force: true });
|
|
187
|
+
const p = spawn("xcrun", ["simctl", "io", udid, "recordVideo", "--codec", "h264", "--force", path],
|
|
188
|
+
{ stdio: ["ignore", "ignore", "pipe"] });
|
|
189
|
+
let err = "";
|
|
190
|
+
p.stderr.on("data", (d) => { err += d.toString(); });
|
|
191
|
+
|
|
192
|
+
return {
|
|
193
|
+
proc: p,
|
|
194
|
+
path,
|
|
195
|
+
/**
|
|
196
|
+
* **Fail here or fail sixty seconds later.**
|
|
197
|
+
*
|
|
198
|
+
* `simctl io recordVideo` can refuse and keep running, and the first
|
|
199
|
+
* version ignored its stderr: a killed run leaves the host recorder
|
|
200
|
+
* registered with CoreSimulator, the next one gets *"Resource busy … Host
|
|
201
|
+
* recording is already in progress"*, writes no file at all, and the
|
|
202
|
+
* failure surfaces only at the end - after the app has been driven through
|
|
203
|
+
* the whole script for nothing. Waiting for the first bytes costs a second
|
|
204
|
+
* and turns that into an error with the fix in it.
|
|
205
|
+
*/
|
|
206
|
+
async ready({ graceMs = 1800, timeoutMs = 6000 } = {}) {
|
|
207
|
+
const t0 = Date.now();
|
|
208
|
+
while (Date.now() - t0 < timeoutMs) {
|
|
209
|
+
if (/error|busy/i.test(err))
|
|
210
|
+
throw new Error(
|
|
211
|
+
`simctl could not start recording:\n ${err.trim().split("\n").filter(Boolean).slice(-2).join("\n ")}\n` +
|
|
212
|
+
(/already in progress/i.test(err)
|
|
213
|
+
? ` A previous run was killed and left the recorder registered. Clear it with:\n` +
|
|
214
|
+
` xcrun simctl shutdown ${udid} && xcrun simctl boot ${udid}`
|
|
215
|
+
: ""),
|
|
216
|
+
);
|
|
217
|
+
// **The file stays 0 bytes for the whole recording** - simctl buffers
|
|
218
|
+
// and writes on SIGINT ("Recording completed. Writing to disk"), so
|
|
219
|
+
// waiting for it to GROW never returns. Its existence is the signal
|
|
220
|
+
// that the recorder took the device; a refusal leaves no file at all.
|
|
221
|
+
if (Date.now() - t0 > graceMs && existsSync(path)) return;
|
|
222
|
+
await sleep(120);
|
|
223
|
+
}
|
|
224
|
+
throw new Error(`simctl created no file at ${path} within ${timeoutMs}ms. ${err.trim()}`);
|
|
225
|
+
},
|
|
226
|
+
async stop() {
|
|
227
|
+
p.kill("SIGINT");
|
|
228
|
+
await new Promise((r) => { p.on("exit", r); setTimeout(r, 8000); });
|
|
229
|
+
await sleep(400);
|
|
230
|
+
},
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* The clapperboard.
|
|
236
|
+
*
|
|
237
|
+
* Nothing reports when `recordVideo` actually opened the capture stream, and
|
|
238
|
+
* the gap between spawning it and the first frame measured 0.4-1.6s here - more
|
|
239
|
+
* than enough to put a caption on the wrong screen. So the run flashes the
|
|
240
|
+
* screen before it does anything else and `compose` finds the flash in the
|
|
241
|
+
* video.
|
|
242
|
+
*
|
|
243
|
+
* Flash the springboard after terminating the app. Apps with custom themes
|
|
244
|
+
* may ignore system appearance changes, making a full-frame marker unreliable.
|
|
245
|
+
*
|
|
246
|
+
* Terminating costs a relaunch, which is why the launch belongs to the recipe
|
|
247
|
+
* and not to the repo's `prepare`: the app has to come up AFTER the anchor.
|
|
248
|
+
*/
|
|
249
|
+
export async function clapper(udid, { bundleId } = {}) {
|
|
250
|
+
if (bundleId) {
|
|
251
|
+
sh("xcrun", ["simctl", "terminate", udid, bundleId]);
|
|
252
|
+
await sleep(1600);
|
|
253
|
+
}
|
|
254
|
+
sh("xcrun", ["simctl", "ui", udid, "appearance", "dark"]);
|
|
255
|
+
const at = Date.now();
|
|
256
|
+
await sleep(900);
|
|
257
|
+
sh("xcrun", ["simctl", "ui", udid, "appearance", "light"]);
|
|
258
|
+
await sleep(700);
|
|
259
|
+
return at;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// ---------------------------------------------------------------------------
|
|
263
|
+
// settling
|
|
264
|
+
// ---------------------------------------------------------------------------
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Wait for the screen to become something else, and then to stop changing.
|
|
268
|
+
*
|
|
269
|
+
* A fixed `sleep` is the reason scripted demos look scripted: it is either too
|
|
270
|
+
* short, and the finger arrives during a push transition, or too long, and the
|
|
271
|
+
* reel pays four dead seconds for the worst case.
|
|
272
|
+
*
|
|
273
|
+
* **`since` is the whole thing, and it took three failures to get here.** The
|
|
274
|
+
* first version waited only for quiet, which cannot tell *the transition has
|
|
275
|
+
* finished* from *it has not started*. The second added "wait for any motion
|
|
276
|
+
* first", which is still not enough: a Debug build behind Metro can run SECONDS
|
|
277
|
+
* behind the script, and while it is behind, the screen it is still showing is
|
|
278
|
+
* perfectly quiet. Every settle then returned on a stale frame, every following
|
|
279
|
+
* tap landed on a screen that was about to be replaced, and a reel came back
|
|
280
|
+
* with the app stuck on one screen for its last ten seconds.
|
|
281
|
+
*
|
|
282
|
+
* With `since` - a frame captured before the gesture that caused the change -
|
|
283
|
+
* the question becomes *is this a different screen yet*, which is the one worth
|
|
284
|
+
* asking. `maxMs` can then be generous, because it returns the moment the app
|
|
285
|
+
* catches up rather than sleeping the worst case.
|
|
286
|
+
*/
|
|
287
|
+
export async function settle(udid, tmp, { maxMs = 6000, quietMs = 300, threshold = 42, graceMs = 1500, since = null } = {}) {
|
|
288
|
+
// JPEG probes reduce capture overhead while recordVideo is running.
|
|
289
|
+
// Full-resolution PNG probes can starve the recorder and distort timing.
|
|
290
|
+
const a = join(tmp, "settle-a.jpg"), b = join(tmp, "settle-b.jpg");
|
|
291
|
+
const t0 = Date.now();
|
|
292
|
+
if (since && existsSync(since)) sh("cp", [since, a]);
|
|
293
|
+
else probe(udid, a);
|
|
294
|
+
|
|
295
|
+
let quiet = 0;
|
|
296
|
+
let moved = false;
|
|
297
|
+
while (Date.now() - t0 < maxMs) {
|
|
298
|
+
const before = Date.now();
|
|
299
|
+
probe(udid, b);
|
|
300
|
+
const psnr = compare(a, b);
|
|
301
|
+
// With a `since` reference the first phase asks *has the screen become
|
|
302
|
+
// something else yet*, so the reference must NOT advance until it has.
|
|
303
|
+
if (!(since && !moved)) sh("cp", [b, a]);
|
|
304
|
+
const spent = Date.now() - before;
|
|
305
|
+
// Persistent background animation can be quiet without becoming identical.
|
|
306
|
+
const busy = psnr <= threshold;
|
|
307
|
+
if (busy) {
|
|
308
|
+
if (since && !moved) sh("cp", [b, a]);
|
|
309
|
+
moved = true;
|
|
310
|
+
quiet = 0;
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
if (!moved && Date.now() - t0 < graceMs) continue;
|
|
314
|
+
quiet += spent;
|
|
315
|
+
if (quiet >= quietMs) return Date.now() - t0;
|
|
316
|
+
}
|
|
317
|
+
return Date.now() - t0;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* PSNR between two frames, in dB, and **`Infinity` when they are identical**.
|
|
322
|
+
*
|
|
323
|
+
* ffmpeg prints `average:inf` for identical input, which `/[\d.]+/` does not
|
|
324
|
+
* match. Defaulting that to 0 - which the first version did - inverts the
|
|
325
|
+
* meaning of the number it feeds: two identical frames come back as maximally
|
|
326
|
+
* different, every settle reads as busy, and every settle in a run then sits
|
|
327
|
+
* until its ceiling. The reel came out 39.7s instead of 19s.
|
|
328
|
+
*/
|
|
329
|
+
export function compare(a, b, { region = null } = {}) {
|
|
330
|
+
const graph = region
|
|
331
|
+
? `[0:v]crop=${region}[a];[1:v]crop=${region}[b];[a][b]psnr`
|
|
332
|
+
: "psnr";
|
|
333
|
+
const r = sh("ffmpeg", ["-hide_banner", "-nostats", "-i", a, "-i", b,
|
|
334
|
+
"-filter_complex", graph, "-f", "null", "-"]);
|
|
335
|
+
const m = /average:([\d.]+|inf)/.exec(r.stderr ?? "");
|
|
336
|
+
if (!m || m[1] === "inf") return Infinity;
|
|
337
|
+
return Number(m[1]);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// ---------------------------------------------------------------------------
|
|
341
|
+
// running a script
|
|
342
|
+
// ---------------------------------------------------------------------------
|
|
343
|
+
|
|
344
|
+
const nowRel = (t0) => Date.now() - t0;
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Run the gesture script and write down what happened when.
|
|
348
|
+
*
|
|
349
|
+
* **The script carries no words.** It is the finger's path through the app and
|
|
350
|
+
* nothing else, which is exactly what makes the seven-language version cheap:
|
|
351
|
+
* one script, seven caption files, seven runs of the same steps.
|
|
352
|
+
*/
|
|
353
|
+
export async function runScript(script, ctx) {
|
|
354
|
+
const { udid, tmp, input, t0, log = () => {} } = ctx;
|
|
355
|
+
const timeline = { marks: {}, gestures: [], steps: [] };
|
|
356
|
+
// The frame as it was before the last gesture. A `settle` uses it to ask
|
|
357
|
+
// *has the screen become something else yet*, which is a different and much
|
|
358
|
+
// more useful question than *has it stopped moving*.
|
|
359
|
+
const pre = join(tmp, "pre-gesture.jpg");
|
|
360
|
+
let sinceGesture = null;
|
|
361
|
+
const note = (kind, extra = {}) => {
|
|
362
|
+
const t = nowRel(t0);
|
|
363
|
+
timeline.steps.push({ t, kind, ...extra });
|
|
364
|
+
return t;
|
|
365
|
+
};
|
|
366
|
+
|
|
367
|
+
for (const step of script.steps) {
|
|
368
|
+
if (step.mark) {
|
|
369
|
+
timeline.marks[step.mark] = nowRel(t0);
|
|
370
|
+
log(` ${String(nowRel(t0)).padStart(6)}ms mark ${step.mark}`);
|
|
371
|
+
continue;
|
|
372
|
+
}
|
|
373
|
+
if (step.wait != null) { note("wait", { ms: step.wait }); await sleep(step.wait); continue; }
|
|
374
|
+
if (step.settle != null) {
|
|
375
|
+
const took = await settle(udid, tmp, {
|
|
376
|
+
maxMs: step.settle === true ? 8000 : step.settle,
|
|
377
|
+
since: sinceGesture,
|
|
378
|
+
});
|
|
379
|
+
sinceGesture = null;
|
|
380
|
+
note("settle", { ms: took });
|
|
381
|
+
log(` ${String(nowRel(t0)).padStart(6)}ms settled after ${took}ms`);
|
|
382
|
+
continue;
|
|
383
|
+
}
|
|
384
|
+
if (step.tap) {
|
|
385
|
+
const [x, y] = step.tap;
|
|
386
|
+
const t = note("tap", { x, y, note: step.note });
|
|
387
|
+
timeline.gestures.push({ kind: "tap", t, points: [[x, y]], durationMs: 240 });
|
|
388
|
+
probe(udid, pre);
|
|
389
|
+
sinceGesture = pre;
|
|
390
|
+
await input.tap(x, y, { holdMs: step.hold });
|
|
391
|
+
log(` ${String(t).padStart(6)}ms tap ${x},${y}${step.note ? ` (${step.note})` : ""}`);
|
|
392
|
+
// Retry can handle a tap swallowed by an unfinished transition.
|
|
393
|
+
// Stop once the target region changes to avoid repeated navigation.
|
|
394
|
+
if (step.retry) {
|
|
395
|
+
// Observe a stable region so continuous background motion does not
|
|
396
|
+
// masquerade as navigation. The default covers the bottom tab-bar area.
|
|
397
|
+
const region = step.retryRegion ?? "iw:ih/10:0:ih*9/10";
|
|
398
|
+
for (let i = 0; i < (step.retry === true ? 4 : step.retry); i++) {
|
|
399
|
+
await sleep(step.retryEveryMs ?? 900);
|
|
400
|
+
probe(udid, post);
|
|
401
|
+
if (compare(pre, post, { region }) <= 42) break;
|
|
402
|
+
log(` tap ignored, trying again (${i + 1})`);
|
|
403
|
+
await input.tap(x, y, { holdMs: step.hold });
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
continue;
|
|
407
|
+
}
|
|
408
|
+
if (step.swipe) {
|
|
409
|
+
const [x1, y1, x2, y2] = step.swipe;
|
|
410
|
+
const dur = step.duration ?? 280;
|
|
411
|
+
const t = note("swipe", { x1, y1, x2, y2, durationMs: dur, note: step.note });
|
|
412
|
+
timeline.gestures.push({ kind: "swipe", t, points: [[x1, y1], [x2, y2]], durationMs: dur + 260 });
|
|
413
|
+
probe(udid, pre);
|
|
414
|
+
sinceGesture = pre;
|
|
415
|
+
await input.swipe(x1, y1, x2, y2, { durationMs: dur, flick: !step.precise, ...(step.bow != null ? { bow: step.bow } : {}) });
|
|
416
|
+
log(` ${String(t).padStart(6)}ms swipe ${x1},${y1} -> ${x2},${y2}${step.note ? ` (${step.note})` : ""}`);
|
|
417
|
+
continue;
|
|
418
|
+
}
|
|
419
|
+
if (step.launch) {
|
|
420
|
+
note("launch", { bundleId: step.launch });
|
|
421
|
+
sh("xcrun", ["simctl", "launch", udid, step.launch]);
|
|
422
|
+
continue;
|
|
423
|
+
}
|
|
424
|
+
if (step.terminate) { sh("xcrun", ["simctl", "terminate", udid, step.terminate]); continue; }
|
|
425
|
+
if (step.shell) {
|
|
426
|
+
// **`<locale>` is why a shell step does not break the one-script rule.**
|
|
427
|
+
// An app that picks its language through a deep link needs the locale in
|
|
428
|
+
// the command, and putting it there literally would fork the script per
|
|
429
|
+
// language - which is the whole thing this recipe exists not to do.
|
|
430
|
+
const cmd = Object.entries(ctx.vars ?? {}).reduce((c, [k, v]) => c.replaceAll(`<${k}>`, String(v)), step.shell);
|
|
431
|
+
note("shell", { cmd });
|
|
432
|
+
log(` ${String(nowRel(t0)).padStart(6)}ms sh ${cmd}`);
|
|
433
|
+
sh("/bin/sh", ["-c", cmd], { cwd: ctx.root, stdio: "inherit" });
|
|
434
|
+
continue;
|
|
435
|
+
}
|
|
436
|
+
throw new Error(`Unknown script step: ${JSON.stringify(step)}`);
|
|
437
|
+
}
|
|
438
|
+
timeline.endedAt = nowRel(t0);
|
|
439
|
+
return timeline;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
export function readJson(p) { return JSON.parse(readFileSync(p, "utf8")); }
|
|
443
|
+
export function writeJson(p, v) { mkdirSync(dirname(p), { recursive: true }); writeFileSync(p, JSON.stringify(v, null, 2) + "\n"); }
|