staysfixed 0.10.0 → 0.11.1
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 +151 -0
- package/README.md +17 -5
- package/docs/getting-started.md +10 -0
- package/docs/how-v2-works.md +5 -2
- package/package.json +2 -2
- package/src/guard/api.js +208 -25
- package/src/guard/run.js +154 -20
- package/src/report/console.js +235 -17
- package/src/report/html.js +75 -19
- package/src/types.js +5 -0
- package/src/v2/adapters/android-driver.js +62 -12
- package/src/v2/adapters/contract.js +18 -4
- package/src/v2/adapters/electron.js +96 -14
- package/src/v2/adapters/http.js +264 -23
- package/src/v2/adapters/ios-driver.js +22 -4
- package/src/v2/adapters/ios.js +5 -2
- package/src/v2/adapters/isolate.js +78 -5
- package/src/v2/adapters/process.js +350 -92
- package/src/v2/adapters/web-driver.js +23 -1
- package/src/v2/adapters/web.js +42 -3
- package/src/v2/adapters/windows.js +32 -15
- package/src/v2/browsers.js +32 -1
- package/src/v2/check.js +319 -9
- package/src/v2/cli.js +345 -3
- package/src/v2/cluster.js +112 -4
- package/src/v2/coverage.js +208 -8
- package/src/v2/detect.js +182 -9
- package/src/v2/doctor.js +214 -39
- package/src/v2/init.js +97 -11
- package/src/v2/mcp/server.js +4 -1
- package/src/v2/mcp/tools.js +291 -24
- package/src/v2/observation.js +57 -5
- package/src/v2/reference.js +133 -14
- package/src/v2/refusal.js +389 -0
- package/src/v2/remote.js +24 -3
- package/src/v2/run.js +306 -16
- package/src/v2/sealed.js +14 -2
- package/src/v2/ship.js +286 -22
- package/src/v2/store.js +101 -2
- package/src/v2/types.js +5 -0
- package/src/v2/waiver.js +9 -2
- package/src/watch/panel.js +12 -1
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,157 @@ numbers follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
8
8
|
|
|
9
9
|
Nothing yet.
|
|
10
10
|
|
|
11
|
+
## [0.11.1] — 2026-08-31
|
|
12
|
+
|
|
13
|
+
Two defects that a Mac could never have shown, both caught by CI on Linux minutes after
|
|
14
|
+
0.11.0 went out. Both were verified as broken and then as fixed on a real Linux machine
|
|
15
|
+
rather than by reading the code.
|
|
16
|
+
|
|
17
|
+
- **A command a guard had been abandoned mid-flight kept running.** Killing the shell is not
|
|
18
|
+
killing what the shell started: a command runs through a shell, so the signal reaches the
|
|
19
|
+
shell and the program it started carries on with a new parent. On macOS the shell usually
|
|
20
|
+
takes its child with it and this was invisible; on Linux it does not, and a command the run
|
|
21
|
+
had given up on finished its work 800 milliseconds later and wrote its file. It now runs in
|
|
22
|
+
its own process group and the group is what gets signalled. (`exec` does not document
|
|
23
|
+
`detached`; `spawn` does, so this moved to `spawn` rather than resting on behaviour that
|
|
24
|
+
happens to work.)
|
|
25
|
+
- **The ordinary way of closing a browser left its throwaway profile behind.** One `rm` is a
|
|
26
|
+
snapshot, and a browser is not one process: the parent exiting says nothing about its
|
|
27
|
+
renderers, which are still writing into the profile while they are reaped. The last-resort
|
|
28
|
+
path had learned this; the polite path — the one every ordinary run uses — swallowed the
|
|
29
|
+
failure with a bare catch. It passes on macOS and on an idle Linux box and fails on a loaded
|
|
30
|
+
one, which is exactly how a race behaves.
|
|
31
|
+
- **`doctor` asked the machine before it asked the project.** For iPhone and Windows apps the
|
|
32
|
+
platform test came first, so on Linux a project containing no iPhone app was told its
|
|
33
|
+
non-existent app could not be reached from this machine — a machine reason given for a
|
|
34
|
+
project fact, which is the exact conflation that sends somebody to install thirty gigabytes
|
|
35
|
+
of Xcode for nothing. The project is asked first now, on every surface and every platform.
|
|
36
|
+
|
|
37
|
+
## [0.11.0] — 2026-08-31
|
|
38
|
+
|
|
39
|
+
Fifty-odd defects, found by running the thing rather than reading it. Two rounds of seven
|
|
40
|
+
lanes, each lane required to reproduce its defect on a throwaway product before touching
|
|
41
|
+
anything, and each fix held by a test that fails without it.
|
|
42
|
+
|
|
43
|
+
**All eight surfaces have now actually been driven against a real product** — command-line
|
|
44
|
+
tools, libraries, servers, websites, Electron, Android, iOS and, last of all, native Windows.
|
|
45
|
+
That sentence was not true before this release. Each of the four that had only ever had an
|
|
46
|
+
adapter and tests turned out to hold at least one defect that only running it could find.
|
|
47
|
+
|
|
48
|
+
### The three that could have blessed a broken build
|
|
49
|
+
|
|
50
|
+
- **A refusal was stored as an ordinary value.** When a journey cannot run — the product
|
|
51
|
+
throws on its first line, a command dies before printing anything — what got written down
|
|
52
|
+
was a fact about the crash. Two builds that crash the same way agree at every address, and
|
|
53
|
+
agreement is what this tool reads as "nothing changed". So a product that did nothing at
|
|
54
|
+
all came back *"Nothing that worked has changed. 7 addresses checked"*, `ship` blessed it,
|
|
55
|
+
and the day somebody fixed the product, four findings arrived that nobody had caused. A
|
|
56
|
+
refusal is now a different kind of thing from a value: never compared to one, never
|
|
57
|
+
compared to another refusal as though both were answers, and refused at `ship` in
|
|
58
|
+
`shouldCut`, where every caller passes through.
|
|
59
|
+
- **`settle()` assigned the verdict instead of narrowing it**, throwing away every not-a-pass
|
|
60
|
+
the engine had already decided — a run where the product never answered, and a run drowning
|
|
61
|
+
in wobble, both came back through that line as a pass. Accounting may take a pass away; it
|
|
62
|
+
may never hand one back.
|
|
63
|
+
- **A reference cut from a tree with uncommitted changes was booted from its commit.** The
|
|
64
|
+
record is filed under a fingerprint of the tree that was walked, because the files checked
|
|
65
|
+
are not the files git has. Paired mode walked the commit and called it the old build, so an
|
|
66
|
+
address the record holds a real value for was reported as *"is there now and was not
|
|
67
|
+
before"*.
|
|
68
|
+
|
|
69
|
+
### It reported things as checked that were never checked
|
|
70
|
+
|
|
71
|
+
- **A root-level server went unread the moment the project also had a `src/` folder.** A
|
|
72
|
+
deleted route and a 200 turning into a 500 both came back "Nothing that worked has changed".
|
|
73
|
+
- **`ship` blessed a run in which every journey refused**, and **the command-line surface was
|
|
74
|
+
hard-coded ready** and never looked at the project.
|
|
75
|
+
- **`init` said a library "can be checked here now" and is "covered in full"** about an
|
|
76
|
+
`index.js` that does not exist.
|
|
77
|
+
- **`init` asked the machine what it could do before working out the settings**, so on a fresh
|
|
78
|
+
project every question that is answered by reading the settings was answered against none.
|
|
79
|
+
A plain Node command-line tool was told it needed "a command to run" by the same run that
|
|
80
|
+
had just written `node cli.js --help` into its settings.
|
|
81
|
+
|
|
82
|
+
### It stated things it had never looked for
|
|
83
|
+
|
|
84
|
+
- **"No Windows desktop is reachable from here"** on a project whose settings named the
|
|
85
|
+
machine — with that machine reachable, signed in and unlocked the whole time. Nothing had
|
|
86
|
+
been dialled. Naming a machine in your own settings is the ask; the rest of your ssh config
|
|
87
|
+
is still left alone, and when nothing was dialled the sentence now says so.
|
|
88
|
+
- **The live panel called every failed guard "broken again"**, with the story of the original
|
|
89
|
+
bug printed underneath as though it were proof. A guard that ran out of time did not answer
|
|
90
|
+
the question, and one that asserted nothing never asked it.
|
|
91
|
+
- The HTML report said *"All 3 bugs that were fixed are still fixed"* over three guards that
|
|
92
|
+
were skipped, and *"Everything that worked still works"* over a run that checked nothing at
|
|
93
|
+
all.
|
|
94
|
+
|
|
95
|
+
### Nothing hangs, and nothing waits in silence
|
|
96
|
+
|
|
97
|
+
- **Every wait in the phone, desktop and Windows adapters now has a limit, and every limit
|
|
98
|
+
says what it was waiting for.** The cause was never a missing timeout: waits were settled by
|
|
99
|
+
a child's `close` event, which does not mean the program ended — it means nobody anywhere is
|
|
100
|
+
still holding its pipes, and one orphaned grandchild refuses that for ever. A verdict
|
|
101
|
+
printed, and then nothing, for ever. That is what the unexplained Electron deadlock was.
|
|
102
|
+
- **A mistyped limit removed the limit.** `Number("10s")` is `NaN`, and every comparison
|
|
103
|
+
against `NaN` is false, so a step with a typo in its settings looped without end.
|
|
104
|
+
- **A stock Vite app cost three minutes and told you nothing.** Vite ignores the port and host
|
|
105
|
+
it is handed and binds the name `localhost`, which on a Mac is the IPv6 loopback — so the
|
|
106
|
+
site came up on `[::1]` and the check knocked on `127.0.0.1` about 450 times a side. Boot
|
|
107
|
+
waits now knock on both, and a command that ignores its port, or exits, is named in seconds:
|
|
108
|
+
three minutes became ten seconds.
|
|
109
|
+
- **A person at a terminal is told what the run is waiting for.** The running commentary
|
|
110
|
+
reached the live panel and nothing else, so a long wait was a blank screen — which is
|
|
111
|
+
indistinguishable from the tool being broken.
|
|
112
|
+
- **Reclaiming a scratch folder now stops what is still running inside it.** Four servers from
|
|
113
|
+
the previous day were still up on this machine, out of folders that had already been deleted.
|
|
114
|
+
|
|
115
|
+
### The measurement no longer measures itself
|
|
116
|
+
|
|
117
|
+
- **With nothing edited, a check compared the build against the same build** — and called
|
|
118
|
+
whatever flickered between two runs minutes apart a change nobody made. On a stock Next.js
|
|
119
|
+
app with a link between two pages, four runs in five reported a difference, and one claimed
|
|
120
|
+
the change "made something non-deterministic". Two runs of one build are a wobble
|
|
121
|
+
measurement, which is this tool's own word for it, and it now says so.
|
|
122
|
+
- **The record of what "working" means moved on its own.** The store keeps every capture a
|
|
123
|
+
build ever produced and the reader took the newest, so anybody checking out the old commit
|
|
124
|
+
and running a check quietly replaced the standard. Only `ship` decides what working means.
|
|
125
|
+
- **A request cancelled by our own teardown was reported as the product complaining.** Next.js
|
|
126
|
+
starts a prefetch behind every internal link; closing the page aborts it, and the browser
|
|
127
|
+
reports that exactly like a real failure. It is recorded as a hole now — louder than a
|
|
128
|
+
complaint, never quieter.
|
|
129
|
+
|
|
130
|
+
### The report no longer disagrees with itself
|
|
131
|
+
|
|
132
|
+
- **A renamed field was described only as the field that appeared**, never the one that
|
|
133
|
+
vanished — the half that breaks every caller, on the channel that exists to catch it.
|
|
134
|
+
- **Two byte-identical runs produced different `coverage.gaps`**, and `coverage.doorsWalked`
|
|
135
|
+
counted doors as walked in the same report whose only line about them said "was not tried".
|
|
136
|
+
- **`ship` said "nothing is being compared against anything yet"** with a reference in force.
|
|
137
|
+
- **`ship` read the product name only out of a JSON settings file** — and every settings file
|
|
138
|
+
`init` writes is JavaScript. So it blessed under the package name while `check` recorded
|
|
139
|
+
under the settings name, and the two never met: a project could ship and check for ever
|
|
140
|
+
without once comparing anything.
|
|
141
|
+
|
|
142
|
+
### The person and the agent get the same truth
|
|
143
|
+
|
|
144
|
+
- **Five commands a person did not have:** `coverage`, `explain`, `prove`, `waive` and
|
|
145
|
+
`intent`. All are commands now, `check` prints the finding ids so there is something to hand
|
|
146
|
+
them, and a test asserts the two readers get byte-identical facts.
|
|
147
|
+
- **An intent sealed at the command line was recorded as "an agent, over MCP".**
|
|
148
|
+
- **Six places sent a person to something only an agent can call** — a tool name, a JSON
|
|
149
|
+
field, an `include:` block — and `check --help` still said intent and waive live only on the
|
|
150
|
+
MCP server.
|
|
151
|
+
- **`staysfixed_waive` refused the only file in the product**, because an agent holds absolute
|
|
152
|
+
paths and the seal expected relative ones; **`staysfixed_coverage` forced the machine survey
|
|
153
|
+
offline** and reported the forced answer as a fact about the world.
|
|
154
|
+
|
|
155
|
+
### What a person is handed when something goes wrong
|
|
156
|
+
|
|
157
|
+
- An unwritable `TMPDIR` handed over a raw `ENOENT ... mkdtemp`; a check run inside a nested
|
|
158
|
+
project silently checked the parent's product; `flake` said "No check here has ever changed
|
|
159
|
+
its mind" about a register whose own JSON showed a guard flipping, and gave an untrue
|
|
160
|
+
reason; a monorepo container folder was announced as a product.
|
|
161
|
+
|
|
11
162
|
## [0.10.0] — 2026-08-31
|
|
12
163
|
|
|
13
164
|
The night the four surfaces nobody had pointed it at were pointed at it, and the biggest
|
package/README.md
CHANGED
|
@@ -166,7 +166,13 @@ pretend otherwise.
|
|
|
166
166
|
| Steps taken from a recorded session, or rejected at birth for not repeating twice | **Written, not wired.** The code is in `src/v2/journeys/` with tests around it, and nothing on the check path calls it yet. Ask for `--journeys recorded` and you are told so by name. |
|
|
167
167
|
| Android APKs on an emulator | **The adapter is here.** It reads everything the APK declares with nothing installed and no Java, and where there is an emulator it installs one build at a time and walks it. Whether *this* machine can run one is a separate question, and `doctor` asks the adapter itself rather than keeping a second opinion — most of what it wants installs with a command; accepting Google's licence, once, needs a person. Two emulator snapshots restoring byte-identically is unproven, so Android compares against the stored record and says which mode it used. |
|
|
168
168
|
| The iOS simulator | **The adapter is here.** It reads what the app bundle declares with nothing running, and where Xcode and a simulator runtime are present it installs one build at a time, boots it and reads what is on the screen. It is new. Paired running costs two `xcodebuild` passes, so it is for before a release rather than for every edit, and like Android it compares against the stored record and says which mode it used. Ask `doctor` what it is actually covering on your machine before trusting a clean run. |
|
|
169
|
-
| Native Windows GUI (a real Win32 app, not an Electron one) | **
|
|
169
|
+
| Native Windows GUI (a real Win32 app, not an Electron one) | **Works,** and it has now been driven end to end: a real Win32 window on a Windows 11 desktop reached over ssh, 10 addresses read out of the UI Automation tree, a reference cut, and the next run compared against it. Nothing was installed on that machine — the program that reads the screen is sent down the connection each run. Windows shows one desktop, so two builds can never run at once: the comparison is genuinely weaker here than anywhere else, and a run says so rather than hiding it. |
|
|
170
|
+
|
|
171
|
+
All eight surfaces — command-line tools, libraries, servers, websites, Electron,
|
|
172
|
+
Android, iOS and native Windows — have now actually been run against a real
|
|
173
|
+
product, rather than only having an adapter and tests. That sentence was not true
|
|
174
|
+
before 2026-08-31, and the four that were unproven each turned out to have at
|
|
175
|
+
least one defect that only running them could find.
|
|
170
176
|
|
|
171
177
|
`staysfixed check` is the front door for both. Version 1's flags still mean
|
|
172
178
|
exactly what they meant yesterday — `--pictures`, `--guards` and `--only` reach
|
|
@@ -357,7 +363,10 @@ everything, because it did not: it checked every way in *this tool knows about*.
|
|
|
357
363
|
staysfixed check --json # coverage.doorsKnown, coverage.doorsWalked, coverage.gaps
|
|
358
364
|
```
|
|
359
365
|
|
|
360
|
-
|
|
366
|
+
At a terminal it is `staysfixed coverage`; over MCP it is `staysfixed_coverage`.
|
|
367
|
+
Both roads reach the same code — that stopped being true for a while, and a person
|
|
368
|
+
got a strictly worse answer about their own product than an agent did, which is
|
|
369
|
+
now a test. Every `staysfixed_check` reply says it in
|
|
361
370
|
words directly under the headline, and the JSON form carries `notChecked` and
|
|
362
371
|
`doorsNeverOpened` as fields of their own rather than only as prose — a number an
|
|
363
372
|
agent has to go looking for is a number it skips.
|
|
@@ -634,9 +643,12 @@ Full wiring for every client: [docs/mcp.md](docs/mcp.md).
|
|
|
634
643
|
Every version ships knowing, in machine-readable form and in plain English: what
|
|
635
644
|
it can check on this machine right now and what it cannot; what is missing that
|
|
636
645
|
would unlock more, and whether the tool can install it itself or a person has to;
|
|
637
|
-
which other machines
|
|
638
|
-
|
|
639
|
-
|
|
646
|
+
which other machines your ssh config names, and — for the ones your own settings
|
|
647
|
+
name, plus any you ask about with `--machines` — whether they answer, what they
|
|
648
|
+
run and what they are short of; and the shape of its own results, so an agent can
|
|
649
|
+
act on them without being taught. It does **not** dial your machines unasked: the
|
|
650
|
+
first command a stranger runs must not open connections to their production
|
|
651
|
+
servers, so naming a machine in your settings is what asks about it. That is `staysfixed doctor --json`, and it is
|
|
640
652
|
`staysfixed_capabilities` over MCP.
|
|
641
653
|
|
|
642
654
|
---
|
package/docs/getting-started.md
CHANGED
|
@@ -297,6 +297,16 @@ Say these once, when someone asks how much it covers. They are permanent, they a
|
|
|
297
297
|
| `staysfixed check --against <ref>` | Compare against a tag, commit or marker. |
|
|
298
298
|
| `staysfixed check --selfcheck` | Prove the engine still catches deliberate breakage. |
|
|
299
299
|
| `staysfixed ship` | The build that went out is now what "working" means. |
|
|
300
|
+
| `staysfixed coverage` | What the last check did NOT look at. Read it before calling anything safe. |
|
|
301
|
+
| `staysfixed intent "<what you meant to change>"` | Seal what you meant to change, before you check. |
|
|
302
|
+
| `staysfixed explain <id>` | One finding from the last check, in full. |
|
|
303
|
+
| `staysfixed prove <id>` | Undo your own edit and re-measure, to see whether it really caused the finding. |
|
|
304
|
+
| `staysfixed waive <id> --why "<reason>"` | Record that a difference was intended. It is not approval. |
|
|
305
|
+
|
|
306
|
+
Those last five existed only over MCP until 2026-08-31, which meant a person at a terminal
|
|
307
|
+
got a strictly worse answer about their own product than an agent did. Every one of them is
|
|
308
|
+
now a command as well, and both roads reach the same code. `check` prints the finding ids, so
|
|
309
|
+
there is something to hand them.
|
|
300
310
|
|
|
301
311
|
If you have to write a settings block by hand — something `init` could not know, a second
|
|
302
312
|
product, a journey through a screen — every option is in
|
package/docs/how-v2-works.md
CHANGED
|
@@ -366,8 +366,11 @@ It carries:
|
|
|
366
366
|
the seven channels are reachable for each
|
|
367
367
|
- what is missing, why it matters, and the exact command that would fix it —
|
|
368
368
|
marked with whether the tool can do it itself or a person has to
|
|
369
|
-
- which other machines
|
|
370
|
-
|
|
369
|
+
- which other machines your ssh config names, and whether the ones it is allowed
|
|
370
|
+
to dial answer — the ones your own settings name, plus any you ask about with
|
|
371
|
+
`--machines` — so a working SSH host is never presented as something to go and
|
|
372
|
+
set up. Nothing is dialled unasked: the first command a stranger runs must not
|
|
373
|
+
open connections to their production servers
|
|
371
374
|
- the shape of its own results, so an agent can act on them without being taught
|
|
372
375
|
- what it will never be able to see, on any machine
|
|
373
376
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "staysfixed",
|
|
3
|
-
"version": "0.
|
|
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
|
|
3
|
+
"version": "0.11.1",
|
|
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",
|
|
7
7
|
"author": "Asad Iqbal",
|
package/src/guard/api.js
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* off IS the guard's own words, in the guard's own order.
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
-
import {
|
|
18
|
+
import { spawn } from 'node:child_process';
|
|
19
19
|
import fsp from 'node:fs/promises';
|
|
20
20
|
import path from 'node:path';
|
|
21
21
|
import { StaysFixedError } from '../core/errors.js';
|
|
@@ -31,6 +31,26 @@ export class ExpectationFailed extends Error {
|
|
|
31
31
|
}
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
/**
|
|
35
|
+
* The run has given up on this guard, and the guard is still going.
|
|
36
|
+
*
|
|
37
|
+
* Every door in the object a guard is handed throws this once the attempt that opened it has
|
|
38
|
+
* been abandoned. Measured on 2026-08-31: a guard with `timeoutMs: 200`, ticking every 25
|
|
39
|
+
* milliseconds, had written 7 lines to a file by the time the run reported it and 26 half a
|
|
40
|
+
* second later — the timeout is a `Promise.race`, and losing a race does not stop the loser.
|
|
41
|
+
* It went on clicking, reading and holding a page that the guard after it was already using.
|
|
42
|
+
*
|
|
43
|
+
* Nobody prints this. By the time it is thrown the verdict for this guard is already written,
|
|
44
|
+
* and the point of it is only to unwind a body the run has stopped listening to.
|
|
45
|
+
*/
|
|
46
|
+
export class GuardAbandoned extends Error {
|
|
47
|
+
/** @param {string} message */
|
|
48
|
+
constructor(message) {
|
|
49
|
+
super(message);
|
|
50
|
+
this.name = 'GuardAbandoned';
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
34
54
|
const DEFAULT_RUN_TIMEOUT = 60_000;
|
|
35
55
|
|
|
36
56
|
/** Commands can print a lot; 10MB before we cut them off. */
|
|
@@ -57,6 +77,11 @@ const ACTION = 'did';
|
|
|
57
77
|
* @property {(step: import('../types.js').CheckStep) => void} [onStep]
|
|
58
78
|
* Called as each claim and each action starts, and again as it settles.
|
|
59
79
|
* The two calls carry the same `key`.
|
|
80
|
+
* @property {AbortSignal} [signal]
|
|
81
|
+
* Raised when the run has given up on this guard — its clock ran out, or its
|
|
82
|
+
* attempt has simply already returned. Every door here refuses from then on, so
|
|
83
|
+
* the body stops at its very next step instead of driving the app behind a run
|
|
84
|
+
* that has moved on.
|
|
60
85
|
*/
|
|
61
86
|
|
|
62
87
|
/**
|
|
@@ -70,8 +95,67 @@ const ACTION = 'did';
|
|
|
70
95
|
export function makeGuardApi(page, project, opts = {}) {
|
|
71
96
|
const root = project.paths.root;
|
|
72
97
|
const onStep = typeof opts.onStep === 'function' ? opts.onStep : null;
|
|
98
|
+
const givenUp = opts.signal;
|
|
73
99
|
let counted = 0;
|
|
74
100
|
|
|
101
|
+
/**
|
|
102
|
+
* Refuse to do anything more once the run has given up on this guard.
|
|
103
|
+
*
|
|
104
|
+
* This is what makes a timeout mean something. Before it, a guard that ran out of time
|
|
105
|
+
* kept its whole body running: measured on 2026-08-31, 7 file writes by the time the run
|
|
106
|
+
* reported it and 26 half a second later, every one of them free to click the page the
|
|
107
|
+
* next guard had just started on. Refusing at the door is the only stopping there is —
|
|
108
|
+
* JavaScript cannot take a promise back — but it is enough, because a guard reaches the
|
|
109
|
+
* app through this object, and it reaches it constantly.
|
|
110
|
+
*
|
|
111
|
+
* @returns {void}
|
|
112
|
+
*/
|
|
113
|
+
function stopHere() {
|
|
114
|
+
if (!givenUp?.aborted) return;
|
|
115
|
+
throw refusal();
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** @returns {GuardAbandoned} */
|
|
119
|
+
function refusal() {
|
|
120
|
+
return new GuardAbandoned(
|
|
121
|
+
'The run had already given up on this guard and moved on, so this step was refused rather than run.',
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* The page with one extra rule: once the run has given up on this guard, it refuses.
|
|
127
|
+
*
|
|
128
|
+
* `app.page` is documented as "the whole page", so a guard that drives the app itself
|
|
129
|
+
* never passes through the five methods below — and those were exactly the guards left
|
|
130
|
+
* holding a page after their own timeout. The wrapper belongs to this attempt and dies
|
|
131
|
+
* with it; the real handle is untouched, and every other part of the tool keeps using it.
|
|
132
|
+
*
|
|
133
|
+
* @param {import('../types.js').PageApi} handle
|
|
134
|
+
* @returns {import('../types.js').PageApi}
|
|
135
|
+
*/
|
|
136
|
+
function refusable(handle) {
|
|
137
|
+
if (!givenUp) return handle;
|
|
138
|
+
/** @type {Record<string, unknown>} */
|
|
139
|
+
const doors = {};
|
|
140
|
+
for (const key of Object.keys(handle)) {
|
|
141
|
+
const value = /** @type {Record<string, unknown>} */ (/** @type {unknown} */ (handle))[key];
|
|
142
|
+
if (typeof value !== 'function') {
|
|
143
|
+
doors[key] = value;
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
// Refused in the same shape the door answers in. `goto` is awaited and `consoleErrors`
|
|
147
|
+
// is read, and a refusal that arrives as a thrown error where a value was expected —
|
|
148
|
+
// or as a rejected promise where none was — is a second surprise on top of the first.
|
|
149
|
+
const answersWithAPromise = value.constructor?.name === 'AsyncFunction';
|
|
150
|
+
doors[key] = (/** @type {unknown[]} */ ...args) => {
|
|
151
|
+
if (!givenUp.aborted) return /** @type {(...a: unknown[]) => unknown} */ (value).apply(handle, args);
|
|
152
|
+
if (answersWithAPromise) return Promise.reject(refusal());
|
|
153
|
+
throw refusal();
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
return /** @type {import('../types.js').PageApi} */ (/** @type {unknown} */ (doors));
|
|
157
|
+
}
|
|
158
|
+
|
|
75
159
|
/**
|
|
76
160
|
* Hand one step out, and never let that matter.
|
|
77
161
|
*
|
|
@@ -111,7 +195,7 @@ export function makeGuardApi(page, project, opts = {}) {
|
|
|
111
195
|
}
|
|
112
196
|
|
|
113
197
|
return {
|
|
114
|
-
page,
|
|
198
|
+
page: refusable(page),
|
|
115
199
|
project,
|
|
116
200
|
|
|
117
201
|
/**
|
|
@@ -119,6 +203,7 @@ export function makeGuardApi(page, project, opts = {}) {
|
|
|
119
203
|
* @returns {Promise<void>}
|
|
120
204
|
*/
|
|
121
205
|
async open(to) {
|
|
206
|
+
stopHere();
|
|
122
207
|
const settle = announce(ACTION, `opened ${short(to)}`);
|
|
123
208
|
try {
|
|
124
209
|
await page.goto(to);
|
|
@@ -134,6 +219,7 @@ export function makeGuardApi(page, project, opts = {}) {
|
|
|
134
219
|
* @returns {Promise<void>}
|
|
135
220
|
*/
|
|
136
221
|
async click(selector) {
|
|
222
|
+
stopHere();
|
|
137
223
|
const settle = announce(ACTION, `clicked ${short(selector)}`);
|
|
138
224
|
try {
|
|
139
225
|
await page.click(selector);
|
|
@@ -150,6 +236,7 @@ export function makeGuardApi(page, project, opts = {}) {
|
|
|
150
236
|
* @returns {Promise<void>}
|
|
151
237
|
*/
|
|
152
238
|
async expect(claim, check) {
|
|
239
|
+
stopHere();
|
|
153
240
|
if (typeof claim !== 'string' || claim.trim() === '') {
|
|
154
241
|
throw new StaysFixedError('An expectation needs a sentence in front of it.', {
|
|
155
242
|
hint: 'Write it the way you would say it: expect("the sidebar is hidden", () => ...). That sentence is what a person reads when the guard fails.',
|
|
@@ -202,34 +289,129 @@ export function makeGuardApi(page, project, opts = {}) {
|
|
|
202
289
|
* @returns {Promise<{code: number, stdout: string, stderr: string}>}
|
|
203
290
|
*/
|
|
204
291
|
async run(cmd, runOpts = {}) {
|
|
292
|
+
// Before the command is started, not after. A guard the run has given up on must not
|
|
293
|
+
// be able to leave a process of its own behind it.
|
|
294
|
+
stopHere();
|
|
205
295
|
const cwd = runOpts.cwd ? path.resolve(root, runOpts.cwd) : root;
|
|
206
296
|
const timeoutMs = runOpts.timeoutMs ?? DEFAULT_RUN_TIMEOUT;
|
|
207
297
|
const settle = announce(ACTION, `ran ${short(cmd)}`);
|
|
208
298
|
|
|
209
299
|
/** @type {Promise<{code: number, stdout: string, stderr: string}>} */
|
|
210
|
-
const finished = new Promise((resolve) => {
|
|
211
|
-
exec
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
300
|
+
const finished = new Promise((resolve, reject) => {
|
|
301
|
+
// `spawn` with `shell: true` rather than `exec`, for one reason: `detached`.
|
|
302
|
+
//
|
|
303
|
+
// KILLING THE SHELL IS NOT KILLING WHAT THE SHELL STARTED. A command runs through a
|
|
304
|
+
// shell, so a signal reaches the shell and the program it started carries on with a
|
|
305
|
+
// new parent. On a Mac the shell usually takes its child with it and this was
|
|
306
|
+
// invisible; on Linux it does not, and a command the run had given up on finished its
|
|
307
|
+
// work 800ms later, wrote its file, and proved it — caught by CI on 2026-08-31
|
|
308
|
+
// against a green Mac suite. `detached` puts the shell and everything it starts in
|
|
309
|
+
// one process group, and the group is what gets signalled.
|
|
310
|
+
//
|
|
311
|
+
// `exec` cannot do this: `detached` is not one of its documented options. It happens
|
|
312
|
+
// to be passed through today, and a tool built on not lying should not rest on that.
|
|
313
|
+
const child = spawn(cmd, {
|
|
314
|
+
cwd,
|
|
315
|
+
shell: true,
|
|
316
|
+
detached: process.platform !== 'win32',
|
|
317
|
+
windowsHide: true,
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
let out = '';
|
|
321
|
+
let err = '';
|
|
322
|
+
let tooMuch = false;
|
|
323
|
+
/** @type {'ran'|'gave up'|'ran out of time'} */
|
|
324
|
+
let how = 'ran';
|
|
325
|
+
let done = false;
|
|
326
|
+
|
|
327
|
+
/** Stop the shell AND everything it started. */
|
|
328
|
+
const stopEverything = () => {
|
|
329
|
+
if (!child.pid) return;
|
|
330
|
+
try {
|
|
331
|
+
if (process.platform === 'win32') child.kill('SIGKILL');
|
|
332
|
+
else process.kill(-child.pid, 'SIGKILL');
|
|
333
|
+
} catch {
|
|
334
|
+
// Already gone, which is the good case.
|
|
335
|
+
}
|
|
336
|
+
};
|
|
337
|
+
|
|
338
|
+
child.stdout?.setEncoding('utf8');
|
|
339
|
+
child.stderr?.setEncoding('utf8');
|
|
340
|
+
child.stdout?.on('data', (/** @type {string} */ chunk) => {
|
|
341
|
+
if (out.length + chunk.length > MAX_OUTPUT) {
|
|
342
|
+
tooMuch = true;
|
|
343
|
+
out = (out + chunk).slice(0, MAX_OUTPUT);
|
|
344
|
+
stopEverything();
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
out += chunk;
|
|
348
|
+
});
|
|
349
|
+
child.stderr?.on('data', (/** @type {string} */ chunk) => {
|
|
350
|
+
if (err.length + chunk.length > MAX_OUTPUT) {
|
|
351
|
+
tooMuch = true;
|
|
352
|
+
err = (err + chunk).slice(0, MAX_OUTPUT);
|
|
353
|
+
stopEverything();
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
err += chunk;
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
const ranOut = setTimeout(() => {
|
|
360
|
+
how = 'ran out of time';
|
|
361
|
+
stopEverything();
|
|
362
|
+
}, timeoutMs);
|
|
363
|
+
if (typeof ranOut.unref === 'function') ranOut.unref();
|
|
364
|
+
|
|
365
|
+
const gaveUp = () => {
|
|
366
|
+
how = 'gave up';
|
|
367
|
+
stopEverything();
|
|
368
|
+
};
|
|
369
|
+
givenUp?.addEventListener('abort', gaveUp, { once: true });
|
|
370
|
+
if (givenUp?.aborted) gaveUp();
|
|
371
|
+
|
|
372
|
+
/** @param {number} code */
|
|
373
|
+
const finish = (code) => {
|
|
374
|
+
if (done) return;
|
|
375
|
+
done = true;
|
|
376
|
+
clearTimeout(ranOut);
|
|
377
|
+
givenUp?.removeEventListener('abort', gaveUp);
|
|
378
|
+
|
|
379
|
+
// Stopped because the run gave up on this guard, not because the command ran long.
|
|
380
|
+
// Saying "stopped after 60 seconds" here would be a made-up reason.
|
|
381
|
+
if (how === 'gave up') {
|
|
382
|
+
reject(
|
|
383
|
+
new GuardAbandoned(
|
|
384
|
+
'The run gave up on this guard while this command was still going, so the command was stopped.',
|
|
385
|
+
),
|
|
386
|
+
);
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
if (how === 'ran out of time') {
|
|
390
|
+
// 124 is what `timeout(1)` uses, so a guard can spot it.
|
|
391
|
+
resolve({ code: 124, stdout: out, stderr: `${err}\n(the command was stopped after ${humanTime(timeoutMs)})` });
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
if (tooMuch) {
|
|
395
|
+
resolve({ code: 124, stdout: out, stderr: `${err}\n(the command was stopped after printing more than this tool will keep)` });
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
resolve({ code, stdout: out, stderr: err });
|
|
399
|
+
};
|
|
400
|
+
|
|
401
|
+
child.on('error', (/** @type {any} */ e) => {
|
|
402
|
+
if (done) return;
|
|
403
|
+
done = true;
|
|
404
|
+
clearTimeout(ranOut);
|
|
405
|
+
givenUp?.removeEventListener('abort', gaveUp);
|
|
406
|
+
if (how === 'gave up') {
|
|
407
|
+
reject(new GuardAbandoned('The run gave up on this guard while this command was still going, so the command was stopped.'));
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
resolve({ code: 1, stdout: out, stderr: `${err}\n${String(e?.message ?? e)}` });
|
|
411
|
+
});
|
|
412
|
+
// `close`, not `exit`: exit fires when the shell ends, and everything it printed has
|
|
413
|
+
// to have been read before the answer is handed back.
|
|
414
|
+
child.on('close', (/** @type {number|null} */ code) => finish(typeof code === 'number' ? code : 1));
|
|
233
415
|
});
|
|
234
416
|
|
|
235
417
|
const outcome = await finished;
|
|
@@ -247,6 +429,7 @@ export function makeGuardApi(page, project, opts = {}) {
|
|
|
247
429
|
* @returns {Promise<string>}
|
|
248
430
|
*/
|
|
249
431
|
async read(file) {
|
|
432
|
+
stopHere();
|
|
250
433
|
const full = path.resolve(root, file);
|
|
251
434
|
const relative = path.relative(root, full);
|
|
252
435
|
// A guard belongs to one project; reading outside it makes the guard depend
|