staysfixed 0.7.2 → 0.8.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.
Files changed (59) hide show
  1. package/CHANGELOG.md +342 -0
  2. package/README.md +191 -55
  3. package/docs/design-v2.md +24 -4
  4. package/docs/getting-started.md +18 -5
  5. package/docs/guards.md +2 -2
  6. package/docs/how-v2-works.md +12 -11
  7. package/docs/mcp.md +17 -8
  8. package/docs/settings.md +549 -0
  9. package/docs/watching.md +10 -4
  10. package/examples/staysfixed.config.electron.js +17 -6
  11. package/examples/staysfixed.config.web.js +22 -5
  12. package/package.json +2 -1
  13. package/src/cli/index.js +55 -46
  14. package/src/cli/watch-flags.js +54 -0
  15. package/src/core/config.js +23 -3
  16. package/src/guard/run.js +49 -1
  17. package/src/report/console.js +15 -2
  18. package/src/v2/adapters/android-driver.js +6 -1
  19. package/src/v2/adapters/android.js +97 -2
  20. package/src/v2/adapters/contract.js +42 -5
  21. package/src/v2/adapters/electron.js +72 -6
  22. package/src/v2/adapters/http.js +11 -2
  23. package/src/v2/adapters/ios-driver.js +64 -14
  24. package/src/v2/adapters/ios.js +247 -25
  25. package/src/v2/adapters/process.js +728 -66
  26. package/src/v2/adapters/python.js +495 -0
  27. package/src/v2/adapters/source.js +373 -18
  28. package/src/v2/adapters/web-driver.js +94 -24
  29. package/src/v2/adapters/web.js +142 -9
  30. package/src/v2/adapters/windows.js +18 -1
  31. package/src/v2/browsers.js +9 -1
  32. package/src/v2/cause.js +61 -17
  33. package/src/v2/check.js +530 -66
  34. package/src/v2/ci.js +130 -35
  35. package/src/v2/cli.js +42 -24
  36. package/src/v2/cluster.js +164 -13
  37. package/src/v2/coverage.js +43 -176
  38. package/src/v2/detect.js +308 -60
  39. package/src/v2/doctor.js +285 -45
  40. package/src/v2/init.js +162 -61
  41. package/src/v2/intent.js +9 -23
  42. package/src/v2/journeys/from-suite.js +336 -30
  43. package/src/v2/journeys/index.js +99 -6
  44. package/src/v2/mcp/tools.js +10 -11
  45. package/src/v2/normalise.js +169 -23
  46. package/src/v2/observation.js +19 -33
  47. package/src/v2/rank.js +216 -23
  48. package/src/v2/reference.js +40 -10
  49. package/src/v2/remote.js +113 -18
  50. package/src/v2/run.js +103 -14
  51. package/src/v2/sealed.js +0 -20
  52. package/src/v2/selfcheck.js +190 -13
  53. package/src/v2/ship.js +29 -5
  54. package/src/v2/store.js +67 -1
  55. package/src/v2/types.js +12 -2
  56. package/src/v2/waiver.js +64 -54
  57. package/src/v2/watch/events.js +60 -215
  58. package/src/v2/watch/focus.js +14 -4
  59. package/src/v2/watch/panel.js +167 -17
package/src/cli/index.js CHANGED
@@ -12,6 +12,8 @@ import { setLogLevel } from '../core/log.js';
12
12
  import { V2_COMMANDS } from '../v2/cli.js';
13
13
  import { SHIP_COMMANDS } from '../v2/ship.js';
14
14
  import { INIT_COMMANDS } from '../v2/init.js';
15
+ import { BROWSERS_COMMAND } from '../v2/browsers.js';
16
+ export { watchFlags } from './watch-flags.js';
15
17
 
16
18
  /** @type {{version?: string}} */
17
19
  const pkg = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8'));
@@ -61,9 +63,22 @@ export const VERSION = pkg.version ?? '0.0.0';
61
63
  * @property {() => Promise<{run: (ctx: CliContext) => Promise<number>}>} [load]
62
64
  */
63
65
 
