@arjunkhera/atlas 0.3.6 → 0.3.8

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.
@@ -0,0 +1,58 @@
1
+ // Redaction by value. A run holds a set of secret values: the run secrets,
2
+ // the values of the named secrets, and every credential a persona holds.
3
+ // Everything the library writes as evidence passes through it, so no value in
4
+ // the set reaches an evidence file, a CI log or a pull request.
5
+ //
6
+ // Besides the set, it removes three shapes that are never evidence: a JSON
7
+ // web token, a bearer credential and a signed query string.
8
+
9
+ const SHAPES = [
10
+ [/\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g, '[redacted:jwt]'],
11
+ [/\b(Bearer)\s+[A-Za-z0-9._~+/=-]{8,}/gi, '$1 [redacted]'],
12
+ [/([?&](?:X-Amz-Signature|X-Amz-Credential|X-Amz-Security-Token|Signature|sig|access_token)=)[^&\s"']+/gi, '$1[redacted]'],
13
+ ];
14
+
15
+ const escape = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
16
+
17
+ export const MIN_SECRET_LENGTH = 4;
18
+
19
+ export class Redactor {
20
+ constructor(values = []) {
21
+ this.values = new Set();
22
+ this.add(values);
23
+ }
24
+
25
+ // Adds a value or a list of values. A value shorter than MIN_SECRET_LENGTH
26
+ // is not added: it would damage ordinary text.
27
+ add(value) {
28
+ for (const one of [].concat(value ?? [])) {
29
+ if (typeof one === 'string' && one.length >= MIN_SECRET_LENGTH) this.values.add(one);
30
+ }
31
+ this.pattern = null;
32
+ return this;
33
+ }
34
+
35
+ text(input) {
36
+ if (typeof input !== 'string') return input;
37
+ if (!this.pattern) {
38
+ const sorted = [...this.values].sort((a, b) => b.length - a.length);
39
+ this.pattern = sorted.length ? new RegExp(sorted.map(escape).join('|'), 'g') : false;
40
+ }
41
+ let out = this.pattern ? input.replace(this.pattern, '[redacted]') : input;
42
+ for (const [shape, replacement] of SHAPES) out = out.replace(shape, replacement);
43
+ return out;
44
+ }
45
+
46
+ deep(value) {
47
+ if (typeof value === 'string') return this.text(value);
48
+ if (Array.isArray(value)) return value.map((one) => this.deep(one));
49
+ if (value instanceof Error) return this.text(value.message);
50
+ if (value && typeof value === 'object') {
51
+ return Object.fromEntries(Object.entries(value).map(([key, one]) => [this.text(key), this.deep(one)]));
52
+ }
53
+ return value;
54
+ }
55
+ }
56
+
57
+ // A name that says its value is a secret.
58
+ export const SECRET_NAME = /(TOKEN|SECRET|KEY|PASSWORD|CREDENTIAL)/i;
@@ -0,0 +1,325 @@
1
+ // The scenario reader. A scenario is a Markdown file. Its front matter holds
2
+ // the fixed parts. Its sections hold the words. Code reads these parts:
3
+ // the front matter, the `Start with` items, the case tables, the numbered
4
+ // steps (with their waits), and the assertion lines. Code never reads the
5
+ // other words; agents and people do.
6
+ //
7
+ // Names in backticks are checked against tests.yaml by the link check.
8
+ //
9
+ // Fingerprints, method fp1 (design section 8.2, open question 17): the first
10
+ // six hex characters of the SHA-256 of
11
+ // - the normalized words of the assertion, then
12
+ // - one line for each setup, fixture, data rule, `Start with` item and step
13
+ // that the assertion names, directly or through a step it names, then
14
+ // - the `through` and the `runs-in` lists of the front matter (the lead's
15
+ // answer to OQ-17), sorted. A change to either shows as a changed assertion.
16
+ // Normalized means: NFC, LF line ends, every run of white space cut to one
17
+ // space, trimmed. The rows of a case table are not in it. Each table has its
18
+ // own fingerprint, so a new row adds a case with no code change.
19
+ import { readFileSync } from 'node:fs';
20
+ import { createHash } from 'node:crypto';
21
+ import { parseYaml, YamlError } from './yaml.mjs';
22
+ import { parseLimit } from './wait.mjs';
23
+
24
+ export const FINGERPRINT_METHOD = 'fp1';
25
+
26
+ export const normalize = (text) => String(text).normalize('NFC').replace(/\r\n?/g, '\n').replace(/\s+/g, ' ').trim();
27
+ export const sha256 = (text) => createHash('sha256').update(text).digest('hex');
28
+ export const fingerprintOf = (text) => sha256(normalize(text)).slice(0, 6);
29
+
30
+ const ASSERTION = /^- (e\d+) (exact|judged)(?:,\s*(gate))?:\s*(.+)$/;
31
+ const TABLE_HEADING = /^###\s+(T\d+)\s*:?\s*(.*)$/;
32
+ const WAIT_LIMIT = /give up after\s+(\d+(?:\.\d+)?)\s*(ms|milliseconds?|s|seconds?|m|min|minutes?|h|hours?)\b/i;
33
+ const names = (text) => [...String(text).matchAll(/`([^`]+)`/g)].map((m) => m[1]);
34
+
35
+ const sortKeys = (x) => {
36
+ if (Array.isArray(x)) return x.map(sortKeys);
37
+ if (x && typeof x === 'object') return Object.fromEntries(Object.keys(x).sort().map((k) => [k, sortKeys(x[k])]));
38
+ return x;
39
+ };
40
+
41
+ // ---------------------------------------------------------------- tables
42
+
43
+ function tableCells(line) {
44
+ return line.trim().replace(/^\||\|$/g, '').split('|').map((c) => c.trim());
45
+ }
46
+
47
+ // Every combination of the values, or a small set that holds every pair of values.
48
+ export function combinations(dimensions) {
49
+ let out = [{}];
50
+ for (const [name, values] of Object.entries(dimensions)) out = out.flatMap((one) => values.map((value) => ({ ...one, [name]: value })));
51
+ return out;
52
+ }
53
+
54
+ export function pairs(dimensions) {
55
+ const names2 = Object.keys(dimensions);
56
+ if (names2.length < 2) return combinations(dimensions);
57
+ const key = (a, va, b, vb) => `${a}\u0000${va}\u0000${b}\u0000${vb}`;
58
+ const uncovered = new Set();
59
+ for (let i = 0; i < names2.length; i += 1) {
60
+ for (let j = i + 1; j < names2.length; j += 1) {
61
+ for (const va of dimensions[names2[i]]) for (const vb of dimensions[names2[j]]) uncovered.add(key(names2[i], va, names2[j], vb));
62
+ }
63
+ }
64
+ const gain = (partial, dim, value) => {
65
+ let n = 0;
66
+ for (const other of Object.keys(partial)) {
67
+ const [a, b] = names2.indexOf(other) < names2.indexOf(dim) ? [other, dim] : [dim, other];
68
+ const va = a === other ? partial[other] : value;
69
+ const vb = b === other ? partial[other] : value;
70
+ if (uncovered.has(key(a, va, b, vb))) n += 1;
71
+ }
72
+ return n;
73
+ };
74
+ const cases = [];
75
+ while (uncovered.size) {
76
+ const [seed] = uncovered;
77
+ const [a, va, b, vb] = seed.split('\u0000');
78
+ const one = { [a]: va, [b]: vb };
79
+ for (const dim of names2) {
80
+ if (dim in one) continue;
81
+ let best = dimensions[dim][0];
82
+ let bestGain = -1;
83
+ for (const value of dimensions[dim]) {
84
+ const g = gain(one, dim, value);
85
+ if (g > bestGain) { best = value; bestGain = g; }
86
+ }
87
+ one[dim] = best;
88
+ }
89
+ const ordered = Object.fromEntries(names2.map((n) => [n, one[n]]));
90
+ for (let i = 0; i < names2.length; i += 1) {
91
+ for (let j = i + 1; j < names2.length; j += 1) uncovered.delete(key(names2[i], ordered[names2[i]], names2[j], ordered[names2[j]]));
92
+ }
93
+ cases.push(ordered);
94
+ }
95
+ return cases;
96
+ }
97
+
98
+ function readTable(name, title, line, blockLines) {
99
+ const text = blockLines.map((l) => l.text);
100
+ const grids = [];
101
+ let current = null;
102
+ for (const { text: raw } of blockLines) {
103
+ if (raw.trim().startsWith('|')) {
104
+ const cells = tableCells(raw);
105
+ if (cells.every((c) => /^:?-+:?$/.test(c))) continue;
106
+ if (!current) { current = []; grids.push(current); }
107
+ current.push(cells);
108
+ } else current = null;
109
+ }
110
+ const dimensions = {};
111
+ let inDimensions = false;
112
+ let cover = null;
113
+ for (const { text: raw } of blockLines) {
114
+ const t = raw.trim();
115
+ if (/^Dimensions:/i.test(t)) { inDimensions = true; continue; }
116
+ if (/^Cover:/i.test(t)) { inDimensions = false; cover = /every pair/i.test(t) ? 'pair' : 'combination'; continue; }
117
+ if (inDimensions) {
118
+ const m = /^-\s*([^:]+):\s*(.+)$/.exec(t);
119
+ if (m) dimensions[m[1].trim()] = m[2].split(',').map((v) => v.trim()).filter(Boolean);
120
+ else if (t === '') inDimensions = false;
121
+ }
122
+ }
123
+ const combination = Object.keys(dimensions).length > 0;
124
+ const [header, ...rows] = grids[0] ?? [[]];
125
+ const objects = rows.map((cells) => Object.fromEntries(header.map((h, i) => [h, cells[i] ?? ''])));
126
+ let count;
127
+ if (combination) {
128
+ const effective = cover ?? 'combination';
129
+ count = effective === 'pair' ? pairs(dimensions).length : Object.values(dimensions).reduce((n, v) => n * v.length, 1);
130
+ } else {
131
+ count = rows.length * Math.max(0, header.length - 1);
132
+ }
133
+ return {
134
+ name, title, line, kind: combination ? 'combination' : 'rule', header, rows: objects, grids,
135
+ dimensions, cover: combination ? (cover ?? 'combination') : null, count,
136
+ fingerprint: fingerprintOf(text.filter((t) => t.trim()).join('\n')),
137
+ };
138
+ }
139
+
140
+ // ---------------------------------------------------------------- the reader
141
+
142
+ // Reads scenario text. Returns the parts, and `problems`: format faults, each
143
+ // with a line. The link check reports every problem.
144
+ export function parseScenario(source, file = 'scenario.md') {
145
+ const text = String(source).replace(/\r\n?/g, '\n');
146
+ const lines = text.split('\n');
147
+ const problems = [];
148
+ const problem = (line, message) => problems.push({ line, message });
149
+ const out = {
150
+ file, id: null, title: '', fields: {}, fieldLine: () => null, through: [], start: [], steps: [], assertions: [],
151
+ tables: new Map(), sections: new Map(), problems, sha256: sha256(text), method: FINGERPRINT_METHOD,
152
+ };
153
+
154
+ if (lines[0] !== '---') { problem(1, 'a scenario starts with a front matter block (---)'); return out; }
155
+ const end = lines.indexOf('---', 1);
156
+ if (end < 0) { problem(1, 'the front matter block has no closing ---'); return out; }
157
+ try {
158
+ const parsed = parseYaml(lines.slice(1, end).join('\n'));
159
+ out.fields = parsed.value && typeof parsed.value === 'object' && !Array.isArray(parsed.value) ? parsed.value : {};
160
+ out.fieldLine = (key) => { const n = parsed.lineOf([key]); return n === null ? 1 : n + 1; };
161
+ } catch (error) {
162
+ if (!(error instanceof YamlError)) throw error;
163
+ problem(1 + (error.line ?? 0), `the front matter cannot be read: ${error.message.replace(/ \(line \d+\)$/, '')}`);
164
+ }
165
+ const f = out.fields;
166
+ out.id = typeof f.id === 'string' ? f.id : null;
167
+ if (!out.id) problem(2, 'the front matter has no id');
168
+ else if (!/^[a-z0-9][a-z0-9-]*$/.test(out.id)) problem(out.fieldLine('id'), `the id "${out.id}" must be lower case letters, digits and hyphens`);
169
+ const list = (key) => (Array.isArray(f[key]) ? f[key].map(String) : f[key] === undefined ? [] : [String(f[key])]);
170
+ out.through = list('through');
171
+ if (!out.through.length) problem(out.fieldLine('through'), 'the front matter has no through: the ways in that the steps use');
172
+ if (!list('runs-in').length) problem(out.fieldLine('runs-in'), 'the front matter has no runs-in');
173
+ if (f.limit === undefined) problem(out.fieldLine('limit'), 'the front matter has no limit');
174
+ else { try { parseLimit(f.limit); } catch (e) { problem(out.fieldLine('limit'), e.message); } }
175
+
176
+ // Sections.
177
+ let section = null;
178
+ let title = '';
179
+ let tableBlock = null;
180
+ const sectionLines = new Map();
181
+ const flushTable = () => {
182
+ if (!tableBlock) return;
183
+ if (out.tables.has(tableBlock.name)) problem(tableBlock.line, `the case table ${tableBlock.name} is named twice`);
184
+ else {
185
+ const table = readTable(tableBlock.name, tableBlock.title, tableBlock.line, tableBlock.lines);
186
+ if (!table.rows.length && table.kind === 'rule') problem(table.line, `the case table ${table.name} has no rows`);
187
+ out.tables.set(table.name, table);
188
+ }
189
+ tableBlock = null;
190
+ };
191
+ for (let i = end + 1; i < lines.length; i += 1) {
192
+ const raw = lines[i];
193
+ const number = i + 1;
194
+ if (/^# /.test(raw) && !title) { title = raw.slice(2).trim(); continue; }
195
+ const heading = /^##\s+(.+?)\s*$/.exec(raw);
196
+ if (heading && !raw.startsWith('###')) {
197
+ flushTable();
198
+ section = heading[1].toLowerCase();
199
+ if (!sectionLines.has(section)) sectionLines.set(section, []);
200
+ out.sections.set(section, number);
201
+ continue;
202
+ }
203
+ if (section === null) continue;
204
+ sectionLines.get(section).push({ text: raw, number });
205
+ if (section === 'cases') {
206
+ const t = TABLE_HEADING.exec(raw);
207
+ if (t) { flushTable(); tableBlock = { name: t[1], title: t[2], line: number, lines: [] }; continue; }
208
+ if (tableBlock) tableBlock.lines.push({ text: raw, number });
209
+ }
210
+ }
211
+ flushTable();
212
+ out.title = title || (typeof f.title === 'string' ? f.title : '');
213
+
214
+ // Items with continuation lines: a marker line, then indented lines.
215
+ const items = (name, marker) => {
216
+ const result = [];
217
+ for (const { text: raw, number } of sectionLines.get(name) ?? []) {
218
+ if (!raw.trim()) continue;
219
+ const m = marker.exec(raw);
220
+ if (m) result.push({ line: number, body: raw, marker: m, text: raw.slice(m[0].length).trim() });
221
+ else if (/^\s+\S/.test(raw) && result.length) { const last = result.at(-1); last.text = `${last.text} ${raw.trim()}`; last.body += `\n${raw}`; }
222
+ else result.push({ line: number, body: raw, marker: null, text: raw.trim() });
223
+ }
224
+ return result;
225
+ };
226
+
227
+ for (const item of items('start with', /^-\s+/)) {
228
+ if (!item.marker) { problem(item.line, 'a "Start with" line must be a list item (- ...)'); continue; }
229
+ const defined = /^`([^`]+)`\s*:/.exec(item.text);
230
+ out.start.push({ line: item.line, text: item.text, name: defined ? defined[1] : null, names: names(item.text) });
231
+ }
232
+
233
+ const seenSteps = new Set();
234
+ for (const item of items('steps', /^(\d+)\.\s+/)) {
235
+ if (!item.marker) { problem(item.line, 'a step must be a numbered item (1. ...)'); continue; }
236
+ const n = Number(item.marker[1]);
237
+ if (seenSteps.has(n)) problem(item.line, `step ${n} is written twice`);
238
+ seenSteps.add(n);
239
+ const persona = /^As `([^`]+)`,\s*(?:through `([^`]+)`,\s*)?/i.exec(item.text);
240
+ const keeps = [...item.text.matchAll(/\bKeep\b[^.`]*?\bas `([^`]+)`/gi)].map((m) => m[1]);
241
+ const isWait = /^Wait until\b/i.test(item.text);
242
+ const limit = isWait ? WAIT_LIMIT.exec(item.text) : null;
243
+ out.steps.push({
244
+ n, line: item.line, text: item.text, names: names(item.text),
245
+ persona: persona ? persona[1] : null, way: persona?.[2] ?? null, keeps,
246
+ wait: isWait ? { limit: limit ? limit[0].replace(/^give up after\s+/i, '') : null } : null,
247
+ });
248
+ }
249
+ const ordered = out.steps.every((s, i) => s.n === i + 1);
250
+ if (!ordered && out.steps.length) problem(out.steps[0].line, 'the steps must be numbered 1, 2, 3 ... in order');
251
+
252
+ const seen = new Set();
253
+ for (const item of items('assertions', /^(?=-)/)) {
254
+ const flat = `${item.body.split('\n')[0]}${item.body.includes('\n') ? ` ${item.body.split('\n').slice(1).map((s) => s.trim()).join(' ')}` : ''}`;
255
+ const m = ASSERTION.exec(flat);
256
+ if (!m) { problem(item.line, `an assertion line must read "- eN exact: words" or "- eN judged: words"; found "${flat.slice(0, 60)}"`); continue; }
257
+ if (seen.has(m[1])) problem(item.line, `the assertion ${m[1]} is written twice`);
258
+ seen.add(m[1]);
259
+ out.assertions.push({ id: m[1], kind: m[2], gate: m[3] === 'gate', words: m[4].trim(), line: item.line, names: names(m[4]), fp: null });
260
+ }
261
+ if (!out.assertions.length) problem(out.sections.get('assertions') ?? 1, 'the scenario has no assertions');
262
+ else if (!out.sections.has('assertions')) problem(1, 'the scenario has no "## Assertions" section');
263
+ return out;
264
+ }
265
+
266
+ export function readScenario(path) {
267
+ return parseScenario(readFileSync(path, 'utf8'), String(path));
268
+ }
269
+
270
+ // ---------------------------------------------------------------- fingerprints
271
+
272
+ const runsIn = (scenario) => [].concat(scenario.fields['runs-in'] ?? []).map(String);
273
+
274
+ // Sets `fp` and `fullId` on every assertion. `tests` is the parsed value of
275
+ // tests.yaml. Returns the scenario.
276
+ export function addFingerprints(scenario, tests) {
277
+ const start = new Map(scenario.start.filter((s) => s.name).map((s) => [s.name, s]));
278
+ const kept = new Map();
279
+ for (const step of scenario.steps) for (const k of step.keeps) if (!kept.has(k)) kept.set(k, step);
280
+ const steps = new Map(scenario.steps.map((s) => [s.n, s]));
281
+ const setups = tests?.setups ?? {};
282
+ const fixtures = tests?.fixtures ?? {};
283
+ const data = tests?.data ?? {};
284
+ const stepRefs = (text) => [...String(text).matchAll(/\bsteps?\s+(\d+)/gi)].map((m) => Number(m[1]));
285
+
286
+ for (const a of scenario.assertions) {
287
+ const parts = new Map();
288
+ const visited = new Set();
289
+ const queue = [];
290
+ const visit = (name) => {
291
+ if (start.has(name)) {
292
+ if (!parts.has(`start ${name}`)) { parts.set(`start ${name}`, normalize(start.get(name).text)); queue.push(...start.get(name).names); }
293
+ } else if (kept.has(name)) visitStep(kept.get(name).n);
294
+ if (Object.hasOwn(setups, name)) parts.set(`setup ${name}`, JSON.stringify(sortKeys(setups[name])));
295
+ if (Object.hasOwn(fixtures, name)) parts.set(`fixture ${name}`, JSON.stringify(sortKeys(fixtures[name])));
296
+ if (Object.hasOwn(data, name)) parts.set(`data ${name}`, JSON.stringify(sortKeys(data[name])));
297
+ };
298
+ const visitStep = (n) => {
299
+ const step = steps.get(n);
300
+ if (!step || visited.has(n)) return;
301
+ visited.add(n);
302
+ parts.set(`step ${String(n).padStart(4, '0')}`, normalize(step.text));
303
+ queue.push(...step.names);
304
+ };
305
+ queue.push(...a.names);
306
+ for (const n of stepRefs(a.words)) visitStep(n);
307
+ const done = new Set();
308
+ while (queue.length) {
309
+ const name = queue.shift();
310
+ if (done.has(name)) continue;
311
+ done.add(name);
312
+ visit(name);
313
+ }
314
+ const lines = [...parts].sort(([x], [y]) => (x < y ? -1 : 1)).map(([k, v]) => `${k}=${v}`);
315
+ lines.push(`through=${[...scenario.through].sort().join(',')}`, `runs-in=${runsIn(scenario).sort().join(',')}`);
316
+ a.fp = fingerprintOf([a.words, ...lines].join('\n'));
317
+ a.fullId = `${scenario.id}/${a.id}#${a.fp}`;
318
+ }
319
+ return scenario;
320
+ }
321
+
322
+ // The fingerprint of a whole scenario file is its sha256. Tables carry their own.
323
+ export function tableFingerprints(scenario) {
324
+ return Object.fromEntries([...scenario.tables].map(([name, t]) => [name, t.fingerprint]));
325
+ }
@@ -0,0 +1,74 @@
1
+ // Stand-ins: a local replacement for an outside service.
2
+ //
3
+ // The protocol between the library and a stand-in, in three lines:
4
+ // 1. The library picks a free port. It gives the port to the stand-in as the
5
+ // PORT variable and as ATLAS_STAND_IN_PORT, and fills {port} in the
6
+ // start command. The stand-in listens on 127.0.0.1 at that port.
7
+ // 2. GET /__atlas/calls answers 200 with {"calls": [ ... ]}: one entry for
8
+ // each call that the product made. Calls to /__atlas/* do not count.
9
+ // A call from the library carries the header `x-atlas-library: 1` (a
10
+ // ready poll, a calls read, any call of the kit). It does not count
11
+ // either. Only a call without that header can be the first call.
12
+ // 3. Before step 1 the library reads that list. An empty list means the
13
+ // product never called the stand-in, and the run is `blocked`.
14
+ // `serveStandIn` does all three. A repo writes only the handler.
15
+ import { createServer } from 'node:http';
16
+
17
+ export const CALLS_PATH = '/__atlas/calls';
18
+ // Every call that the library makes to a stand-in carries this header.
19
+ export const LIBRARY_HEADER = 'x-atlas-library';
20
+
21
+ // handler({ method, path, query, headers, body, raw }) returns
22
+ // { status = 200, body = null, headers = {} }.
23
+ export function createStandIn(handler) {
24
+ const calls = [];
25
+ const server = createServer((req, res) => {
26
+ const chunks = [];
27
+ req.on('data', (chunk) => chunks.push(chunk));
28
+ req.on('end', async () => {
29
+ const url = new URL(req.url, 'http://stand-in.local');
30
+ if (url.pathname === CALLS_PATH) {
31
+ res.writeHead(200, { 'content-type': 'application/json' });
32
+ res.end(JSON.stringify({ calls }));
33
+ return;
34
+ }
35
+ const raw = Buffer.concat(chunks).toString('utf8');
36
+ let body = raw;
37
+ try { body = raw ? JSON.parse(raw) : null; } catch { /* keep the text */ }
38
+ if (req.headers[LIBRARY_HEADER] !== '1' && !url.pathname.startsWith('/__atlas/')) calls.push({ method: req.method, path: url.pathname, at: new Date().toISOString() });
39
+ try {
40
+ const answer = (await handler({ method: req.method, path: url.pathname, query: Object.fromEntries(url.searchParams), headers: req.headers, body, raw, calls })) ?? {};
41
+ const payload = answer.body === undefined || answer.body === null ? '' : (typeof answer.body === 'string' ? answer.body : JSON.stringify(answer.body));
42
+ res.writeHead(answer.status ?? 200, { ...(payload && typeof answer.body !== 'string' ? { 'content-type': 'application/json' } : {}), ...(answer.headers ?? {}) });
43
+ res.end(payload);
44
+ } catch (error) {
45
+ res.writeHead(500, { 'content-type': 'application/json' });
46
+ res.end(JSON.stringify({ error: String(error.message) }));
47
+ }
48
+ });
49
+ });
50
+ return {
51
+ calls,
52
+ server,
53
+ listen: (port = 0, host = '127.0.0.1') => new Promise((resolve, reject) => {
54
+ server.once('error', reject);
55
+ server.listen(port, host, () => resolve(`http://${host}:${server.address().port}`));
56
+ }),
57
+ close: () => new Promise((resolve) => { server.close(() => resolve()); server.closeAllConnections?.(); }),
58
+ };
59
+ }
60
+
61
+ // For a stand-in that the library starts as a process: listens on the port that the library gives.
62
+ export async function serveStandIn(handler, { port = Number(process.env.ATLAS_STAND_IN_PORT ?? process.env.PORT) } = {}) {
63
+ if (!Number.isInteger(port) || port <= 0) throw new Error('no port: the library gives the port in ATLAS_STAND_IN_PORT');
64
+ const standIn = createStandIn(handler);
65
+ await standIn.listen(port);
66
+ return standIn;
67
+ }
68
+
69
+ // How many calls a stand-in reports. Accepts a list, a number, or {calls} / {count}.
70
+ export function countCalls(body) {
71
+ const value = body && typeof body === 'object' && !Array.isArray(body) ? (body.calls ?? body.count) : body;
72
+ if (Array.isArray(value)) return value.length;
73
+ return Number.isFinite(Number(value)) ? Number(value) : 0;
74
+ }