staysfixed 0.3.0 → 0.4.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/README.md +534 -402
- package/package.json +8 -3
- package/src/cli/index.js +14 -0
- package/src/v2/adapters/android-driver.js +1705 -0
- package/src/v2/adapters/android.js +1117 -0
- package/src/v2/adapters/contract.js +565 -0
- package/src/v2/adapters/electron.js +1594 -0
- package/src/v2/adapters/http.js +733 -0
- package/src/v2/adapters/ios-driver.js +1551 -0
- package/src/v2/adapters/ios.js +989 -0
- package/src/v2/adapters/isolate.js +739 -0
- package/src/v2/adapters/process.js +920 -0
- package/src/v2/adapters/source.js +1241 -0
- package/src/v2/adapters/web-driver.js +1532 -0
- package/src/v2/adapters/web.js +1009 -0
- package/src/v2/adapters/windows.js +1329 -0
- package/src/v2/browsers.js +1203 -0
- package/src/v2/cause.js +364 -0
- package/src/v2/check.js +1331 -0
- package/src/v2/ci.js +1209 -0
- package/src/v2/cli.js +657 -0
- package/src/v2/cluster.js +372 -0
- package/src/v2/coverage.js +1116 -0
- package/src/v2/detect.js +1199 -0
- package/src/v2/doctor.js +1690 -0
- package/src/v2/escalate.js +679 -0
- package/src/v2/init.js +1394 -0
- package/src/v2/intent.js +659 -0
- package/src/v2/journeys/from-routes.js +498 -0
- package/src/v2/journeys/from-suite.js +988 -0
- package/src/v2/journeys/index.js +651 -0
- package/src/v2/journeys/record.js +516 -0
- package/src/v2/mcp/server.js +374 -0
- package/src/v2/mcp/tools.js +1571 -0
- package/src/v2/normalise.js +783 -0
- package/src/v2/observation.js +877 -0
- package/src/v2/rank.js +672 -0
- package/src/v2/reference.js +1051 -0
- package/src/v2/remote.js +911 -0
- package/src/v2/run.js +964 -0
- package/src/v2/sealed.js +564 -0
- package/src/v2/selfcheck.js +564 -0
- package/src/v2/ship.js +684 -0
- package/src/v2/store.js +703 -0
- package/src/v2/types.js +503 -0
- package/src/v2/waiver.js +511 -0
- package/src/watch/panel.js +73 -44
|
@@ -0,0 +1,733 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Servers and APIs.
|
|
3
|
+
*
|
|
4
|
+
* The shape of this one is the same as the CLI adapter's, with the expensive part moved:
|
|
5
|
+
* booting a server is slow and answering a request is fast, so the boot happens once per
|
|
6
|
+
* build in `prepare` and every route is walked against the one running copy. What each
|
|
7
|
+
* route gives back — its status, the headers that mean something, its body — is the
|
|
8
|
+
* results channel. What it QUIETLY DID while answering — the files it wrote, the services
|
|
9
|
+
* it called — is the effects channel, and that is the half a response-body diff misses
|
|
10
|
+
* entirely. A route that still returns `{"ok":true}` but has stopped writing the record is
|
|
11
|
+
* broken, and only the second half sees it.
|
|
12
|
+
*
|
|
13
|
+
* WHERE THE ROUTES COME FROM. Out of the source, never out of a crawl. Crawling finds the
|
|
14
|
+
* pages somebody linked to; the source lists every route there is, including the four
|
|
15
|
+
* nobody links to and the one that was deleted this morning. It is also free, exact, and
|
|
16
|
+
* does not need the server running to produce the list.
|
|
17
|
+
*
|
|
18
|
+
* THE PORT AND THE DATA. Every boot gets a port nobody else is on, a scratch copy of the
|
|
19
|
+
* project, a scratch home folder and a restored fixture, so two builds walked minutes apart
|
|
20
|
+
* see the same rows. The two builds are NEVER booted at the same time: two servers on one
|
|
21
|
+
* machine fight over ports, locks and data directories, and that fight looks exactly like a
|
|
22
|
+
* regression.
|
|
23
|
+
*
|
|
24
|
+
* WHAT IT REFUSES. Outbound connections, all of them, at the socket. The server may ask its
|
|
25
|
+
* payment provider for a charge; the ask is recorded — same amount, same currency, same
|
|
26
|
+
* endpoint — and the connection never completes. A migration that destroys data is not run
|
|
27
|
+
* twice; it is not run at all, and the run says so. Neither refusal is ever reported as a
|
|
28
|
+
* pass.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import fsp from 'node:fs/promises';
|
|
32
|
+
import net from 'node:net';
|
|
33
|
+
import path from 'node:path';
|
|
34
|
+
import { spawn } from 'node:child_process';
|
|
35
|
+
import {
|
|
36
|
+
defineAdapter, joinPath, notCovered, observation, sizeBucket, stableValue,
|
|
37
|
+
timeBucket, trimForStorage, undoOurFootprint,
|
|
38
|
+
} from './contract.js';
|
|
39
|
+
import {
|
|
40
|
+
compareTrees, copyForScratch, frozenEnvironment, readWatcher, snapshotTree, watcherScript,
|
|
41
|
+
} from './process.js';
|
|
42
|
+
import { readContract, readFileRoutes } from './source.js';
|
|
43
|
+
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
// Headers
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The headers worth comparing.
|
|
50
|
+
*
|
|
51
|
+
* Short on purpose. `date` changes every second, `content-length` is just the body counted
|
|
52
|
+
* again, `server` and `connection` belong to the runtime rather than to the product. What
|
|
53
|
+
* is left is the set a client's behaviour actually depends on: what type the body is, where
|
|
54
|
+
* it was redirected to, what it is allowed to cache, who it may be shared with, and what it
|
|
55
|
+
* says when it says no.
|
|
56
|
+
*/
|
|
57
|
+
const HEADERS_THAT_MATTER = new Set([
|
|
58
|
+
'content-type', 'content-language', 'content-disposition', 'content-encoding',
|
|
59
|
+
'cache-control', 'location', 'allow', 'vary', 'retry-after', 'www-authenticate',
|
|
60
|
+
'access-control-allow-origin', 'access-control-allow-methods', 'x-frame-options',
|
|
61
|
+
'content-security-policy', 'strict-transport-security', 'x-content-type-options',
|
|
62
|
+
]);
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Pull out the headers worth comparing, plus two derived facts.
|
|
66
|
+
*
|
|
67
|
+
* A cookie's VALUE is a session id — new on every request, and comparing it would report a
|
|
68
|
+
* difference every single run. Its NAME is the promise, and a route that stopped setting
|
|
69
|
+
* the session cookie is a real regression, so the names are kept and the values are not.
|
|
70
|
+
* An etag is the same story one level down: whether there is one is a promise, what it says
|
|
71
|
+
* is a hash of the body, which is already being compared.
|
|
72
|
+
*
|
|
73
|
+
* @param {Headers} headers
|
|
74
|
+
* @returns {Record<string, string|string[]>}
|
|
75
|
+
*/
|
|
76
|
+
export function headersThatMatter(headers) {
|
|
77
|
+
/** @type {Record<string, string|string[]>} */
|
|
78
|
+
const kept = {};
|
|
79
|
+
for (const [name, value] of headers) {
|
|
80
|
+
if (HEADERS_THAT_MATTER.has(name.toLowerCase())) kept[name.toLowerCase()] = value;
|
|
81
|
+
}
|
|
82
|
+
const cookies = headers.getSetCookie?.() ?? [];
|
|
83
|
+
if (cookies.length > 0) {
|
|
84
|
+
kept['sets cookies named'] = cookies.map((c) => c.split('=')[0].trim()).sort();
|
|
85
|
+
}
|
|
86
|
+
if (headers.has('etag')) kept['has an etag'] = 'yes';
|
|
87
|
+
return kept;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
// Bodies
|
|
92
|
+
// ---------------------------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* @typedef {object} ReadBody
|
|
96
|
+
* @property {import('./contract.js').JsonValue} value What is compared.
|
|
97
|
+
* @property {import('./contract.js').JsonValue} [shape] The key paths and their types, when
|
|
98
|
+
* the body is JSON. Steady while the
|
|
99
|
+
* values churn, so an added or removed
|
|
100
|
+
* field shows up on its own.
|
|
101
|
+
* @property {number} bytes
|
|
102
|
+
* @property {boolean} truncated
|
|
103
|
+
*/
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Turn a response body into something comparable.
|
|
107
|
+
*
|
|
108
|
+
* JSON gets its keys sorted, because two runs can build the same object in a different
|
|
109
|
+
* order and that is not a difference. Anything that is not text at all is reduced to its
|
|
110
|
+
* size and a fingerprint: an image that changed says so, and nobody has to store the image
|
|
111
|
+
* to find out.
|
|
112
|
+
*
|
|
113
|
+
* @param {string} contentType
|
|
114
|
+
* @param {string} text
|
|
115
|
+
* @returns {ReadBody}
|
|
116
|
+
*/
|
|
117
|
+
export function readBody(contentType, text) {
|
|
118
|
+
const bytes = Buffer.byteLength(text, 'utf8');
|
|
119
|
+
const type = contentType.toLowerCase();
|
|
120
|
+
|
|
121
|
+
// A 204, a HEAD, a redirect. An empty body is a perfectly good answer and saying it
|
|
122
|
+
// "claimed to be JSON and was not" would be a difference reported on every single run.
|
|
123
|
+
if (text === '') return { value: 'nothing at all', bytes: 0, truncated: false };
|
|
124
|
+
|
|
125
|
+
if (type.includes('json')) {
|
|
126
|
+
try {
|
|
127
|
+
const parsed = JSON.parse(text);
|
|
128
|
+
return { value: stableValue(parsed), shape: shapeOf(parsed), bytes, truncated: false };
|
|
129
|
+
} catch {
|
|
130
|
+
// A route that claims JSON and sends something else is itself the finding.
|
|
131
|
+
return { value: `said it was JSON but was not: ${trimForStorage(text, 2000).text}`, bytes, truncated: false };
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
const kept = trimForStorage(text);
|
|
135
|
+
return { value: kept.text, bytes, truncated: kept.truncated };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* The key paths in a JSON value and the type at each, with every array collapsed to "a list
|
|
140
|
+
* of N things shaped like this".
|
|
141
|
+
*
|
|
142
|
+
* This is the channel that catches a field being renamed while every value stays plausible.
|
|
143
|
+
* Comparing the body alone catches it too, but it catches it buried inside a diff of the
|
|
144
|
+
* whole body; comparing the shape separately makes it a finding of its own with a name.
|
|
145
|
+
*
|
|
146
|
+
* @param {unknown} value
|
|
147
|
+
* @param {string} [at]
|
|
148
|
+
* @returns {import('./contract.js').JsonValue}
|
|
149
|
+
*/
|
|
150
|
+
export function shapeOf(value, at = '') {
|
|
151
|
+
if (value === null) return 'nothing';
|
|
152
|
+
if (Array.isArray(value)) {
|
|
153
|
+
if (value.length === 0) return 'an empty list';
|
|
154
|
+
return { 'a list of': value.length, 'each one': shapeOf(value[0], `${at}[]`) };
|
|
155
|
+
}
|
|
156
|
+
if (typeof value === 'object') {
|
|
157
|
+
/** @type {Record<string, import('./contract.js').JsonValue>} */
|
|
158
|
+
const out = {};
|
|
159
|
+
for (const key of Object.keys(value).sort()) out[key] = shapeOf(/** @type {any} */ (value)[key], `${at}.${key}`);
|
|
160
|
+
return out;
|
|
161
|
+
}
|
|
162
|
+
return typeof value;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ---------------------------------------------------------------------------
|
|
166
|
+
// Ports and booting
|
|
167
|
+
// ---------------------------------------------------------------------------
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Find a port nobody is using, by briefly being the one using it.
|
|
171
|
+
*
|
|
172
|
+
* There is a gap between letting go of the port and the server taking it, and something
|
|
173
|
+
* else can slip in. Nothing can close that gap on any operating system, so the boot retries
|
|
174
|
+
* instead of pretending it cannot happen.
|
|
175
|
+
*
|
|
176
|
+
* @returns {Promise<number>}
|
|
177
|
+
*/
|
|
178
|
+
export function freePort() {
|
|
179
|
+
return new Promise((resolve, reject) => {
|
|
180
|
+
const probe = net.createServer();
|
|
181
|
+
probe.on('error', reject);
|
|
182
|
+
probe.listen(0, '127.0.0.1', () => {
|
|
183
|
+
const address = probe.address();
|
|
184
|
+
const port = typeof address === 'object' && address ? address.port : 0;
|
|
185
|
+
probe.close(() => (port ? resolve(port) : reject(new Error('the operating system did not give us a port'))));
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Wait until something is listening, or give up.
|
|
192
|
+
* @param {number} port
|
|
193
|
+
* @param {object} [opts]
|
|
194
|
+
* @param {number} [opts.timeoutMs]
|
|
195
|
+
* @param {() => string|null} [opts.crashed] Called between tries; a string means stop now.
|
|
196
|
+
* @returns {Promise<{up: boolean, why: string, ms: number}>}
|
|
197
|
+
*/
|
|
198
|
+
export async function waitForServer(port, opts = {}) {
|
|
199
|
+
const timeoutMs = opts.timeoutMs ?? 60000;
|
|
200
|
+
const started = Date.now();
|
|
201
|
+
for (;;) {
|
|
202
|
+
const crash = opts.crashed?.();
|
|
203
|
+
if (crash) return { up: false, why: crash, ms: Date.now() - started };
|
|
204
|
+
const open = await new Promise((resolve) => {
|
|
205
|
+
const socket = net.connect({ port, host: '127.0.0.1' });
|
|
206
|
+
const done = (/** @type {boolean} */ answer) => { socket.destroy(); resolve(answer); };
|
|
207
|
+
socket.setTimeout(1000);
|
|
208
|
+
socket.on('connect', () => done(true));
|
|
209
|
+
socket.on('error', () => done(false));
|
|
210
|
+
socket.on('timeout', () => done(false));
|
|
211
|
+
});
|
|
212
|
+
if (open) return { up: true, why: `The server answered on port ${port}.`, ms: Date.now() - started };
|
|
213
|
+
if (Date.now() - started > timeoutMs) {
|
|
214
|
+
return { up: false, why: `The server never answered on port ${port} within ${timeoutMs / 1000} seconds.`, ms: Date.now() - started };
|
|
215
|
+
}
|
|
216
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Commands that would destroy data if they were run.
|
|
222
|
+
*
|
|
223
|
+
* Deliberately blunt. The cost of stopping a restore that was actually safe is a line in the
|
|
224
|
+
* report saying so; the cost of running one that was not is somebody's data, twice.
|
|
225
|
+
*/
|
|
226
|
+
const DESTRUCTIVE = /\b(drop\s+(database|schema|table)|truncate\b|delete\s+from\b(?![^;]*\bwhere\b)|rm\s+-rf|--force-reset|db\s+push\s+--accept-data-loss|migrate\s+reset)\b/i;
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* @param {string} command
|
|
230
|
+
* @returns {{safe: boolean, why: string}}
|
|
231
|
+
*/
|
|
232
|
+
export function looksDestructive(command) {
|
|
233
|
+
const match = DESTRUCTIVE.exec(command);
|
|
234
|
+
if (!match) return { safe: true, why: 'Nothing in this command destroys data.' };
|
|
235
|
+
return {
|
|
236
|
+
safe: false,
|
|
237
|
+
why: `This command contains "${match[0]}", which destroys data. It was not run. Everything that depended on it is reported as not checked, which is a hole in the check, not a pass.`,
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// ---------------------------------------------------------------------------
|
|
242
|
+
// Routes, out of the source
|
|
243
|
+
// ---------------------------------------------------------------------------
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* @typedef {object} RouteJourneyDetail
|
|
247
|
+
* @property {string} method
|
|
248
|
+
* @property {string} route The route as the code writes it, params and all.
|
|
249
|
+
* @property {string} url The route with sample values filled in.
|
|
250
|
+
* @property {string[]} unfilled Parameters nobody gave us a value for.
|
|
251
|
+
* @property {Record<string,string>} [headers]
|
|
252
|
+
* @property {import('./contract.js').JsonValue} [body]
|
|
253
|
+
*/
|
|
254
|
+
|
|
255
|
+
/** The two ways a route says "a value goes here". */
|
|
256
|
+
const PARAM = /:([A-Za-z0-9_]+)|\[\.{3}?([A-Za-z0-9_]+)\]/g;
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Fill a route's parameters in from the samples the project supplied.
|
|
260
|
+
*
|
|
261
|
+
* A route with a parameter nobody has given a sample for is NOT quietly skipped and NOT
|
|
262
|
+
* guessed at with a 1. It is walked as far as it can be and reported as needing a sample,
|
|
263
|
+
* because "we did not check this" and "this is fine" are the two answers that must never be
|
|
264
|
+
* allowed to look alike.
|
|
265
|
+
*
|
|
266
|
+
* @param {string} route
|
|
267
|
+
* @param {Record<string,string>} samples
|
|
268
|
+
* @returns {{url: string, unfilled: string[]}}
|
|
269
|
+
*/
|
|
270
|
+
export function fillRoute(route, samples) {
|
|
271
|
+
/** @type {string[]} */
|
|
272
|
+
const unfilled = [];
|
|
273
|
+
const url = route.replace(PARAM, (whole, colon, bracket) => {
|
|
274
|
+
const name = colon ?? bracket;
|
|
275
|
+
const sample = samples[name];
|
|
276
|
+
if (sample === undefined) { unfilled.push(name); return whole; }
|
|
277
|
+
return encodeURIComponent(sample);
|
|
278
|
+
});
|
|
279
|
+
return { url, unfilled };
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// ---------------------------------------------------------------------------
|
|
283
|
+
// The adapter
|
|
284
|
+
// ---------------------------------------------------------------------------
|
|
285
|
+
|
|
286
|
+
/** What one prepared build is holding open. */
|
|
287
|
+
const running = new Map();
|
|
288
|
+
|
|
289
|
+
export const httpAdapter = defineAdapter({
|
|
290
|
+
name: 'http',
|
|
291
|
+
title: 'Servers and APIs',
|
|
292
|
+
describe:
|
|
293
|
+
'Boots the server once on a port nobody else is on, with a restored fixture and a scratch copy of the project, then walks every route read out of the source and reports what came back and what the server quietly did while answering. Every outbound connection is recorded and refused, and a restore command that would destroy data is not run at all. Routes with a parameter nobody has given a sample value for are reported as needing one, never skipped silently.',
|
|
294
|
+
channels: ['results', 'complaints', 'effects', 'counters'],
|
|
295
|
+
|
|
296
|
+
/** @param {import('./contract.js').AdapterProject} project */
|
|
297
|
+
async detect(project) {
|
|
298
|
+
const config = project.config ?? {};
|
|
299
|
+
/** @type {import('./contract.js').Missing[]} */
|
|
300
|
+
const missing = [];
|
|
301
|
+
|
|
302
|
+
let pkg = null;
|
|
303
|
+
try { pkg = JSON.parse(await fsp.readFile(path.join(project.root, 'package.json'), 'utf8')); } catch { /* fine */ }
|
|
304
|
+
const dependencies = { ...pkg?.dependencies, ...pkg?.devDependencies };
|
|
305
|
+
const framework = ['express', 'fastify', 'hono', 'koa', 'next', 'polka', '@hapi/hapi']
|
|
306
|
+
.find((name) => name in dependencies);
|
|
307
|
+
|
|
308
|
+
const reading = await readContract({ root: project.root });
|
|
309
|
+
const routes = [...reading.doors.filter((d) => d.kind === 'route'), ...await readFileRoutes(project.root)];
|
|
310
|
+
|
|
311
|
+
if (!config.start) {
|
|
312
|
+
missing.push({
|
|
313
|
+
what: 'the command that starts the server',
|
|
314
|
+
unlocks: 'everything — the routes can be listed from the source without it, but none of them can be walked',
|
|
315
|
+
howToGet: pkg?.scripts?.start
|
|
316
|
+
? `This project has "npm start". Put {"start": "npm start"} under "http" in the config if that is the right one.`
|
|
317
|
+
: 'Put {"start": "..."} under "http" in the config, and use the PORT environment variable it is given.',
|
|
318
|
+
blocking: true,
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
if (!config.restore) {
|
|
322
|
+
missing.push({
|
|
323
|
+
what: 'a way to put the data back how it was',
|
|
324
|
+
unlocks: 'comparing two builds against the same rows — without it the two runs see whatever the first one left behind, and every difference after the first write is meaningless',
|
|
325
|
+
howToGet: 'Put {"restore": "..."} under "http" in the config: a command that resets the database or the data folder to a known state. It must not be one that destroys data it cannot rebuild.',
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
const withParams = routes.filter((r) => PARAM.test(r.name)).length;
|
|
329
|
+
PARAM.lastIndex = 0;
|
|
330
|
+
if (withParams > 0 && !config.samples) {
|
|
331
|
+
missing.push({
|
|
332
|
+
what: `sample values for the parameters in ${withParams} route${withParams === 1 ? '' : 's'}`,
|
|
333
|
+
unlocks: `walking ${withParams === 1 ? 'that route' : 'those routes'} at all instead of reporting ${withParams === 1 ? 'it' : 'them'} as unchecked`,
|
|
334
|
+
howToGet: 'Put {"samples": {"id": "1", "slug": "..."}} under "http" in the config — one real value per parameter name.',
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const applies = routes.length > 0 || Boolean(config.start) || Boolean(framework);
|
|
339
|
+
return {
|
|
340
|
+
applies,
|
|
341
|
+
confidence: config.start ? (routes.length > 0 ? 1 : 0.6) : 0.3,
|
|
342
|
+
why: applies
|
|
343
|
+
? `${routes.length} route${routes.length === 1 ? '' : 's'} ${routes.length === 1 ? 'was' : 'were'} read out of the source${framework ? `, and this project uses ${framework}` : ''}. ${config.start ? 'There is a command to start it.' : 'There is no command to start it yet, so nothing can be walked.'}`
|
|
344
|
+
: 'No routes were found in the source and no web framework is installed, so this does not look like a server.',
|
|
345
|
+
missing,
|
|
346
|
+
notes: [
|
|
347
|
+
'Routes come from reading the code, not from crawling — so a route nobody links to is checked like any other.',
|
|
348
|
+
'The two builds are booted one after the other, never at the same time. Two servers on one machine fight over the port and the data, and that fight looks exactly like a regression.',
|
|
349
|
+
],
|
|
350
|
+
};
|
|
351
|
+
},
|
|
352
|
+
|
|
353
|
+
/** @param {import('./contract.js').AdapterProject} project */
|
|
354
|
+
async journeys(project) {
|
|
355
|
+
const config = project.config ?? {};
|
|
356
|
+
const samples = config.samples ?? {};
|
|
357
|
+
const reading = await readContract({ root: project.root });
|
|
358
|
+
const routes = [...reading.doors.filter((d) => d.kind === 'route'), ...await readFileRoutes(project.root)];
|
|
359
|
+
|
|
360
|
+
/** @type {Map<string, import('./contract.js').Journey>} */
|
|
361
|
+
const journeys = new Map();
|
|
362
|
+
for (const route of routes) {
|
|
363
|
+
const method = route.detail === 'MOUNT' || route.detail === 'ANY' ? 'GET' : route.detail;
|
|
364
|
+
const { url, unfilled } = fillRoute(route.name, samples);
|
|
365
|
+
const id = `${method} ${route.name}`;
|
|
366
|
+
if (journeys.has(id)) continue;
|
|
367
|
+
journeys.set(id, {
|
|
368
|
+
name: id,
|
|
369
|
+
describe: `ask the server for ${method} ${route.name}`,
|
|
370
|
+
source: 'code',
|
|
371
|
+
surface: 'server',
|
|
372
|
+
from: route.file,
|
|
373
|
+
channels: ['results', 'complaints', 'effects', 'counters'],
|
|
374
|
+
steps: [{ act: 'request', method, route: route.name, url, unfilled }],
|
|
375
|
+
// A route that changes something is walked — against a restored fixture, that is
|
|
376
|
+
// the whole point. Only a route the project itself marks as irreversible is held
|
|
377
|
+
// back, and even then only when nothing is watching to refuse the effect.
|
|
378
|
+
irreversible: (config.irreversible ?? []).includes(route.name),
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
for (const extra of config.requests ?? []) {
|
|
383
|
+
const { url, unfilled } = fillRoute(String(extra.url ?? extra.route), samples);
|
|
384
|
+
const id = String(extra.name ?? `${extra.method ?? 'GET'} ${extra.url}`);
|
|
385
|
+
journeys.set(id, {
|
|
386
|
+
name: id,
|
|
387
|
+
describe: String(extra.describe ?? extra.why ?? `ask the server for ${extra.method ?? 'GET'} ${extra.url}`),
|
|
388
|
+
source: 'code',
|
|
389
|
+
surface: 'server',
|
|
390
|
+
from: 'the project config',
|
|
391
|
+
channels: ['results', 'complaints', 'effects', 'counters'],
|
|
392
|
+
steps: [{ act: 'request', method: String(extra.method ?? 'GET'), route: String(extra.url), url, unfilled, headers: extra.headers, body: extra.body }],
|
|
393
|
+
irreversible: extra.irreversible === true,
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
return [...journeys.values()].sort((a, b) => a.name.localeCompare(b.name));
|
|
398
|
+
},
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* @param {import('./contract.js').Build} build
|
|
402
|
+
* @param {import('./contract.js').RunContext} ctx
|
|
403
|
+
*/
|
|
404
|
+
async prepare(build, ctx) {
|
|
405
|
+
const config = ctx.config ?? /** @type {any} */ (build).config ?? {};
|
|
406
|
+
const base = path.join(ctx.scratchDir, `server-${build.id.slice(0, 12)}`);
|
|
407
|
+
const work = path.join(base, 'work');
|
|
408
|
+
const home = path.join(base, 'home');
|
|
409
|
+
const tmp = path.join(base, 'tmp');
|
|
410
|
+
await fsp.mkdir(home, { recursive: true });
|
|
411
|
+
await fsp.mkdir(tmp, { recursive: true });
|
|
412
|
+
|
|
413
|
+
/** @type {string[]} */
|
|
414
|
+
const notes = [];
|
|
415
|
+
const copy = await copyForScratch(build.root, work);
|
|
416
|
+
if (!copy.copied) {
|
|
417
|
+
return { build, root: work, ready: false, why: copy.why, dispose: async () => { await fsp.rm(base, { recursive: true, force: true }); } };
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
const port = await freePort();
|
|
421
|
+
const reportFile = path.join(base, 'watch.jsonl');
|
|
422
|
+
const watcher = path.join(base, 'watcher.mjs');
|
|
423
|
+
await fsp.writeFile(watcher, watcherScript({ reportFile, allowLoopback: true }), 'utf8');
|
|
424
|
+
|
|
425
|
+
const env = frozenEnvironment({
|
|
426
|
+
clock: ctx.clock,
|
|
427
|
+
seed: ctx.seed,
|
|
428
|
+
home,
|
|
429
|
+
tmp,
|
|
430
|
+
extra: {
|
|
431
|
+
PORT: String(port),
|
|
432
|
+
HOST: '127.0.0.1',
|
|
433
|
+
NODE_ENV: config.nodeEnv ?? 'production',
|
|
434
|
+
...config.env,
|
|
435
|
+
NODE_OPTIONS: `${process.env.NODE_OPTIONS ?? ''} --import file://${watcher.split(path.sep).join('/')}`.trim(),
|
|
436
|
+
},
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
// Put the data back before booting, but never with a command that destroys something it
|
|
440
|
+
// cannot rebuild. A refused restore does not stop the run; it makes every finding after
|
|
441
|
+
// it suspect, and the run says exactly that.
|
|
442
|
+
let restored = 'No restore command was given, so the server booted against whatever data was in the scratch copy.';
|
|
443
|
+
if (config.restore) {
|
|
444
|
+
const verdict = looksDestructive(String(config.restore));
|
|
445
|
+
if (!verdict.safe) {
|
|
446
|
+
restored = verdict.why;
|
|
447
|
+
notes.push(verdict.why);
|
|
448
|
+
} else {
|
|
449
|
+
const result = await new Promise((resolve) => {
|
|
450
|
+
const child = spawn(String(config.restore), { shell: true, cwd: work, env, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
451
|
+
/** @type {Buffer[]} */
|
|
452
|
+
const err = [];
|
|
453
|
+
child.stderr?.on('data', (c) => err.push(c));
|
|
454
|
+
child.on('error', (e) => resolve({ code: null, stderr: e.message }));
|
|
455
|
+
child.on('close', (code) => resolve({ code, stderr: Buffer.concat(err).toString('utf8') }));
|
|
456
|
+
});
|
|
457
|
+
restored = result.code === 0
|
|
458
|
+
? 'The data was put back to a known state before booting.'
|
|
459
|
+
: `The restore command failed, so the data is not in a known state: ${trimForStorage(result.stderr, 500).text}`;
|
|
460
|
+
if (result.code !== 0) notes.push(restored);
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
if (!config.start) {
|
|
465
|
+
return {
|
|
466
|
+
build, root: work, ready: false,
|
|
467
|
+
why: 'There is no command to start the server, so nothing can be walked. The routes are still listed from the source.',
|
|
468
|
+
dispose: async () => { await fsp.rm(base, { recursive: true, force: true }); },
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/** @type {Buffer[]} */
|
|
473
|
+
const bootErr = [];
|
|
474
|
+
/** @type {Buffer[]} */
|
|
475
|
+
const bootOut = [];
|
|
476
|
+
let exited = /** @type {string|null} */ (null);
|
|
477
|
+
const child = spawn(String(config.start), { shell: true, cwd: work, env, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
478
|
+
child.stdout?.on('data', (c) => bootOut.push(c));
|
|
479
|
+
child.stderr?.on('data', (c) => bootErr.push(c));
|
|
480
|
+
child.on('close', (code, signal) => {
|
|
481
|
+
exited = `The server stopped before it answered — exit code ${code}${signal ? `, killed by ${signal}` : ''}.`;
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
const up = await waitForServer(port, {
|
|
485
|
+
timeoutMs: config.startTimeoutMs ?? 60000,
|
|
486
|
+
crashed: () => exited,
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
if (!up.up) {
|
|
490
|
+
child.kill('SIGTERM');
|
|
491
|
+
return {
|
|
492
|
+
build, root: work, ready: false,
|
|
493
|
+
why: `${up.why} What it printed while trying: ${trimForStorage(Buffer.concat(bootErr).toString('utf8') || Buffer.concat(bootOut).toString('utf8'), 1500).text || '(nothing)'}`,
|
|
494
|
+
dispose: async () => { child.kill('SIGKILL'); await fsp.rm(base, { recursive: true, force: true }); },
|
|
495
|
+
};
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// Whether anything is watching from the inside is a fact, not a guess: the watcher
|
|
499
|
+
// writes one line the moment it loads, so the file existing after boot is the proof. It
|
|
500
|
+
// decides whether a route the project called irreversible may be walked at all.
|
|
501
|
+
const watcherInForce = (await readWatcher(reportFile)).inForce;
|
|
502
|
+
running.set(build.id, { base, work, home, tmp, port, reportFile, child, config, bootErr, watcherInForce });
|
|
503
|
+
|
|
504
|
+
return {
|
|
505
|
+
build,
|
|
506
|
+
root: work,
|
|
507
|
+
ready: true,
|
|
508
|
+
why: `${copy.why} ${restored} It came up on port ${port} in ${timeBucket(up.ms)}. ${watcherInForce ? 'Outbound connections are being watched and refused, so a route that calls a payment provider can be walked safely.' : 'Nothing is watching this server from the inside — it is not a Node program, or it replaced the environment it was started with — so routes that reach off this machine are left alone.'}${notes.length > 0 ? ` ${notes.join(' ')}` : ''}`,
|
|
509
|
+
facts: { port, work, base: `http://127.0.0.1:${port}` },
|
|
510
|
+
dispose: async () => {
|
|
511
|
+
const held = running.get(build.id);
|
|
512
|
+
running.delete(build.id);
|
|
513
|
+
if (!held) return;
|
|
514
|
+
// Only ever the process we started. Somebody else's server on this machine is
|
|
515
|
+
// somebody else's business.
|
|
516
|
+
held.child.kill('SIGTERM');
|
|
517
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
518
|
+
if (held.child.exitCode === null) held.child.kill('SIGKILL');
|
|
519
|
+
await fsp.rm(base, { recursive: true, force: true });
|
|
520
|
+
},
|
|
521
|
+
};
|
|
522
|
+
},
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* @param {import('./contract.js').Journey} journey
|
|
526
|
+
* @param {import('./contract.js').PreparedBuild} build
|
|
527
|
+
* @param {import('./contract.js').RunContext} ctx
|
|
528
|
+
* @returns {Promise<import('./contract.js').Observation[]>}
|
|
529
|
+
*/
|
|
530
|
+
async run(journey, build, ctx) {
|
|
531
|
+
const held = running.get(build.build.id);
|
|
532
|
+
if (!build.ready || !held) {
|
|
533
|
+
return [notCovered({
|
|
534
|
+
channel: 'results',
|
|
535
|
+
path: joinPath('api', journey.name, 'answered at all'),
|
|
536
|
+
reason: 'crashed',
|
|
537
|
+
says: `"${journey.describe}" was not tried: ${build.why}`,
|
|
538
|
+
})];
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
const detail = /** @type {RouteJourneyDetail} */ (/** @type {any} */ (journey.steps?.[0] ?? {}));
|
|
542
|
+
|
|
543
|
+
if (detail.unfilled?.length > 0) {
|
|
544
|
+
return [notCovered({
|
|
545
|
+
channel: 'results',
|
|
546
|
+
path: joinPath('api', journey.name, 'answered at all'),
|
|
547
|
+
reason: 'needs a sample',
|
|
548
|
+
says: `${detail.method} ${detail.route} was not tried, because nobody has said what ${detail.unfilled.map((p) => `"${p}"`).join(' and ')} should be. Put a real value under "http.samples" in the config and this route starts being checked.`,
|
|
549
|
+
})];
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
// A route the project called irreversible is still WALKED, as long as the refusal
|
|
553
|
+
// boundary is proven to be in force. That is the design: the ask is what gets compared —
|
|
554
|
+
// same endpoint, same amount, same currency — and the connection carrying it never
|
|
555
|
+
// completes. Skipping the route entirely would throw away the one observation that
|
|
556
|
+
// matters. With nothing watching, though, there is no boundary, and it is left alone.
|
|
557
|
+
if (journey.irreversible && !held.watcherInForce && ctx.allowIrreversible !== true) {
|
|
558
|
+
return [notCovered({
|
|
559
|
+
channel: 'effects',
|
|
560
|
+
path: joinPath('api', journey.name, 'answered at all'),
|
|
561
|
+
reason: 'irreversible',
|
|
562
|
+
says: `${detail.method} ${detail.route} was left alone. The project marked it as spending money, sending a message or destroying data, and nothing is watching this server from the inside, so there is no way to stop it happening for real. This is a hole in what was checked, not a pass.`,
|
|
563
|
+
})];
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
const watchFolders = held.config.watch ?? ['.'];
|
|
567
|
+
const before = await snapshotForFolders(held.work, watchFolders);
|
|
568
|
+
const watchedBefore = (await readWatcher(held.reportFile)).reachedOut.length;
|
|
569
|
+
|
|
570
|
+
const started = Date.now();
|
|
571
|
+
/** @type {Response|null} */
|
|
572
|
+
let answer = null;
|
|
573
|
+
/** @type {string} */
|
|
574
|
+
let text = '';
|
|
575
|
+
/** @type {string|null} */
|
|
576
|
+
let failure = null;
|
|
577
|
+
try {
|
|
578
|
+
answer = await fetch(`http://127.0.0.1:${held.port}${detail.url}`, {
|
|
579
|
+
method: detail.method,
|
|
580
|
+
headers: { accept: '*/*', ...detail.headers },
|
|
581
|
+
body: detail.body === undefined || detail.method === 'GET' || detail.method === 'HEAD'
|
|
582
|
+
? undefined
|
|
583
|
+
: JSON.stringify(detail.body),
|
|
584
|
+
redirect: 'manual',
|
|
585
|
+
signal: AbortSignal.timeout(journey.timeoutMs ?? 30000),
|
|
586
|
+
});
|
|
587
|
+
text = await answer.text();
|
|
588
|
+
} catch (error) {
|
|
589
|
+
failure = error instanceof Error ? error.message : String(error);
|
|
590
|
+
}
|
|
591
|
+
const ms = Date.now() - started;
|
|
592
|
+
|
|
593
|
+
const after = await snapshotForFolders(held.work, watchFolders);
|
|
594
|
+
const watched = await readWatcher(held.reportFile);
|
|
595
|
+
|
|
596
|
+
return describeRequest({
|
|
597
|
+
journey, detail, answer, text, failure, ms,
|
|
598
|
+
changes: compareTrees(before, after),
|
|
599
|
+
reachedOut: watched.reachedOut.slice(watchedBefore),
|
|
600
|
+
footprint: { dirs: [held.base, held.tmp, held.home], projectRoot: build.build.root, ports: [held.port] },
|
|
601
|
+
});
|
|
602
|
+
},
|
|
603
|
+
|
|
604
|
+
async teardown() {
|
|
605
|
+
for (const [, held] of running) {
|
|
606
|
+
held.child.kill('SIGTERM');
|
|
607
|
+
}
|
|
608
|
+
running.clear();
|
|
609
|
+
},
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
/**
|
|
613
|
+
* @param {string} root
|
|
614
|
+
* @param {string[]} folders
|
|
615
|
+
*/
|
|
616
|
+
async function snapshotForFolders(root, folders) {
|
|
617
|
+
/** @type {Map<string,string>} */
|
|
618
|
+
const all = new Map();
|
|
619
|
+
for (const folder of folders) {
|
|
620
|
+
const full = path.resolve(root, folder);
|
|
621
|
+
for (const [file, mark] of await snapshotTree(full)) {
|
|
622
|
+
all.set(path.join(path.relative(root, full), file), mark);
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
return all;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
// ---------------------------------------------------------------------------
|
|
629
|
+
// Turning one request into observations
|
|
630
|
+
// ---------------------------------------------------------------------------
|
|
631
|
+
|
|
632
|
+
/**
|
|
633
|
+
* @param {object} input
|
|
634
|
+
* @param {import('./contract.js').Journey} input.journey
|
|
635
|
+
* @param {RouteJourneyDetail} input.detail
|
|
636
|
+
* @param {Response|null} input.answer
|
|
637
|
+
* @param {string} input.text
|
|
638
|
+
* @param {string|null} input.failure
|
|
639
|
+
* @param {number} input.ms
|
|
640
|
+
* @param {import('./process.js').FileChange[]} input.changes
|
|
641
|
+
* @param {Array<{host: string, port: number|null}>} input.reachedOut
|
|
642
|
+
* @param {{dirs: string[], projectRoot?: string, ports?: number[]}} input.footprint
|
|
643
|
+
* @returns {import('./contract.js').Observation[]}
|
|
644
|
+
*/
|
|
645
|
+
export function describeRequest(input) {
|
|
646
|
+
const { journey, detail, answer, failure, ms, footprint } = input;
|
|
647
|
+
const id = journey.name;
|
|
648
|
+
/** @type {import('./contract.js').Observation[]} */
|
|
649
|
+
const out = [];
|
|
650
|
+
const asked = `${detail.method} ${detail.route}`;
|
|
651
|
+
|
|
652
|
+
if (!answer) {
|
|
653
|
+
out.push(observation({
|
|
654
|
+
channel: 'complaints',
|
|
655
|
+
path: joinPath('api', id, 'answered'),
|
|
656
|
+
value: `no answer: ${failure}`,
|
|
657
|
+
says: `${asked} gave no answer at all: ${failure}. A route that used to answer and now does not is the loudest kind of regression.`,
|
|
658
|
+
}));
|
|
659
|
+
return out;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
out.push(observation({
|
|
663
|
+
channel: 'results',
|
|
664
|
+
path: joinPath('api', id, 'status'),
|
|
665
|
+
value: answer.status,
|
|
666
|
+
says: `${asked} answered ${answer.status}${answer.status >= 400 ? ', which is a refusal' : ''}.`,
|
|
667
|
+
}));
|
|
668
|
+
|
|
669
|
+
const headers = headersThatMatter(answer.headers);
|
|
670
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
671
|
+
out.push(observation({
|
|
672
|
+
channel: 'results',
|
|
673
|
+
path: joinPath('api', id, 'header', name),
|
|
674
|
+
value: undoOurFootprint(Array.isArray(value) ? value.join(', ') : value, footprint),
|
|
675
|
+
says: `${asked} answered with ${name}: ${Array.isArray(value) ? value.join(', ') : value}.`,
|
|
676
|
+
}));
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
const body = readBody(answer.headers.get('content-type') ?? '', undoOurFootprint(input.text, footprint));
|
|
680
|
+
out.push(observation({
|
|
681
|
+
channel: 'results',
|
|
682
|
+
path: joinPath('api', id, 'body'),
|
|
683
|
+
value: body.value,
|
|
684
|
+
says: body.truncated
|
|
685
|
+
? `What ${asked} sent back, with the middle left out — the whole of it is ${sizeBucket(body.bytes)}.`
|
|
686
|
+
: `What ${asked} sent back.`,
|
|
687
|
+
}));
|
|
688
|
+
if (body.shape !== undefined) {
|
|
689
|
+
out.push(observation({
|
|
690
|
+
channel: 'results',
|
|
691
|
+
path: joinPath('api', id, 'shape'),
|
|
692
|
+
value: body.shape,
|
|
693
|
+
says: `The fields ${asked} sends back and what type each one is. This stays the same while the values change, so a renamed or dropped field shows up on its own instead of buried in a diff of the whole body.`,
|
|
694
|
+
}));
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
for (const change of input.changes) {
|
|
698
|
+
out.push(observation({
|
|
699
|
+
channel: 'effects',
|
|
700
|
+
path: joinPath('file', id, change.file),
|
|
701
|
+
value: change.what === 'deleted' ? 'deleted' : { what: change.what, contents: change.now ?? '' },
|
|
702
|
+
says: change.what === 'deleted'
|
|
703
|
+
? `Answering ${asked} deleted ${change.file}.`
|
|
704
|
+
: `Answering ${asked} ${change.what} ${change.file}. A route that still answers correctly but has stopped writing this file is broken, and only this line sees it.`,
|
|
705
|
+
}));
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
/** @type {Map<string, number>} */
|
|
709
|
+
const hosts = new Map();
|
|
710
|
+
for (const attempt of input.reachedOut) {
|
|
711
|
+
const key = attempt.port ? `${attempt.host}:${attempt.port}` : attempt.host;
|
|
712
|
+
hosts.set(key, (hosts.get(key) ?? 0) + 1);
|
|
713
|
+
}
|
|
714
|
+
for (const [host, times] of [...hosts].sort()) {
|
|
715
|
+
out.push(observation({
|
|
716
|
+
channel: 'effects',
|
|
717
|
+
path: joinPath('net', id, host),
|
|
718
|
+
value: `tried ${times} time${times === 1 ? '' : 's'}, refused every time`,
|
|
719
|
+
says: `While answering ${asked} the server tried to call ${host} and was refused. That it asked, and what it asked for, are compared; what would have come back is not, because it was never allowed to happen.`,
|
|
720
|
+
covered: false,
|
|
721
|
+
reason: 'irreversible',
|
|
722
|
+
}));
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
out.push(observation({
|
|
726
|
+
channel: 'counters',
|
|
727
|
+
path: joinPath('count', id, 'duration'),
|
|
728
|
+
value: timeBucket(ms),
|
|
729
|
+
says: `${asked} took ${timeBucket(ms)}. Deliberately rough: exact timings differ on every run and would drown everything else.`,
|
|
730
|
+
}));
|
|
731
|
+
|
|
732
|
+
return out;
|
|
733
|
+
}
|