64
- /** @type {ArgSpec} */
66
+ /**
67
+ * The flags every command takes.
68
+ *
69
+ * `no-color` is declared as its own switch rather than as `color`, and that is not a
70
+ * spelling choice. Colour is settled in bin/staysfixed.js, before anything else is
71
+ * imported, because src/core/log.js decides once at load whether it may paint — so by the
72
+ * time a command is parsed the answer is already fixed and nothing here could change it.
73
+ * Declaring `color` made `--color` a real flag that turned nothing on, which is the worst
74
+ * kind: a person types it, the tool accepts it, and nothing happens. There is no way to
75
+ * force colour ON from here, so the only honest thing to offer is the half that works.
76
+ * `--no-color` is named so `--help` and the parser agree; the work is already done.
77
+ *
78
+ * @type {ArgSpec}
79
+ */
65
80
  const GLOBAL_SPEC = {
66
- booleans: ['verbose', 'quiet', 'help', 'version', 'color'],
81
+ booleans: ['verbose', 'quiet', 'help', 'version', 'no-color'],
67
82
  strings: ['config', 'cwd'],
68
83
  alias: { v: 'verbose', q: 'quiet', h: 'help', V: 'version' },
69
84
  };
@@ -76,7 +91,7 @@ const GLOBAL_VALUE_FLAGS = new Set(['--config', '--cwd']);
76
91
  * are declared once here so the two commands cannot drift apart.
77
92
  */
78
93
  const WATCH_SPEC = {
79
- booleans: ['watch', 'watch-front', 'keep-open', 'profile'],
94
+ booleans: ['watch', 'watch-front', 'keep-open', 'profile', 'snap'],
80
95
  strings: ['watch-side', 'watch-width'],
81
96
  };
82
97
 
@@ -86,12 +101,20 @@ const WATCH_OPTIONS = [
86
101
  ['--watch-side <side>', 'Which side of the app the panel sits on: left or right. Default right.'],
87
102
  ['--watch-width <n>', 'How wide the panel is, in pixels. Default 460.'],
88
103
  ['--no-keep-open', 'Close the panel as soon as the run finishes.'],
104
+ ['--no-snap', 'Leave both windows exactly where they are instead of putting them side by side.'],
89
105
  ['--watch-front', 'Bring the panel to the front. By default it opens behind your work.'],
90
106
  ['--profile', 'Print where the time went when the run is over.'],
91
107
  ];
92
108
 
