staysfixed 0.11.1 → 0.13.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 +108 -2
- package/README.md +77 -19
- package/docs/design-v2.md +8 -7
- package/docs/getting-started.md +5 -3
- package/docs/guards.md +18 -0
- package/docs/how-v2-works.md +43 -10
- package/docs/mcp.md +6 -4
- package/docs/settings.md +11 -2
- package/package.json +1 -1
- package/src/cli/approve.js +4 -1
- package/src/cli/flake.js +4 -1
- package/src/cli/mark.js +5 -1
- package/src/cli/status.js +53 -1
- package/src/cli/trace.js +27 -2
- package/src/core/config.js +136 -25
- package/src/core/stop-tree.js +109 -0
- package/src/drive/browser.js +20 -31
- package/src/drive/page.js +74 -2
- package/src/guard/api.js +14 -9
- package/src/types.js +1 -1
- package/src/v2/adapters/android.js +220 -11
- package/src/v2/adapters/child.js +15 -17
- package/src/v2/adapters/contract.js +122 -1
- package/src/v2/adapters/extension.js +1988 -0
- package/src/v2/adapters/http.js +152 -30
- package/src/v2/adapters/ios-driver.js +95 -12
- package/src/v2/adapters/ios.js +220 -10
- package/src/v2/adapters/isolate.js +169 -14
- package/src/v2/adapters/linux-driver.js +1028 -0
- package/src/v2/adapters/linux.js +1324 -0
- package/src/v2/adapters/macos-driver.js +913 -0
- package/src/v2/adapters/macos.js +1374 -0
- package/src/v2/adapters/process.js +72 -8
- package/src/v2/adapters/source.js +254 -7
- package/src/v2/adapters/web.js +69 -19
- package/src/v2/browsers.js +145 -25
- package/src/v2/cause.js +46 -5
- package/src/v2/check.js +465 -47
- package/src/v2/cli.js +21 -1
- package/src/v2/coverage.js +556 -19
- package/src/v2/detect.js +742 -42
- package/src/v2/doctor.js +125 -18
- package/src/v2/escalate.js +57 -11
- package/src/v2/init.js +574 -23
- package/src/v2/journeys/answers-probe.js +376 -0
- package/src/v2/journeys/from-exports.js +456 -0
- package/src/v2/journeys/from-suite.js +9 -1
- package/src/v2/journeys/index.js +3 -3
- package/src/v2/journeys/record-session.js +839 -0
- package/src/v2/journeys/record.js +12 -0
- package/src/v2/mcp/tools.js +193 -27
- package/src/v2/observation.js +145 -0
- package/src/v2/run.js +133 -9
- package/src/v2/selfcheck.js +297 -11
- package/src/v2/store.js +16 -1
- package/src/v2/types.js +1 -1
- package/src/v2/watch/events.js +6 -0
package/src/v2/run.js
CHANGED
|
@@ -30,7 +30,9 @@ import {
|
|
|
30
30
|
subtractWobble,
|
|
31
31
|
sameValue,
|
|
32
32
|
indexByPath,
|
|
33
|
-
|
|
33
|
+
noAnswerJourneys,
|
|
34
|
+
populationDriftNote,
|
|
35
|
+
wobbleShape,
|
|
34
36
|
} from './observation.js';
|
|
35
37
|
// `diffCaptures` is no longer called from here directly. Everything goes through
|
|
36
38
|
// `compareAnswers`, which is that same comparison with one rule around it: an address where
|
|
@@ -268,6 +270,20 @@ export async function runCheck(opts) {
|
|
|
268
270
|
const comparedJourneys = [];
|
|
269
271
|
/** @type {Wobble[]} */
|
|
270
272
|
const wobbles = [];
|
|
273
|
+
// JOURNEYS THAT PRODUCED NO ANSWER, kept as a list rather than folded into a total.
|
|
274
|
+
//
|
|
275
|
+
// This is the scope fix of 2026-08-31. The storm rule — more addresses wobbled than held
|
|
276
|
+
// still — was asked once, of every journey added together, and on a real Next.js site
|
|
277
|
+
// 179 unsteady addresses out of 2849 is not a storm by any reading. Underneath that
|
|
278
|
+
// total sat four journeys unsteady at 69%, 75% and twice 100% of their own addresses,
|
|
279
|
+
// every one of them printed in the coverage list as "could not be compared... there is
|
|
280
|
+
// no answer here" — while the run exited 0 and said `ok`. A page where every single
|
|
281
|
+
// address disagreed with itself was folded into a pass by the pages that behaved.
|
|
282
|
+
// Reproduced here the same day on a six-page Next.js site with two pages made unsteady
|
|
283
|
+
// on the server, where the browser freeze cannot reach them: four journeys at 61%, 98%,
|
|
284
|
+
// 98% and 100%, one timed-out walk, `ok: true`, exit code 0.
|
|
285
|
+
/** @type {{journey: string, why: string, looked: number}[]} */
|
|
286
|
+
const noAnswer = [];
|
|
271
287
|
/** @type {Wobble[]} */
|
|
272
288
|
const referenceWobbles = [];
|
|
273
289
|
/** @type {string[]} */
|
|
@@ -312,11 +328,19 @@ export async function runCheck(opts) {
|
|
|
312
328
|
surface: journey.surface,
|
|
313
329
|
});
|
|
314
330
|
}
|
|
315
|
-
|
|
316
|
-
|
|
331
|
+
// ASKED OF THIS ONE JOURNEY, and the answer is kept. The same call used to happen
|
|
332
|
+
// here and go nowhere but the coverage list: the gap was written, the verdict never
|
|
333
|
+
// read it, and the run went out `ok: true` with "there is no answer here" printed
|
|
334
|
+
// four times inside it. A hole nothing refuses to pass over is a hole nobody acts on.
|
|
335
|
+
for (const dead of noAnswerJourneys([wobble])) {
|
|
336
|
+
noAnswer.push({ journey: journey.describe || journey.name, why: dead.why, looked: dead.looked });
|
|
337
|
+
// A journey that came back with nothing at all already has its own line, three
|
|
338
|
+
// lines above, in words that fit it better. Saying it twice teaches the reader to
|
|
339
|
+
// skim the one list in this tool that must never be skimmed.
|
|
340
|
+
if (dead.looked === 0) continue;
|
|
317
341
|
gaps.push({
|
|
318
342
|
what: `"${journey.describe || journey.name}" could not be compared: the new build did not answer it the same way twice.`,
|
|
319
|
-
why:
|
|
343
|
+
why: dead.why,
|
|
320
344
|
unlockedBy: 'Run it again on a quiet machine. If it happens twice, something in the product does not survive being started a second time.',
|
|
321
345
|
surface: journey.surface,
|
|
322
346
|
});
|
|
@@ -488,6 +512,42 @@ export async function runCheck(opts) {
|
|
|
488
512
|
// in the gap list is a fact most readers will never meet.
|
|
489
513
|
/** @type {string[]} */
|
|
490
514
|
const runNotes = [];
|
|
515
|
+
// THE NUMBER OF ADDRESSES THIS RUN LOOKED AT IS NOT ALLOWED TO MOVE IN SILENCE.
|
|
516
|
+
//
|
|
517
|
+
// Measured 2026-08-31, three checks of one untouched Next.js site minutes apart with
|
|
518
|
+
// nothing edited between them: 2364, 2684 and 2861 addresses looked at, and 0, 179 and
|
|
519
|
+
// 500 of them unsteady. A tool whose whole method is running one thing twice and
|
|
520
|
+
// subtracting what disagrees cannot give three answers to one question and expect to be
|
|
521
|
+
// believed. Part of that spread is the product genuinely wobbling, which is exactly what
|
|
522
|
+
// the measurement is for and is reported as wobble. The ADDRESS COUNT moving by five
|
|
523
|
+
// hundred is not the product: it is the two passes of one build walking over different
|
|
524
|
+
// ground, because a request was cancelled on one of them, a page finished loading on one
|
|
525
|
+
// of them, or the walk ran out of time on one of them. Every one of those already has a
|
|
526
|
+
// line in this list; what had no line at all was the drift itself, so the total simply
|
|
527
|
+
// came out different each run with nothing anywhere saying why.
|
|
528
|
+
//
|
|
529
|
+
// It cannot be made steady from here — the causes are in the walk, not the arithmetic —
|
|
530
|
+
// so it is named, and the count both passes actually reached is named beside it as the
|
|
531
|
+
// number worth quoting. A number that moves and says nothing is worse than a smaller one
|
|
532
|
+
// that is honest.
|
|
533
|
+
const drift = populationDriftNote(wobble);
|
|
534
|
+
if (drift) {
|
|
535
|
+
const worst = wobbles
|
|
536
|
+
.map((w) => ({ journey: w.journey, drifted: wobbleShape(w).drifted }))
|
|
537
|
+
.filter((w) => w.drifted > 0)
|
|
538
|
+
.sort((x, y) => y.drifted - x.drifted);
|
|
539
|
+
const named = worst.slice(0, 4).map((w) => `${w.journey} (${w.drifted})`).join(', ');
|
|
540
|
+
runNotes.push(drift);
|
|
541
|
+
gaps.push({
|
|
542
|
+
what: 'The two runs of the new build did not walk over the same addresses, so the number this run says it looked at will not be the same number next time.',
|
|
543
|
+
why:
|
|
544
|
+
`${drift} Worst in ${named}${worst.length > 4 ? `, and ${worst.length - 4} more` : ''}. ` +
|
|
545
|
+
'Every address that only one of the two passes reached was never compared with anything, on either side.',
|
|
546
|
+
unlockedBy:
|
|
547
|
+
'Find what makes an address turn up on one pass and not the other — a request the browser cancels when the page is torn down, a page that only sometimes finishes loading, a walk that runs out of time — and either make it steady or take it out of what is watched. The timeouts and torn walks that cause it are named separately in this same list.',
|
|
548
|
+
});
|
|
549
|
+
}
|
|
550
|
+
|
|
491
551
|
const kept = await remember(opts, walked);
|
|
492
552
|
if (kept.why) {
|
|
493
553
|
runNotes.push(
|
|
@@ -798,6 +858,16 @@ export async function runCheck(opts) {
|
|
|
798
858
|
subtraction.newlyUnstable.length === 0 &&
|
|
799
859
|
subtraction.couldNotTell !== true &&
|
|
800
860
|
answersLost === 0 &&
|
|
861
|
+
// AND NOT ONE JOURNEY MAY HAVE COME BACK WITH NO ANSWER. `couldNotTell` above is
|
|
862
|
+
// this same law asked of the whole run added together, and adding is what hid it:
|
|
863
|
+
// 179 unsteady addresses out of 2849 is not a storm, and four of that run's twelve
|
|
864
|
+
// journeys were unsteady at 69%, 75% and twice 100% of their own. Whatever those
|
|
865
|
+
// four disagreed with themselves about was dropped before it could be compared, so
|
|
866
|
+
// the quiet underneath them is the quiet of nothing having been looked at — and it
|
|
867
|
+
// was being counted towards a pass by the journeys that behaved. Measured on a real
|
|
868
|
+
// Next.js site, 2026-08-31, where the run exited 0 and printed `ok` with "there is
|
|
869
|
+
// no answer here" written inside it four times.
|
|
870
|
+
noAnswer.length === 0 &&
|
|
801
871
|
// AND SOMETHING HAS TO HAVE BEEN COMPARED. There is already a branch above for the
|
|
802
872
|
// case where no journey had an old-build side at all; this is the same law one notch
|
|
803
873
|
// finer, for the run where every journey HAD a record and every address in it holds
|
|
@@ -825,6 +895,12 @@ export async function runCheck(opts) {
|
|
|
825
895
|
// for as long as it did is that the silence looked exactly like agreement.
|
|
826
896
|
unanswered: uncompared.length,
|
|
827
897
|
lost: answersLost,
|
|
898
|
+
// Named, not counted. "4 journeys could not be compared" sends the reader to a
|
|
899
|
+
// list of thirty-odd coverage lines to find out which four; the names cost one
|
|
900
|
+
// line and are what somebody acts on. `looked` rides along because a journey that
|
|
901
|
+
// disagreed with itself and a journey that saw nothing at all are two different
|
|
902
|
+
// pieces of news and must not be described in one borrowed sentence.
|
|
903
|
+
noAnswer: noAnswer.map((d) => ({ journey: d.journey, looked: d.looked })),
|
|
828
904
|
}),
|
|
829
905
|
startedAt,
|
|
830
906
|
started,
|
|
@@ -1304,15 +1380,44 @@ function warningGaps(mode, provedLive) {
|
|
|
1304
1380
|
* @param {BuildFingerprint} reference
|
|
1305
1381
|
* @param {boolean} provedLive
|
|
1306
1382
|
* @param {number} dropped Suspicions the old build turned out to have as well.
|
|
1307
|
-
* @param {{compared: number, asked: number, addresses: number, unanswered?: number, lost?: number}} how
|
|
1383
|
+
* @param {{compared: number, asked: number, addresses: number, unanswered?: number, lost?: number, noAnswer?: {journey: string, looked: number}[]}} how
|
|
1308
1384
|
* How much of the run this sentence covers: journeys that were really put beside the old
|
|
1309
|
-
* build, journeys asked for, the addresses really compared,
|
|
1310
|
-
*
|
|
1385
|
+
* build, journeys asked for, the addresses really compared, the addresses that could not
|
|
1386
|
+
* be compared because one side of them was a refusal rather than an answer, and the
|
|
1387
|
+
* journeys that produced no answer at all because the new build would not answer them the
|
|
1388
|
+
* same way twice.
|
|
1311
1389
|
* @returns {string}
|
|
1312
1390
|
*/
|
|
1313
1391
|
function summarise(findings, subtraction, warning, notes, reference, provedLive, dropped, how) {
|
|
1314
1392
|
const against = provedLive ? `${nameOf(reference)}, run live` : `the stored record of ${nameOf(reference)}`;
|
|
1315
1393
|
const parts = [];
|
|
1394
|
+
// FIRST, ALWAYS, AND BEFORE THE HEADLINE. A journey with no answer in it is the one thing
|
|
1395
|
+
// that must not be reachable by reading one more sentence: the headline is all some
|
|
1396
|
+
// readers get, and "Nothing that worked has changed" sitting on top of four journeys that
|
|
1397
|
+
// were never compared is the exact false all-clear this whole tool exists to refuse.
|
|
1398
|
+
// Until 2026-08-31 that sentence was printed, `ok` was true, and the run exited 0.
|
|
1399
|
+
const dead = how.noAnswer ?? [];
|
|
1400
|
+
if (dead.length > 0) {
|
|
1401
|
+
const names = dead.map((d) => d.journey);
|
|
1402
|
+
const empty = dead.filter((d) => d.looked === 0).length;
|
|
1403
|
+
const stormy = dead.length - empty;
|
|
1404
|
+
const because = [];
|
|
1405
|
+
if (stormy > 0) {
|
|
1406
|
+
because.push(
|
|
1407
|
+
`The new build disagreed with itself about most of what ${stormy === dead.length ? plural(stormy, 'that journey looks', 'those journeys look') : `${stormy} of them look`} at, so almost everything ${plural(stormy, 'it', 'they')} saw was dropped before it could be compared with anything.`,
|
|
1408
|
+
);
|
|
1409
|
+
}
|
|
1410
|
+
if (empty > 0) {
|
|
1411
|
+
because.push(
|
|
1412
|
+
`${empty === dead.length ? `${plural(empty, 'It was', 'They were')}` : `${empty} of them ${plural(empty, 'was', 'were')}`} walked twice and came back with nothing at all to look at.`,
|
|
1413
|
+
);
|
|
1414
|
+
}
|
|
1415
|
+
parts.push(
|
|
1416
|
+
`NO ANSWER FOR ${dead.length} OF THE ${how.asked} ${plural(how.asked, 'JOURNEY', 'JOURNEYS')} HERE: ${names.slice(0, 4).join(', ')}${names.length > 4 ? `, and ${names.length - 4} more` : ''}. ` +
|
|
1417
|
+
`${because.join(' ')} ` +
|
|
1418
|
+
`The quiet underneath ${plural(dead.length, 'it', 'them')} is the quiet of nothing having been looked at, not of nothing having changed. This is not a pass and not a failure, and the journeys that did answer do not make it one.`,
|
|
1419
|
+
);
|
|
1420
|
+
}
|
|
1316
1421
|
// How much of the run this sentence is actually about. A run that compared four of its
|
|
1317
1422
|
// seventeen journeys is not a run that found nothing; it is a run that mostly did not look,
|
|
1318
1423
|
// and the first sentence is the only one some readers get.
|
|
@@ -1357,10 +1462,29 @@ function summarise(findings, subtraction, warning, notes, reference, provedLive,
|
|
|
1357
1462
|
// paragraphs down in a list.
|
|
1358
1463
|
const n = how.lost ?? 0;
|
|
1359
1464
|
parts.push(
|
|
1360
|
-
`Nothing that
|
|
1465
|
+
`THIS BUILD ANSWERS AT FEWER PLACES THAN THE LAST ONE. Nothing that could still be compared has changed — but ${n} ${plural(n, 'address', 'addresses')} the old build answers at could not be answered by this build at all, so ${plural(n, 'it was', 'they were')} not compared. That is coverage this build has taken away, and it is not a pass. ${how.addresses} ${plural(how.addresses, 'address was', 'addresses were')} really put beside ${against}.${reach}`,
|
|
1466
|
+
);
|
|
1467
|
+
} else if (findings.length === 0 && dead.length > 0) {
|
|
1468
|
+
// "Nothing that worked has changed" is not available to a run that could not read part
|
|
1469
|
+
// of itself. What IS true is said instead, with the size of the hole beside it, so the
|
|
1470
|
+
// sentence cannot be quoted as an all-clear by anybody who reads only this far.
|
|
1471
|
+
parts.push(
|
|
1472
|
+
`SOME OF THIS PRODUCT COULD NOT BE READ AT ALL. Nothing that could be compared has changed: ${how.addresses} ${plural(how.addresses, 'address was', 'addresses were')} really put beside ${against} — and the ${plural(dead.length, 'journey', 'journeys')} named above ${plural(dead.length, 'is', 'are')} not among them, so a break inside ${plural(dead.length, 'it', 'them')} would look exactly like this.${reach}`,
|
|
1361
1473
|
);
|
|
1362
1474
|
} else if (findings.length === 0) {
|
|
1363
|
-
|
|
1475
|
+
// A WHOLE JOURNEY WITH NOTHING ON THE OTHER SIDE IS NOT A PASS, and the headline is the
|
|
1476
|
+
// only line some readers get. This happens for real on the day somebody upgrades: the
|
|
1477
|
+
// tool learns to watch something new — calling a library's exported functions, say — and
|
|
1478
|
+
// the record made by the old version has no answers to hold the new ones against. The run
|
|
1479
|
+
// said so, three sentences down, under a headline reading "Nothing that worked has
|
|
1480
|
+
// changed". A broken library upgraded into this state read as clean. Measured 2026-08-31.
|
|
1481
|
+
if (missed > 0) {
|
|
1482
|
+
parts.push(
|
|
1483
|
+
`PART OF THIS RUN HAD NOTHING TO BE COMPARED AGAINST. Nothing that could be compared has changed — but ${missed} of ${how.asked} ${plural(how.asked, 'journey', 'journeys')} had nothing on the old build's side to be held against, so ${plural(missed, 'it was', 'they were')} not checked at all, and a break inside ${plural(missed, 'it', 'them')} would look exactly like this. That is missing coverage rather than a failure — it is what a journey the old build never had looks like, and it is what an upgrade that learned to watch something new looks like. Ship once from a build you are happy with, or run with --paired, and the next check covers ${plural(missed, 'it', 'them')} properly. ${how.addresses} ${plural(how.addresses, 'address was', 'addresses were')} really put beside ${against}.${reach}`,
|
|
1484
|
+
);
|
|
1485
|
+
} else {
|
|
1486
|
+
parts.push(`Nothing that worked has changed. ${how.addresses} ${plural(how.addresses, 'address', 'addresses')} checked against ${against}.${reach}`);
|
|
1487
|
+
}
|
|
1364
1488
|
} else {
|
|
1365
1489
|
const sealed = findings.filter((f) => f.sealed).length;
|
|
1366
1490
|
parts.push(
|
package/src/v2/selfcheck.js
CHANGED
|
@@ -6,16 +6,20 @@
|
|
|
6
6
|
* as the evidence that it can still go red, and there is no other way to get
|
|
7
7
|
* that evidence: you cannot test a difference engine by reading it.
|
|
8
8
|
*
|
|
9
|
-
* So this builds
|
|
10
|
-
* commit and an uncommitted change on top - which is exactly the shape
|
|
11
|
-
* thing an agent points this tool at - runs the engine over each, and
|
|
12
|
-
* loudly if a break gets through.
|
|
9
|
+
* So this builds a tiny product per case, each as a real repository with a
|
|
10
|
+
* working commit and an uncommitted change on top - which is exactly the shape
|
|
11
|
+
* of the thing an agent points this tool at - runs the engine over each, and
|
|
12
|
+
* fails loudly if a break gets through. A few of them go further and ship,
|
|
13
|
+
* because what "working" MEANS is only ever written down by shipping, and the
|
|
14
|
+
* cases about that record cannot be set up any other way.
|
|
13
15
|
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
* a
|
|
16
|
+
* Most are breaks that MUST be caught. The rest are the other half of the same
|
|
17
|
+
* promise, and they matter just as much: pairs that must produce NO findings at
|
|
18
|
+
* all. A tool that cries wolf gets switched off, and a tool that is switched off
|
|
19
|
+
* catches nothing, so a false alarm fails this run exactly the way a miss does.
|
|
20
|
+
* No count is given here on purpose - it was wrong within a fortnight of being
|
|
21
|
+
* written, and a comment that has to be edited every time a case is added is a
|
|
22
|
+
* comment that ends up lying. The list below is the count.
|
|
19
23
|
*
|
|
20
24
|
* staysfixed check --selfcheck
|
|
21
25
|
* node src/v2/selfcheck.js --only rounded --keep
|
|
@@ -37,6 +41,11 @@ import { fileURLToPath } from 'node:url';
|
|
|
37
41
|
// ever looked in different places, the corpus would be proving something other
|
|
38
42
|
// than what an agent actually runs, which is worse than having no corpus at all.
|
|
39
43
|
import { loadEngine } from './mcp/tools.js';
|
|
44
|
+
// Shipping is the one thing in this tool that decides what "working" means, and three of the
|
|
45
|
+
// cases below are about exactly that decision, so they have to make it for real. This is the
|
|
46
|
+
// same function `staysfixed ship` calls; going round it — writing a reference pointer by
|
|
47
|
+
// hand — would prove the engine works against a record no person could ever produce.
|
|
48
|
+
import { onShip } from './ship.js';
|
|
40
49
|
|
|
41
50
|
const run = promisify(execFile);
|
|
42
51
|
|
|
@@ -71,6 +80,17 @@ const run = promisify(execFile);
|
|
|
71
80
|
* @property {Record<string, unknown>[]} [journeys]
|
|
72
81
|
* A journeys file of its own, for a case about what happens when a journey cannot be
|
|
73
82
|
* walked. Left out, every case gets the one-step "run it" journey.
|
|
83
|
+
* @property {'the commit that works'|'whatever was shipped'} [compareWith]
|
|
84
|
+
* Which build this case holds the product against. Every case but the ones about what
|
|
85
|
+
* "working" MEANS uses the default and names the commit, because that is the shortest way
|
|
86
|
+
* to a fixture with two builds in it. A case about the record itself cannot: the record is
|
|
87
|
+
* only written by shipping, so those cases ship in `prepare` and then ask the engine the
|
|
88
|
+
* way a person does — by asking nothing, and letting it read what the project calls working.
|
|
89
|
+
* @property {boolean} [paired]
|
|
90
|
+
* Whether to boot the old build and walk it live. True unless a case says otherwise, because
|
|
91
|
+
* it is the strongest comparison there is. A case about a reference that CANNOT honestly be
|
|
92
|
+
* booted has to say false: asking for a paired run against one is refused outright, which is
|
|
93
|
+
* the right answer to give a person and a useless one to write a corpus case against.
|
|
74
94
|
* @property {(dir: string, working: string) => Promise<{ready: boolean, why: string}>} [prepare]
|
|
75
95
|
* Bend the machine around the product before the engine runs — take away permission to
|
|
76
96
|
* write, for instance. Answering `ready: false` means this machine cannot be made to do
|
|
@@ -573,8 +593,271 @@ export const CASES = [
|
|
|
573
593
|
'report.js': `console.log('${broken ? 'three' : 'two'} orders');\n`,
|
|
574
594
|
}),
|
|
575
595
|
},
|
|
596
|
+
|
|
597
|
+
{
|
|
598
|
+
name: 'a product that never started is not a product that has not changed',
|
|
599
|
+
breaks:
|
|
600
|
+
'Nothing runs at all. This product throws on its first line, prints nothing and stops with a failure code, and it does it on BOTH builds — which is the ordinary shape of a project somebody is halfway through fixing. What the walk then wrote down was a stack trace and an exit code: two facts, both true, both about the crash rather than about the product. Two builds that crash the same way agree at every one of those addresses, and agreement is the silence this tool reads as "nothing changed". Measured on 2026-08-31 it came back "Nothing that worked has changed. 7 addresses checked" about a product that had done nothing whatsoever, `ship` blessed it as the standard, and fixing the product the next day produced four findings nobody had caused — every real value differing from the stored crash.',
|
|
601
|
+
expect: 'no answer',
|
|
602
|
+
// The word that has to be in the closing paragraph. A run may perfectly well come back
|
|
603
|
+
// with no findings here; what it may never do is present that as a comparison. Saying
|
|
604
|
+
// "refusal" is saying the addresses hold no answer on one side or the other, which is the
|
|
605
|
+
// whole difference between "I looked and found nothing" and "there was nothing to look at".
|
|
606
|
+
summaryMustSay: [/refusal/i],
|
|
607
|
+
build: (broken) => ({
|
|
608
|
+
// Its own package.json, without the `bin` entry every other fixture carries. That entry
|
|
609
|
+
// is a door, the source reader finds it without running anything, and two addresses that
|
|
610
|
+
// compare perfectly well are enough to stop this being a run where NOTHING was compared —
|
|
611
|
+
// which is the exact shape this case is about. The product here does nothing at all, and
|
|
612
|
+
// the fixture has to be a product that offers nothing else either.
|
|
613
|
+
'package.json': JSON.stringify({ name: 'widget', version: '1.0.0', type: 'module' }, null, 2) + '\n',
|
|
614
|
+
'cli.js': [
|
|
615
|
+
"import { readFileSync } from 'node:fs';",
|
|
616
|
+
'// The first line, and it throws: the settings file this needs is not there. Nothing',
|
|
617
|
+
'// below it ever runs, on either build.',
|
|
618
|
+
"const settings = JSON.parse(readFileSync('settings.json', 'utf8'));",
|
|
619
|
+
broken ? "console.log('orders: could not be loaded');" : 'console.log(`orders: ${settings.orders}`);',
|
|
620
|
+
'',
|
|
621
|
+
].join('\n'),
|
|
622
|
+
}),
|
|
623
|
+
},
|
|
624
|
+
|
|
625
|
+
{
|
|
626
|
+
name: 'coverage this build took away is never handed back as a pass',
|
|
627
|
+
breaks:
|
|
628
|
+
'Two journeys. One of them is untouched and agrees perfectly; the other stops answering, because the program behind it now throws on its first line. So there are no findings — there is nothing at those addresses to differ from — and the honest verdict is that this run is not a pass: an address that used to be watched and cannot be now is coverage this change took away. The engine works that out correctly and then handed it to the bookkeeping, and until 2026-08-31 the bookkeeping ASSIGNED the verdict rather than narrowing it: one line, `ok = no findings and no new wobble`, which threw away every not-a-pass already decided. Accounting may take a pass away. It may never hand one back.',
|
|
629
|
+
expect: 'no answer',
|
|
630
|
+
summaryMustSay: [/coverage this build has taken away/i],
|
|
631
|
+
journeys: [
|
|
632
|
+
{
|
|
633
|
+
name: 'run-it',
|
|
634
|
+
describe: 'Run the main program.',
|
|
635
|
+
source: 'code',
|
|
636
|
+
surface: 'cli',
|
|
637
|
+
steps: [{ act: 'run', run: 'node cli.js', note: 'the part of the product that still works' }],
|
|
638
|
+
},
|
|
639
|
+
{
|
|
640
|
+
name: 'run-the-report',
|
|
641
|
+
describe: 'Run the report.',
|
|
642
|
+
source: 'code',
|
|
643
|
+
surface: 'cli',
|
|
644
|
+
steps: [{ act: 'run', run: 'node report.js', note: 'the part that stops answering' }],
|
|
645
|
+
},
|
|
646
|
+
],
|
|
647
|
+
build: (broken) => ({
|
|
648
|
+
'package.json': PKG,
|
|
649
|
+
'cli.js': "console.log('two orders');\n",
|
|
650
|
+
'report.js': broken
|
|
651
|
+
? [
|
|
652
|
+
"import { readFileSync } from 'node:fs';",
|
|
653
|
+
"const rows = JSON.parse(readFileSync('nowhere.json', 'utf8'));",
|
|
654
|
+
'console.log(`report rows ${rows.length}`);',
|
|
655
|
+
'',
|
|
656
|
+
].join('\n')
|
|
657
|
+
: "console.log('report rows 3');\n",
|
|
658
|
+
}),
|
|
659
|
+
},
|
|
660
|
+
|
|
661
|
+
{
|
|
662
|
+
name: 'a standard cut from a tree git does not have is never walked out of git',
|
|
663
|
+
breaks:
|
|
664
|
+
'The product shipped from a working tree with uncommitted changes in it, which is how most things actually ship. The record is filed under a fingerprint of that TREE — an id like `work-76ac0155c8b9`, deliberately not the commit\'s — because the files that were checked are not the files git has. Then somebody threw the uncommitted work away, so the product is the old commit again and a field has gone from what it prints. That is a real regression against what shipped, and the record on disk holds the value it is missing. Until 2026-08-31 a run would export the COMMIT, boot it, call it the old build, see it missing the same field, and drop the finding as something the old build does too. A clean answer, produced by walking a build nobody ever shipped.',
|
|
665
|
+
expect: 'a finding',
|
|
666
|
+
mustSay: [/email/i],
|
|
667
|
+
compareWith: 'whatever was shipped',
|
|
668
|
+
// Not paired, and that is the case rather than a limitation of it. Asking for a paired run
|
|
669
|
+
// against a reference cut from a tree git does not have is refused in one sentence, which
|
|
670
|
+
// is the right answer and not one a corpus can learn anything from. The damage was always
|
|
671
|
+
// done on the ordinary run: the old build gets booted anyway, to prove the differences that
|
|
672
|
+
// survived, and that is the road this walks.
|
|
673
|
+
paired: false,
|
|
674
|
+
prepare: shipFromATreeWithUncommittedWork,
|
|
675
|
+
build: (broken) => ({ 'package.json': PKG, 'cli.js': theReply(broken) }),
|
|
676
|
+
},
|
|
677
|
+
|
|
678
|
+
{
|
|
679
|
+
name: 'with nothing edited, nothing can be a change',
|
|
680
|
+
breaks:
|
|
681
|
+
'Nothing at all, and there is nothing on the other side of the comparison either: this is the build already on record as working, run again. Measured 2026-08-31 on a stock Next.js app with two pages and a link between them — its own prefetch is started by the browser and cancelled when the page is torn down, so it lands in about four runs in five. Ten checks of that untouched tree, minutes apart, gave three reports of a difference, one report that "the change made something non-deterministic", and six clean ones. Identical bytes, four different answers, and the one thing that was certainly true — that nothing had been edited — was the thing the report denied. A race is no use in a corpus that has to mean the same thing every time it runs, so the fixture stands in for it with something that behaves the same way and never flickers: a local file nobody commits, holding the name of the machine this was set up on. The working tree has it and a checkout of the commit does not, so the address answers one way here and another way there while the build is byte for byte the same build.',
|
|
682
|
+
expect: 'nothing',
|
|
683
|
+
// Both sentences, and each carries half of it. The first is the headline saying what this
|
|
684
|
+
// run really was; the second proves an address DID move and was filed as the build
|
|
685
|
+
// disagreeing with itself rather than dropped. A case that went quiet because nothing
|
|
686
|
+
// happened would pass the first and fail the second, which is the point of having it.
|
|
687
|
+
summaryMustSay: [/compared with itself/i, /answered differently across two runs of one build/i],
|
|
688
|
+
build: () => ({
|
|
689
|
+
'package.json': PKG,
|
|
690
|
+
// Under `out/`, which the corpus gitignores, so it is in the working tree and not in
|
|
691
|
+
// the commit — exactly like the .env or the machine-local settings file every real
|
|
692
|
+
// project has one of.
|
|
693
|
+
'out/machine.txt': 'lab-7\n',
|
|
694
|
+
'cli.js': [
|
|
695
|
+
"import fs from 'node:fs';",
|
|
696
|
+
'// A product that says which machine it was set up on, out of a file nobody commits.',
|
|
697
|
+
"const machine = fs.existsSync('out/machine.txt') ? fs.readFileSync('out/machine.txt', 'utf8').trim() : 'unknown';",
|
|
698
|
+
'console.log(`machine ${machine}`);',
|
|
699
|
+
"console.log('total 10.005');",
|
|
700
|
+
'',
|
|
701
|
+
].join('\n'),
|
|
702
|
+
}),
|
|
703
|
+
},
|
|
704
|
+
|
|
705
|
+
{
|
|
706
|
+
name: 'what working means moves only when somebody ships',
|
|
707
|
+
breaks:
|
|
708
|
+
'Nothing, and the product prints exactly what it printed on the day it shipped. What moved in between is the RECORD: somebody put the shipped build back on another machine and ran an ordinary check, which stored fresh captures of it. The store keeps every capture a build ever produced and the reader took the newest, so that run quietly became the definition of working — and the next check compared today against a stranger\'s afternoon rather than against what was blessed. Only `ship` decides what working means; a record that drifts on its own is that one rule leaking. Measured and fixed 2026-08-31, and the two captures blessed at ship time are written down beside the cut for exactly this reason.',
|
|
709
|
+
expect: 'nothing',
|
|
710
|
+
// Proof that it was quiet for the right reason. Silence over a comparison that never
|
|
711
|
+
// happened is the failure this whole corpus exists to make impossible, so the closing
|
|
712
|
+
// sentence has to name a count of addresses that really were put side by side.
|
|
713
|
+
summaryMustSay: [/addresses checked/i],
|
|
714
|
+
compareWith: 'whatever was shipped',
|
|
715
|
+
paired: false,
|
|
716
|
+
prepare: letTheRecordDriftAfterShipping,
|
|
717
|
+
build: () => ({
|
|
718
|
+
'package.json': PKG,
|
|
719
|
+
'cli.js': [
|
|
720
|
+
"import fs from 'node:fs';",
|
|
721
|
+
'// The machine name lives in a file nobody commits, so it can differ from one run of',
|
|
722
|
+
'// this very build to the next without the build itself changing at all.',
|
|
723
|
+
"const machine = fs.existsSync('out/machine.txt') ? fs.readFileSync('out/machine.txt', 'utf8').trim() : 'unknown';",
|
|
724
|
+
'console.log(`machine ${machine}`);',
|
|
725
|
+
"console.log('two orders');",
|
|
726
|
+
'',
|
|
727
|
+
].join('\n'),
|
|
728
|
+
}),
|
|
729
|
+
},
|
|
576
730
|
];
|
|
577
731
|
|
|
732
|
+
/**
|
|
733
|
+
* The reply this product prints, with and without the field that was shipped.
|
|
734
|
+
*
|
|
735
|
+
* One function because two cases need the same two versions of it: the build that is put in
|
|
736
|
+
* the commit, and the build that is really shipped on top of it.
|
|
737
|
+
*
|
|
738
|
+
* @param {boolean} withEmail
|
|
739
|
+
* @returns {string}
|
|
740
|
+
*/
|
|
741
|
+
function theReply(withEmail) {
|
|
742
|
+
return [
|
|
743
|
+
'const person = {',
|
|
744
|
+
' id: 7,',
|
|
745
|
+
" name: 'Ada',",
|
|
746
|
+
withEmail ? " email: 'ada@example.com'," : null,
|
|
747
|
+
" city: 'London',",
|
|
748
|
+
'};',
|
|
749
|
+
'console.log(JSON.stringify(person));',
|
|
750
|
+
'',
|
|
751
|
+
]
|
|
752
|
+
.filter((line) => line !== null)
|
|
753
|
+
.join('\n');
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
/**
|
|
757
|
+
* Ship the working tree, uncommitted changes and all, and then throw the uncommitted work away.
|
|
758
|
+
*
|
|
759
|
+
* The state this leaves behind is an ordinary Tuesday: a release cut from what was actually on
|
|
760
|
+
* disk, and a tree that has since gone back to the commit. What makes it worth a case is that
|
|
761
|
+
* the record and the commit are two different builds, and everything downstream believed
|
|
762
|
+
* otherwise — so the value the record holds gets walked against a build that never had it.
|
|
763
|
+
*
|
|
764
|
+
* `ready: false` where the machine will not perform it. A refused cut is not a failed case: it
|
|
765
|
+
* means this fixture never reached the state the case is about, and calling that a pass would
|
|
766
|
+
* be the corpus telling the kind of lie it exists to catch.
|
|
767
|
+
*
|
|
768
|
+
* @param {string} dir
|
|
769
|
+
* @returns {Promise<{ready: boolean, why: string}>}
|
|
770
|
+
*/
|
|
771
|
+
async function shipFromATreeWithUncommittedWork(dir) {
|
|
772
|
+
const engine = await loadEngine();
|
|
773
|
+
if (!engine.parts.check) return { ready: false, why: 'the difference engine is not in this build' };
|
|
774
|
+
const journeys = path.join(dir, 'journeys.json');
|
|
775
|
+
|
|
776
|
+
// Proved, not assumed. The whole case rests on the tree being one git does not have, and a
|
|
777
|
+
// fixture that came out clean would ship a plain commit and test nothing at all.
|
|
778
|
+
const changed = await git(dir, ['status', '--porcelain']);
|
|
779
|
+
if (changed.trim() === '') return { ready: false, why: 'the working tree came out clean, so there was no uncommitted change to ship from' };
|
|
780
|
+
|
|
781
|
+
// One walk, so the store has a record of what this build does. Nothing is compared: there is
|
|
782
|
+
// no reference yet, which is exactly where a project starts.
|
|
783
|
+
try {
|
|
784
|
+
await engine.parts.check({ cwd: dir, configFile: undefined, against: undefined, paired: false, journeys, only: [] });
|
|
785
|
+
} catch (e) {
|
|
786
|
+
return { ready: false, why: `the first walk did not work: ${why(e)}` };
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
/** @type {any} */
|
|
790
|
+
let shipped;
|
|
791
|
+
try {
|
|
792
|
+
// No product name is passed, deliberately. A person runs `staysfixed ship` and nothing
|
|
793
|
+
// else, so the name has to be worked out the same way `check` works it out — and the day
|
|
794
|
+
// those two readers disagreed, a project shipped and checked under different names for its
|
|
795
|
+
// whole life and never compared anything. Letting it derive here keeps that in the corpus.
|
|
796
|
+
shipped = await onShip({ root: dir, note: 'shipped from the working tree' });
|
|
797
|
+
} catch (e) {
|
|
798
|
+
return { ready: false, why: `shipping did not work: ${why(e)}` };
|
|
799
|
+
}
|
|
800
|
+
if (shipped?.cut !== true) return { ready: false, why: `nothing was made the standard, so there is no record to compare against: ${shipped?.summary ?? 'no reason given'}` };
|
|
801
|
+
|
|
802
|
+
// And now somebody throws the uncommitted work away — a discarded stash, a `git checkout .`,
|
|
803
|
+
// an agent tidying up. The product is the commit again, and a field that was in what shipped
|
|
804
|
+
// has gone with it.
|
|
805
|
+
await writeAll(dir, { 'cli.js': theReply(false) });
|
|
806
|
+
return { ready: true, why: '' };
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
/**
|
|
810
|
+
* Ship, then let somebody else run an ordinary check on the very same build.
|
|
811
|
+
*
|
|
812
|
+
* That second run stores fresh captures of the shipped build, and it is the whole case: the
|
|
813
|
+
* store keeps every capture a build ever produced, and the moment the reader takes the newest
|
|
814
|
+
* of them, a stranger's afternoon has quietly replaced what "working" means. Nobody shipped.
|
|
815
|
+
*
|
|
816
|
+
* The last two lines matter as much as the rest. The machine name is put back so the product
|
|
817
|
+
* really is doing what it did on the day it shipped — the case is about the RECORD moving, not
|
|
818
|
+
* the product — and a note file is added so this build is not the reference build itself, which
|
|
819
|
+
* would make the run silent for a completely different and correct reason.
|
|
820
|
+
*
|
|
821
|
+
* @param {string} dir
|
|
822
|
+
* @returns {Promise<{ready: boolean, why: string}>}
|
|
823
|
+
*/
|
|
824
|
+
async function letTheRecordDriftAfterShipping(dir) {
|
|
825
|
+
const engine = await loadEngine();
|
|
826
|
+
if (!engine.parts.check) return { ready: false, why: 'the difference engine is not in this build' };
|
|
827
|
+
const journeys = path.join(dir, 'journeys.json');
|
|
828
|
+
/** @param {string} machine */
|
|
829
|
+
const setUpOn = async (machine) => writeAll(dir, { 'out/machine.txt': `${machine}\n` });
|
|
830
|
+
|
|
831
|
+
await setUpOn('lab-7');
|
|
832
|
+
try {
|
|
833
|
+
await engine.parts.check({ cwd: dir, configFile: undefined, against: undefined, paired: false, journeys, only: [] });
|
|
834
|
+
} catch (e) {
|
|
835
|
+
return { ready: false, why: `the walk before shipping did not work: ${why(e)}` };
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
/** @type {any} */
|
|
839
|
+
let shipped;
|
|
840
|
+
try {
|
|
841
|
+
shipped = await onShip({ root: dir, note: 'the build that works' });
|
|
842
|
+
} catch (e) {
|
|
843
|
+
return { ready: false, why: `shipping did not work: ${why(e)}` };
|
|
844
|
+
}
|
|
845
|
+
if (shipped?.cut !== true) return { ready: false, why: `nothing was made the standard, so there is no record to drift: ${shipped?.summary ?? 'no reason given'}` };
|
|
846
|
+
|
|
847
|
+
// Somebody else, on another machine, checks the same build out and runs a check. Nothing
|
|
848
|
+
// about that is unusual and nothing about it is shipping.
|
|
849
|
+
await setUpOn('lab-9');
|
|
850
|
+
try {
|
|
851
|
+
await engine.parts.check({ cwd: dir, configFile: undefined, against: undefined, paired: false, journeys, only: [] });
|
|
852
|
+
} catch (e) {
|
|
853
|
+
return { ready: false, why: `the second walk of the shipped build did not work: ${why(e)}` };
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
await setUpOn('lab-7');
|
|
857
|
+
await fsp.writeFile(path.join(dir, 'NOTES.md'), 'A note about the product. Nothing runs it.\n');
|
|
858
|
+
return { ready: true, why: '' };
|
|
859
|
+
}
|
|
860
|
+
|
|
578
861
|
/**
|
|
579
862
|
* Take away the store's permission to be written to, and say honestly when this machine
|
|
580
863
|
* will not let that happen.
|
|
@@ -836,8 +1119,11 @@ async function runOne(check, workDir, c, attempt) {
|
|
|
836
1119
|
result = await check({
|
|
837
1120
|
cwd: dir,
|
|
838
1121
|
configFile: undefined,
|
|
839
|
-
|
|
840
|
-
|
|
1122
|
+
// Naming the commit is what gives most cases two builds without anybody shipping. A
|
|
1123
|
+
// case about what the RECORD says is working must not name one: it has to be answered
|
|
1124
|
+
// out of the store, which is the only place a person's `staysfixed ship` writes to.
|
|
1125
|
+
against: c.compareWith === 'whatever was shipped' ? undefined : working,
|
|
1126
|
+
paired: c.paired !== false,
|
|
841
1127
|
journeys: path.join(dir, 'journeys.json'),
|
|
842
1128
|
only: [],
|
|
843
1129
|
});
|
package/src/v2/store.js
CHANGED
|
@@ -33,6 +33,7 @@ import fs from 'node:fs';
|
|
|
33
33
|
import fsp from 'node:fs/promises';
|
|
34
34
|
import path from 'node:path';
|
|
35
35
|
import crypto from 'node:crypto';
|
|
36
|
+
import { pathToFileURL } from 'node:url';
|
|
36
37
|
import { safeName, findConfigFile } from '../core/paths.js';
|
|
37
38
|
import { StaysFixedError } from '../core/errors.js';
|
|
38
39
|
import { sortObservations } from './observation.js';
|
|
@@ -145,7 +146,21 @@ async function productInSettings(configFile) {
|
|
|
145
146
|
const parsed = JSON.parse(await fsp.readFile(configFile, 'utf8'));
|
|
146
147
|
return typeof parsed?.product === 'string' && parsed.product ? parsed.product : null;
|
|
147
148
|
}
|
|
148
|
-
|
|
149
|
+
// `pathToFileURL`, rather than gluing `file://` to the front of a path.
|
|
150
|
+
//
|
|
151
|
+
// Measured on a real Windows 11 machine on 2026-08-31, and the answer was not the one
|
|
152
|
+
// expected: `file://C:\Users\me\staysfixed.config.mjs` DOES load, because the URL rules
|
|
153
|
+
// treat a drive letter sitting where the host should be as part of the path and turn the
|
|
154
|
+
// backslashes round. Even a folder with a space in it survives that. So this was not the
|
|
155
|
+
// Windows bug it looked like.
|
|
156
|
+
//
|
|
157
|
+
// It is still wrong, and it is changed for the one shape where the luck runs out: a
|
|
158
|
+
// project on a network share. `\\server\share\app` glued to `file://` becomes
|
|
159
|
+
// `file:////server/share/app`, which names no host and no file; `pathToFileURL` makes it
|
|
160
|
+
// `file://server/share/app`, which is the file. The catch below would have turned that
|
|
161
|
+
// into a silent "no product name in the settings", and the release would have been
|
|
162
|
+
// recorded under the wrong name rather than the one the person wrote down.
|
|
163
|
+
const module = await import(pathToFileURL(configFile).href);
|
|
149
164
|
const raw = module.default ?? module.config ?? module;
|
|
150
165
|
return typeof raw?.product === 'string' && raw.product ? raw.product : null;
|
|
151
166
|
} catch {
|
package/src/v2/types.js
CHANGED
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
|
|
40
40
|
/**
|
|
41
41
|
* Where an observation came from, when it matters which platform produced it.
|
|
42
|
-
* @typedef {'cli'|'library'|'server'|'web'|'electron'|'android'|'ios'|'windows'} Surface
|
|
42
|
+
* @typedef {'cli'|'library'|'server'|'web'|'electron'|'android'|'ios'|'windows'|'linux'|'macos'|'extension'} Surface
|
|
43
43
|
*/
|
|
44
44
|
|
|
45
45
|
// ---------------------------------------------------------------------------
|
package/src/v2/watch/events.js
CHANGED
|
@@ -56,9 +56,12 @@ export const SURFACE_WORDS = Object.freeze({
|
|
|
56
56
|
server: 'Server',
|
|
57
57
|
web: 'Website',
|
|
58
58
|
electron: 'Desktop app',
|
|
59
|
+
extension: 'Browser extension',
|
|
59
60
|
android: 'Android phone',
|
|
60
61
|
ios: 'iPhone',
|
|
61
62
|
windows: 'Windows app',
|
|
63
|
+
linux: 'Linux desktop app',
|
|
64
|
+
macos: 'Mac app',
|
|
62
65
|
});
|
|
63
66
|
|
|
64
67
|
/**
|
|
@@ -71,9 +74,12 @@ export const SURFACE_NOTES = Object.freeze({
|
|
|
71
74
|
server: 'started on its own port, watched through what it answered',
|
|
72
75
|
web: 'opened in a browser, watched through what the page says its controls do',
|
|
73
76
|
electron: 'launched with its own data folder, watched through the app and its channels',
|
|
77
|
+
extension: 'loaded into a throwaway browser, watched through its manifest, its own pages, and what it does to somebody else\'s page',
|
|
74
78
|
android: 'installed on an emulator, watched through what is on the screen',
|
|
75
79
|
ios: 'installed on a simulator, watched through what is on the screen',
|
|
76
80
|
windows: 'driven on a real Windows desktop, watched through what is on the screen',
|
|
81
|
+
linux: 'opened on a real Linux desktop, watched through what the accessibility bus says its controls do',
|
|
82
|
+
macos: 'opened in the background on a Mac, watched through what is on the screen',
|
|
77
83
|
});
|
|
78
84
|
|
|
79
85
|
/**
|