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.
- package/CHANGELOG.md +342 -0
- package/README.md +191 -55
- package/docs/design-v2.md +24 -4
- package/docs/getting-started.md +18 -5
- package/docs/guards.md +2 -2
- package/docs/how-v2-works.md +12 -11
- package/docs/mcp.md +17 -8
- package/docs/settings.md +549 -0
- package/docs/watching.md +10 -4
- package/examples/staysfixed.config.electron.js +17 -6
- package/examples/staysfixed.config.web.js +22 -5
- package/package.json +2 -1
- package/src/cli/index.js +55 -46
- package/src/cli/watch-flags.js +54 -0
- package/src/core/config.js +23 -3
- package/src/guard/run.js +49 -1
- package/src/report/console.js +15 -2
- package/src/v2/adapters/android-driver.js +6 -1
- package/src/v2/adapters/android.js +97 -2
- package/src/v2/adapters/contract.js +42 -5
- package/src/v2/adapters/electron.js +72 -6
- package/src/v2/adapters/http.js +11 -2
- package/src/v2/adapters/ios-driver.js +64 -14
- package/src/v2/adapters/ios.js +247 -25
- package/src/v2/adapters/process.js +728 -66
- package/src/v2/adapters/python.js +495 -0
- package/src/v2/adapters/source.js +373 -18
- package/src/v2/adapters/web-driver.js +94 -24
- package/src/v2/adapters/web.js +142 -9
- package/src/v2/adapters/windows.js +18 -1
- package/src/v2/browsers.js +9 -1
- package/src/v2/cause.js +61 -17
- package/src/v2/check.js +530 -66
- package/src/v2/ci.js +130 -35
- package/src/v2/cli.js +42 -24
- package/src/v2/cluster.js +164 -13
- package/src/v2/coverage.js +43 -176
- package/src/v2/detect.js +308 -60
- package/src/v2/doctor.js +285 -45
- package/src/v2/init.js +162 -61
- package/src/v2/intent.js +9 -23
- package/src/v2/journeys/from-suite.js +336 -30
- package/src/v2/journeys/index.js +99 -6
- package/src/v2/mcp/tools.js +10 -11
- package/src/v2/normalise.js +169 -23
- package/src/v2/observation.js +19 -33
- package/src/v2/rank.js +216 -23
- package/src/v2/reference.js +40 -10
- package/src/v2/remote.js +113 -18
- package/src/v2/run.js +103 -14
- package/src/v2/sealed.js +0 -20
- package/src/v2/selfcheck.js +190 -13
- package/src/v2/ship.js +29 -5
- package/src/v2/store.js +67 -1
- package/src/v2/types.js +12 -2
- package/src/v2/waiver.js +64 -54
- package/src/v2/watch/events.js +60 -215
- package/src/v2/watch/focus.js +14 -4
- 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
|
-
/**
|
|
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
|
-
/**
|
|
94
|
-
|
|
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
|
|
456
|
-
strings: [...(base.strings
|
|
457
|
-
arrays: [...(base.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
|
+
}
|
package/src/core/config.js
CHANGED
|
@@ -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
|
-
//
|
|
53
|
-
//
|
|
54
|
-
|
|
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 */
|
package/src/report/console.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
|
615
|
-
|
|
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
|
-
|
|
618
|
-
|
|
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 ${
|
|
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
|
+
}
|