93
- /** @type {Record<string, CommandEntry>} */
94
- const COMMANDS = {
109
+ /**
110
+ * Every command, and the only list of them. `--help` is printed from this, and every
111
+ * flag any command accepts is declared in its `spec` here — so a flag that prints in the
112
+ * help and a flag the parser knows about cannot drift apart. It is exported so that can
113
+ * be checked from outside rather than by reading two lists side by side.
114
+ *
115
+ * @type {Record<string, CommandEntry>}
116
+ */
117
+ export const COMMANDS = {
95
118
  init: {
96
119
  summary: 'Set this project up. Takes about thirty seconds.',
97
120
  usage: 'staysfixed init [--force] [--json]',
@@ -227,6 +250,16 @@ const COMMANDS = {
227
250
  examples: ['staysfixed mcp', 'staysfixed mcp --v1'],
228
251
  spec: { booleans: ['v1'] },
229
252
  },
253
+
254
+ /*
255
+ * `browsers` was written, tested, given a finished command entry in src/v2/browsers.js
256
+ * with a comment saying "wiring it up is one line" — and that line was never written.
257
+ * README.md told people to run `npx staysfixed browsers` and `--clean` to tidy up after
258
+ * an interrupted run, and both answered "There is no command called browsers". Somebody
259
+ * whose disk was filling with abandoned browser profiles had no way to clear them and no
260
+ * reason to doubt the page telling them there was.
261
+ */
262
+ browsers: BROWSERS_COMMAND,
230
263
  };
231
264
 
232
265
  /*
@@ -352,45 +385,8 @@ function contextFor(parsed, cwd, configFile) {
352
385
  * @property {boolean} [foreground]
353
386
  */
354
387
 
355
- /**
356
- * Read the panel flags. Shared by `check` and `walk` so the two behave the same.
357
- * @param {CliContext} ctx
358
- * @returns {WatchFlags}
359
- */
360
- export function watchFlags(ctx) {
361
- /** @type {WatchFlags} */
362
- const flags = { enabled: ctx.bool('watch') };
363
-
364
- const side = ctx.str('watch-side');
365
- if (side !== undefined) {
366
- if (side !== 'left' && side !== 'right') {
367
- throw new StaysFixedError(`--watch-side has to be left or right, not "${side}".`, {
368
- hint: 'Write it as `--watch-side left` or `--watch-side right`.',
369
- });
370
- }
371
- flags.side = side;
372
- }
373
-
374
- const width = ctx.str('watch-width');
375
- if (width !== undefined) {
376
- const n = Number(width);
377
- // A panel narrower than this cannot show the before-and-after pictures side
378
- // by side, which is the only reason to open it.
379
- if (!Number.isFinite(n) || n < 240) {
380
- throw new StaysFixedError(`--watch-width has to be a number of pixels, 240 or more — I got "${width}".`, {
381
- hint: 'Write it as `--watch-width 520`.',
382
- });
383
- }
384
- flags.width = Math.round(n);
385
- }
388
+ // `watchFlags` moved to ./watch-flags.js — see the note there about the import cycle.
386
389
 
387
- // Only mention these when they were actually typed, so --no-keep-open turns the
388
- // panel off at the end without a bare --watch turning it on against the settings.
389
- if (ctx.flags['keep-open'] !== undefined) flags.keepOpen = ctx.flags['keep-open'] === true;
390
- if (ctx.bool('watch-front')) flags.foreground = true;
391
-
392
- return flags;
393
- }
394
390
 
395
391
  /**
396
392
  * The panel settings a project's settings file carries, if it carries any.
@@ -446,15 +442,28 @@ function splitCommand(argv) {
446
442
  }
447
443
 
448
444
  /**
445
+ * The global flags plus one command's own — with the command winning any name they share.
446
+ *
447
+ * That last part is the whole reason this is not a concatenation. `--version` is global and
448
+ * means "print the tool's version"; `staysfixed ship --version 0.14.0` means "the release
449
+ * that went out was called 0.14.0", and it is in that command's own help. Merged naively,
450
+ * the name landed in both lists, the parser reads booleans first, and `staysfixed ship
451
+ * --version 0.14.0` printed `0.7.2` and shipped nothing at all — no error, no clue, and the
452
+ * release script that called it carried on. A command's own list of flags is the more
453
+ * specific statement of what that command means, so it wins.
454
+ *
449
455
  * @param {ArgSpec} base
450
456
  * @param {ArgSpec} extra
451
457
  * @returns {ArgSpec}
452
458
  */
453
459
  function mergeSpec(base, extra) {
460
+ const claimed = new Set([...(extra.booleans ?? []), ...(extra.strings ?? []), ...(extra.arrays ?? [])]);
461
+ /** @param {string[]|undefined} names */
462
+ const keep = (names) => (names ?? []).filter((name) => !claimed.has(name));
454
463
  return {
455
- booleans: [...(base.booleans ?? []), ...(extra.booleans ?? [])],
456
- strings: [...(base.strings ?? []), ...(extra.strings ?? [])],
457
- arrays: [...(base.arrays ?? []), ...(extra.arrays ?? [])],
464
+ booleans: [...keep(base.booleans), ...(extra.booleans ?? [])],
465
+ strings: [...keep(base.strings), ...(extra.strings ?? [])],
466
+ arrays: [...keep(base.arrays), ...(extra.arrays ?? [])],
458
467
  alias: { ...(base.alias ?? {}), ...(extra.alias ?? {}) },
459
468
  };
460
469
  }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Reading the --watch flags off a command line.
3
+ *
4
+ * This lives on its own, and not in `src/cli/index.js` where it started, because both
5
+ * halves of the tool need it and the import went in a circle: version 1's command table
6
+ * imports version 2's commands, and version 2's command file imported this back out of
7
+ * version 1's table. That worked only while the modules happened to load in a helpful
8
+ * order — the day another import was added to version 2, the whole command line failed
9
+ * with "cannot access V2_COMMANDS before initialization" and nothing ran at all.
10
+ *
11
+ * A shared thing that both sides need belongs to neither of them.
12
+ */
13
+
14
+ import { StaysFixedError } from '../core/errors.js';
15
+
16
+ /**
17
+ * Read the panel flags. Shared by `check` and `walk` so the two behave the same.
18
+ * @param {import('./index.js').CliContext} ctx
19
+ * @returns {import('./index.js').WatchFlags}
20
+ */
21
+ export function watchFlags(ctx) {
22
+ /** @type {import('./index.js').WatchFlags} */
23
+ const flags = { enabled: ctx.bool('watch') };
24
+
25
+ const side = ctx.str('watch-side');
26
+ if (side !== undefined) {
27
+ if (side !== 'left' && side !== 'right') {
28
+ throw new StaysFixedError(`--watch-side has to be left or right, not "${side}".`, {
29
+ hint: 'Write it as `--watch-side left` or `--watch-side right`.',
30
+ });
31
+ }
32
+ flags.side = side;
33
+ }
34
+
35
+ const width = ctx.str('watch-width');
36
+ if (width !== undefined) {
37
+ const n = Number(width);
38
+ // A panel narrower than this cannot show the before-and-after pictures side
39
+ // by side, which is the only reason to open it.
40
+ if (!Number.isFinite(n) || n < 240) {
41
+ throw new StaysFixedError(`--watch-width has to be a number of pixels, 240 or more — I got "${width}".`, {
42
+ hint: 'Write it as `--watch-width 520`.',
43
+ });
44
+ }
45
+ flags.width = Math.round(n);
46
+ }
47
+
48
+ // Only mention these when they were actually typed, so --no-keep-open turns the
49
+ // panel off at the end without a bare --watch turning it on against the settings.
50
+ if (ctx.flags['keep-open'] !== undefined) flags.keepOpen = ctx.flags['keep-open'] === true;
51
+ if (ctx.bool('watch-front')) flags.foreground = true;
52
+
53
+ return flags;
54
+ }
@@ -49,9 +49,29 @@ export const DEFAULT_FREEZE = {
49
49
 
50
50
  /** @type {Required<Omit<import('../types.js').ToleranceConfig,'maxPixels'>>} */
51
51
  export const DEFAULT_TOLERANCE = {
52
- // 0.05% of pixels. On a 1440x900 @2x picture that is about 1300 pixels enough
53
- // for font hinting noise, nowhere near enough to hide a missing stylesheet.
54
- pixels: 0.0005,
52
+ // Nothing is allowed through by default, and that is a change made after measuring.
53
+ //
54
+ // This used to be 0.0005 — 0.05% of the picture — with a comment saying it was "enough for
55
+ // font hinting noise, nowhere near enough to hide a missing stylesheet". The first half was
56
+ // a guess and the second half was wrong. On a 2880x1800 picture, 0.05% is **2,592 pixels**.
57
+ // Changing `<h1>Welcome</h1>` to `<h1>Welcom</h1>` — one letter missing from the main
58
+ // heading of the page, plainly visible to anybody looking at it — moves **593**. So the
59
+ // check reported "Everything that worked still works" over a page that was visibly wrong.
60
+ // That is the exact failure this tool exists to prevent, produced by the tool itself.
61
+ //
62
+ // The number that replaces it was measured rather than chosen. Ten fresh takes of the same
63
+ // build, on a real page, compared against the approved picture: **zero differing pixels,
64
+ // every time.** The freeze layer underneath — the stopped clock, the killed motion, the
65
+ // seeded randomness, the pinned text rendering, and the settle loop that keeps shooting
66
+ // until two frames come back identical — is what makes that true. Where nothing wobbles,
67
+ // an allowance buys nothing at all and costs you the one thing you came for.
68
+ //
69
+ // Version 2 answers this properly by measuring each product's own wobble and subtracting
70
+ // it, which is why it has no tolerance setting and never will. Version 1 cannot do that
71
+ // without becoming version 2, so it does the honest next-best thing: allow nothing, and
72
+ // let a project that genuinely wobbles say so out loud with `tolerance.pixels`. A run that
73
+ // uses an allowance now says so, and says how much of it was used.
74
+ pixels: 0,
55
75
  threshold: 0.12,
56
76
  antialiasing: true,
57
77
  };
package/src/guard/run.js CHANGED
@@ -289,7 +289,7 @@ async function attemptGuard(project, app, guard, baseUrl, timeoutMs, onStep) {
289
289
  };
290
290
  }
291
291
  const raw = error instanceof Error ? error.message : String(error);
292
- return { ok: false, message: `${raw}${consoleNote(app)}` };
292
+ return { ok: false, message: `${explainApiSlip(raw, app.page, project)}${consoleNote(app)}` };
293
293
  } finally {
294
294
  // The losing side of the race keeps running otherwise, and a stray timer
295
295
  // holds the process open long after the run is reported.
@@ -299,6 +299,54 @@ async function attemptGuard(project, app, guard, baseUrl, timeoutMs, onStep) {
299
299
  return { ok: true };
300
300
  }
301
301
 
302
+ /**
303
+ * Turn "page.goto is not a function" into one sentence a person can act on.
304
+ *
305
+ * A guard is the first code most people write against this tool, and the object it is handed
306
+ * is not the shape anybody arrives expecting. Reach for a name from a browser library that is
307
+ * not there and JavaScript answers with its own sentence, which is true, useless, and exactly
308
+ * the kind of raw error this project promises never to print. The first guard written against
309
+ * it while proving the tool still worked failed this way.
310
+ *
311
+ * Two things it must not do, both learned by getting them wrong first:
312
+ *
313
+ * - **Do not trust the receiver's name.** The guard above called its one parameter `page`,
314
+ * so the error read `page.goto is not a function` — but the parameter holds `app`, and the
315
+ * honest answer is `app.open()`. Reading that name as if it meant the page suggested
316
+ * "did you mean goto()", which is the very thing they had just written.
317
+ * - **Do not list every method.** An earlier version printed all thirty names on the page
318
+ * handle inline. It was complete, unreadable, and it destroyed the results table it sat in.
319
+ *
320
+ * So: name the six things on `app`, say where the rest live, and stop.
321
+ *
322
+ * @param {string} raw
323
+ * @param {import('../types.js').PageHandle} page
324
+ * @param {import('../types.js').Project} project
325
+ * @returns {string}
326
+ */
327
+ export function explainApiSlip(raw, page, project) {
328
+ const missing = /^(?:\w+\.)?(\w+) is not a function$/.exec(String(raw || ''));
329
+ if (!missing) return raw;
330
+ const method = missing[1];
331
+ const api = makeGuardApi(page, project, {});
332
+ const onApp = Object.keys(api).filter((k) => typeof (/** @type {any} */ (api))[k] === 'function').sort();
333
+ if (onApp.includes(method)) return raw;
334
+
335
+ // Only ever suggested from what `app` itself offers, and only when one name is clearly the
336
+ // one meant. A guess between three is worse than no guess.
337
+ const near = onApp.filter((n) => n.toLowerCase().includes(method.toLowerCase()) || method.toLowerCase().includes(n.toLowerCase()));
338
+ const browserish = /^(goto|navigate|visit|load|open|click|type|fill|press|hover|wait|screenshot|querySelector|\$)/i.test(method);
339
+ const meant = near.length === 1 ? ` You probably want \`app.${near[0]}()\`.`
340
+ : browserish ? ' To go to a page it is `app.open(\'/path\')`; anything a browser does is on `app.page`.'
341
+ : '';
342
+
343
+ return (
344
+ `This guard called \`${method}()\` on what it was handed, and there is no such thing there.${meant} ` +
345
+ `A guard is given one object — call it \`app\` — with ${onApp.map((n) => `\`${n}()\``).join(', ')}. ` +
346
+ 'The whole page is `app.page`. There is a worked example in `examples/guards/`.'
347
+ );
348
+ }
349
+
302
350
  /** A timeout, kept apart from a real error so the wording stays ours. */
303
351
  class TookTooLong extends Error {
304
352
  /** @param {string} message */
@@ -210,9 +210,22 @@ export function printPictureResult(r) {
210
210
  const time = paint.grey(duration(r.durationMs ?? 0));
211
211
  const line = `${r.name.padEnd(NAME_WIDTH)} ${pictureOutcome(r)}`;
212
212
  switch (r.status) {
213
- case 'passed':
214
- say(`${paint.green(sym(mark.pass))} ${r.name.padEnd(NAME_WIDTH)} ${paint.grey('still the same')} ${time}`);
213
+ case 'passed': {
214
+ // "Still the same" has to mean the same, or it is the most expensive sentence here.
215
+ //
216
+ // A picture that differs and is waved through by an allowance was reported as
217
+ // identical, in the same words as one that matched byte for byte. That is how a
218
+ // missing letter in a heading — 593 pixels, plainly visible — came back as "still the
219
+ // same" while an allowance of 2,592 quietly absorbed it. Nothing is allowed through by
220
+ // default any more, so this is rare; when a project sets `tolerance.pixels` because its
221
+ // product genuinely wobbles, the line says what its setting just swallowed.
222
+ const swallowed = r.diffPixels ?? 0;
223
+ const note = swallowed > 0
224
+ ? paint.grey(`the same, apart from ${swallowed} ${swallowed === 1 ? 'pixel your tolerance allowed' : 'pixels your tolerance allowed'}`)
225
+ : paint.grey('still the same');
226
+ say(`${paint.green(sym(mark.pass))} ${r.name.padEnd(NAME_WIDTH)} ${note} ${time}`);
215
227
  break;
228
+ }
216
229
  case 'changed':
217
230
  say(`${paint.red(sym(mark.fail))} ${paint.red(line)} ${time}`);
218
231
  if (r.approvedSize && r.size && (r.approvedSize.width !== r.size.width || r.approvedSize.height !== r.size.height)) {
@@ -1393,8 +1393,13 @@ export async function permissionsHeld(device, pkg) {
1393
1393
  * and pids are stripped from the text kept, since all three differ on every run and none of
1394
1394
  * them is ever the finding.
1395
1395
  *
1396
+ * There is no time window here, and there used to be a `sinceMs` in this signature that
1397
+ * nothing read. The window comes from the other end: the adapter clears both log buffers
1398
+ * before it walks anything, so what is left is this run. An option that silently does
1399
+ * nothing is worse than no option, because a caller passing it believes it worked.
1400
+ *
1396
1401
  * @param {Device} device
1397
- * @param {{pkg: string, pid?: number, sinceMs?: number}} what
1402
+ * @param {{pkg: string, pid?: number}} what
1398
1403
  * @returns {Promise<{crashes: string[], anrs: string[], errors: string[], lines: string[], raw: string}>}
1399
1404
  */
1400
1405
  export async function complaints(device, what) {
@@ -73,6 +73,100 @@ const CLEAN_SNAPSHOT = 'staysfixed-clean';
73
73
  /** The journey that needs no device at all. */
74
74
  const DECLARED = 'what the app declares';
75
75
 
76
+ // ---------------------------------------------------------------------------
77
+ // The virtual device this tool asks for
78
+ // ---------------------------------------------------------------------------
79
+
80
+ /**
81
+ * The API level to build a virtual device at when nothing says otherwise.
82
+ *
83
+ * Newer is the safe direction and it is the only direction that is safe. A device must be at
84
+ * least as new as the app's `minSdkVersion` or the app cannot be installed on it at all; an app
85
+ * that TARGETS something older installs on a newer device and runs under compatibility rules.
86
+ * So the failure from picking too high is a behaviour difference the tool would report, and the
87
+ * failure from picking too low is "it would not install", which looks like a broken product.
88
+ *
89
+ * When there is an APK in hand its own minSdk is read and this is raised to match — see
90
+ * `deviceFor`. This number is only the answer for somebody who has not built anything yet.
91
+ */
92
+ export const EMULATOR_API = 35;
93
+
94
+ /**
95
+ * The processor the image has to be built for.
96
+ *
97
+ * Both files that recommended an image used to hardcode `arm64-v8a`, so the command handed to
98
+ * anybody on an Intel Mac or an x86 Linux box named an image that does not exist for their
99
+ * machine and failed with a message about the image rather than about the architecture.
100
+ *
101
+ * @returns {string|null} null when this machine's architecture has no emulator image at all.
102
+ */
103
+ export function emulatorAbi() {
104
+ // Only three answers exist, and anything else has to say so rather than pick one.
105
+ //
106
+ // This used to be arm64 or x86_64, with x86_64 as the fallback for every other
107
+ // architecture. On a 32-bit Intel machine, or an s390x, or anything else Node runs on, that
108
+ // names an emulator image Google does not publish — so the command handed over as "the tool
109
+ // can do this itself" fails with an error about a package that does not exist, and the
110
+ // person is left looking for a typo in a line this tool wrote for them.
111
+ if (process.arch === 'arm64') return 'arm64-v8a';
112
+ if (process.arch === 'x64') return 'x86_64';
113
+ if (process.arch === 'ia32') return 'x86';
114
+ return null;
115
+ }
116
+
117
+ /**
118
+ * What to install, and the two commands that install it — kept together on purpose.
119
+ *
120
+ * The warning is part of the answer, not a footnote beside it. A Play Store image refuses root
121
+ * permanently, and without root the files an app writes are invisible, so a person who follows
122
+ * a command printed without this sentence ends up with a device that silently checks less than
123
+ * they think. That happened because two files each had their own copy of the command and only
124
+ * one carried the warning. There is one copy now, and the warning cannot be separated from it.
125
+ *
126
+ * `google_apis` and NOT `google_apis_playstore` is the whole of the rule.
127
+ *
128
+ * @param {{api?: number, name?: string}} [opts]
129
+ * @returns {{image: string|null, install: string|null, create: string|null, why: string, both: string}} image, install and create are null where this machine has no emulator image at all; `why` and `both` then say so.
130
+ */
131
+ export function deviceToMake(opts = {}) {
132
+ const api = Math.max(EMULATOR_API, opts.api ?? 0);
133
+ const name = opts.name ?? 'staysfixed';
134
+ const abi = emulatorAbi();
135
+ const warning = 'Pick a plain Google APIs image, NOT a Play Store one: a Play Store device refuses root forever, and without root the files an app writes cannot be seen.';
136
+
137
+ // No image exists for this machine, and saying so beats naming one that does not exist.
138
+ //
139
+ // Google publishes emulator images for arm64, x86_64 and x86 and nothing else. Handing
140
+ // somebody a command that fails on a package nobody has ever published sends them looking
141
+ // for a typo in a line this tool wrote for them, which is a worse place to be than being
142
+ // told plainly that their machine cannot run one.
143
+ if (!abi) {
144
+ const cannot = `No Android emulator image is published for a ${process.arch} machine, so an emulator cannot be created here. Plug in a real Android device, or run the check from a machine on arm64, x86_64 or x86.`;
145
+ return { image: null, install: null, create: null, why: cannot, both: cannot };
146
+ }
147
+
148
+ const image = `system-images;android-${api};google_apis;${abi}`;
149
+ const install = `sdkmanager --install "${image}"`;
150
+ const create = `avdmanager create avd -n ${name} -k "${image}"`;
151
+ return {
152
+ image,
153
+ install,
154
+ create,
155
+ why: warning,
156
+ both: `${install} then ${create}. ${warning}`,
157
+ };
158
+ }
159
+
160
+ /**
161
+ * The same answer, raised to whatever the app in front of us actually needs.
162
+ *
163
+ * @param {{minSdk?: number|null}|null} apk
164
+ * @returns {ReturnType<typeof deviceToMake>}
165
+ */
166
+ export function deviceFor(apk) {
167
+ return deviceToMake({ api: apk?.minSdk ?? 0 });
168
+ }
169
+
76
170
  // ---------------------------------------------------------------------------
77
171
  // Finding the APK
78
172
  // ---------------------------------------------------------------------------
@@ -522,7 +616,7 @@ export const androidAdapter = defineAdapter({
522
616
  missing.push({
523
617
  what: 'a virtual device for the emulator to run',
524
618
  unlocks: 'having somewhere to install the app',
525
- howToGet: 'sdkmanager --install "system-images;android-33;google_apis;arm64-v8a" then avdmanager create avd -n staysfixed -k "system-images;android-33;google_apis;arm64-v8a". Pick a plain Google APIs image, NOT a Play Store one: a Play Store device refuses root forever, and without root the files an app writes cannot be seen.',
619
+ howToGet: deviceFor(apk).both,
526
620
  });
527
621
  }
528
622
 
@@ -531,7 +625,8 @@ export const androidAdapter = defineAdapter({
531
625
  missing.push({
532
626
  what: 'a virtual device built from a plain Google APIs image rather than a Play Store one',
533
627
  unlocks: 'seeing the files the app writes, and stopping the clock. A Play Store device refuses root permanently, and both of those need it',
534
- howToGet: 'avdmanager create avd -n staysfixed -k "system-images;android-33;google_apis;arm64-v8a"',
628
+ // Null on a machine with no emulator image at all; `why` then carries the reason.
629
+ howToGet: deviceFor(apk).create ?? deviceFor(apk).why,
535
630
  });
536
631
  }
537
632
  if (usable.some((d) => !d.emulator)) {
@@ -611,11 +611,21 @@ export function undoOurFootprint(text, footprint) {
611
611
  * @returns {{text: string, truncated: boolean, bytes: number}}
612
612
  */
613
613
  export function trimForStorage(text, limit = 64 * 1024) {
614
- const bytes = Buffer.byteLength(text, 'utf8');
615
- if (bytes <= limit) return { text, truncated: false, bytes };
614
+ const whole = Buffer.from(String(text), 'utf8');
615
+ const bytes = whole.length;
616
+ if (bytes <= limit) return { text: String(text), truncated: false, bytes };
616
617
  const keep = Math.floor(limit / 2);
617
- const head = text.slice(0, keep);
618
- const tail = text.slice(-keep);
618
+ // Cut in BYTES, which is what the limit is counted in. This used to cut in characters, and
619
+ // on anything that is not plain ASCII the two are not the same number: a screenful of
620
+ // box-drawing or CJK is three bytes a character, so 90,000 bytes of it is only 30,000
621
+ // characters, both halves took the WHOLE text, and the stored value came out at 180,000
622
+ // bytes — the entire output twice, under a marker claiming 24,464 bytes had been left out
623
+ // of the middle. Three lies at once: the limit was not applied, the count was wrong, and
624
+ // the observation was marked not-fully-covered when in fact nothing had been dropped.
625
+ const headEnd = backToACharacter(whole, keep);
626
+ const tailStart = onToACharacter(whole, bytes - keep);
627
+ const head = whole.subarray(0, headEnd).toString('utf8');
628
+ const tail = whole.subarray(tailStart).toString('utf8');
619
629
  // The marker used to carry a COARSE size bucket, and the doc above it claimed a fingerprint
620
630
  // of the whole that was never actually computed. Both halves of that were wrong, and the
621
631
  // result was the worst thing this tool can produce: a change that happened entirely in the
@@ -636,8 +646,35 @@ export function trimForStorage(text, limit = 64 * 1024) {
636
646
  // as not fully covered, the coverage ledger states the hole, and the whole text is written
637
647
  // to the evidence folder so anybody can look.
638
648
  return {
639
- text: `${head}\n... exactly ${bytes - keep * 2} bytes left out of the middle of ${bytes} ...\n${tail}`,
649
+ text: `${head}\n... exactly ${tailStart - headEnd} bytes left out of the middle of ${bytes} ...\n${tail}`,
640
650
  truncated: true,
641
651
  bytes,
642
652
  };
643
653
  }
654
+
655
+ /**
656
+ * Cutting a multi-byte character in half turns it into a replacement character, which is a
657
+ * difference nobody made and which would then move about between runs. These two walk the cut
658
+ * to the nearest place a character actually starts — backwards for the head, forwards for the
659
+ * tail, so the two halves can never grow into each other.
660
+ *
661
+ * @param {Buffer} buffer
662
+ * @param {number} at
663
+ * @returns {number}
664
+ */
665
+ function backToACharacter(buffer, at) {
666
+ let cut = at;
667
+ while (cut > 0 && (buffer[cut] & 0xC0) === 0x80) cut -= 1;
668
+ return cut;
669
+ }
670
+
671
+ /**
672
+ * @param {Buffer} buffer
673
+ * @param {number} at
674
+ * @returns {number}
675
+ */
676
+ function onToACharacter(buffer, at) {
677
+ let cut = at;
678
+ while (cut < buffer.length && (buffer[cut] & 0xC0) === 0x80) cut += 1;
679
+ return cut;
680
+ }