staysfixed 0.13.0 → 0.15.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 +85 -0
- package/package.json +1 -1
- package/src/guard/api.js +43 -0
- package/src/guard/run.js +34 -4
- package/src/report/console.js +23 -1
- package/src/types.js +8 -0
- package/src/v2/check.js +40 -1
- package/src/v2/escalate.js +34 -1
- package/src/v2/init.js +15 -0
- package/src/v2/watch/dialogs.js +410 -0
- package/src/v2/watch/panel.js +27 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,91 @@ numbers follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.15.0] — 2026-09-02
|
|
10
|
+
|
|
11
|
+
### A guard can say it cannot be answered here
|
|
12
|
+
|
|
13
|
+
Fifty guards were written for a real product in one night and put through a real run. Several
|
|
14
|
+
failed on that machine for reasons that had nothing to do with the product: one wanted a paired
|
|
15
|
+
second machine, one an Android phone, several a session in a state nothing had put it in.
|
|
16
|
+
Nothing was broken. They were being asked somewhere that could not answer them.
|
|
17
|
+
|
|
18
|
+
Until now such a guard had two ways out and both were wrong. **Passing is a lie** — it reports
|
|
19
|
+
that a bug did not come back, having looked for nothing, which is the false all-clear this whole
|
|
20
|
+
tool exists to prevent, wearing the friendliest face it has. **Failing is a false alarm**, and
|
|
21
|
+
worse than it sounds: the line printed over a failed guard is "bugs that were already fixed are
|
|
22
|
+
back", so somebody hunts a regression that never happened, and after twice they stop believing
|
|
23
|
+
any of them.
|
|
24
|
+
|
|
25
|
+
- `cannotRunHere(why)` on a guard's run context stops it and reports **not proved**, with the
|
|
26
|
+
reason and what would let it run. Counted as neither a pass nor a failure, and excluded from
|
|
27
|
+
what the run says it looked at.
|
|
28
|
+
- It **cannot** be used after an expectation has already failed. A door out of a red run is a
|
|
29
|
+
door somebody would eventually walk through.
|
|
30
|
+
- It is for what the MACHINE is missing, never for what the product is doing — using it because
|
|
31
|
+
the product looks wrong would turn a real finding into silence.
|
|
32
|
+
- `not proved` is its own verdict everywhere it is printed, distinct from `left out on purpose`,
|
|
33
|
+
which means somebody deliberately switched a guard off. The story of the original bug is
|
|
34
|
+
deliberately not printed underneath it: under a guard that never asked its question, "why this
|
|
35
|
+
guard exists" reads as that bug being back.
|
|
36
|
+
|
|
37
|
+
On the run that prompted it, 56 guards came back 40 held, 11 not proved, 5 failed — and all five
|
|
38
|
+
failures were real.
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
## [0.14.0] — 2026-09-02
|
|
42
|
+
|
|
43
|
+
Four defects, every one of them found by WATCHING this tool work rather than by reading it.
|
|
44
|
+
The owner could not find its live window, then recorded four minutes of it running and the
|
|
45
|
+
recording contained three faults nobody had noticed in a month of testing.
|
|
46
|
+
|
|
47
|
+
### A system dialog can no longer stall a run in silence
|
|
48
|
+
|
|
49
|
+
A real check froze for two of its four minutes behind a macOS alert — *"A keychain cannot be
|
|
50
|
+
found to store 'Terminal Deck Key'"* — while a journey waited for a person who was never going
|
|
51
|
+
to arrive. The run finished and reported one journey with no answer, and never said why.
|
|
52
|
+
|
|
53
|
+
Every adapter here starts the product under test in a throwaway settings folder, which is
|
|
54
|
+
exactly the condition that makes an application ask the operating system for something it has
|
|
55
|
+
never been granted. These dialogs are a normal consequence of how this tool works, so they are
|
|
56
|
+
its problem.
|
|
57
|
+
|
|
58
|
+
- Modal boxes belonging to an application **this run started** are noticed, closed and reported
|
|
59
|
+
with their own words. Nothing else is touched.
|
|
60
|
+
- The button pressed comes from a fixed list that decline, dismiss or close — never one that
|
|
61
|
+
grants, resets, deletes or agrees. On the alert that started this, the other button was
|
|
62
|
+
*Reset To Defaults*, on a keychain, and it was the default.
|
|
63
|
+
- A dialog with nothing safe on it is **left exactly as it is** and named in the result instead.
|
|
64
|
+
- `AXModal` is the test, not the subrole: an ordinary Terminal window reports the same
|
|
65
|
+
`AXDialog` subrole the real alert does, so matching on that would send this tool hunting for
|
|
66
|
+
buttons to press in somebody's actual work.
|
|
67
|
+
|
|
68
|
+
### The report survives the findings
|
|
69
|
+
|
|
70
|
+
When a verdict landed, everything the run had spent four minutes drawing — the walk, every
|
|
71
|
+
journey and its address count, the wobble, what survived, what was not checked — vanished. The
|
|
72
|
+
footer holding "Needs a person" had no cap, so seventeen findings grew it past the height of the
|
|
73
|
+
panel and squeezed the body to **eighteen pixels holding 41,115** of content.
|
|
74
|
+
|
|
75
|
+
The footer is capped and its list scrolls inside itself; the body keeps a floor. Held both ways
|
|
76
|
+
by a guard, including against the first over-correction, which lost the same detail from the
|
|
77
|
+
other end by letting the footer collapse to its label.
|
|
78
|
+
|
|
79
|
+
### The live window says that it exists
|
|
80
|
+
|
|
81
|
+
`--watch` is off by default, the window opens behind your work on purpose, and nothing anywhere
|
|
82
|
+
ever mentioned it. Three correct decisions adding up to a feature nobody could find. `check` now
|
|
83
|
+
says one line on a project that has a screen: that the window is there and how to open it, or,
|
|
84
|
+
when it is open, which side of the screen it went to.
|
|
85
|
+
|
|
86
|
+
### Something added is no longer described as a deletion
|
|
87
|
+
|
|
88
|
+
An escalation read *"SessionBar is there now and was not before"* and then, one line below,
|
|
89
|
+
*"Say whether that deletion is meant to happen."* A sealed class says what a change TOUCHES; it
|
|
90
|
+
says nothing about which way it moved, and the sentence a person is asked to rule on has to
|
|
91
|
+
describe what actually happened.
|
|
92
|
+
|
|
93
|
+
|
|
9
94
|
### The self-check corpus knows about the five false all-clears found on 2026-08-31
|
|
10
95
|
|
|
11
96
|
Five defects were found and fixed that day, each of which could have blessed a broken build,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "staysfixed",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.0",
|
|
4
4
|
"description": "Prove that what already worked still works after an agent changed the code. Picture checks, guards for fixed bugs, a pre-release walkthrough, and known-good markers \u2014 as a CLI and as an MCP server.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/src/guard/api.js
CHANGED
|
@@ -32,6 +32,34 @@ export class ExpectationFailed extends Error {
|
|
|
32
32
|
}
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
/**
|
|
36
|
+
* This guard cannot answer its question on this machine, and that is not a verdict.
|
|
37
|
+
*
|
|
38
|
+
* Added 2026-09-02, after fifty guards were written for a real product in one night and four
|
|
39
|
+
* of them failed for reasons that had nothing to do with the product: one wanted a paired
|
|
40
|
+
* machine, one an Android phone, two a session in a particular state. Nothing was broken. The
|
|
41
|
+
* guards were simply being run somewhere that could not answer them.
|
|
42
|
+
*
|
|
43
|
+
* Until this existed a guard in that position had two ways out and both were wrong. Passing is
|
|
44
|
+
* a lie — it reports that a bug did not come back, having checked nothing. Failing is a false
|
|
45
|
+
* alarm, and worse than it sounds: the line a run prints for a failed guard is "bugs that were
|
|
46
|
+
* already fixed are back", so somebody goes hunting a regression that never happened, and after
|
|
47
|
+
* it happens twice they stop believing any of them.
|
|
48
|
+
*
|
|
49
|
+
* "Not proved" is a third answer and it is the honest one. It is the same distance from a pass
|
|
50
|
+
* as "nothing was compared" is from "nothing changed", which is the distinction this whole tool
|
|
51
|
+
* is built on, and it had a hole in it exactly here.
|
|
52
|
+
*/
|
|
53
|
+
export class GuardCannotRunHere extends Error {
|
|
54
|
+
/** @param {string} why Plain English: what is missing, and what would let it run. */
|
|
55
|
+
constructor(why) {
|
|
56
|
+
super(why);
|
|
57
|
+
this.name = 'GuardCannotRunHere';
|
|
58
|
+
/** @type {string} */
|
|
59
|
+
this.why = why;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
35
63
|
/**
|
|
36
64
|
* The run has given up on this guard, and the guard is still going.
|
|
37
65
|
*
|
|
@@ -199,6 +227,21 @@ export function makeGuardApi(page, project, opts = {}) {
|
|
|
199
227
|
page: refusable(page),
|
|
200
228
|
project,
|
|
201
229
|
|
|
230
|
+
/**
|
|
231
|
+
* Say this guard cannot be answered here, and stop.
|
|
232
|
+
*
|
|
233
|
+
* Use it for what the machine is missing, never for what the product is doing: no phone
|
|
234
|
+
* attached, no second machine paired, no signed-in account to switch between. A guard that
|
|
235
|
+
* reaches for this because the product looks wrong has turned a real finding into silence.
|
|
236
|
+
*
|
|
237
|
+
* @param {string} why What is missing, and what would let it run.
|
|
238
|
+
* @returns {never}
|
|
239
|
+
*/
|
|
240
|
+
cannotRunHere(why) {
|
|
241
|
+
announce(ACTION, `cannot run here: ${short(String(why))}`)('ok');
|
|
242
|
+
throw new GuardCannotRunHere(String(why));
|
|
243
|
+
},
|
|
244
|
+
|
|
202
245
|
/**
|
|
203
246
|
* @param {string} to
|
|
204
247
|
* @returns {Promise<void>}
|
package/src/guard/run.js
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* this one is not" is most of the value of running it at all.
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
|
-
import { makeGuardApi, ExpectationFailed, GuardAbandoned } from './api.js';
|
|
19
|
+
import { makeGuardApi, ExpectationFailed, GuardAbandoned, GuardCannotRunHere } from './api.js';
|
|
20
20
|
import { resetWindow } from '../drive/launch.js';
|
|
21
21
|
import { emitEvent } from '../core/events.js';
|
|
22
22
|
|
|
@@ -29,6 +29,7 @@ const FRESH_KEY = 'fresh';
|
|
|
29
29
|
* @typedef {import('../types.js').GuardResult & {
|
|
30
30
|
* retriedToPass?: boolean,
|
|
31
31
|
* assertedNothing?: boolean,
|
|
32
|
+
* cannotRunHere?: boolean,
|
|
32
33
|
* timedOut?: boolean,
|
|
33
34
|
* checks?: import('../types.js').CheckStep[],
|
|
34
35
|
* }} GuardRunResult
|
|
@@ -41,6 +42,9 @@ const FRESH_KEY = 'fresh';
|
|
|
41
42
|
* @property {string} [failedAt]
|
|
42
43
|
* @property {boolean} [timedOut] The clock ran out before the guard answered. Not the same
|
|
43
44
|
* as the answer being no — see the note on the result below.
|
|
45
|
+
* @property {boolean} [cannotRunHere] The guard said this machine cannot answer it and stopped.
|
|
46
|
+
* `ok` is true because nothing went wrong; the flag is what stops
|
|
47
|
+
* it being counted as a bug that did not come back.
|
|
44
48
|
*/
|
|
45
49
|
|
|
46
50
|
/** @typedef {(step: import('../types.js').CheckStep) => void} StepSink */
|
|
@@ -150,12 +154,16 @@ export async function runGuards(project, app, guards, opts = {}) {
|
|
|
150
154
|
// Its OWN questions, not the runner's. Every guard gets a "fresh start" step from this
|
|
151
155
|
// file whether it asks anything or not, so counting the whole list would always find one.
|
|
152
156
|
const asked = checks.filter((c) => c.key !== FRESH_KEY && !String(c.key ?? '').endsWith(`-${FRESH_KEY}`));
|
|
153
|
-
|
|
157
|
+
// A guard that stopped because this machine cannot answer it did not "check nothing" in the
|
|
158
|
+
// sense the rule below is about — it deliberately declined to claim anything, which is the
|
|
159
|
+
// opposite failure and has to be told apart from it.
|
|
160
|
+
const couldNotRun = outcome.cannotRunHere === true;
|
|
161
|
+
const assertedNothing = outcome.ok && asked.length === 0 && !couldNotRun;
|
|
154
162
|
|
|
155
163
|
/** @type {GuardRunResult} */
|
|
156
164
|
const result = {
|
|
157
165
|
name: guard.name,
|
|
158
|
-
status: outcome.ok && !assertedNothing ? 'passed' : 'failed',
|
|
166
|
+
status: couldNotRun ? 'skipped' : outcome.ok && !assertedNothing ? 'passed' : 'failed',
|
|
159
167
|
file: guard.file,
|
|
160
168
|
because: guard.because,
|
|
161
169
|
durationMs: Date.now() - startedAt,
|
|
@@ -163,7 +171,13 @@ export async function runGuards(project, app, guards, opts = {}) {
|
|
|
163
171
|
};
|
|
164
172
|
if (checks.length > 0) result.checks = checks;
|
|
165
173
|
|
|
166
|
-
if (
|
|
174
|
+
if (couldNotRun) {
|
|
175
|
+
// Neither a pass nor a failure, and it must not be summarised as either. The message
|
|
176
|
+
// carries what is missing so somebody reading a run can tell at a glance whether it is
|
|
177
|
+
// worth plugging a phone in, or whether this guard will never run on a build machine.
|
|
178
|
+
result.cannotRunHere = true;
|
|
179
|
+
result.message = outcome.message ?? 'This guard could not be answered on this machine.';
|
|
180
|
+
} else if (assertedNothing) {
|
|
167
181
|
result.assertedNothing = true;
|
|
168
182
|
// The story of the bug is not repeated in here. It travels on `because`, and the console,
|
|
169
183
|
// the HTML report, the MCP answer and the live panel each print it themselves — so
|
|
@@ -368,6 +382,22 @@ async function attemptGuard(project, app, guard, baseUrl, timeoutMs, onStep) {
|
|
|
368
382
|
message: `This should still be true, and it is not: "${error.claim}".${consoleNote(app)}`,
|
|
369
383
|
};
|
|
370
384
|
}
|
|
385
|
+
// NOT PROVED, which is neither of the two answers above it.
|
|
386
|
+
//
|
|
387
|
+
// The guard asked for something this machine does not have — a paired machine, a phone, a
|
|
388
|
+
// second signed-in account — and stopped. Nothing about the product was learned, so nothing
|
|
389
|
+
// about the product may be reported. Passing here would be the false all-clear this whole
|
|
390
|
+
// tool exists to prevent, and failing would say "a bug that was already fixed is back" about
|
|
391
|
+
// a bug nobody looked for.
|
|
392
|
+
if (error instanceof GuardCannotRunHere) {
|
|
393
|
+
return {
|
|
394
|
+
ok: true,
|
|
395
|
+
cannotRunHere: true,
|
|
396
|
+
message:
|
|
397
|
+
`Not proved here: ${error.why} Nothing about this bug was checked on this run, so nothing here ` +
|
|
398
|
+
`says it has not come back.`,
|
|
399
|
+
};
|
|
400
|
+
}
|
|
371
401
|
// Out of time is its own answer, and it is not "no". The guard was still going when the
|
|
372
402
|
// clock stopped, so all anyone knows is that nobody asked it anything it managed to
|
|
373
403
|
// finish. Said in those words rather than as a returned bug.
|
package/src/report/console.js
CHANGED
|
@@ -127,17 +127,25 @@ function shorten(s, max) {
|
|
|
127
127
|
* - `unanswered` — nobody got an answer: it ran out of time, or it asserted nothing at all.
|
|
128
128
|
* Not a pass, and not a returned bug either.
|
|
129
129
|
* - `left out` — marked skip. Never ran.
|
|
130
|
+
* - `not proved` — the guard asked for something this machine has not got and declined: no
|
|
131
|
+
* phone attached, no machine paired, no server. Distinct from `left out`,
|
|
132
|
+
* which is somebody deliberately switching a guard off. Somebody reading
|
|
133
|
+
* "left out on purpose" against a guard that WANTED to run and could not
|
|
134
|
+
* would go looking for the person who disabled it.
|
|
130
135
|
* - `held` — asked, and the answer was yes.
|
|
131
136
|
*
|
|
132
137
|
* Anything unrecognised counts as `unanswered`, never as `held`: the one thing that must
|
|
133
138
|
* never happen here is a result nobody understood being read as a clean bill of health.
|
|
134
139
|
*
|
|
135
140
|
* @param {import('../types.js').GuardResult} guard
|
|
136
|
-
* @returns {'held'|'back'|'unanswered'|'left out'}
|
|
141
|
+
* @returns {'held'|'back'|'unanswered'|'left out'|'not proved'}
|
|
137
142
|
*/
|
|
138
143
|
export function guardVerdict(guard) {
|
|
139
144
|
const g = /** @type {any} */ (guard ?? {});
|
|
140
145
|
if (g.status === 'passed') return 'held';
|
|
146
|
+
// Read before the plain 'skipped' below it: both wear that status and they mean opposite
|
|
147
|
+
// things about whether anybody wanted this guard to run.
|
|
148
|
+
if (g.cannotRunHere === true) return 'not proved';
|
|
141
149
|
if (g.status === 'skipped') return 'left out';
|
|
142
150
|
if (g.timedOut === true || g.assertedNothing === true) return 'unanswered';
|
|
143
151
|
if (g.status === 'failed') return 'back';
|
|
@@ -162,6 +170,10 @@ function guardOutcome(g) {
|
|
|
162
170
|
return 'still holds';
|
|
163
171
|
case 'left out':
|
|
164
172
|
return 'left out on purpose';
|
|
173
|
+
case 'not proved':
|
|
174
|
+
// The reason, not a fixed phrase: what is missing is the only thing worth reading here,
|
|
175
|
+
// and it is the difference between "plug a phone in" and "this will never run on CI".
|
|
176
|
+
return shorten(String(g.message || 'could not be answered on this machine'), 90);
|
|
165
177
|
case 'unanswered':
|
|
166
178
|
if (any.timedOut === true) return 'ran out of time — nothing was proved either way';
|
|
167
179
|
if (any.assertedNothing === true) return 'checks nothing, so it is protecting nothing';
|
|
@@ -387,6 +399,16 @@ export function printGuardResult(r) {
|
|
|
387
399
|
say(`${paint.grey(sym(mark.info))} ${paint.grey(`${name} left out on purpose`)}`);
|
|
388
400
|
return;
|
|
389
401
|
}
|
|
402
|
+
// NOT PROVED gets its own line, and deliberately NOT the story of the bug underneath it.
|
|
403
|
+
// The story is printed to say whether a failure matters; under a guard that never asked its
|
|
404
|
+
// question, "why this guard exists: long messages used to vanish" reads as that bug being
|
|
405
|
+
// back — which is exactly the impression this whole file exists to prevent. What belongs
|
|
406
|
+
// here is what is missing, so somebody can decide whether to go and plug it in.
|
|
407
|
+
if (verdict === 'not proved') {
|
|
408
|
+
say(`${paint.yellow(sym(mark.warn))} ${paint.yellow(`${name} ${r.message || 'could not be answered on this machine'}`)} ${time}`);
|
|
409
|
+
if (r.file) detail(` ${shortPath(r.file)}`);
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
390
412
|
// A question nobody answered is not painted like a bug coming back. It still keeps the run
|
|
391
413
|
// out of the green — `allClear` counts it — but the colour a person scans for should not
|
|
392
414
|
// say "regression" about something the run has no opinion on.
|
package/src/types.js
CHANGED
|
@@ -273,6 +273,9 @@
|
|
|
273
273
|
|
|
274
274
|
/**
|
|
275
275
|
* @typedef {object} GuardApi
|
|
276
|
+
* @property {(why: string) => never} cannotRunHere Stop, saying this machine cannot answer
|
|
277
|
+
* this guard. For what the MACHINE is
|
|
278
|
+
* missing, never for what the product does.
|
|
276
279
|
* @property {PageApi} page Full page control.
|
|
277
280
|
* @property {(path: string) => Promise<void>} open Shorthand for page.goto.
|
|
278
281
|
* @property {(selector: string) => Promise<void>} click
|
|
@@ -506,6 +509,11 @@ export {};
|
|
|
506
509
|
* as a pass — but it is not a bug coming back, and anything
|
|
507
510
|
* drawing this stream has to be able to tell the two apart.
|
|
508
511
|
* @property {boolean} [assertedNothing] A guard that finished without asking a single question.
|
|
512
|
+
* @property {boolean} [cannotRunHere] A guard that stopped because this machine cannot answer it —
|
|
513
|
+
* no phone attached, no second machine paired, no second account
|
|
514
|
+
* to switch between. Reported with the status 'skipped': it is
|
|
515
|
+
* not a pass, because nothing about the bug was checked, and it
|
|
516
|
+
* is not a failure, because nothing about the product went wrong.
|
|
509
517
|
* @property {string} [thumbnail] A small JPEG as a data: URI — an instant preview, shown
|
|
510
518
|
* while the real file is still being written.
|
|
511
519
|
* @property {string} [shotFile] file:// URL of the FULL-RESOLUTION picture just taken.
|
package/src/v2/check.js
CHANGED
|
@@ -32,7 +32,7 @@ import { createHash } from 'node:crypto';
|
|
|
32
32
|
import { promisify } from 'node:util';
|
|
33
33
|
|
|
34
34
|
import { StaysFixedError, messageOf } from '../core/errors.js';
|
|
35
|
-
import { warn, detail, shortPath } from '../core/log.js';
|
|
35
|
+
import { say, warn, detail, shortPath } from '../core/log.js';
|
|
36
36
|
import { findConfigFile, rootForConfig } from '../core/paths.js';
|
|
37
37
|
import { sha256 } from '../core/hash.js';
|
|
38
38
|
|
|
@@ -46,6 +46,7 @@ import { whatChanged, NOT_THE_TOOLS_OWN_FOLDER } from './rank.js';
|
|
|
46
46
|
|
|
47
47
|
import { attachWatcher, watchOptionsFrom } from './watch/index.js';
|
|
48
48
|
import { guardTheScreen, describeGuard } from './watch/focus.js';
|
|
49
|
+
import { watchForDialogs, describeDialogs } from './watch/dialogs.js';
|
|
49
50
|
import {
|
|
50
51
|
isOffScreen, moveWindowByPid, offScreen, windowBoundsByPid, withoutTakingTheScreen,
|
|
51
52
|
} from './watch/window.js';
|
|
@@ -1447,7 +1448,29 @@ async function mindTheScreen(project, events) {
|
|
|
1447
1448
|
// Nothing will appear and nobody asked for a window: there is no screen to look after.
|
|
1448
1449
|
if (!wantsPanel && !couldShow) return null;
|
|
1449
1450
|
|
|
1451
|
+
// A WINDOW NOBODY KNOWS ABOUT IS A WINDOW THAT DOES NOT EXIST.
|
|
1452
|
+
//
|
|
1453
|
+
// This tool draws a live view of a run — surfaces, journeys ticking, the reference it is
|
|
1454
|
+
// measuring against, the findings as they land. It is off unless somebody types `--watch`,
|
|
1455
|
+
// and even then it opens BEHIND their work on purpose and never comes forward again. All
|
|
1456
|
+
// three of those are right on their own, and together they meant the owner ran this tool
|
|
1457
|
+
// for weeks and never once saw the window, because nothing anywhere told him it was there.
|
|
1458
|
+
// `staysfixed init` mentions it, which helps exactly once and only if you read the setup.
|
|
1459
|
+
//
|
|
1460
|
+
// So the run itself says it, once, on a project where there is something to watch.
|
|
1461
|
+
if (!wantsPanel) {
|
|
1462
|
+
say('There is a live window for this run — add --watch to open it beside what is being checked.');
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1450
1465
|
const guard = guardTheScreen();
|
|
1466
|
+
// THE BOXES THIS RUN CAUSES ARE THIS RUN'S PROBLEM.
|
|
1467
|
+
//
|
|
1468
|
+
// Every adapter starts the thing under test in a throwaway settings folder, which is exactly
|
|
1469
|
+
// the condition that makes an application ask the operating system for something it has never
|
|
1470
|
+
// been granted. On 2026-09-01 that was a keychain, and the alert it raised sat on screen for
|
|
1471
|
+
// two minutes of a four-minute run while a journey waited behind it for a person who was
|
|
1472
|
+
// never going to arrive. Only ours, only harmless buttons, and everything seen is reported.
|
|
1473
|
+
const dialogs = watchForDialogs({ elapsed: () => events.elapsed() });
|
|
1451
1474
|
// Said under --verbose rather than always, because a person who was not interrupted
|
|
1452
1475
|
// should not be told about the machinery that did not interrupt them. It is here at all
|
|
1453
1476
|
// so that "the guard is running" is something anybody can see rather than take on trust.
|
|
@@ -1473,6 +1496,14 @@ async function mindTheScreen(project, events) {
|
|
|
1473
1496
|
// AND --watch-front is somebody asking, in so many words, for this window in front.
|
|
1474
1497
|
// Claiming it would have the guard undoing the flag a second after it was obeyed.
|
|
1475
1498
|
onOpen: (browser) => {
|
|
1499
|
+
// AND SAY WHERE IT WENT. The window opens behind whatever the person is doing and
|
|
1500
|
+
// never asks for the screen again, which is the behaviour they asked for — but it
|
|
1501
|
+
// makes a window that came up correctly look exactly like one that never came up.
|
|
1502
|
+
// One line, at the moment it opens, is the whole difference between the two.
|
|
1503
|
+
say(
|
|
1504
|
+
`The live window is open on the ${watch.side === 'left' ? 'left' : 'right'} of your screen` +
|
|
1505
|
+
(watch.foreground === true ? '.' : ", behind what you are working on — it will not come forward on its own."),
|
|
1506
|
+
);
|
|
1476
1507
|
if (browser.borrowed || watch.foreground === true) return;
|
|
1477
1508
|
guard.claim(browser.name);
|
|
1478
1509
|
},
|
|
@@ -1489,6 +1520,7 @@ async function mindTheScreen(project, events) {
|
|
|
1489
1520
|
// Claimed the instant the process exists, before it has drawn anything. A moment
|
|
1490
1521
|
// later and its first appearance is read as the person choosing it.
|
|
1491
1522
|
guard.claim(app.name);
|
|
1523
|
+
dialogs.claim(app.name);
|
|
1492
1524
|
events.emit({
|
|
1493
1525
|
type: 'note',
|
|
1494
1526
|
at: events.elapsed(),
|
|
@@ -1506,7 +1538,14 @@ async function mindTheScreen(project, events) {
|
|
|
1506
1538
|
handingBack ??= (async () => {
|
|
1507
1539
|
stopped = true;
|
|
1508
1540
|
stopListening();
|
|
1541
|
+
// One last look before letting go: a box that came up during the final journey is the
|
|
1542
|
+
// one most likely to explain a missing answer, and the periodic sweep may not have
|
|
1543
|
+
// come round again before the run ended.
|
|
1544
|
+
await dialogs.sweepNow().catch(() => {});
|
|
1545
|
+
await dialogs.stop();
|
|
1509
1546
|
await guard.release();
|
|
1547
|
+
const said = describeDialogs(dialogs.report());
|
|
1548
|
+
if (said) events.emit({ type: 'note', at: events.elapsed(), message: said });
|
|
1510
1549
|
const line = describeGuard(guard.report());
|
|
1511
1550
|
if (line) events.emit({ type: 'note', at: events.elapsed(), message: line });
|
|
1512
1551
|
await Promise.allSettled(placing);
|
package/src/v2/escalate.js
CHANGED
|
@@ -609,6 +609,31 @@ function buildEscalations(product, record, verdict) {
|
|
|
609
609
|
};
|
|
610
610
|
}
|
|
611
611
|
|
|
612
|
+
/**
|
|
613
|
+
* Which way a finding moved, read off the differences it stands for.
|
|
614
|
+
*
|
|
615
|
+
* A sealed class says what a change TOUCHES. It does not say what the change DID, and the
|
|
616
|
+
* two were treated as one thing until 2026-09-01, when a real run on a real product printed
|
|
617
|
+
* `"SessionBar" is there now and was not before` and then asked, one line below it, "Say
|
|
618
|
+
* whether that deletion is meant to happen." Nothing had been deleted. That sentence is the
|
|
619
|
+
* one a person is asked to rule on, and it described the opposite of what happened.
|
|
620
|
+
*
|
|
621
|
+
* So the direction is read rather than assumed. 'gone' only when something actually went
|
|
622
|
+
* away, 'new' only when everything is an arrival, and 'changed' for a mix or anything else —
|
|
623
|
+
* because a sentence that has to hedge is still better than one that is wrong.
|
|
624
|
+
*
|
|
625
|
+
* @param {DecidedFinding} f
|
|
626
|
+
* @returns {'gone'|'new'|'changed'}
|
|
627
|
+
*/
|
|
628
|
+
function whichWay(f) {
|
|
629
|
+
const kinds = (f.differences ?? []).map((d) => d && d.kind).filter(Boolean);
|
|
630
|
+
if (kinds.length === 0 && f.sample && f.sample.kind) kinds.push(f.sample.kind);
|
|
631
|
+
if (kinds.length === 0) return 'changed';
|
|
632
|
+
if (kinds.every((k) => k === 'vanished')) return 'gone';
|
|
633
|
+
if (kinds.every((k) => k === 'appeared')) return 'new';
|
|
634
|
+
return 'changed';
|
|
635
|
+
}
|
|
636
|
+
|
|
612
637
|
/**
|
|
613
638
|
* @param {DecidedFinding} f
|
|
614
639
|
* @returns {string}
|
|
@@ -619,7 +644,15 @@ function sealedTodo(f) {
|
|
|
619
644
|
if (cls === 'guard') return 'This is a bug you already reported once, coming back. Say whether it goes back on the list, or the guard was wrong.';
|
|
620
645
|
if (cls === 'money') return 'Say whether that is the amount you wanted. If it is, shipping makes it the new normal; if it is not, nothing ships.';
|
|
621
646
|
if (cls === 'sign-in') return 'Say whether signing in is meant to behave like that now. Nothing ships until you do.';
|
|
622
|
-
if (cls === 'data-loss')
|
|
647
|
+
if (cls === 'data-loss') {
|
|
648
|
+
// The class means "this touches losing data". Which way it moved is a separate fact, and
|
|
649
|
+
// saying the wrong one at somebody who is about to rule on it is worse than saying less.
|
|
650
|
+
const worth = ' This one is worth thirty seconds before anything ships.';
|
|
651
|
+
const way = whichWay(f);
|
|
652
|
+
if (way === 'gone') return `Say whether that deletion is meant to happen.${worth}`;
|
|
653
|
+
if (way === 'new') return `Say whether that is meant to be there. It can lose data.${worth}`;
|
|
654
|
+
return `Say whether that change is meant to happen. It can lose data.${worth}`;
|
|
655
|
+
}
|
|
623
656
|
if (cls === 'crash') return 'This has to be fixed before anything ships. Nobody needs to decide anything, but you should know it happened.';
|
|
624
657
|
return `Say whether that is what you wanted${where ? `, at ${where}` : ''}. If it is, ship — shipping is what makes it the new normal.`;
|
|
625
658
|
}
|
package/src/v2/init.js
CHANGED
|
@@ -2256,6 +2256,21 @@ function nextCommands(readiness, project) {
|
|
|
2256
2256
|
? 'The first real run. It walks everything and shows you what it sees. It cannot record what "working" means — only shipping does that.'
|
|
2257
2257
|
: 'The first real run. Nothing here is fully set up yet, so it walks what it can reach and says plainly what it left out — which is more useful than waiting.',
|
|
2258
2258
|
});
|
|
2259
|
+
// Nothing said this existed, anywhere. A person ran a check, watched a blank terminal for
|
|
2260
|
+
// minutes, and reasonably asked why there was no window — the panel was there the whole
|
|
2261
|
+
// time behind a flag nobody had been told about. A run that looks like nothing is
|
|
2262
|
+
// happening is indistinguishable from a run that is stuck, which is the state somebody
|
|
2263
|
+
// kills it in. Said once, here, where a person is deciding what to type next.
|
|
2264
|
+
// Measured 2026-09-01.
|
|
2265
|
+
const hasAScreen =
|
|
2266
|
+
(project.pages?.length ?? 0) > 0
|
|
2267
|
+
|| project.products.some((p) => ['web', 'electron', 'ios', 'android', 'macos', 'linux', 'windows', 'extension'].includes(String(p.surface)));
|
|
2268
|
+
if (hasAScreen) {
|
|
2269
|
+
next.push({
|
|
2270
|
+
command: 'staysfixed check --watch',
|
|
2271
|
+
what: 'The same run, with a window beside it drawing every screen as it is walked. Worth it once, to see what this actually does. It opens behind what you are working on; --watch-front brings it forward.',
|
|
2272
|
+
});
|
|
2273
|
+
}
|
|
2259
2274
|
}
|
|
2260
2275
|
if (project.tests.files > 0) {
|
|
2261
2276
|
next.push({ command: 'staysfixed check --journeys suite', what: `Walk the ${project.tests.files} test${project.tests.files === 1 ? '' : 's'} this project already has, under instrumentation.` });
|
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The system dialogs this tool causes, and what to do about them.
|
|
3
|
+
*
|
|
4
|
+
* ## What went wrong, 2026-09-01
|
|
5
|
+
*
|
|
6
|
+
* A real check on a real product ran for four minutes and spent the last two of them frozen.
|
|
7
|
+
* The owner recorded it. On screen, over everything, was a macOS alert:
|
|
8
|
+
*
|
|
9
|
+
* Keychain Not Found
|
|
10
|
+
* A keychain cannot be found to store "Terminal Deck Key."
|
|
11
|
+
* [?] [Cancel] [Reset To Defaults]
|
|
12
|
+
*
|
|
13
|
+
* The panel sat on "Walking ask the private channel voice:status to answer" at 23 of 27
|
|
14
|
+
* journeys and never moved. When the run finally gave up it said only that one journey had no
|
|
15
|
+
* answer. It never said WHY, and the why was a modal box waiting for a person who was not
|
|
16
|
+
* going to be there — on a machine where nobody had been asked to sit and watch.
|
|
17
|
+
*
|
|
18
|
+
* ## Why this is the tool's problem and not the product's
|
|
19
|
+
*
|
|
20
|
+
* Every adapter here starts the thing under test in a THROWAWAY settings folder, on its own
|
|
21
|
+
* ports, under its own name, so that a run can never touch the real install. That isolation is
|
|
22
|
+
* the whole design, and a fresh settings folder is exactly the condition that makes an
|
|
23
|
+
* application ask the operating system for something it has never been granted: a keychain, a
|
|
24
|
+
* microphone, the Documents folder, permission to control another app. So these dialogs are a
|
|
25
|
+
* NORMAL consequence of how this tool works. A tool that causes them and then hangs behind
|
|
26
|
+
* them, silently, is broken in its own right.
|
|
27
|
+
*
|
|
28
|
+
* ## The three rules, and why each one is a rule
|
|
29
|
+
*
|
|
30
|
+
* ONLY OURS. A dialog is touched only when it belongs to an application this run started, and
|
|
31
|
+
* the caller supplies that list — the same `ours` bookkeeping the screen guard keeps. His own
|
|
32
|
+
* apps ask him things all day and none of it is any of this tool's business. Getting this
|
|
33
|
+
* wrong would mean a background check silently answering a prompt in his email client.
|
|
34
|
+
*
|
|
35
|
+
* ONLY HARMLESS BUTTONS. The button pressed comes from a fixed list of ones that decline,
|
|
36
|
+
* dismiss or close, and nothing else is ever pressed. On the very dialog that started this,
|
|
37
|
+
* the other button was "Reset To Defaults" — on a keychain — which is a change to the machine
|
|
38
|
+
* that no automated run may make on somebody's behalf. When there is no harmless button, the
|
|
39
|
+
* dialog is left exactly as it is and reported instead. A tool that cannot act safely says so;
|
|
40
|
+
* it does not guess.
|
|
41
|
+
*
|
|
42
|
+
* SAY IT HAPPENED. Every dialog seen is recorded with its words, whether it could be dismissed
|
|
43
|
+
* or not, and the check reports it. "No answer for one journey" is a fact with no cause in it.
|
|
44
|
+
* "No answer, because your app put up a box saying a keychain cannot be found, and I closed it"
|
|
45
|
+
* is the same fact with the thing a person needs in order to act.
|
|
46
|
+
*
|
|
47
|
+
* Everything here is best effort and every failure is swallowed: no window server, no Apple
|
|
48
|
+
* Events permission, a locked screen or a machine that is not a Mac all mean there are no
|
|
49
|
+
* dialogs to clear, which is never a reason to fail a check.
|
|
50
|
+
*/
|
|
51
|
+
|
|
52
|
+
import { execFile } from 'node:child_process';
|
|
53
|
+
import { promisify } from 'node:util';
|
|
54
|
+
|
|
55
|
+
import { detail } from '../../core/log.js';
|
|
56
|
+
|
|
57
|
+
const run = promisify(execFile);
|
|
58
|
+
|
|
59
|
+
/** How often to look. Dialogs are not urgent — a person would take a second to notice one too. */
|
|
60
|
+
const LOOK_EVERY_MS = 1200;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The only buttons this file will ever press.
|
|
64
|
+
*
|
|
65
|
+
* Every one of them declines, dismisses or closes. Nothing here grants anything, changes a
|
|
66
|
+
* setting, deletes anything or agrees to anything. The list is deliberately short and
|
|
67
|
+
* deliberately literal: a fuzzy match would eventually press "Allow" because it contained
|
|
68
|
+
* "low", and the whole safety of this file is that it cannot.
|
|
69
|
+
*/
|
|
70
|
+
const SAFE_BUTTONS = [
|
|
71
|
+
'Cancel',
|
|
72
|
+
"Don't Allow",
|
|
73
|
+
'Don’t Allow',
|
|
74
|
+
'Deny',
|
|
75
|
+
'Not Now',
|
|
76
|
+
'Later',
|
|
77
|
+
'Close',
|
|
78
|
+
'Dismiss',
|
|
79
|
+
'No',
|
|
80
|
+
'Quit',
|
|
81
|
+
];
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Buttons that must never be pressed even if some future edit adds them above.
|
|
85
|
+
*
|
|
86
|
+
* A second, independent gate. `SAFE_BUTTONS` is the allow-list and this is the veto, and a
|
|
87
|
+
* button has to get past both. It exists because the failure this file guards against is not
|
|
88
|
+
* "the wrong button was pressed once" — it is "somebody widened the allow-list a year from now
|
|
89
|
+
* and nobody noticed what it now includes".
|
|
90
|
+
*/
|
|
91
|
+
const NEVER_PRESS = [
|
|
92
|
+
'reset',
|
|
93
|
+
'allow',
|
|
94
|
+
'delete',
|
|
95
|
+
'erase',
|
|
96
|
+
'remove',
|
|
97
|
+
'ok',
|
|
98
|
+
'yes',
|
|
99
|
+
'continue',
|
|
100
|
+
'agree',
|
|
101
|
+
'accept',
|
|
102
|
+
'grant',
|
|
103
|
+
'always',
|
|
104
|
+
'trust',
|
|
105
|
+
'update',
|
|
106
|
+
'install',
|
|
107
|
+
'send',
|
|
108
|
+
'share',
|
|
109
|
+
];
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* @typedef {object} SeenDialog
|
|
113
|
+
* @property {string} app The application it belongs to.
|
|
114
|
+
* @property {string} title Its window title, when it has one.
|
|
115
|
+
* @property {string} says The text on it, joined into one line.
|
|
116
|
+
* @property {string|null} closed The button pressed, or null when it was left alone.
|
|
117
|
+
* @property {string|null} why Why it was left alone, when it was.
|
|
118
|
+
* @property {number} at Milliseconds into the run.
|
|
119
|
+
*/
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Is this a button this file is allowed to press?
|
|
123
|
+
*
|
|
124
|
+
* @param {string} label
|
|
125
|
+
* @returns {boolean}
|
|
126
|
+
*/
|
|
127
|
+
export function mayPress(label) {
|
|
128
|
+
const name = String(label ?? '').trim();
|
|
129
|
+
if (name.length === 0) return false;
|
|
130
|
+
const lower = name.toLowerCase();
|
|
131
|
+
// The veto runs first and on the whole label, so "Reset To Defaults" is refused before
|
|
132
|
+
// anything else gets a chance to like the look of it.
|
|
133
|
+
for (const banned of NEVER_PRESS) {
|
|
134
|
+
if (lower.split(/[^a-z’']+/).includes(banned)) return false;
|
|
135
|
+
}
|
|
136
|
+
return SAFE_BUTTONS.some((safe) => safe.toLowerCase() === lower);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Pick the button to press out of what a dialog offers.
|
|
141
|
+
*
|
|
142
|
+
* @param {string[]} labels
|
|
143
|
+
* @returns {string|null}
|
|
144
|
+
*/
|
|
145
|
+
export function safeButton(labels) {
|
|
146
|
+
const offered = (labels ?? []).map((l) => String(l ?? '').trim()).filter(Boolean);
|
|
147
|
+
// In SAFE_BUTTONS order rather than in the dialog's order, so the answer does not depend on
|
|
148
|
+
// how somebody laid their buttons out.
|
|
149
|
+
for (const wanted of SAFE_BUTTONS) {
|
|
150
|
+
const hit = offered.find((l) => l.toLowerCase() === wanted.toLowerCase() && mayPress(l));
|
|
151
|
+
if (hit) return hit;
|
|
152
|
+
}
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Every modal box currently up, for the applications named.
|
|
158
|
+
*
|
|
159
|
+
* WHAT COUNTS AS A DIALOG, and this took measuring rather than guessing. The obvious test is
|
|
160
|
+
* the window's subrole, and it is wrong: on this Mac an ordinary Terminal window, Activity
|
|
161
|
+
* Monitor and System Settings all report `AXDialog`, the same value the real keychain alert
|
|
162
|
+
* reports. A tool matching on that would go hunting for buttons to press on somebody's actual
|
|
163
|
+
* work. The attribute that does separate them is **AXModal** — measured true on the alert and
|
|
164
|
+
* false on all four of his open Terminal windows — which is also the honest definition of the
|
|
165
|
+
* thing being fixed here: a box that blocks. Sheets are included because a sheet is modal to
|
|
166
|
+
* the window it hangs off whatever it says about itself.
|
|
167
|
+
*
|
|
168
|
+
* One `osascript` call for the whole sweep. The output is one record per line, tab separated,
|
|
169
|
+
* because parsing AppleScript's own list syntax is a worse idea than choosing a separator.
|
|
170
|
+
*
|
|
171
|
+
* @param {string[]} apps
|
|
172
|
+
* @returns {Promise<{app: string, title: string, says: string, buttons: string[]}[]>}
|
|
173
|
+
*/
|
|
174
|
+
export async function dialogsUp(apps) {
|
|
175
|
+
if (process.platform !== 'darwin' || !apps || apps.length === 0) return [];
|
|
176
|
+
const list = '{' + apps.map((a) => JSON.stringify(String(a))).join(', ') + '}';
|
|
177
|
+
const script = `
|
|
178
|
+
set out to ""
|
|
179
|
+
tell application "System Events"
|
|
180
|
+
repeat with wanted in ${list}
|
|
181
|
+
repeat with proc in (every application process whose name is (wanted as string))
|
|
182
|
+
try
|
|
183
|
+
repeat with win in (every window of proc)
|
|
184
|
+
set isDialog to false
|
|
185
|
+
try
|
|
186
|
+
if (value of attribute "AXModal" of win) is true then set isDialog to true
|
|
187
|
+
end try
|
|
188
|
+
try
|
|
189
|
+
if subrole of win is "AXSystemDialog" then set isDialog to true
|
|
190
|
+
end try
|
|
191
|
+
-- NOT "sheets". Inside a System Events tell block that word is an element name,
|
|
192
|
+
-- so "set sheets to {}" is read as "set every sheet to {}" and throws -10006.
|
|
193
|
+
-- The error was swallowed by the try around this loop and the whole sweep came
|
|
194
|
+
-- back empty, which looked exactly like a machine with no dialogs on it.
|
|
195
|
+
set sheetCount to 0
|
|
196
|
+
set firstSheet to missing value
|
|
197
|
+
try
|
|
198
|
+
set theSheets to every sheet of win
|
|
199
|
+
set sheetCount to (count of theSheets)
|
|
200
|
+
if sheetCount > 0 then set firstSheet to item 1 of theSheets
|
|
201
|
+
end try
|
|
202
|
+
if (isDialog) or (sheetCount > 0) then
|
|
203
|
+
set target to win
|
|
204
|
+
if sheetCount > 0 then set target to firstSheet
|
|
205
|
+
set theTitle to ""
|
|
206
|
+
try
|
|
207
|
+
set theTitle to name of target as string
|
|
208
|
+
end try
|
|
209
|
+
set theText to ""
|
|
210
|
+
try
|
|
211
|
+
repeat with t in (every static text of target)
|
|
212
|
+
set theText to theText & (value of t as string) & " "
|
|
213
|
+
end repeat
|
|
214
|
+
end try
|
|
215
|
+
set theButtons to ""
|
|
216
|
+
try
|
|
217
|
+
repeat with b in (every button of target)
|
|
218
|
+
set theButtons to theButtons & (name of b as string) & "|"
|
|
219
|
+
end repeat
|
|
220
|
+
end try
|
|
221
|
+
set out to out & (name of proc as string) & tab & theTitle & tab & theText & tab & theButtons & linefeed
|
|
222
|
+
end if
|
|
223
|
+
end repeat
|
|
224
|
+
end try
|
|
225
|
+
end repeat
|
|
226
|
+
end repeat
|
|
227
|
+
end tell
|
|
228
|
+
return out
|
|
229
|
+
`;
|
|
230
|
+
try {
|
|
231
|
+
const { stdout } = await run('osascript', ['-e', script], { timeout: 6000 });
|
|
232
|
+
return stdout
|
|
233
|
+
.split('\n')
|
|
234
|
+
.map((line) => line.replace(/\r$/, ''))
|
|
235
|
+
.filter((line) => line.trim().length > 0)
|
|
236
|
+
.map((line) => {
|
|
237
|
+
const [app = '', title = '', says = '', buttons = ''] = line.split('\t');
|
|
238
|
+
return {
|
|
239
|
+
app: app.trim(),
|
|
240
|
+
title: title.trim(),
|
|
241
|
+
says: says.replace(/\s+/g, ' ').trim(),
|
|
242
|
+
buttons: buttons.split('|').map((b) => b.trim()).filter(Boolean),
|
|
243
|
+
};
|
|
244
|
+
})
|
|
245
|
+
.filter((d) => d.app.length > 0);
|
|
246
|
+
} catch {
|
|
247
|
+
// No window server, no permission, a locked screen. There is nothing to clear.
|
|
248
|
+
return [];
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Press one button on one application's frontmost dialog.
|
|
254
|
+
*
|
|
255
|
+
* @param {string} app
|
|
256
|
+
* @param {string} button
|
|
257
|
+
* @returns {Promise<boolean>}
|
|
258
|
+
*/
|
|
259
|
+
export async function pressButton(app, button) {
|
|
260
|
+
if (process.platform !== 'darwin') return false;
|
|
261
|
+
if (!mayPress(button)) return false;
|
|
262
|
+
const script = `
|
|
263
|
+
tell application "System Events"
|
|
264
|
+
tell (first application process whose name is ${JSON.stringify(app)})
|
|
265
|
+
repeat with win in (every window of it)
|
|
266
|
+
try
|
|
267
|
+
set target to win
|
|
268
|
+
try
|
|
269
|
+
if (count of (every sheet of win)) > 0 then set target to item 1 of (every sheet of win)
|
|
270
|
+
end try
|
|
271
|
+
click (first button of target whose name is ${JSON.stringify(button)})
|
|
272
|
+
return "pressed"
|
|
273
|
+
end try
|
|
274
|
+
end repeat
|
|
275
|
+
end tell
|
|
276
|
+
end tell
|
|
277
|
+
return "no"
|
|
278
|
+
`;
|
|
279
|
+
try {
|
|
280
|
+
const { stdout } = await run('osascript', ['-e', script], { timeout: 6000 });
|
|
281
|
+
return stdout.trim() === 'pressed';
|
|
282
|
+
} catch {
|
|
283
|
+
return false;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* @typedef {object} DialogWatcher
|
|
289
|
+
* @property {(name: string) => void} claim Name an application this run started.
|
|
290
|
+
* @property {() => Promise<void>} stop Stop watching. Safe to call twice.
|
|
291
|
+
* @property {() => SeenDialog[]} report Every dialog seen, in the order they appeared.
|
|
292
|
+
* @property {() => Promise<void>} sweepNow Look once, right now. For tests and for the end of a run.
|
|
293
|
+
*/
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Watch for modal boxes belonging to what this run started, close the harmless ones, and
|
|
297
|
+
* remember all of them.
|
|
298
|
+
*
|
|
299
|
+
* `look` and `press` exist for the same reason the screen guard has them: the decisions here —
|
|
300
|
+
* whose dialog it is, which button qualifies, what gets recorded when nothing can be pressed —
|
|
301
|
+
* are the part that has to be right, and they are unreachable behind two `osascript` calls
|
|
302
|
+
* that answer differently on every machine and not at all on most.
|
|
303
|
+
*
|
|
304
|
+
* @param {{claims?: string[], everyMs?: number, elapsed?: () => number, look?: (apps: string[]) => Promise<any[]>, press?: (app: string, button: string) => Promise<boolean>}} [opts]
|
|
305
|
+
* @returns {DialogWatcher}
|
|
306
|
+
*/
|
|
307
|
+
export function watchForDialogs(opts = {}) {
|
|
308
|
+
const everyMs = opts.everyMs ?? LOOK_EVERY_MS;
|
|
309
|
+
const look = opts.look ?? dialogsUp;
|
|
310
|
+
const press = opts.press ?? pressButton;
|
|
311
|
+
const elapsed = opts.elapsed ?? (() => 0);
|
|
312
|
+
|
|
313
|
+
/** @type {Set<string>} */
|
|
314
|
+
const ours = new Set(opts.claims ?? []);
|
|
315
|
+
/** @type {SeenDialog[]} */
|
|
316
|
+
const seen = [];
|
|
317
|
+
/** Dialogs already recorded, so one box sitting there for a minute is one line, not fifty. */
|
|
318
|
+
const already = new Set();
|
|
319
|
+
let stopped = process.platform !== 'darwin' && !opts.look;
|
|
320
|
+
/** @type {ReturnType<typeof setTimeout>|null} */
|
|
321
|
+
let timer = null;
|
|
322
|
+
|
|
323
|
+
const sweep = async () => {
|
|
324
|
+
if (stopped || ours.size === 0) return;
|
|
325
|
+
let up = [];
|
|
326
|
+
try {
|
|
327
|
+
up = await look([...ours]);
|
|
328
|
+
} catch {
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
for (const d of up ?? []) {
|
|
332
|
+
const key = `${d.app}${d.title}${d.says}`;
|
|
333
|
+
if (already.has(key)) continue;
|
|
334
|
+
const button = safeButton(d.buttons ?? []);
|
|
335
|
+
let closed = null;
|
|
336
|
+
let why = null;
|
|
337
|
+
if (button) {
|
|
338
|
+
const done = await press(d.app, button).catch(() => false);
|
|
339
|
+
if (done) closed = button;
|
|
340
|
+
else why = `tried to press "${button}" and the click did not land`;
|
|
341
|
+
} else {
|
|
342
|
+
// Recorded, not guessed at. The offered buttons go into the reason so a person reading
|
|
343
|
+
// the run knows exactly what they are being asked and why nothing was pressed.
|
|
344
|
+
why =
|
|
345
|
+
(d.buttons ?? []).length > 0
|
|
346
|
+
? `nothing on it is safe for a machine to press: ${(d.buttons ?? []).join(', ')}`
|
|
347
|
+
: 'it offered no button this tool could find';
|
|
348
|
+
}
|
|
349
|
+
already.add(key);
|
|
350
|
+
seen.push({ app: d.app, title: d.title ?? '', says: d.says ?? '', closed, why, at: elapsed() });
|
|
351
|
+
detail(
|
|
352
|
+
`A dialog from ${d.app} said "${d.says || d.title}" — ` +
|
|
353
|
+
(closed ? `closed it with "${closed}".` : `left it alone: ${why}.`),
|
|
354
|
+
);
|
|
355
|
+
}
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
const tick = async () => {
|
|
359
|
+
await sweep();
|
|
360
|
+
if (!stopped) timer = setTimeout(tick, everyMs);
|
|
361
|
+
};
|
|
362
|
+
if (!stopped) timer = setTimeout(tick, everyMs);
|
|
363
|
+
|
|
364
|
+
return {
|
|
365
|
+
claim: (name) => {
|
|
366
|
+
if (name) ours.add(name);
|
|
367
|
+
},
|
|
368
|
+
stop: async () => {
|
|
369
|
+
stopped = true;
|
|
370
|
+
if (timer) clearTimeout(timer);
|
|
371
|
+
timer = null;
|
|
372
|
+
},
|
|
373
|
+
report: () => seen.slice(),
|
|
374
|
+
sweepNow: sweep,
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* One plain sentence about the dialogs a run met, or nothing when it met none.
|
|
380
|
+
*
|
|
381
|
+
* Written to be read after the verdict, by somebody who has just been told a journey had no
|
|
382
|
+
* answer and needs to know why.
|
|
383
|
+
*
|
|
384
|
+
* @param {SeenDialog[]} seen
|
|
385
|
+
* @returns {string|null}
|
|
386
|
+
*/
|
|
387
|
+
export function describeDialogs(seen) {
|
|
388
|
+
const all = seen ?? [];
|
|
389
|
+
if (all.length === 0) return null;
|
|
390
|
+
const closed = all.filter((d) => d.closed);
|
|
391
|
+
const left = all.filter((d) => !d.closed);
|
|
392
|
+
const parts = [];
|
|
393
|
+
if (closed.length > 0) {
|
|
394
|
+
const first = closed[0];
|
|
395
|
+
parts.push(
|
|
396
|
+
`${closed.length === 1 ? 'A box' : `${closed.length} boxes`} came up from the app this run started and ` +
|
|
397
|
+
`${closed.length === 1 ? 'was' : 'were'} closed with "${first.closed}"` +
|
|
398
|
+
`${first.says ? `: "${first.says}"` : ''}. Anything walked while it was up may have had no answer for that reason.`,
|
|
399
|
+
);
|
|
400
|
+
}
|
|
401
|
+
if (left.length > 0) {
|
|
402
|
+
const first = left[0];
|
|
403
|
+
parts.push(
|
|
404
|
+
`${left.length === 1 ? 'A box is' : `${left.length} boxes are`} still up and ${left.length === 1 ? 'was' : 'were'} ` +
|
|
405
|
+
`left alone, because ${first.why}${first.says ? `. It says: "${first.says}"` : ''}. ` +
|
|
406
|
+
'Nothing here presses a button that could change your machine.',
|
|
407
|
+
);
|
|
408
|
+
}
|
|
409
|
+
return parts.join(' ');
|
|
410
|
+
}
|
package/src/v2/watch/panel.js
CHANGED
|
@@ -568,7 +568,10 @@ button { font: inherit; color: inherit; }
|
|
|
568
568
|
}
|
|
569
569
|
|
|
570
570
|
/* --- the scrolling body -------------------------------------------------- */
|
|
571
|
-
|
|
571
|
+
/* A FLOOR, so the report can never be reduced to a slit again. Whatever else is on screen,
|
|
572
|
+
the body keeps enough height to show a heading and a few rows under it — which is the
|
|
573
|
+
difference between "there is more here, scroll" and "the detail is gone". */
|
|
574
|
+
.scroll { flex: 1 1 auto; min-height: 132px; overflow-y: auto; overflow-x: hidden; padding: 0 var(--pad) 18px; }
|
|
572
575
|
.scroll::-webkit-scrollbar { width: 9px; }
|
|
573
576
|
.scroll::-webkit-scrollbar-thumb { background: var(--resting); border-radius: 999px; }
|
|
574
577
|
.scroll::-webkit-scrollbar-track { background: transparent; }
|
|
@@ -778,8 +781,25 @@ button { font: inherit; color: inherit; }
|
|
|
778
781
|
.nothing { padding: 30px 16px; color: var(--faint); font-size: var(--t-body); line-height: 1.8; text-align: center; }
|
|
779
782
|
|
|
780
783
|
/* --- the only thing that ever reaches a person --------------------------- */
|
|
784
|
+
/* THE FOOTER MAY NEVER EAT THE WINDOW.
|
|
785
|
+
It used to be "flex: 0 0 auto" with nothing capping it, so its height was simply however
|
|
786
|
+
many things needed a person. On a real run that found seventeen, the footer grew past the
|
|
787
|
+
height of the whole panel and squeezed the scrolling body — the walk, the wobble, what
|
|
788
|
+
survived, what was not checked, every journey and its address count — down to EIGHTEEN
|
|
789
|
+
PIXELS holding 41,115 of content. Measured, not guessed: clientHeight 18, scrollHeight
|
|
790
|
+
41115. What a person saw at the end of a run was one clipped line of text where the whole
|
|
791
|
+
report had been a second earlier, which reads exactly like the tool throwing its detail
|
|
792
|
+
away at the moment it finishes.
|
|
793
|
+
So it shrinks now, the list inside it scrolls, and the body below keeps a floor. */
|
|
781
794
|
.foot {
|
|
795
|
+
/* Sizes to its content, and stops at half the window. Not "0 1 auto": that let the flex
|
|
796
|
+
row below it take every pixel and shrank the footer to its label, so seventeen things
|
|
797
|
+
needing a person rendered at zero height — the same detail lost, from the other end. */
|
|
782
798
|
flex: 0 0 auto;
|
|
799
|
+
max-height: 52%;
|
|
800
|
+
min-height: 0;
|
|
801
|
+
display: flex;
|
|
802
|
+
flex-direction: column;
|
|
783
803
|
padding: 13px var(--pad) 15px;
|
|
784
804
|
background: var(--glass);
|
|
785
805
|
backdrop-filter: blur(22px) saturate(120%);
|
|
@@ -787,6 +807,12 @@ button { font: inherit; color: inherit; }
|
|
|
787
807
|
border-top: 1px solid var(--line);
|
|
788
808
|
animation: arrive 340ms var(--ease) both;
|
|
789
809
|
}
|
|
810
|
+
/* The label above and the sentence below stay put; only the list moves. A person reading
|
|
811
|
+
"Needs a person" must never have to scroll to find out that is what they are reading. */
|
|
812
|
+
#needs { flex: 1 1 auto; min-height: 0; overflow-y: auto; overflow-x: hidden; }
|
|
813
|
+
#needs::-webkit-scrollbar { width: 9px; }
|
|
814
|
+
#needs::-webkit-scrollbar-thumb { background: var(--resting); border-radius: 999px; }
|
|
815
|
+
#needs::-webkit-scrollbar-track { background: transparent; }
|
|
790
816
|
.nextlabel {
|
|
791
817
|
font-size: var(--t-label); font-weight: 600; letter-spacing: 0.2em;
|
|
792
818
|
text-transform: uppercase; color: var(--wait); margin-bottom: 8px;
|