staysfixed 0.7.2 → 0.9.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 +429 -0
- package/README.md +193 -57
- package/docs/design-v2.md +24 -4
- package/docs/getting-started.md +19 -6
- package/docs/guards.md +2 -2
- package/docs/how-v2-works.md +12 -11
- package/docs/mcp.md +17 -8
- package/docs/settings.md +564 -0
- package/docs/watching.md +10 -4
- package/examples/staysfixed.config.electron.js +17 -6
- package/examples/staysfixed.config.web.js +22 -5
- package/package.json +2 -1
- package/src/cli/index.js +55 -46
- package/src/cli/status.js +45 -1
- package/src/cli/watch-flags.js +54 -0
- package/src/core/config.js +54 -3
- package/src/core/paths.js +15 -0
- package/src/guard/run.js +70 -3
- package/src/report/console.js +50 -6
- package/src/run.js +11 -0
- package/src/types.js +3 -0
- package/src/v2/adapters/android-driver.js +6 -1
- package/src/v2/adapters/android.js +97 -2
- package/src/v2/adapters/child.js +101 -0
- package/src/v2/adapters/contract.js +42 -5
- package/src/v2/adapters/electron.js +72 -6
- package/src/v2/adapters/http.js +18 -11
- package/src/v2/adapters/ios-driver.js +64 -14
- package/src/v2/adapters/ios.js +247 -25
- package/src/v2/adapters/process.js +783 -71
- package/src/v2/adapters/python.js +495 -0
- package/src/v2/adapters/source.js +373 -18
- package/src/v2/adapters/web-driver.js +134 -24
- package/src/v2/adapters/web.js +149 -18
- package/src/v2/adapters/windows.js +18 -1
- package/src/v2/browsers.js +66 -3
- package/src/v2/cause.js +61 -17
- package/src/v2/check.js +653 -69
- package/src/v2/ci.js +130 -35
- package/src/v2/cli.js +65 -42
- package/src/v2/cluster.js +220 -14
- package/src/v2/coverage.js +43 -176
- package/src/v2/detect.js +308 -60
- package/src/v2/doctor.js +353 -54
- package/src/v2/escalate.js +5 -1
- package/src/v2/init.js +183 -66
- package/src/v2/intent.js +9 -23
- package/src/v2/journeys/from-suite.js +336 -30
- package/src/v2/journeys/index.js +99 -6
- package/src/v2/mcp/tools.js +90 -16
- package/src/v2/normalise.js +169 -23
- package/src/v2/observation.js +19 -33
- package/src/v2/rank.js +216 -23
- package/src/v2/reference.js +160 -24
- package/src/v2/remote.js +113 -18
- package/src/v2/run.js +103 -14
- package/src/v2/sealed.js +0 -20
- package/src/v2/selfcheck.js +190 -13
- package/src/v2/ship.js +55 -5
- package/src/v2/store.js +67 -1
- package/src/v2/types.js +12 -2
- package/src/v2/waiver.js +64 -54
- package/src/v2/watch/events.js +60 -215
- package/src/v2/watch/focus.js +14 -4
- package/src/v2/watch/panel.js +167 -17
|
@@ -67,43 +67,73 @@ import { globToRegExp } from '../../freeze/network.js';
|
|
|
67
67
|
*/
|
|
68
68
|
|
|
69
69
|
/**
|
|
70
|
-
* Find
|
|
70
|
+
* Find the browser library, in every place it could honestly be.
|
|
71
71
|
*
|
|
72
|
-
*
|
|
73
|
-
* different answers: is it beside us, and is it in the project we were pointed at. Both are
|
|
74
|
-
* tried, because a project that already drives its own tests with Playwright should not be
|
|
75
|
-
* asked to install a second copy.
|
|
72
|
+
* ## Two packages, not one
|
|
76
73
|
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
74
|
+
* `playwright` and `playwright-core` are the same driver. The difference is that the first
|
|
75
|
+
* downloads its own copy of Chromium when it installs — about 150MB — and the second
|
|
76
|
+
* downloads nothing and expects to be told where a browser already is. This tool depends on
|
|
77
|
+
* `playwright-core` and finds the browser itself, because it already knows how: `browsers.js`
|
|
78
|
+
* locates Chrome for Testing, Chrome, Edge or Chromium on the machine, and deliberately
|
|
79
|
+
* prefers one that is not the browser the person actually uses.
|
|
80
|
+
*
|
|
81
|
+
* Both names are tried, in both places, because a project that already drives its own tests
|
|
82
|
+
* with the full `playwright` should never be asked to install a second copy of the same thing.
|
|
83
|
+
*
|
|
84
|
+
* ## Why it is loaded like this
|
|
85
|
+
*
|
|
86
|
+
* With `import()` rather than named at the top of the file: a tool that cannot start at all
|
|
87
|
+
* because a browser library is missing is a tool that cannot tell you what is missing.
|
|
88
|
+
*
|
|
89
|
+
* ## What went wrong here before, so it does not happen twice
|
|
90
|
+
*
|
|
91
|
+
* `playwright` was removed from this package's dependencies on the grounds that nothing in
|
|
92
|
+
* `src/` imported it. Nothing does — this line does, and a search for a static import cannot
|
|
93
|
+
* see it. The result shipped: 0.7.2 told every agent that asked that web apps and sites could
|
|
94
|
+
* be checked "here and now", and then answered every website check with "Playwright is not
|
|
95
|
+
* installed, so no web page can be opened". A tool that is wrong about its own headline
|
|
96
|
+
* ability is worse than one that lacks it. `test/v2/web-driver.test.js` now holds the
|
|
97
|
+
* dependency in place by name.
|
|
80
98
|
*
|
|
81
99
|
* @param {object} [opts]
|
|
82
100
|
* @param {string} [opts.projectRoot] The project being checked. Looked in second.
|
|
83
101
|
* @returns {Promise<PlaywrightState>}
|
|
84
102
|
*/
|
|
85
103
|
export async function loadPlaywright(opts = {}) {
|
|
86
|
-
const install = 'npm install
|
|
104
|
+
const install = 'npm install playwright-core';
|
|
87
105
|
/** @type {any} */
|
|
88
106
|
let mod = null;
|
|
89
107
|
/** @type {string|undefined} */
|
|
90
108
|
let version;
|
|
109
|
+
/** @type {string|null} */
|
|
110
|
+
let loadedName = null;
|
|
91
111
|
|
|
92
112
|
/** @param {any} loaded */
|
|
93
113
|
const unwrap = (loaded) => (loaded && loaded.chromium ? loaded : (loaded?.default ?? null));
|
|
94
114
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
115
|
+
// `playwright` first, because a project that has the full package has a browser downloaded
|
|
116
|
+
// with it, and using that is one less thing to go looking for.
|
|
117
|
+
for (const name of ['playwright', 'playwright-core']) {
|
|
118
|
+
if (mod) break;
|
|
119
|
+
try {
|
|
120
|
+
mod = unwrap(await import(name));
|
|
121
|
+
if (mod) loadedName = name;
|
|
122
|
+
} catch {
|
|
123
|
+
// Not beside us under this name. Try the next, then the project we were pointed at.
|
|
124
|
+
}
|
|
99
125
|
}
|
|
100
126
|
|
|
101
127
|
if (!mod && opts.projectRoot) {
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
128
|
+
for (const name of ['playwright', 'playwright-core']) {
|
|
129
|
+
if (mod) break;
|
|
130
|
+
try {
|
|
131
|
+
const require = createRequire(path.join(opts.projectRoot, 'package.json'));
|
|
132
|
+
mod = unwrap(await import(require.resolve(name)));
|
|
133
|
+
if (mod) loadedName = name;
|
|
134
|
+
} catch {
|
|
135
|
+
// Not there either. That is an answer, and it is reported as one.
|
|
136
|
+
}
|
|
107
137
|
}
|
|
108
138
|
}
|
|
109
139
|
|
|
@@ -111,14 +141,14 @@ export async function loadPlaywright(opts = {}) {
|
|
|
111
141
|
return {
|
|
112
142
|
ok: false,
|
|
113
143
|
state: 'no package',
|
|
114
|
-
why: '
|
|
144
|
+
why: 'The browser driver is not installed, so no web page can be opened. Everything read out of the source still works; nothing that needs a browser does.',
|
|
115
145
|
howToGet: install,
|
|
116
146
|
};
|
|
117
147
|
}
|
|
118
148
|
|
|
119
149
|
try {
|
|
120
150
|
const require = createRequire(import.meta.url);
|
|
121
|
-
version = String(require('playwright/package.json
|
|
151
|
+
version = String(require(`${loadedName ?? 'playwright-core'}/package.json`).version);
|
|
122
152
|
} catch {
|
|
123
153
|
// A version we cannot read is not a reason to refuse to run.
|
|
124
154
|
}
|
|
@@ -131,14 +161,43 @@ export async function loadPlaywright(opts = {}) {
|
|
|
131
161
|
executable = undefined;
|
|
132
162
|
}
|
|
133
163
|
|
|
134
|
-
|
|
164
|
+
let there = Boolean(executable) && (await exists(/** @type {string} */ (executable)));
|
|
165
|
+
|
|
166
|
+
// The driver's own browser is not the only browser.
|
|
167
|
+
//
|
|
168
|
+
// `playwright-core` downloads nothing, so it always names a Chromium that is not there.
|
|
169
|
+
// That is not a failure — this tool already knows how to find a browser, and has a
|
|
170
|
+
// considered opinion about which one: `browsers.js` prefers Chrome for Testing over the
|
|
171
|
+
// browser the person actually uses, precisely so a check can never take over their
|
|
172
|
+
// windows, their profile or their sign-ins.
|
|
173
|
+
//
|
|
174
|
+
// So: ask it. Only when there is no browser on the machine at all is this a real "no".
|
|
175
|
+
/** @type {string|undefined} */
|
|
176
|
+
let borrowedFrom;
|
|
177
|
+
/** True when the browser found is the one the PERSON uses, not a separate one. */
|
|
178
|
+
let borrowedTheirOwn = false;
|
|
179
|
+
if (!there) {
|
|
180
|
+
try {
|
|
181
|
+
const { surveyBrowsers } = await import('../browsers.js');
|
|
182
|
+
const survey = await surveyBrowsers({ headless: true });
|
|
183
|
+
if (survey.chosen?.binary && (await exists(survey.chosen.binary))) {
|
|
184
|
+
executable = survey.chosen.binary;
|
|
185
|
+
borrowedFrom = survey.chosen.name;
|
|
186
|
+
borrowedTheirOwn = survey.borrowingHis === true || survey.chosen.everyday === true;
|
|
187
|
+
there = true;
|
|
188
|
+
}
|
|
189
|
+
} catch {
|
|
190
|
+
// Nothing found, or the survey itself would not run. Reported as "no browser" below.
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
135
194
|
if (!there) {
|
|
136
195
|
return {
|
|
137
196
|
ok: false,
|
|
138
197
|
state: 'no browser',
|
|
139
198
|
chromium: mod.chromium,
|
|
140
199
|
version,
|
|
141
|
-
why: `
|
|
200
|
+
why: `The browser driver ${version ?? ''} is installed, and there is no browser on this machine for it to open. This is one command and nobody has to be asked.`.trim(),
|
|
142
201
|
howToGet: 'npx playwright install chromium',
|
|
143
202
|
executable,
|
|
144
203
|
};
|
|
@@ -150,10 +209,35 @@ export async function loadPlaywright(opts = {}) {
|
|
|
150
209
|
chromium: mod.chromium,
|
|
151
210
|
version,
|
|
152
211
|
executable,
|
|
153
|
-
why:
|
|
212
|
+
why: browserNote(version, borrowedFrom, borrowedTheirOwn),
|
|
154
213
|
};
|
|
155
214
|
}
|
|
156
215
|
|
|
216
|
+
/**
|
|
217
|
+
* What to say about the browser a check will open.
|
|
218
|
+
*
|
|
219
|
+
* "a separate application from the browser you use" used to be said whatever was found — and
|
|
220
|
+
* the one case where that sentence matters is the case where it is false. With no downloaded
|
|
221
|
+
* test browser anywhere, the survey falls back to the person's OWN browser, and this then
|
|
222
|
+
* told them the opposite of what was about to happen. A reassurance is only worth anything
|
|
223
|
+
* if it is withheld when it is not true.
|
|
224
|
+
*
|
|
225
|
+
* Exported so the wording is a test rather than a thing somebody has to notice.
|
|
226
|
+
*
|
|
227
|
+
* @param {string|undefined} version The driver version, if it said one.
|
|
228
|
+
* @param {string|undefined} borrowedFrom The browser found, if one had to be borrowed.
|
|
229
|
+
* @param {boolean} borrowedTheirOwn True when that browser is the person's own.
|
|
230
|
+
* @returns {string}
|
|
231
|
+
*/
|
|
232
|
+
export function browserNote(version, borrowedFrom, borrowedTheirOwn) {
|
|
233
|
+
const v = version ?? '';
|
|
234
|
+
if (!borrowedFrom) return `The browser driver ${v} is here and its Chromium is downloaded, so pages can be opened.`.trim();
|
|
235
|
+
if (borrowedTheirOwn) {
|
|
236
|
+
return `The browser driver ${v} is here and the only browser on this machine is the one you use yourself (${borrowedFrom}). It will be opened invisibly with a throwaway profile, so your own settings, cookies and tabs are never touched — but it is your browser, not a separate one. \`npx playwright install chromium\` gives checks one of their own.`.trim();
|
|
237
|
+
}
|
|
238
|
+
return `The browser driver ${v} is here and it will open ${borrowedFrom}, which is a separate application from the browser you use, so pages can be opened.`.trim();
|
|
239
|
+
}
|
|
240
|
+
|
|
157
241
|
/**
|
|
158
242
|
* @param {string} file
|
|
159
243
|
* @returns {Promise<boolean>}
|
|
@@ -192,6 +276,7 @@ async function exists(file) {
|
|
|
192
276
|
*
|
|
193
277
|
* @param {object} opts
|
|
194
278
|
* @param {any} opts.chromium
|
|
279
|
+
* @param {string} [opts.executable] Which browser to open. From `loadPlaywright`.
|
|
195
280
|
* @param {string} opts.scratchDir
|
|
196
281
|
* @param {{width: number, height: number, deviceScaleFactor?: number}} [opts.viewport]
|
|
197
282
|
* @param {'light'|'dark'} [opts.colorScheme]
|
|
@@ -209,6 +294,15 @@ export async function openWindow(opts) {
|
|
|
209
294
|
await fsp.mkdir(profileDir, { recursive: true });
|
|
210
295
|
|
|
211
296
|
const context = await opts.chromium.launchPersistentContext(profileDir, {
|
|
297
|
+
// Which browser, said out loud rather than left to the driver's default.
|
|
298
|
+
//
|
|
299
|
+
// `playwright-core` has no browser of its own, so without this it looks for one that was
|
|
300
|
+
// never downloaded and the launch fails with a path nobody recognises. `loadPlaywright`
|
|
301
|
+
// has already decided which browser this machine should open — usually Chrome for
|
|
302
|
+
// Testing, deliberately not the browser the person uses — and this is where that decision
|
|
303
|
+
// is honoured. Left out when there is nothing to say, so the full `playwright` keeps
|
|
304
|
+
// using the Chromium it downloaded for itself.
|
|
305
|
+
...(opts.executable ? { executablePath: opts.executable } : {}),
|
|
212
306
|
headless: opts.headed !== true,
|
|
213
307
|
viewport,
|
|
214
308
|
deviceScaleFactor,
|
|
@@ -1243,7 +1337,9 @@ export function flattenAria(nodes) {
|
|
|
1243
1337
|
// Two things with the same name in the same place have to be told apart somehow, and
|
|
1244
1338
|
// counting is the only honest way left. The count is kept per place, so it cannot
|
|
1245
1339
|
// spread: adding a row to one list never renumbers another.
|
|
1246
|
-
|
|
1340
|
+
// The separator is written as the escape, never as the byte. A raw NUL makes grep and
|
|
1341
|
+
// file(1) treat this whole module as binary and skip it without saying so.
|
|
1342
|
+
const key = `${scope.join(' ')}\u0000${node.role}\u0000${node.name ?? ''}`;
|
|
1247
1343
|
const nth = (seen.get(key) ?? 0) + 1;
|
|
1248
1344
|
seen.set(key, nth);
|
|
1249
1345
|
|
|
@@ -1419,6 +1515,20 @@ export async function runStep(page, step, opts = {}) {
|
|
|
1419
1515
|
break;
|
|
1420
1516
|
}
|
|
1421
1517
|
}
|
|
1518
|
+
// A step that did nothing at all is almost always a word this tool does not know, and
|
|
1519
|
+
// saying nothing about it is the worst outcome available: the journey walks on, the sign-in
|
|
1520
|
+
// never happens, every page behind the login wall photographs the login page, and the run
|
|
1521
|
+
// comes back clean. `staysfixed init` itself shipped `{ fill: '#email', with: 'a@b.c' }`
|
|
1522
|
+
// as its sign-in example, and neither word is in the vocabulary.
|
|
1523
|
+
if (did.length === 0) {
|
|
1524
|
+
const known = new Set([...ACTION_ORDER, 'text', 'timeoutMs', 'name', 'note', 'act', 'checkpoint', 'describe']);
|
|
1525
|
+
const unknown = Object.keys(step).filter((k) => !known.has(k));
|
|
1526
|
+
if (unknown.length > 0) {
|
|
1527
|
+
throw new Error(
|
|
1528
|
+
`This step does nothing: ${unknown.map((k) => `\`${k}\``).join(', ')} ${unknown.length === 1 ? 'is not a word' : 'are not words'} this tool knows, so the step was skipped and whatever it was meant to do did not happen. The steps it understands are: ${ACTION_ORDER.join(', ')} — with \`text\` beside \`type\`. To type into a field: { type: '#email', text: 'a@b.c' }.`,
|
|
1529
|
+
);
|
|
1530
|
+
}
|
|
1531
|
+
}
|
|
1422
1532
|
return did;
|
|
1423
1533
|
}
|
|
1424
1534
|
|
package/src/v2/adapters/web.js
CHANGED
|
@@ -36,10 +36,10 @@
|
|
|
36
36
|
* Every refusal is reported as a hole in the check. None of them is ever reported as a pass.
|
|
37
37
|
*/
|
|
38
38
|
|
|
39
|
+
import crypto from 'node:crypto';
|
|
39
40
|
import fs from 'node:fs';
|
|
40
41
|
import fsp from 'node:fs/promises';
|
|
41
42
|
import path from 'node:path';
|
|
42
|
-
import { spawn } from 'node:child_process';
|
|
43
43
|
|
|
44
44
|
import {
|
|
45
45
|
countBucket, defineAdapter, howLongItTook, joinPath, notCovered, observation, sizeBucket,
|
|
@@ -49,6 +49,7 @@ import { copyForScratch, frozenEnvironment } from './process.js';
|
|
|
49
49
|
import { freePort, looksDestructive, waitForServer } from './http.js';
|
|
50
50
|
import { applyFreeze, prepareForShutter } from '../../freeze/index.js';
|
|
51
51
|
import { settle } from '../../freeze/settle.js';
|
|
52
|
+
import { spawnServer, stopServer } from './child.js';
|
|
52
53
|
import {
|
|
53
54
|
actOf, countRoles, flattenAria, inkOf, loadPlaywright, openWindow, parseAria, runStep, short,
|
|
54
55
|
watchTheWire, whereItIs, withLimit,
|
|
@@ -70,6 +71,26 @@ const VIEWPORT = { width: 1280, height: 800, deviceScaleFactor: 1 };
|
|
|
70
71
|
/** Folders that never hold a page worth walking. */
|
|
71
72
|
const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build', 'out', '.next', 'coverage', '.staysfixed']);
|
|
72
73
|
|
|
74
|
+
/**
|
|
75
|
+
* Why a folder would not open, in words somebody can act on.
|
|
76
|
+
*
|
|
77
|
+
* The code alone is no use to the person who has to fix it: `EACCES` on its own has sent
|
|
78
|
+
* more than one person looking for a bug in the tool.
|
|
79
|
+
*
|
|
80
|
+
* @param {unknown} error
|
|
81
|
+
* @returns {string}
|
|
82
|
+
*/
|
|
83
|
+
function whyNotOpened(error) {
|
|
84
|
+
const code = String(/** @type {any} */ (error)?.code ?? '');
|
|
85
|
+
if (code === 'EACCES' || code === 'EPERM') return 'this account does not have permission to open it';
|
|
86
|
+
if (code === 'ENOENT') return 'it was there when the walk started and is not there now';
|
|
87
|
+
if (code === 'ENOTDIR') return 'something in the way is a file, not a folder';
|
|
88
|
+
if (code === 'ELOOP') return 'the links in it point round in a circle';
|
|
89
|
+
if (code === 'EMFILE' || code === 'ENFILE') return 'this machine ran out of open files while reading it';
|
|
90
|
+
const said = String(/** @type {any} */ (error)?.message ?? error ?? '').trim();
|
|
91
|
+
return said === '' ? 'the reason was not given' : said;
|
|
92
|
+
}
|
|
93
|
+
|
|
73
94
|
/**
|
|
74
95
|
* The pages a project has, read out of its folder names.
|
|
75
96
|
*
|
|
@@ -83,12 +104,29 @@ const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build', 'out', '.nex
|
|
|
83
104
|
* reported as needing a sample value rather than guessed at, because "we did not check
|
|
84
105
|
* this" and "this is fine" must never be allowed to look alike.
|
|
85
106
|
*
|
|
107
|
+
* A FOLDER THIS CANNOT OPEN IS NAMED, not skipped. It used to `continue` on the read that
|
|
108
|
+
* failed, which threw away that folder and every page underneath it without one word — and
|
|
109
|
+
* a page that was never listed is never walked, never counted, and never missed. The run
|
|
110
|
+
* then reported that nothing had changed about a section of the site it had not looked at.
|
|
111
|
+
* This is the same shape of bug as three others that were found and closed elsewhere in the
|
|
112
|
+
* tool; this was the fourth place it was living.
|
|
113
|
+
*
|
|
86
114
|
* @param {string} root
|
|
115
|
+
* @param {{unreadable?: {folder: string, why: string}[]}} [collect]
|
|
116
|
+
* Hand in an object and every folder that could not be opened is pushed onto
|
|
117
|
+
* `collect.unreadable`, with the reason in plain English. Callers that pass nothing get
|
|
118
|
+
* the old shape back and lose the holes, so the two callers inside this file both pass
|
|
119
|
+
* one; `detect` turns them into something a person can act on and `journeys` turns them
|
|
120
|
+
* into missing coverage, which is what stops a folder nobody could read reading as a
|
|
121
|
+
* folder with nothing in it.
|
|
87
122
|
* @returns {Promise<{url: string, file: string, needs: string[]}[]>}
|
|
88
123
|
*/
|
|
89
|
-
export async function readPageRoutes(root) {
|
|
124
|
+
export async function readPageRoutes(root, collect = {}) {
|
|
90
125
|
/** @type {Map<string, {url: string, file: string, needs: string[]}>} */
|
|
91
126
|
const found = new Map();
|
|
127
|
+
const unreadable = collect.unreadable;
|
|
128
|
+
/** @type {Set<string>} */
|
|
129
|
+
const alreadySaid = new Set();
|
|
92
130
|
|
|
93
131
|
/**
|
|
94
132
|
* @param {string} base
|
|
@@ -104,7 +142,12 @@ export async function readPageRoutes(root) {
|
|
|
104
142
|
let entries;
|
|
105
143
|
try {
|
|
106
144
|
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
107
|
-
} catch {
|
|
145
|
+
} catch (error) {
|
|
146
|
+
const folder = path.relative(root, dir) || '.';
|
|
147
|
+
if (unreadable && !alreadySaid.has(folder)) {
|
|
148
|
+
alreadySaid.add(folder);
|
|
149
|
+
unreadable.push({ folder, why: whyNotOpened(error) });
|
|
150
|
+
}
|
|
108
151
|
continue;
|
|
109
152
|
}
|
|
110
153
|
for (const entry of entries) {
|
|
@@ -163,9 +206,20 @@ export async function readPageRoutes(root) {
|
|
|
163
206
|
* already has them), a journeys file if one was named, and the pages read out of the folder
|
|
164
207
|
* names for everything neither of those covers. Never invented, never crawled.
|
|
165
208
|
*
|
|
209
|
+
* TWO SCREENS WITH ONE NAME ARE TWO SCREENS. They used to be one: the list is built in a
|
|
210
|
+
* Map keyed by name, so the second `set` overwrote the first, and the screen that lost was
|
|
211
|
+
* never walked, never compared and never counted as a door nobody opened — missing from the
|
|
212
|
+
* very ledger that exists to catch exactly this. Now the second one is told apart by a
|
|
213
|
+
* number and both are walked. Two entries that are the same in every respect really are one
|
|
214
|
+
* screen written down twice, and only those are folded together.
|
|
215
|
+
*
|
|
166
216
|
* @param {object} input
|
|
167
217
|
* @param {Record<string, any>} input.config
|
|
168
218
|
* @param {{url: string, file: string, needs: string[]}[]} input.pages
|
|
219
|
+
* @param {{folder: string, why: string}[]} [input.unreadable]
|
|
220
|
+
* Folders `readPageRoutes` could not open. Each becomes a journey that says it was not
|
|
221
|
+
* walked and why, which the engine records as missing coverage. A page behind one of them
|
|
222
|
+
* was never listed, so nothing else in the run would ever mention it.
|
|
169
223
|
* @returns {Journey[]}
|
|
170
224
|
*/
|
|
171
225
|
export function journeysFrom(input) {
|
|
@@ -173,19 +227,50 @@ export function journeysFrom(input) {
|
|
|
173
227
|
const samples = config.samples ?? {};
|
|
174
228
|
/** @type {Map<string, Journey>} */
|
|
175
229
|
const journeys = new Map();
|
|
230
|
+
/** @type {Map<string, string>} name -> what that journey is, so a true duplicate is spotted */
|
|
231
|
+
const shapes = new Map();
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* A name nothing else is using. Numbering rather than overwriting: a screen that is
|
|
235
|
+
* dropped here is dropped everywhere, silently, for the rest of the run.
|
|
236
|
+
*
|
|
237
|
+
* @param {string} wanted
|
|
238
|
+
* @returns {string}
|
|
239
|
+
*/
|
|
240
|
+
const freeName = (wanted) => {
|
|
241
|
+
if (!journeys.has(wanted)) return wanted;
|
|
242
|
+
for (let n = 2; ; n += 1) {
|
|
243
|
+
const tried = `${wanted} (${n})`;
|
|
244
|
+
if (!journeys.has(tried)) return tried;
|
|
245
|
+
}
|
|
246
|
+
};
|
|
176
247
|
|
|
177
248
|
// v1 called these "screens" and this reads them unchanged, on purpose: nobody should have
|
|
178
249
|
// to write their steps twice to get a second kind of check out of them.
|
|
179
250
|
for (const screen of [...(config.screens ?? []), ...(config.journeys ?? [])]) {
|
|
180
251
|
if (!screen || typeof screen !== 'object') continue;
|
|
181
|
-
const
|
|
252
|
+
const wanted = String(screen.name ?? screen.url ?? 'a screen');
|
|
182
253
|
/** @type {Record<string, any>[]} */
|
|
183
254
|
const steps = [];
|
|
184
255
|
if (screen.url !== undefined) steps.push({ act: 'open', goto: String(screen.url), note: `open ${screen.url}` });
|
|
185
256
|
for (const step of screen.steps ?? []) steps.push({ act: actOf(step), ...step });
|
|
257
|
+
|
|
258
|
+
// The same name AND the same steps is one screen listed twice — the settings and the
|
|
259
|
+
// journeys file both naming it, most often — and folding those together loses nothing.
|
|
260
|
+
// A different set of steps under the same name is a different screen.
|
|
261
|
+
const shape = JSON.stringify(steps);
|
|
262
|
+
if (shapes.get(wanted) === shape) continue;
|
|
263
|
+
const name = freeName(wanted);
|
|
264
|
+
shapes.set(name, shape);
|
|
186
265
|
journeys.set(name, {
|
|
187
266
|
name,
|
|
188
|
-
describe: String(
|
|
267
|
+
describe: String(
|
|
268
|
+
screen.describe ??
|
|
269
|
+
screen.why ??
|
|
270
|
+
(name === wanted
|
|
271
|
+
? `walk ${name}`
|
|
272
|
+
: `walk ${name} — the settings name two different screens "${wanted}", so this is the second of them`)
|
|
273
|
+
),
|
|
189
274
|
source: 'code',
|
|
190
275
|
surface: 'web',
|
|
191
276
|
from: 'the project settings',
|
|
@@ -205,8 +290,7 @@ export function journeysFrom(input) {
|
|
|
205
290
|
if (sample === undefined) unfilled.push(need);
|
|
206
291
|
else url = url.replace(new RegExp(`\\[\\.{0,3}${need}\\]|:${need}`), encodeURIComponent(String(sample)));
|
|
207
292
|
}
|
|
208
|
-
const name = `page ${page.url}
|
|
209
|
-
if (journeys.has(name)) continue;
|
|
293
|
+
const name = freeName(`page ${page.url}`);
|
|
210
294
|
journeys.set(name, {
|
|
211
295
|
name,
|
|
212
296
|
describe: `open ${page.url} and read what the screen says`,
|
|
@@ -230,6 +314,28 @@ export function journeysFrom(input) {
|
|
|
230
314
|
});
|
|
231
315
|
}
|
|
232
316
|
|
|
317
|
+
// Last, and after the front-page fallback so a project made entirely of holes still gets
|
|
318
|
+
// one real journey. A folder nobody could open is a journey that says out loud it was not
|
|
319
|
+
// walked: the engine turns a journey carrying `skip` into missing coverage, which is the
|
|
320
|
+
// only route from here to the ledger. Without it, a page behind that folder was never
|
|
321
|
+
// listed, so nothing anywhere in the run would ever have mentioned it — and a page nobody
|
|
322
|
+
// listed reads exactly like a page that is fine.
|
|
323
|
+
for (const hole of input.unreadable ?? []) {
|
|
324
|
+
const name = freeName(`the pages under ${hole.folder}`);
|
|
325
|
+
journeys.set(name, {
|
|
326
|
+
name,
|
|
327
|
+
describe: `the pages under ${hole.folder}`,
|
|
328
|
+
source: 'code',
|
|
329
|
+
surface: 'web',
|
|
330
|
+
from: hole.folder,
|
|
331
|
+
channels: [],
|
|
332
|
+
steps: /** @type {any} */ ([]),
|
|
333
|
+
skip:
|
|
334
|
+
`"${hole.folder}" could not be opened while looking for this project's pages — ${hole.why} — so nothing under it was listed. ` +
|
|
335
|
+
'Any page in there was not walked and is not in the count of pages that were. This is a hole, not a pass.',
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
|
|
233
339
|
return [...journeys.values()].sort((a, b) => a.name.localeCompare(b.name));
|
|
234
340
|
}
|
|
235
341
|
|
|
@@ -290,7 +396,16 @@ export const webAdapter = defineAdapter({
|
|
|
290
396
|
const framework = ['next', 'react', 'vue', 'svelte', 'astro', '@remix-run/react', 'nuxt', 'solid-js', 'preact', 'vite']
|
|
291
397
|
.find((name) => name in dependencies);
|
|
292
398
|
|
|
293
|
-
|
|
399
|
+
/** @type {{unreadable: {folder: string, why: string}[]}} */
|
|
400
|
+
const collect = { unreadable: [] };
|
|
401
|
+
const pages = await readPageRoutes(project.root, collect);
|
|
402
|
+
if (collect.unreadable.length > 0) {
|
|
403
|
+
missing.push({
|
|
404
|
+
what: `permission to read ${collect.unreadable.length === 1 ? 'a folder' : `${collect.unreadable.length} folders`} this project's pages live in: ${collect.unreadable.map((u) => `${u.folder} (${u.why})`).join(', ')}`,
|
|
405
|
+
unlocks: 'listing the pages under them at all. Nothing under a folder that will not open is walked, counted, or missed, so the run is quiet about that part of the site',
|
|
406
|
+
howToGet: `Give this account permission to read ${collect.unreadable.length === 1 ? 'it' : 'them'} — on a Mac or Linux that is: chmod +rx ${collect.unreadable[0].folder}`,
|
|
407
|
+
});
|
|
408
|
+
}
|
|
294
409
|
const address = config.url ?? config.baseUrl ?? null;
|
|
295
410
|
|
|
296
411
|
if (!config.start && !address) {
|
|
@@ -338,7 +453,10 @@ export const webAdapter = defineAdapter({
|
|
|
338
453
|
|
|
339
454
|
/** @param {import('./contract.js').AdapterProject} project */
|
|
340
455
|
async journeys(project) {
|
|
341
|
-
|
|
456
|
+
/** @type {{unreadable: {folder: string, why: string}[]}} */
|
|
457
|
+
const collect = { unreadable: [] };
|
|
458
|
+
const pages = await readPageRoutes(project.root, collect);
|
|
459
|
+
return journeysFrom({ config: project.config ?? {}, pages, unreadable: collect.unreadable });
|
|
342
460
|
},
|
|
343
461
|
|
|
344
462
|
/**
|
|
@@ -419,7 +537,7 @@ export const webAdapter = defineAdapter({
|
|
|
419
537
|
if (!verdict.safe) notes.push(verdict.why);
|
|
420
538
|
else {
|
|
421
539
|
const done = await new Promise((resolve) => {
|
|
422
|
-
const child =
|
|
540
|
+
const child = spawnServer(String(config.restore), { cwd: work, env, stdio: 'ignore' });
|
|
423
541
|
child.on('error', () => resolve(false));
|
|
424
542
|
child.on('close', (code) => resolve(code === 0));
|
|
425
543
|
});
|
|
@@ -431,7 +549,7 @@ export const webAdapter = defineAdapter({
|
|
|
431
549
|
const said = [];
|
|
432
550
|
/** @type {string|null} */
|
|
433
551
|
let exited = null;
|
|
434
|
-
const child =
|
|
552
|
+
const child = spawnServer(String(config.start), { cwd: work, env });
|
|
435
553
|
child.stdout?.on('data', (c) => said.push(c));
|
|
436
554
|
child.stderr?.on('data', (c) => said.push(c));
|
|
437
555
|
child.on('close', (code, signal) => {
|
|
@@ -440,14 +558,14 @@ export const webAdapter = defineAdapter({
|
|
|
440
558
|
|
|
441
559
|
const up = await waitForServer(port, { timeoutMs: config.startTimeoutMs ?? 90000, crashed: () => exited });
|
|
442
560
|
if (!up.up) {
|
|
443
|
-
child
|
|
561
|
+
await stopServer(child);
|
|
444
562
|
return {
|
|
445
563
|
build,
|
|
446
564
|
root: work,
|
|
447
565
|
ready: false,
|
|
448
566
|
why: `${up.why} What it printed while trying: ${trimForStorage(Buffer.concat(said).toString('utf8'), 1500).text || '(nothing)'}`,
|
|
449
567
|
dispose: async () => {
|
|
450
|
-
child
|
|
568
|
+
await stopServer(child);
|
|
451
569
|
await fsp.rm(base, { recursive: true, force: true });
|
|
452
570
|
},
|
|
453
571
|
};
|
|
@@ -466,9 +584,7 @@ export const webAdapter = defineAdapter({
|
|
|
466
584
|
running.delete(build.id);
|
|
467
585
|
if (!held) return;
|
|
468
586
|
// Only ever the process we started ourselves.
|
|
469
|
-
held.child
|
|
470
|
-
await new Promise((r) => setTimeout(r, 400));
|
|
471
|
-
if (held.child && held.child.exitCode === null) held.child.kill('SIGKILL');
|
|
587
|
+
await stopServer(held.child);
|
|
472
588
|
await fsp.rm(base, { recursive: true, force: true });
|
|
473
589
|
},
|
|
474
590
|
};
|
|
@@ -525,6 +641,9 @@ export const webAdapter = defineAdapter({
|
|
|
525
641
|
const viewport = viewportFrom(config);
|
|
526
642
|
const window = await openWindow({
|
|
527
643
|
chromium: held.playwright.chromium,
|
|
644
|
+
// Which browser was chosen for this machine. Without it a driver that downloads no
|
|
645
|
+
// browser of its own looks for one that is not there.
|
|
646
|
+
executable: held.playwright.executable,
|
|
528
647
|
scratchDir: ctx.scratchDir,
|
|
529
648
|
viewport,
|
|
530
649
|
colorScheme: config.colorScheme ?? 'light',
|
|
@@ -658,7 +777,7 @@ export const webAdapter = defineAdapter({
|
|
|
658
777
|
},
|
|
659
778
|
|
|
660
779
|
async teardown() {
|
|
661
|
-
for (const [, held] of running) held.child
|
|
780
|
+
for (const [, held] of running) await stopServer(held.child);
|
|
662
781
|
running.clear();
|
|
663
782
|
},
|
|
664
783
|
});
|
|
@@ -1001,9 +1120,21 @@ export function complaintKey(message) {
|
|
|
1001
1120
|
}
|
|
1002
1121
|
|
|
1003
1122
|
/**
|
|
1123
|
+
* A name a picture can be saved under.
|
|
1124
|
+
*
|
|
1125
|
+
* Cut with a fingerprint on the end, never cut alone. This is a FILE name: two checkpoints
|
|
1126
|
+
* whose names agreed for eighty characters were saved over each other, so the picture
|
|
1127
|
+
* offered as evidence for one finding was a photograph of a different screen — and nothing
|
|
1128
|
+
* about it looked wrong. Long file names are the normal case here, because the name is the
|
|
1129
|
+
* build, the journey and the checkpoint run together.
|
|
1130
|
+
*
|
|
1004
1131
|
* @param {string} name
|
|
1005
1132
|
* @returns {string}
|
|
1006
1133
|
*/
|
|
1007
1134
|
function fileSafe(name) {
|
|
1008
|
-
|
|
1135
|
+
const clean = String(name).replace(/[^A-Za-z0-9._-]+/g, '-');
|
|
1136
|
+
if (clean === '') return 'checkpoint';
|
|
1137
|
+
if (clean.length <= 80) return clean;
|
|
1138
|
+
const mark = crypto.createHash('sha256').update(clean).digest('hex').slice(0, 8);
|
|
1139
|
+
return `${clean.slice(0, 71)}-${mark}`;
|
|
1009
1140
|
}
|
|
@@ -1201,7 +1201,24 @@ export const windowsAdapter = defineAdapter({
|
|
|
1201
1201
|
// Pixels last, and only as evidence. A picture is written to the evidence folder and
|
|
1202
1202
|
// pointed at; it is never the thing compared.
|
|
1203
1203
|
const shot = await runner.call('shot', { hwnd: window.hwnd }, { timeoutMs: 45_000 });
|
|
1204
|
-
|
|
1204
|
+
// THREE WAYS OUT OF HERE AND TWO OF THEM USED TO BE SILENT. A picture that failed,
|
|
1205
|
+
// came back empty, or came back bigger than the cap simply produced no observation
|
|
1206
|
+
// at all — so the pixels channel dropped out of the run without a word, and the
|
|
1207
|
+
// ledger reported the same coverage as a run where every window was photographed.
|
|
1208
|
+
// A cap is a decision and a decision has to be visible; a failure is a hole and a
|
|
1209
|
+
// hole has to be named. Neither is a reason to lose the rest of the walk.
|
|
1210
|
+
const tooBig = shot.ok === true && Boolean(shot.png) && Number(shot.bytes) > MAX_SHOT_BYTES;
|
|
1211
|
+
if (!shot.ok || !shot.png || tooBig) {
|
|
1212
|
+
seen.push(notCovered({
|
|
1213
|
+
channel: 'pixels',
|
|
1214
|
+
path: joinPath('screen', label, 'picture'),
|
|
1215
|
+
reason: tooBig ? 'too big' : 'crashed',
|
|
1216
|
+
says: tooBig
|
|
1217
|
+
? `The picture of "${label}" came back at ${sizeBucket(Number(shot.bytes))}, over the ${sizeBucket(MAX_SHOT_BYTES)} this keeps, so it was not stored. Every other channel still looked at that window; only the picture is missing.`
|
|
1218
|
+
: `No picture of "${label}" could be taken${shot.error ? `: ${String(shot.error)}` : '.'} Every other channel still looked at that window; only the picture is missing.`,
|
|
1219
|
+
}));
|
|
1220
|
+
}
|
|
1221
|
+
if (shot.ok && shot.png && !tooBig) {
|
|
1205
1222
|
const file = path.join(ctx.evidenceDir, `windows-${journey.name}-${label.replace(/[^a-z0-9]+/gi, '-')}.png`);
|
|
1206
1223
|
await fsp.writeFile(file, Buffer.from(String(shot.png), 'base64'));
|
|
1207
1224
|
seen.push(observation({
|