staysfixed 0.12.0 → 0.14.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 -2
- package/docs/guards.md +18 -0
- package/docs/how-v2-works.md +10 -0
- package/package.json +1 -1
- package/src/cli/approve.js +4 -1
- package/src/cli/flake.js +4 -1
- package/src/cli/mark.js +5 -1
- package/src/cli/status.js +53 -1
- package/src/cli/trace.js +27 -2
- package/src/core/config.js +136 -25
- package/src/core/stop-tree.js +109 -0
- package/src/drive/browser.js +20 -31
- package/src/drive/page.js +74 -2
- package/src/guard/api.js +14 -9
- package/src/types.js +1 -1
- package/src/v2/adapters/child.js +15 -17
- package/src/v2/adapters/contract.js +122 -1
- package/src/v2/adapters/http.js +152 -30
- package/src/v2/adapters/isolate.js +169 -14
- package/src/v2/adapters/process.js +72 -8
- package/src/v2/adapters/source.js +254 -7
- package/src/v2/adapters/web.js +69 -19
- package/src/v2/browsers.js +136 -24
- package/src/v2/cause.js +46 -5
- package/src/v2/check.js +372 -35
- package/src/v2/cli.js +19 -1
- package/src/v2/coverage.js +555 -18
- package/src/v2/detect.js +737 -40
- package/src/v2/doctor.js +3 -3
- package/src/v2/escalate.js +91 -12
- package/src/v2/init.js +577 -21
- package/src/v2/journeys/answers-probe.js +376 -0
- package/src/v2/journeys/from-exports.js +456 -0
- package/src/v2/journeys/from-suite.js +9 -1
- package/src/v2/mcp/tools.js +185 -12
- package/src/v2/observation.js +145 -0
- package/src/v2/run.js +133 -9
- package/src/v2/selfcheck.js +297 -11
- package/src/v2/store.js +16 -1
- package/src/v2/watch/dialogs.js +410 -0
- package/src/v2/watch/panel.js +27 -1
|
@@ -532,10 +532,27 @@ export function readFile(relFile, text) {
|
|
|
532
532
|
// Command-line flags. These are a mention, not a proof — a string that looks like a flag
|
|
533
533
|
// may be one this program accepts or one it passes on to something else. It gets its own
|
|
534
534
|
// wording so nobody mistakes the two.
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
535
|
+
//
|
|
536
|
+
// A CSS CUSTOM PROPERTY IS NOT A FLAG. `style={{ '--px': '1rem' }}` is a component setting
|
|
537
|
+
// its own spacing, and it was read here as a command-line flag this program accepts.
|
|
538
|
+
// Measured 2026-08-31 on a real site: eleven of them became doors, padding the denominator
|
|
539
|
+
// of the coverage ledger with eleven things no journey can ever walk, so a site whose real
|
|
540
|
+
// coverage was good read as worse than it was and the real never-opened doors sat in a list
|
|
541
|
+
// of invented ones.
|
|
542
|
+
//
|
|
543
|
+
// An object key is what tells them apart, and it is decided by BOTH neighbours, not one. A
|
|
544
|
+
// key is a string with a colon after it AND an opening brace or a comma in front of it.
|
|
545
|
+
// Checking only the colon would also throw away `cond ? '--verbose' : '--quiet'`, where the
|
|
546
|
+
// first branch has a colon after it too — and dropping a real flag is the wrong mistake to
|
|
547
|
+
// make here: an invented door makes the ledger read worse than the truth, a missing one
|
|
548
|
+
// makes it read better, and only one of those two can end in somebody believing a clean
|
|
549
|
+
// result they should not have.
|
|
550
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
551
|
+
const token = tokens[i];
|
|
552
|
+
if (token.t !== 'string' || !/^--[a-z0-9][a-z0-9-]*$/i.test(token.v)) continue;
|
|
553
|
+
const isAKey = tokens[i + 1]?.v === ':' && (tokens[i - 1]?.v === '{' || tokens[i - 1]?.v === ',');
|
|
554
|
+
if (isAKey) continue;
|
|
555
|
+
doors.push(door('command', token.v, 'a flag this file mentions', relFile, token.line, inTest, true, 'literal'));
|
|
539
556
|
}
|
|
540
557
|
|
|
541
558
|
// A server written straight on node:http registers nothing. There is no `app.get` for the
|
|
@@ -1285,6 +1302,19 @@ async function collectFiles(root, folders, maxFileBytes) {
|
|
|
1285
1302
|
// Routes that live in the filesystem rather than in a call
|
|
1286
1303
|
// ---------------------------------------------------------------------------
|
|
1287
1304
|
|
|
1305
|
+
/**
|
|
1306
|
+
* What each Next.js metadata file is actually served at. The file is named for what it is;
|
|
1307
|
+
* the address it answers on is a different word, and asking for the file's own name gets a
|
|
1308
|
+
* 404 — which would then be reported as a route the build has lost.
|
|
1309
|
+
*/
|
|
1310
|
+
const SERVED_AS = /** @type {Record<string, string>} */ ({
|
|
1311
|
+
sitemap: 'sitemap.xml',
|
|
1312
|
+
robots: 'robots.txt',
|
|
1313
|
+
manifest: 'manifest.webmanifest',
|
|
1314
|
+
'opengraph-image': 'opengraph-image',
|
|
1315
|
+
icon: 'icon',
|
|
1316
|
+
});
|
|
1317
|
+
|
|
1288
1318
|
/**
|
|
1289
1319
|
* Every route that is not written as a call in JavaScript.
|
|
1290
1320
|
*
|
|
@@ -1292,6 +1322,14 @@ async function collectFiles(root, folders, maxFileBytes) {
|
|
|
1292
1322
|
* Both layouts are handled: an app folder, where a `route` file's exported method names are
|
|
1293
1323
|
* the verbs, and a pages/api folder, where the file itself is the route.
|
|
1294
1324
|
*
|
|
1325
|
+
* SO DO THE OTHERS, AND UNTIL 2026-08-31 ONLY NEXT.JS WAS READ. Measured that day on a
|
|
1326
|
+
* three-page SvelteKit site with an endpoint and a form in it: "0 routes". Not "we could not
|
|
1327
|
+
* read them" — zero, printed as a fact, in a report that then called the site covered in
|
|
1328
|
+
* full. SvelteKit writes an endpoint as `+server.ts` and a form handler as an `actions`
|
|
1329
|
+
* export in `+page.server.ts`; Nuxt puts its under `server/api`; Astro puts its beside the
|
|
1330
|
+
* pages in `src/pages`. Every one of those is a door somebody can knock on, and a door this
|
|
1331
|
+
* tool cannot see is a door it cannot notice disappearing.
|
|
1332
|
+
*
|
|
1295
1333
|
* Python's routes are read here too, and this is the reason they are read HERE rather than
|
|
1296
1334
|
* somewhere of their own: five places in this tool ask for routes, and four of them would
|
|
1297
1335
|
* have had to be found and changed. A Flask app that had routes in one of them and none in
|
|
@@ -1341,7 +1379,40 @@ export async function readFileRoutes(root) {
|
|
|
1341
1379
|
|
|
1342
1380
|
for (const appDir of ['app', 'src/app']) {
|
|
1343
1381
|
await walk(path.join(root, appDir), async (rel, full) => {
|
|
1344
|
-
|
|
1382
|
+
// METADATA FILES ARE ROUTES, and they were invisible. A Next.js site serves
|
|
1383
|
+
// `/sitemap.xml` and `/robots.txt` from `app/sitemap.ts` and `app/robots.ts`, and this
|
|
1384
|
+
// reader knew about neither, so neither was a door and no journey ever asked for either.
|
|
1385
|
+
// Measured 2026-08-31 on a real site: two entries were deleted out of the sitemap and the
|
|
1386
|
+
// run reported nothing whatever. A sitemap is how a search engine finds a product, so a
|
|
1387
|
+
// sitemap that silently stops listing half of it is exactly the kind of break that is
|
|
1388
|
+
// never noticed until the traffic has already gone.
|
|
1389
|
+
//
|
|
1390
|
+
// The file's name decides the address it is served at, and the folder it sits in decides
|
|
1391
|
+
// what comes in front of that — the same rule as `route.ts`, so route groups in brackets
|
|
1392
|
+
// and private folders starting with an underscore drop out the same way. `twitter-image`
|
|
1393
|
+
// and `apple-icon` work identically and are left out only because nothing has measured
|
|
1394
|
+
// them; adding one is adding a line to this list.
|
|
1395
|
+
const asPosix = rel.split(path.sep).join('/');
|
|
1396
|
+
const metadata = /(^|\/)(sitemap|robots|manifest|opengraph-image|icon)\.[cm]?[jt]sx?$/.exec(asPosix);
|
|
1397
|
+
if (metadata) {
|
|
1398
|
+
const under = '/' + path.dirname(rel)
|
|
1399
|
+
.split(path.sep)
|
|
1400
|
+
.filter((one) => one !== '.' && !(one.startsWith('(') && one.endsWith(')')) && !one.startsWith('_'))
|
|
1401
|
+
.join('/');
|
|
1402
|
+
const prefix = under === '/' ? '' : under.replace(/\/$/, '');
|
|
1403
|
+
doors.push({
|
|
1404
|
+
kind: 'route',
|
|
1405
|
+
name: `${prefix}/${SERVED_AS[metadata[2]]}`,
|
|
1406
|
+
detail: 'GET',
|
|
1407
|
+
file: path.relative(root, full),
|
|
1408
|
+
line: 1,
|
|
1409
|
+
inTest: false,
|
|
1410
|
+
named: true,
|
|
1411
|
+
via: 'a Next.js metadata file',
|
|
1412
|
+
});
|
|
1413
|
+
return;
|
|
1414
|
+
}
|
|
1415
|
+
if (!/(^|\/)route\.[cm]?[jt]sx?$/.test(asPosix)) return;
|
|
1345
1416
|
// A folder in brackets is a grouping, not part of the address; one starting with an
|
|
1346
1417
|
// underscore is private and is not routed at all.
|
|
1347
1418
|
const url = '/' + path.dirname(rel)
|
|
@@ -1366,7 +1437,12 @@ export async function readFileRoutes(root) {
|
|
|
1366
1437
|
});
|
|
1367
1438
|
}
|
|
1368
1439
|
|
|
1369
|
-
|
|
1440
|
+
// Astro keeps its endpoints in `src/pages` too, and an Astro project with an `api` folder in
|
|
1441
|
+
// there would otherwise have every endpoint counted twice — once as a Next.js api route with
|
|
1442
|
+
// the verb unknown, once as an Astro endpoint with the verb read. Two doors where there is
|
|
1443
|
+
// one is the same lie as none where there is one, told the other way round.
|
|
1444
|
+
const astro = await looksLikeAstro(root);
|
|
1445
|
+
for (const pagesDir of astro ? ['pages/api'] : ['pages/api', 'src/pages/api']) {
|
|
1370
1446
|
await walk(path.join(root, pagesDir), async (rel, full) => {
|
|
1371
1447
|
if (!/\.[cm]?[jt]sx?$/.test(rel)) return;
|
|
1372
1448
|
const stem = rel.replace(/\.[cm]?[jt]sx?$/, '').split(path.sep).join('/');
|
|
@@ -1378,6 +1454,114 @@ export async function readFileRoutes(root) {
|
|
|
1378
1454
|
});
|
|
1379
1455
|
}
|
|
1380
1456
|
|
|
1457
|
+
// ── SvelteKit ─────────────────────────────────────────────────────────────
|
|
1458
|
+
// `+server.ts` answers requests on the address of the folder it sits in, and the functions
|
|
1459
|
+
// it exports are the verbs — exactly the same idea as a Next.js `route.ts`, spelled
|
|
1460
|
+
// differently. `+page.server.ts` is not an endpoint, but an `actions` export in one IS: it
|
|
1461
|
+
// is where every form on that page posts to, which makes it one of the busiest doors in a
|
|
1462
|
+
// SvelteKit app and the one most worth noticing the disappearance of.
|
|
1463
|
+
for (const routesDir of ['src/routes', 'routes']) {
|
|
1464
|
+
let anyHere = false;
|
|
1465
|
+
await walk(path.join(root, routesDir), async (rel, full) => {
|
|
1466
|
+
const posix = rel.split(path.sep).join('/');
|
|
1467
|
+
const name = posix.split('/').pop() ?? '';
|
|
1468
|
+
const endpoint = /^\+server\.[cm]?[jt]s$/.test(name);
|
|
1469
|
+
const pageServer = /^\+page(@[^.]*)?\.server\.[cm]?[jt]s$/.test(name);
|
|
1470
|
+
if (!endpoint && !pageServer) return;
|
|
1471
|
+
const url = '/' + posix.split('/').slice(0, -1)
|
|
1472
|
+
.filter((s) => s !== '.' && !(s.startsWith('(') && s.endsWith(')')) && !s.startsWith('_'))
|
|
1473
|
+
.join('/');
|
|
1474
|
+
/** @type {string[]} */
|
|
1475
|
+
let verbs = [];
|
|
1476
|
+
try {
|
|
1477
|
+
const reading = readFile(path.relative(root, full), await fsp.readFile(full, 'utf8'));
|
|
1478
|
+
const named = reading.doors
|
|
1479
|
+
.filter((d) => d.kind === 'export' && typeof d.name === 'string')
|
|
1480
|
+
.map((d) => String(d.name));
|
|
1481
|
+
if (endpoint) {
|
|
1482
|
+
verbs = named.filter((n) => /^(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS|fallback)$/.test(n));
|
|
1483
|
+
// An endpoint file that exports nothing readable is still an endpoint. Reporting no
|
|
1484
|
+
// door at all because the verbs would not parse is the failure this whole file
|
|
1485
|
+
// exists to prevent.
|
|
1486
|
+
if (verbs.length === 0) verbs = ['ANY'];
|
|
1487
|
+
} else if (named.includes('actions')) {
|
|
1488
|
+
verbs = ['POST'];
|
|
1489
|
+
}
|
|
1490
|
+
} catch {
|
|
1491
|
+
if (endpoint) verbs = ['ANY'];
|
|
1492
|
+
}
|
|
1493
|
+
if (verbs.length === 0) return;
|
|
1494
|
+
anyHere = true;
|
|
1495
|
+
for (const verb of verbs) {
|
|
1496
|
+
doors.push({
|
|
1497
|
+
kind: 'route', name: url === '/' ? '/' : url.replace(/\/$/, ''), detail: verb,
|
|
1498
|
+
file: path.relative(root, full), line: 1, inTest: false, named: true,
|
|
1499
|
+
via: 'the folder it lives in',
|
|
1500
|
+
});
|
|
1501
|
+
}
|
|
1502
|
+
});
|
|
1503
|
+
// A project has one routes folder, not two. Stopping after the one that had something in
|
|
1504
|
+
// it keeps a stray top-level `routes/` from being read as a second copy of the site.
|
|
1505
|
+
if (anyHere) break;
|
|
1506
|
+
}
|
|
1507
|
+
|
|
1508
|
+
// ── Nuxt ──────────────────────────────────────────────────────────────────
|
|
1509
|
+
// Nuxt names the verb in the filename — `login.post.ts` is a POST — and serves `server/api`
|
|
1510
|
+
// under `/api` while `server/routes` is served at the top level.
|
|
1511
|
+
//
|
|
1512
|
+
// Only read when the project says it is Nuxt, and that gate is not caution for its own sake.
|
|
1513
|
+
// `server/api/orders.js` is an ordinary place to keep an Express handler, and that file
|
|
1514
|
+
// already has its real route read out of the `router.get(...)` call inside it. Walking the
|
|
1515
|
+
// folder as well would invent a second address beside the true one — two doors reported
|
|
1516
|
+
// where one exists, which makes the door count a thing nobody can trust.
|
|
1517
|
+
const nuxt = await looksLikeNuxt(root);
|
|
1518
|
+
for (const [serverDir, prefix] of /** @type {[string, string][]} */ (nuxt ? [
|
|
1519
|
+
['server/api', '/api'], ['server/routes', ''], ['src/server/api', '/api'], ['src/server/routes', ''],
|
|
1520
|
+
] : [])) {
|
|
1521
|
+
await walk(path.join(root, serverDir), async (rel, full) => {
|
|
1522
|
+
if (!/\.[cm]?[jt]s$/.test(rel)) return;
|
|
1523
|
+
const stem = rel.replace(/\.[cm]?[jt]s$/, '').split(path.sep).join('/');
|
|
1524
|
+
const verbInName = stem.match(/\.(get|post|put|patch|delete|head|options)$/i);
|
|
1525
|
+
const cleaned = (verbInName ? stem.slice(0, -verbInName[0].length) : stem).replace(/\/?index$/, '');
|
|
1526
|
+
doors.push({
|
|
1527
|
+
kind: 'route', name: `${prefix}/${cleaned}`.replace(/\/{2,}/g, '/').replace(/(.)\/$/, '$1') || '/',
|
|
1528
|
+
detail: verbInName ? String(verbInName[1]).toUpperCase() : 'ANY',
|
|
1529
|
+
file: path.relative(root, full), line: 1, inTest: false, named: true,
|
|
1530
|
+
via: 'the folder it lives in',
|
|
1531
|
+
});
|
|
1532
|
+
});
|
|
1533
|
+
}
|
|
1534
|
+
|
|
1535
|
+
// ── Astro ─────────────────────────────────────────────────────────────────
|
|
1536
|
+
// Astro keeps its endpoints in the same folder as its pages, and tells them apart by
|
|
1537
|
+
// extension alone: `.astro` is a page, and a plain `.ts` beside it is an endpoint whose
|
|
1538
|
+
// exported function names are the verbs. Only read when the project says it is Astro,
|
|
1539
|
+
// because that same folder belongs to the Next.js pages router in other projects and a
|
|
1540
|
+
// page read as an endpoint would be counted twice.
|
|
1541
|
+
if (astro) {
|
|
1542
|
+
await walk(path.join(root, 'src/pages'), async (rel, full) => {
|
|
1543
|
+
const posix = rel.split(path.sep).join('/');
|
|
1544
|
+
if (!/\.[cm]?[jt]s$/.test(posix) || /\.d\.[cm]?ts$/.test(posix)) return;
|
|
1545
|
+
const stem = posix.replace(/\.[cm]?[jt]s$/, '').replace(/\/?index$/, '');
|
|
1546
|
+
/** @type {string[]} */
|
|
1547
|
+
let verbs = ['ANY'];
|
|
1548
|
+
try {
|
|
1549
|
+
const reading = readFile(path.relative(root, full), await fsp.readFile(full, 'utf8'));
|
|
1550
|
+
const named = reading.doors
|
|
1551
|
+
.filter((d) => d.kind === 'export' && typeof d.name === 'string' && /^(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS|ALL)$/.test(String(d.name)))
|
|
1552
|
+
.map((d) => String(d.name));
|
|
1553
|
+
if (named.length > 0) verbs = named;
|
|
1554
|
+
} catch { /* an unreadable endpoint is still an endpoint */ }
|
|
1555
|
+
for (const verb of verbs) {
|
|
1556
|
+
doors.push({
|
|
1557
|
+
kind: 'route', name: `/${stem}`.replace(/(.)\/$/, '$1'), detail: verb,
|
|
1558
|
+
file: path.relative(root, full), line: 1, inTest: false, named: true,
|
|
1559
|
+
via: 'the folder it lives in',
|
|
1560
|
+
});
|
|
1561
|
+
}
|
|
1562
|
+
});
|
|
1563
|
+
}
|
|
1564
|
+
|
|
1381
1565
|
const python = await readPythonRoutes(root);
|
|
1382
1566
|
doors.push(...python.doors);
|
|
1383
1567
|
problems.push(...python.problems);
|
|
@@ -1385,6 +1569,53 @@ export async function readFileRoutes(root) {
|
|
|
1385
1569
|
return { doors, problems };
|
|
1386
1570
|
}
|
|
1387
1571
|
|
|
1572
|
+
/**
|
|
1573
|
+
* Is this a Nuxt project?
|
|
1574
|
+
*
|
|
1575
|
+
* Same reason as {@link looksLikeAstro}, for a different folder: `server/api` is Nuxt's
|
|
1576
|
+
* routing and also an ordinary place to keep an Express handler, and only one of those two
|
|
1577
|
+
* turns a filename into an address.
|
|
1578
|
+
*
|
|
1579
|
+
* @param {string} root
|
|
1580
|
+
* @returns {Promise<boolean>}
|
|
1581
|
+
*/
|
|
1582
|
+
async function looksLikeNuxt(root) {
|
|
1583
|
+
for (const name of ['nuxt.config.ts', 'nuxt.config.js', 'nuxt.config.mjs']) {
|
|
1584
|
+
if (fs.existsSync(path.join(root, name))) return true;
|
|
1585
|
+
}
|
|
1586
|
+
try {
|
|
1587
|
+
const pkg = JSON.parse(await fsp.readFile(path.join(root, 'package.json'), 'utf8'));
|
|
1588
|
+
const deps = { ...pkg?.dependencies, ...pkg?.devDependencies };
|
|
1589
|
+
return 'nuxt' in deps || 'nuxt3' in deps || 'nuxt-edge' in deps;
|
|
1590
|
+
} catch {
|
|
1591
|
+
return false;
|
|
1592
|
+
}
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1595
|
+
/**
|
|
1596
|
+
* Is this an Astro project?
|
|
1597
|
+
*
|
|
1598
|
+
* It matters for one reason only: `src/pages/thing.ts` is a server endpoint in Astro and a
|
|
1599
|
+
* whole page in the Next.js pages router, and the two folders are spelled identically. Read
|
|
1600
|
+
* the wrong way, a page gets counted as a door it is not, or a door goes unseen. package.json
|
|
1601
|
+
* settles it, and an `astro.config` file settles it for a project that has not installed
|
|
1602
|
+
* anything yet.
|
|
1603
|
+
*
|
|
1604
|
+
* @param {string} root
|
|
1605
|
+
* @returns {Promise<boolean>}
|
|
1606
|
+
*/
|
|
1607
|
+
async function looksLikeAstro(root) {
|
|
1608
|
+
for (const name of ['astro.config.mjs', 'astro.config.js', 'astro.config.ts', 'astro.config.mts']) {
|
|
1609
|
+
if (fs.existsSync(path.join(root, name))) return true;
|
|
1610
|
+
}
|
|
1611
|
+
try {
|
|
1612
|
+
const pkg = JSON.parse(await fsp.readFile(path.join(root, 'package.json'), 'utf8'));
|
|
1613
|
+
return 'astro' in { ...pkg?.dependencies, ...pkg?.devDependencies };
|
|
1614
|
+
} catch {
|
|
1615
|
+
return false;
|
|
1616
|
+
}
|
|
1617
|
+
}
|
|
1618
|
+
|
|
1388
1619
|
/**
|
|
1389
1620
|
* The commands a package installs and the entries it exports, straight out of its own
|
|
1390
1621
|
* package.json. Exact, because npm reads the same field.
|
|
@@ -1625,7 +1856,23 @@ export const sourceAdapter = defineAdapter({
|
|
|
1625
1856
|
* @param {import('./contract.js').PreparedBuild} build
|
|
1626
1857
|
*/
|
|
1627
1858
|
async run(journey, build) {
|
|
1628
|
-
|
|
1859
|
+
// THE FOLDERS THE JOURNEY NAMES, not the guessed ones.
|
|
1860
|
+
//
|
|
1861
|
+
// This walk read the project's source with no folders at all, so it fell back to the
|
|
1862
|
+
// built-in guess — src, lib, app, bin, server, pages, api, electron, main, packages —
|
|
1863
|
+
// while `journeys()` three functions above had already worked out the real list from the
|
|
1864
|
+
// settings and written it into the step. Measured 2026-08-31 on a real Next.js site: the
|
|
1865
|
+
// contract channel recorded 24 observations against 25 doors and never saw `format`,
|
|
1866
|
+
// `slugify` or `emptyCart`, because they live in a folder the guess does not name.
|
|
1867
|
+
//
|
|
1868
|
+
// This is the worst shape a hole in this tool can take. The contract channel exists for
|
|
1869
|
+
// exactly one job — noticing that an exported function has disappeared — and for every
|
|
1870
|
+
// export outside the guessed folders it was not doing that job, silently, on every run,
|
|
1871
|
+
// while the run reported a clean result. The step already carries the answer.
|
|
1872
|
+
const reading = await readContract({
|
|
1873
|
+
root: build.root,
|
|
1874
|
+
folders: Array.isArray(journey.steps?.[0]?.folders) ? journey.steps[0].folders : undefined,
|
|
1875
|
+
});
|
|
1629
1876
|
const fileRoutes = await readFileRoutes(build.root);
|
|
1630
1877
|
reading.doors.push(...fileRoutes.doors);
|
|
1631
1878
|
// A folder the route walk could not open is a hole in the door list, and the door list is
|
package/src/v2/adapters/web.js
CHANGED
|
@@ -43,7 +43,7 @@ import path from 'node:path';
|
|
|
43
43
|
|
|
44
44
|
import {
|
|
45
45
|
countBucket, defineAdapter, howLongItTook, joinPath, notCovered, observation, sizeBucket,
|
|
46
|
-
timeBucket, trimForStorage, undoOurFootprint,
|
|
46
|
+
timeBucket, trimForStorage, undoOurFootprint, whatItSaid,
|
|
47
47
|
} from './contract.js';
|
|
48
48
|
import { copyForScratch, frozenEnvironment } from './process.js';
|
|
49
49
|
import { freePort, looksDestructive, waitForServer } from './http.js';
|
|
@@ -298,7 +298,16 @@ export function journeysFrom(input) {
|
|
|
298
298
|
surface: 'web',
|
|
299
299
|
from: page.file,
|
|
300
300
|
channels: ['meaning', 'effects', 'complaints', 'results', 'counters', 'pixels'],
|
|
301
|
-
|
|
301
|
+
// `door`, `kind` and `doorDetail` are how the coverage ledger learns that this journey
|
|
302
|
+
// walked that page. Without them a page counted as opened only if an observation happened
|
|
303
|
+
// to land at the page's own address, and this adapter writes everything under
|
|
304
|
+
// `screen.<journey name>` — so every page of every site read as never walked, on runs
|
|
305
|
+
// that had just opened all of them. The HTTP adapter has named its doors this way since
|
|
306
|
+
// it was written; this is the same three fields, for the same reason. A page is reached
|
|
307
|
+
// by asking for its address, so the verb is GET.
|
|
308
|
+
steps: /** @type {any} */ ([
|
|
309
|
+
{ act: 'open', goto: url, note: `open ${page.url}`, unfilled, door: page.url, kind: 'route', doorDetail: 'GET' },
|
|
310
|
+
]),
|
|
302
311
|
});
|
|
303
312
|
}
|
|
304
313
|
|
|
@@ -578,11 +587,20 @@ export const webAdapter = defineAdapter({
|
|
|
578
587
|
});
|
|
579
588
|
if (!up.up) {
|
|
580
589
|
await stopServer(child);
|
|
590
|
+
// The app's own words go first, ahead of the wait's account of which loopback address it
|
|
591
|
+
// knocked on. Same measurement, same date as the HTTP adapter's copy of this: the reason
|
|
592
|
+
// a site will not boot is one line the runtime printed, the sentence built from this is
|
|
593
|
+
// trimmed to 160 characters where a person reads it, and anything after the first
|
|
594
|
+
// sentence was therefore never seen by anybody.
|
|
595
|
+
const printed = Buffer.concat(said).toString('utf8');
|
|
596
|
+
const headline = whatItSaid(printed, { mostLines: 1 });
|
|
581
597
|
return {
|
|
582
598
|
build,
|
|
583
599
|
root: work,
|
|
584
600
|
ready: false,
|
|
585
|
-
why: `${up.why} What it printed while trying: ${
|
|
601
|
+
why: `${headline ? `It said: ${headline}. ` : ''}${up.why} What it printed while trying, in full: ${
|
|
602
|
+
trimForStorage(printed, 1500).text || '(nothing)'
|
|
603
|
+
}`,
|
|
586
604
|
dispose: async () => {
|
|
587
605
|
await stopServer(child);
|
|
588
606
|
await fsp.rm(base, { recursive: true, force: true });
|
|
@@ -984,10 +1002,34 @@ async function lookAt(input) {
|
|
|
984
1002
|
export function describeTraffic(journey, calls, footprint) {
|
|
985
1003
|
/** @type {Observation[]} */
|
|
986
1004
|
const out = [];
|
|
1005
|
+
/** Addresses that only exist because of a request WE cancelled. See below. */
|
|
1006
|
+
const ourOwnFootprint = [];
|
|
987
1007
|
for (const call of calls) {
|
|
988
1008
|
const asked = `${call.method} ${call.pattern}`;
|
|
989
1009
|
const where = joinPath('net', journey.name, asked);
|
|
990
1010
|
|
|
1011
|
+
// A REQUEST OUR OWN TEARDOWN CANCELLED IS NOT PART OF THE PRODUCT'S ADDRESS LIST.
|
|
1012
|
+
//
|
|
1013
|
+
// Next.js starts a prefetch behind every internal link, on its own, with nobody asking.
|
|
1014
|
+
// Closing the page cancels whatever is still in flight. Whether a given prefetch got far
|
|
1015
|
+
// enough to become a call at all is therefore a race between it and our own teardown — so
|
|
1016
|
+
// the ADDRESSES it produces exist on one pass of a build and not on the other. Measured
|
|
1017
|
+
// 2026-08-31 across eight runs a side: with nothing timing out the total is 852 every time,
|
|
1018
|
+
// and about 90 of those 852 — roughly one in nine — were reached by only one of the two
|
|
1019
|
+
// passes. Two byte-identical passes disagreeing about which addresses exist is the
|
|
1020
|
+
// measurement contradicting its own method.
|
|
1021
|
+
//
|
|
1022
|
+
// An earlier fix stopped these being reported as the product complaining, which killed the
|
|
1023
|
+
// phantom findings; it could not stop them moving the count, because the address is still
|
|
1024
|
+
// created on the pass that saw it. This is that fix taken one step further: the whole call
|
|
1025
|
+
// contributes nothing, because none of it is a fact about the product — it is a fact about
|
|
1026
|
+
// when we closed the browser. Nothing is dropped silently: how many there were, and which,
|
|
1027
|
+
// is said in one observation at a fixed address at the end of this function.
|
|
1028
|
+
if (call.unfinishedAtTeardown && !call.refused && !call.failed) {
|
|
1029
|
+
ourOwnFootprint.push(asked);
|
|
1030
|
+
continue;
|
|
1031
|
+
}
|
|
1032
|
+
|
|
991
1033
|
if (call.refused) {
|
|
992
1034
|
out.push(
|
|
993
1035
|
notCovered({
|
|
@@ -1045,22 +1087,6 @@ export function describeTraffic(journey, calls, footprint) {
|
|
|
1045
1087
|
surface: 'web',
|
|
1046
1088
|
}),
|
|
1047
1089
|
);
|
|
1048
|
-
} else if (call.unfinishedAtTeardown) {
|
|
1049
|
-
// Missing coverage, not a complaint. The page was still asking for this when the walk
|
|
1050
|
-
// ended, and the abort that followed is this tool closing the page — reporting it as
|
|
1051
|
-
// the product failing made an unchanged tree look broken in four runs out of five.
|
|
1052
|
-
// Recorded rather than dropped: something was being asked for and how it ended was
|
|
1053
|
-
// never seen, which is a hole, and a hole is louder than a complaint, never quieter.
|
|
1054
|
-
out.push(
|
|
1055
|
-
notCovered({
|
|
1056
|
-
channel: 'effects',
|
|
1057
|
-
path: `${where}.how it finished`,
|
|
1058
|
-
reason: 'refused',
|
|
1059
|
-
says:
|
|
1060
|
-
`The page was still asking for ${asked} when the walk ended, so how that finished was never seen. ` +
|
|
1061
|
-
`The request was cancelled by this tool closing the page, which is not the product doing anything wrong.`,
|
|
1062
|
-
}),
|
|
1063
|
-
);
|
|
1064
1090
|
}
|
|
1065
1091
|
if (call.status !== undefined) {
|
|
1066
1092
|
out.push(
|
|
@@ -1100,6 +1126,30 @@ export function describeTraffic(journey, calls, footprint) {
|
|
|
1100
1126
|
);
|
|
1101
1127
|
}
|
|
1102
1128
|
}
|
|
1129
|
+
|
|
1130
|
+
// Said out loud, at ONE address that does not move, rather than at an address per request.
|
|
1131
|
+
// The count and the list live in the sentence, which is never compared, so this can report a
|
|
1132
|
+
// different number on two passes without ever reporting a difference. It is a hole, and it is
|
|
1133
|
+
// named as one: something was being asked for and how it ended was never seen.
|
|
1134
|
+
if (ourOwnFootprint.length > 0) {
|
|
1135
|
+
const many = ourOwnFootprint.length !== 1;
|
|
1136
|
+
out.push(
|
|
1137
|
+
notCovered({
|
|
1138
|
+
channel: 'effects',
|
|
1139
|
+
path: joinPath('net', journey.name, 'requests still in flight when the walk ended'),
|
|
1140
|
+
reason: 'refused',
|
|
1141
|
+
says:
|
|
1142
|
+
`${ourOwnFootprint.length} request${many ? 's were' : ' was'} still in flight when this walk ended, and ` +
|
|
1143
|
+
`${many ? 'they were' : 'it was'} cancelled by this tool closing the page rather than by anything the ` +
|
|
1144
|
+
`product did — a framework that prefetches behind every link starts these on its own. How ${many ? 'they' : 'it'} ` +
|
|
1145
|
+
`would have finished was never seen: ${ourOwnFootprint.sort().slice(0, 8).join(', ')}` +
|
|
1146
|
+
`${ourOwnFootprint.length > 8 ? `, and ${ourOwnFootprint.length - 8} more` : ''}. ` +
|
|
1147
|
+
`Each one is kept out of the address list on purpose, because whether a cancelled request got far enough ` +
|
|
1148
|
+
`to make an address is a race with our own teardown, and two passes of one build must not disagree about ` +
|
|
1149
|
+
`which addresses exist.`,
|
|
1150
|
+
}),
|
|
1151
|
+
);
|
|
1152
|
+
}
|
|
1103
1153
|
return out;
|
|
1104
1154
|
}
|
|
1105
1155
|
|