@arjunkhera/atlas 0.3.12 → 0.3.14

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,364 @@
1
+ // The browser driver. It opens a web page in Chromium and reads it as a person
2
+ // sees it. It is built on Playwright, which is an optional peer: the kit loads
3
+ // and every other driver works when Playwright is not installed. Only a scenario
4
+ // that uses a `browser` way in needs it. Without it the result is `blocked`, and
5
+ // the reason names the cause.
6
+ //
7
+ // The seven duties of a driver (design section 7.2):
8
+ // 1. It refuses an address that guards.hosts does not allow (`blocked`). It checks
9
+ // the address it opens, the address the page ends on after a redirect, and each
10
+ // request the page makes: a request to another host is stopped and recorded.
11
+ // 2. It sends a credential only to the origin it belongs to. A cookie is set for
12
+ // the origin of the way in. A header goes only with requests to that origin.
13
+ // 3. It records each call and each answer. It also records the console errors,
14
+ // the page errors and the failed requests of the page, as evidence.
15
+ // 4. It removes run secrets and named secrets from what it records, by value. A
16
+ // screenshot is a picture, so the driver masks each part of the page that
17
+ // shows a secret before it takes the picture.
18
+ // 5. It returns the raw answer: { status, body, isError, headers, raw }.
19
+ // 6. It signs in as a persona, never as the person who runs the test. Each persona
20
+ // has its own browser context, so no cookie moves from one persona to another.
21
+ // 7. It closes what it opened: every context and the browser.
22
+ //
23
+ // A request has one of these forms:
24
+ // { open: '/week?x=1' } open a path of the base address (or a full address)
25
+ // { read: finder, timeout?, optional? } read the parts that match the finder
26
+ // { click: finder } click the first part that matches
27
+ // { fill: finder, value } type into the first part that matches
28
+ // { screenshot: 'view' } keep a picture of the page as evidence
29
+ // { method, url, headers?, body? } a plain HTTP call in the context of the persona,
30
+ // for a dev sign-in. The cookie it gets stays in that context.
31
+ //
32
+ // A finder names a page part the way a person would, from the map:
33
+ // { role, name?, exact? } { text, exact? } { label } { css } { hasText, has: finder, within: finder, nth }
34
+ // `css` is the last resort, for a part with no role and no text of its own.
35
+ //
36
+ // `read` waits up to `timeout` (10 seconds) for the first part to show. When none
37
+ // shows, it does not throw: it answers { count: 0, texts: [], found: false }, so an
38
+ // assertion fails and the run reads `fail`, not `blocked`.
39
+ import { createHash } from 'node:crypto';
40
+ import { readFileSync } from 'node:fs';
41
+ import { Blocked } from '../errors.mjs';
42
+ import { hostAllowed, requireAllowed, sameOrigin } from '../guards.mjs';
43
+ import { clip } from '../evidence.mjs';
44
+
45
+ const DEFAULT_TIMEOUT = 10000;
46
+ const NAVIGATION_TIMEOUT = 30000;
47
+ const INTERNAL = /^(?:about:|data:|blob:|chrome-error:)/;
48
+
49
+ // The reason to give when the import of the optional peer fails.
50
+ export function explainMissing(error) {
51
+ const missing = error?.code === 'ERR_MODULE_NOT_FOUND' || error?.code === 'MODULE_NOT_FOUND';
52
+ if (missing && /playwright/.test(String(error.message))) {
53
+ return new Blocked('the browser driver needs the package "playwright" and it is not installed here. Install it in the repo (npm install --save-dev playwright), then install the browser (npx playwright install chromium)');
54
+ }
55
+ return new Blocked(`the package "playwright" could not be loaded: ${error?.message ?? error}`);
56
+ }
57
+
58
+ // Loads the optional peer. The import runs here, in the kit copy of the repo, so it finds the
59
+ // "playwright" of that repo.
60
+ export async function loadPlaywright() {
61
+ try {
62
+ return await import('playwright');
63
+ } catch (error) {
64
+ throw explainMissing(error);
65
+ }
66
+ }
67
+
68
+ // A finder can hold a regular expression. The record shows it as text, because JSON drops it.
69
+ const show = (finder) => JSON.parse(JSON.stringify(finder, (key, value) => (value instanceof RegExp ? value.toString() : value)));
70
+
71
+ const normal = (text) => String(text ?? '').replace(/\s+/g, ' ').trim();
72
+
73
+ // Builds a Playwright locator from a finder.
74
+ export function locate(scope, finder) {
75
+ if (!finder || typeof finder !== 'object') throw new Error('a finder must be an object such as { role: "listitem" }');
76
+ let one = finder.within ? locate(scope, finder.within) : scope;
77
+ const exact = finder.exact === true;
78
+ if (finder.role) one = one.getByRole(finder.role, finder.name === undefined ? {} : { name: finder.name, exact });
79
+ else if (finder.text !== undefined) one = one.getByText(finder.text, { exact });
80
+ else if (finder.label !== undefined) one = one.getByLabel(finder.label, { exact });
81
+ else if (finder.css) one = one.locator(finder.css);
82
+ else throw new Error('a finder needs a role, a text, a label or a css');
83
+ const filter = {};
84
+ if (finder.hasText !== undefined) filter.hasText = finder.hasText;
85
+ if (finder.has) filter.has = locate(scope.page ? scope.page() : scope, finder.has);
86
+ if (Object.keys(filter).length) one = one.filter(filter);
87
+ if (finder.nth !== undefined) one = one.nth(Number(finder.nth));
88
+ return one;
89
+ }
90
+
91
+ // `playwright` is for a test: { chromium } with the same calls. `load` replaces the import, for a test of the missing peer. `executablePath` can come from
92
+ // the environment (ATLAS_BROWSER_EXECUTABLE) when the machine keeps its browser in an odd place.
93
+ export async function createBrowserDriver({ way, base, hosts, record: rawRecord, redactor, attach, playwright, load = loadPlaywright, timeoutMs = DEFAULT_TIMEOUT }) {
94
+ if (!base) throw new Error(`the way in "${way}" has no base address`);
95
+ const origin = new URL(base).origin;
96
+ requireAllowed(base, hosts, `the way in "${way}"`);
97
+ const record = rawRecord && ((call) => rawRecord(redactor ? redactor.deep(call) : call));
98
+ const lib = playwright ?? await load();
99
+ const engine = lib.chromium ?? lib.default?.chromium;
100
+ if (!engine) throw new Blocked('the package "playwright" has no chromium engine');
101
+
102
+ let browser = null;
103
+ let closed = false;
104
+ const contexts = new Map(); // persona name -> { context, page, persona }
105
+
106
+ async function launch() {
107
+ if (browser) return browser;
108
+ const executablePath = process.env.ATLAS_BROWSER_EXECUTABLE || undefined;
109
+ try {
110
+ browser = await engine.launch({ headless: true, executablePath });
111
+ } catch (error) {
112
+ throw new Blocked(`the browser could not start: ${String(error?.message ?? error).split('\n')[0]}. Install it with "npx playwright install chromium", or set ATLAS_BROWSER_EXECUTABLE`);
113
+ }
114
+ return browser;
115
+ }
116
+
117
+ // Addresses that the guards stopped. A stopped request counts once, as stopped: it is no failed request.
118
+ const stopped = new Set();
119
+ const event = (persona, kind, data) => record?.({ way, persona, request: { event: kind }, response: data });
120
+
121
+ // The credential of a persona: cookies go into the context for the base origin; other headers (a bearer
122
+ // token) go only with a request to the base origin. Returns { cookies, extra }.
123
+ function credentialOf({ headers = {}, credential = null }) {
124
+ const extra = {};
125
+ const cookies = [];
126
+ for (const [name, value] of Object.entries(headers)) {
127
+ if (name.toLowerCase() === 'cookie') {
128
+ for (const part of String(value).split(';').map((x) => x.trim()).filter(Boolean)) {
129
+ const at = part.indexOf('=');
130
+ cookies.push({ name: part.slice(0, at), value: part.slice(at + 1), url: origin });
131
+ }
132
+ } else extra[name.toLowerCase()] = String(value);
133
+ }
134
+ if (credential) extra.authorization = `Bearer ${credential}`;
135
+ return { cookies, extra };
136
+ }
137
+
138
+ async function contextOf(persona, given = {}) {
139
+ const key = persona ?? '-';
140
+ const { cookies, extra } = credentialOf(given);
141
+ if (contexts.has(key)) {
142
+ // A credential that comes after the context was made must not be dropped without a word.
143
+ const entry = contexts.get(key);
144
+ for (const [name, value] of Object.entries(extra)) {
145
+ if (entry.extra[name] !== value) throw new Blocked(`the browser context of "${persona ?? 'no persona'}" was made before its credential (${name}) came, so the credential would be dropped. Give the credential on the first call of the persona`);
146
+ }
147
+ if (cookies.length) await entry.context.addCookies(cookies);
148
+ return entry;
149
+ }
150
+ // Service workers could answer a request with no route, so they are blocked.
151
+ const context = await (await launch()).newContext({ viewport: { width: 1280, height: 900 }, serviceWorkers: 'block' });
152
+ const entry = { context, page: null, persona, key, extra };
153
+ if (cookies.length) await context.addCookies(cookies);
154
+ const stop = (url, why, kind = 'request-stopped') => { if (kind === 'request-stopped') stopped.add(url); event(persona, kind, { url: redactUrl(url), why }); };
155
+ const refusal = `guards.hosts allows only ${hosts.join(', ')}`;
156
+ const allowedUrl = (url) => { try { return hostAllowed(new URL(url).hostname, hosts); } catch { return false; } };
157
+
158
+ // Each request goes through here, and so does each hop of a redirect. The route sees the first URL
159
+ // of a chain only, so the driver does not let the browser follow a redirect: it fetches the request
160
+ // with no redirect, checks the Location, and hands the browser the answer. The browser then follows
161
+ // the hop as a new request, and that request comes back here to be checked again. The credential goes
162
+ // only with a request whose own origin is the base origin, so it never follows a hop to another host.
163
+ await context.route('**/*', async (route) => {
164
+ const request = route.request();
165
+ const url = request.url();
166
+ if (INTERNAL.test(url)) return route.continue();
167
+ if (!allowedUrl(url)) { stop(url, refusal); return route.abort('blockedbyclient'); }
168
+ const headers = { ...request.headers() };
169
+ if (sameOrigin(url, origin)) Object.assign(headers, entry.extra);
170
+ else for (const name of Object.keys(entry.extra)) delete headers[name];
171
+ let response;
172
+ try {
173
+ response = await route.fetch({ headers, maxRedirects: 0 });
174
+ } catch (error) {
175
+ stop(url, `the request failed: ${String(error?.message ?? error).split('\n')[0]}`, 'request-failed');
176
+ return route.abort('failed');
177
+ }
178
+ const status = response.status();
179
+ const location = status >= 300 && status < 400 ? response.headers()['location'] : null;
180
+ if (location) {
181
+ let next = null;
182
+ try { next = new URL(location, url).href; } catch { /* a bad Location is refused below */ }
183
+ if (!next || !allowedUrl(next)) { stopped.add(url); stop(next ?? location, `a redirect from ${new URL(url).pathname} leads to an address that ${refusal}`); return route.abort('blockedbyclient'); }
184
+ }
185
+ return route.fulfill({ response });
186
+ });
187
+ // A WebSocket is not a request of the route above. It is checked here, and a host that is not allowed is closed.
188
+ if (typeof context.routeWebSocket === 'function') {
189
+ await context.routeWebSocket(/.*/, (ws) => {
190
+ const url = ws.url();
191
+ if (!allowedUrl(url.replace(/^ws/, 'http'))) { stop(url, refusal); ws.close({ code: 1008, reason: 'refused by guards.hosts' }); return; }
192
+ ws.connectToServer();
193
+ });
194
+ }
195
+ contexts.set(key, entry);
196
+ return entry;
197
+ }
198
+
199
+ const redactUrl = (url) => (redactor ? redactor.text(url) : url);
200
+
201
+ async function pageOf(entry) {
202
+ if (entry.page && !entry.page.isClosed()) return entry.page;
203
+ const page = await entry.context.newPage();
204
+ page.setDefaultTimeout(timeoutMs);
205
+ page.on('console', (message) => { if (message.type() === 'error' && !stopped.has(message.location()?.url)) event(entry.persona, 'console-error', { text: normal(message.text()).slice(0, 500), at: redactUrl(message.location()?.url ?? '') }); });
206
+ page.on('pageerror', (error) => event(entry.persona, 'page-error', { text: normal(error?.message ?? error).slice(0, 500) }));
207
+ // A request failed at the network. A request that the guards stopped is not counted here.
208
+ page.on('requestfailed', (request) => { if (!stopped.has(request.url())) event(entry.persona, 'request-failed', { url: redactUrl(request.url()), why: request.failure()?.errorText ?? 'failed' }); });
209
+ // An HTTP error answer (4xx, 5xx) is listed apart. It is no network failure.
210
+ page.on('response', (response) => { if (response.status() >= 400) event(entry.persona, 'http-error', { url: redactUrl(response.url()), status: response.status() }); });
211
+ entry.page = page;
212
+ return page;
213
+ }
214
+
215
+ const answer = (status, body, headers = {}) => ({ status, body, isError: typeof status === 'number' && status >= 400, headers, raw: typeof body === 'string' ? body : JSON.stringify(body) });
216
+
217
+ // A picture cannot be redacted by value, so each part of the page that shows a secret is masked.
218
+ async function shoot(entry, name, { full = true } = {}) {
219
+ const page = entry.page;
220
+ if (!page || page.isClosed()) throw new Error(`there is no open page to take a screenshot of "${name}"`);
221
+ if (typeof attach !== 'function') throw new Error('this driver was made with no place to keep screenshots');
222
+ const safe = String(name).replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || 'screenshot';
223
+ const path = attach(`${safe}.png`);
224
+ const secrets = redactor ? [...redactor.values] : [];
225
+ const mask = secrets.map((value) => page.getByText(value));
226
+ // An input shows its value, which is no text node. Mark each input whose value holds a secret, and mask it.
227
+ if (secrets.length) {
228
+ await page.evaluate((values) => { for (const el of document.querySelectorAll('input, textarea, select')) if (values.some((v) => String(el.value ?? '').includes(v))) el.setAttribute('data-atlas-mask', '1'); }, secrets);
229
+ mask.push(page.locator('[data-atlas-mask]'));
230
+ }
231
+ let matched = 0;
232
+ for (const one of mask) matched += await one.count();
233
+ await page.screenshot({ path, fullPage: full, mask });
234
+ const bytes = readFileSync(path);
235
+ return { name: `${safe}.png`, bytes: bytes.length, sha256: createHash('sha256').update(bytes).digest('hex'), masked: matched };
236
+ }
237
+
238
+ async function call(request = {}, { credential = null, headers = {}, persona = null } = {}) {
239
+ if (closed) throw new Error(`the browser of "${way}" is closed`);
240
+ // The address is checked before any browser starts.
241
+ let target = null;
242
+ if (request.open !== undefined) {
243
+ target = /^[a-z][a-z0-9+.-]*:/i.test(String(request.open)) ? new URL(request.open) : new URL(`${base.replace(/\/+$/, '')}/${String(request.open).replace(/^\/+/, '')}`);
244
+ requireAllowed(target.href, hosts, `open ${target.pathname}`);
245
+ } else if (request.method) {
246
+ target = request.url ? new URL(request.url) : new URL(`${base.replace(/\/+$/, '')}/${String(request.path ?? '').replace(/^\/+/, '')}`);
247
+ requireAllowed(target.href, hosts, `${request.method} ${target.pathname}`);
248
+ }
249
+ const entry = await contextOf(persona, { headers, credential });
250
+ const log = (shape, response) => record?.({ way, persona, request: shape, response });
251
+
252
+ if (request.open !== undefined) {
253
+ const page = await pageOf(entry);
254
+ let response;
255
+ try {
256
+ response = await page.goto(target.href, { waitUntil: 'load', timeout: NAVIGATION_TIMEOUT });
257
+ } catch (error) {
258
+ const first = String(error?.message ?? error).split('\n')[0];
259
+ // The route stopped the address or a redirect of it. That is a guard refusal, so the result is `blocked`.
260
+ // The page is left in an error state, so it is closed and the next open makes a new one.
261
+ if (/ERR_BLOCKED_BY_CLIENT/.test(first)) {
262
+ try { await page.close(); } catch { /* it is gone */ }
263
+ entry.page = null;
264
+ throw new Blocked(`open ${target.pathname}: a request of the page, or a redirect, led to an address that guards.hosts does not allow, so the guards stopped it`);
265
+ }
266
+ throw new Error(`open ${target.pathname}: ${first}`);
267
+ }
268
+ const landed = page.url();
269
+ if (!INTERNAL.test(landed)) requireAllowed(landed, hosts, `the page ${target.pathname} ended on`);
270
+ const status = response?.status() ?? 0;
271
+ const out = answer(status, { url: redactUrl(landed), title: await page.title() }, response?.headers() ?? {});
272
+ log({ open: redactUrl(target.href) }, { status, body: out.body });
273
+ return out;
274
+ }
275
+
276
+ if (request.read !== undefined) {
277
+ const page = entry.page;
278
+ if (!page || page.isClosed()) throw new Error('read: there is no open page; open one first');
279
+ const wait = request.optional ? 0 : (request.timeout ?? timeoutMs);
280
+ const locator = locate(page, request.read);
281
+ let found = true;
282
+ if (wait > 0) {
283
+ try { await locator.first().waitFor({ state: 'visible', timeout: wait }); } catch { found = false; }
284
+ }
285
+ const texts = (await locator.allTextContents()).map(normal);
286
+ const count = texts.length;
287
+ found = count > 0 && (await locator.first().isVisible());
288
+ const body = { found, count, texts };
289
+ log({ read: show(request.read) }, { status: 200, body: clip(body, redactor) });
290
+ return answer(200, body);
291
+ }
292
+
293
+ if (request.click !== undefined || request.fill !== undefined) {
294
+ const page = entry.page;
295
+ if (!page || page.isClosed()) throw new Error('there is no open page; open one first');
296
+ const finder = request.click ?? request.fill;
297
+ if (request.fill !== undefined && redactor && [...redactor.values].some((v) => String(request.value ?? '').includes(v))) {
298
+ throw new Blocked('a fill with the value of a run secret was refused: a secret must not be typed into a page, because the page could show it or send it on');
299
+ }
300
+ const locator = locate(page, finder).first();
301
+ if (request.fill !== undefined) await locator.fill(String(request.value ?? ''));
302
+ else await locator.click();
303
+ // The value that was typed can be a secret: the record holds a redacted copy.
304
+ log(request.fill !== undefined ? { fill: show(finder), value: '[typed]' } : { click: show(finder) }, { status: 200 });
305
+ return answer(200, { done: true });
306
+ }
307
+
308
+ if (request.screenshot !== undefined) {
309
+ const info = await shoot(entry, request.screenshot);
310
+ log({ screenshot: request.screenshot }, { status: 200, body: info });
311
+ return answer(200, info);
312
+ }
313
+
314
+ if (request.method) {
315
+ const sendHeaders = { ...(request.headers ?? {}) };
316
+ const response = await entry.context.request.fetch(target.href, {
317
+ method: request.method.toUpperCase(), headers: sendHeaders, data: request.body, maxRedirects: 0, failOnStatusCode: false,
318
+ });
319
+ const text = await response.text();
320
+ let body = text;
321
+ if (/json/.test(response.headers()['content-type'] ?? '') && text) { try { body = JSON.parse(text); } catch { /* keep the text */ } }
322
+ const out = answer(response.status(), body, headersOf(response));
323
+ out.raw = text;
324
+ log({ method: request.method.toUpperCase(), url: redactUrl(target.href) }, { status: out.status, body: clip(body, redactor) });
325
+ return out;
326
+ }
327
+
328
+ throw new Error('the browser driver was called with no open, read, click, fill, screenshot or method');
329
+ }
330
+
331
+ // Playwright joins the values of a repeated header with a newline. A cookie reader wants the first.
332
+ function headersOf(response) {
333
+ const out = {};
334
+ for (const { name, value } of response.headersArray()) {
335
+ const key = name.toLowerCase();
336
+ out[key] = out[key] === undefined ? value : `${out[key]}, ${value}`;
337
+ }
338
+ return out;
339
+ }
340
+
341
+ // A picture of every open page, for a run that failed. It never throws.
342
+ async function keepOnFail(label = 'fail') {
343
+ const kept = [];
344
+ for (const entry of contexts.values()) {
345
+ try {
346
+ if (!entry.page || entry.page.isClosed()) continue;
347
+ const info = await shoot(entry, `${label}-${way}-${entry.persona ?? 'page'}`);
348
+ record?.({ way, persona: entry.persona, request: { event: 'screenshot-on-fail' }, response: info });
349
+ kept.push(info);
350
+ } catch { /* the failed run is the news; a missing picture is not */ }
351
+ }
352
+ return kept;
353
+ }
354
+
355
+ async function close() {
356
+ if (closed) return;
357
+ closed = true;
358
+ for (const entry of contexts.values()) { try { await entry.context.close(); } catch { /* the stop goes on */ } }
359
+ contexts.clear();
360
+ if (browser) { try { await browser.close(); } catch { /* the stop goes on */ } browser = null; }
361
+ }
362
+
363
+ return { name: 'browser', way, base, call, keepOnFail, close };
364
+ }
@@ -1,6 +1,8 @@
1
1
  // Builds the driver for a way in from its entry in tests.yaml. A driver that
2
- // the kit does not ship (a browser, a command) comes from the adapter:
3
- // adapter.drivers = { browser: async (ctx) => driver }
2
+ // the kit does not ship (a command, a queue) comes from the adapter:
3
+ // adapter.drivers = { command: async (ctx) => driver }
4
+ // The `browser` driver ships. It needs the package "playwright", which is an optional
5
+ // peer: without it a scenario that uses a browser way in reads `blocked`.
4
6
  // A driver has the shape { name, way, call(request, opts), close() }.
5
7
  import { isAbsolute, resolve } from 'node:path';
6
8
  import { Blocked } from '../errors.mjs';
@@ -8,10 +10,13 @@ import { requireAddressesAllowed, substitute } from '../guards.mjs';
8
10
  import { createHttpDriver } from './http.mjs';
9
11
  import { createMcpStdioDriver } from './mcp-stdio.mjs';
10
12
  import { createFunctionDriver } from './function.mjs';
13
+ import { createBrowserDriver } from './browser.mjs';
11
14
 
12
- export { createHttpDriver, createMcpStdioDriver, createFunctionDriver };
15
+ export { createHttpDriver, createMcpStdioDriver, createFunctionDriver, createBrowserDriver };
13
16
 
14
- // ctx: { way, spec, env, root, hosts, redactor, record, adapter, runId }
17
+ // ctx: { way, spec, env, root, hosts, redactor, record, adapter, runId, attach }
18
+ // `attach(fileName)` gives the path of a file to keep as evidence (a screenshot).
19
+ // `adapter.playwright` is the Playwright module, for a test that has none installed.
15
20
  // In a fault run, ATLAS_PRODUCT_ROOT names a throwaway copy. A process that a
16
21
  // way in starts (an MCP server) and the module of the function driver load from
17
22
  // that copy, so a planted fault is what runs. tests.yaml and the kit stay in the
@@ -41,6 +46,8 @@ export async function makeDriver(ctx) {
41
46
  switch (spec.driver) {
42
47
  case 'http':
43
48
  return createHttpDriver({ way, base: fill(spec.base), hosts, record, redactor });
49
+ case 'browser':
50
+ return createBrowserDriver({ way, base: fill(spec.base), hosts, record, redactor, attach: ctx.attach, playwright: adapter.playwright });
44
51
  case 'mcp-stdio':
45
52
  return createMcpStdioDriver({
46
53
  way, command: commandFor(fill(spec.command), root, productRoot), cwd: spec.cwd ? (isAbsolute(spec.cwd) ? spec.cwd : resolve(productRoot, spec.cwd)) : productRoot, hosts, redactor, record,
@@ -23,7 +23,7 @@ import { parseLimit } from './wait.mjs';
23
23
 
24
24
  const LIBRARY_HEADERS = { [LIBRARY_HEADER]: '1' };
25
25
 
26
- function freePort() {
26
+ function onePort() {
27
27
  return new Promise((resolve, reject) => {
28
28
  const server = createServer();
29
29
  server.once('error', reject);
@@ -31,6 +31,21 @@ function freePort() {
31
31
  });
32
32
  }
33
33
 
34
+ // A free port that no stand-in of this run uses, and that the environment
35
+ // does not name. A product with a fixed port has not started yet when a
36
+ // stand-in takes its port, and a stand-in can listen on another address family.
37
+ export async function freePort(taken = []) {
38
+ for (let tries = 0; tries < 20; tries += 1) {
39
+ const port = await onePort();
40
+ if (!taken.includes(port)) return port;
41
+ }
42
+ throw new Blocked('no free port was found that differs from the ports the run already uses');
43
+ }
44
+
45
+ const portsOf = (standIns) => Object.values(standIns).map((one) => Number(new URL(one.url).port)).filter(Boolean);
46
+ export const namedPorts = (spec) => [...JSON.stringify(spec ?? {}).matchAll(/(?:127\.0\.0\.1|localhost|\[::1\]):(\d{1,5})/g)].map((m) => Number(m[1]))
47
+ .concat(Object.values(spec?.vars ?? {}).filter((v) => /^\d{2,5}$/.test(String(v))).map(Number));
48
+
34
49
  // ---------------------------------------------------------------- ready
35
50
 
36
51
  async function pollReady(spec, ctx, { label, process: proc, hosts, redactor, standInOrigins = [] }) {
@@ -144,7 +159,7 @@ export async function startEnvironment({ root, tests, name, runId, redactor, ada
144
159
  continue;
145
160
  }
146
161
  if (!def?.start) throw new Blocked(`${label} has no "start" command and the adapter does not make it`);
147
- const port = await freePort();
162
+ const port = await freePort([...namedPorts(spec), ...portsOf(standIns)]);
148
163
  const url = `http://127.0.0.1:${port}`;
149
164
  const ctx = { id: runId, port, url };
150
165
  const proc = spawnManaged({
@@ -162,7 +177,7 @@ export async function startEnvironment({ root, tests, name, runId, redactor, ada
162
177
  // 2. The vars: run secrets are new for each placeholder; references resolve in order.
163
178
  const raw = spec.vars ?? {};
164
179
  // The product gets a free port too: `{port}` in start, ready, url and vars.
165
- const productPort = await freePort();
180
+ const productPort = await freePort([...namedPorts(spec), ...portsOf(standIns)]);
166
181
  const ctx = {
167
182
  id: runId, varsFile, port: productPort, url: spec.url ? substitute(spec.url, { id: runId, port: productPort }) : undefined,
168
183
  secret: () => { const s = newSecret(); secretsMade.push(s); redactor.add(s); return s; },
@@ -4,7 +4,7 @@
4
4
  // "evidence": 1, "run_id": "...", "environment": "local",
5
5
  // "scenario": "week-conflict", "title": "...", "source": "scenarios/week-conflict.md",
6
6
  // "source_sha256": "...", "fingerprint_method": "fp1",
7
- // "started": "...", "ended": null, "verdict": "not finished",
7
+ // "commit": "<sha or null>", "started": "...", "ended": null, "verdict": "not finished",
8
8
  // "ways": { "mcp": { "verdict": "pass", "reason": null, "started": "...", "ended": "..." } },
9
9
  // "assertions": [ { "id": "week-conflict/e3#09f598", "way": "mcp", "result": "pass", "proof": { ... } } ],
10
10
  // "tables": { "T1": "<fingerprint>" }, "fresh": [ { "rule": "...", "value": "..." } ],
@@ -18,13 +18,32 @@
18
18
  // (`<scenario>.2.json`). A file is claimed with an exclusive create, so no
19
19
  // earlier run is overwritten. Every string is redacted by value before it is
20
20
  // written.
21
- import { closeSync, mkdirSync, openSync, renameSync, writeFileSync } from 'node:fs';
21
+ import { closeSync, mkdirSync, openSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
22
22
  import { join, relative } from 'node:path';
23
+ import { spawnSync } from 'node:child_process';
23
24
 
24
25
  export const EVIDENCE_SCHEMA = 1;
25
26
 
26
- // The results of one assertion for one way in (design section 6.11).
27
- export const RESULTS = Object.freeze(['pass', 'fail', 'not checked', 'not exercised', 'blocked', 'not here']);
27
+ // The commit under test: the head of the pull request when the environment gives it (GITHUB_HEAD_SHA, or
28
+ // pull_request.head.sha in the event file), else GITHUB_SHA, else the HEAD of the repo, when git can say.
29
+ function commitOf(root) {
30
+ if (process.env.GITHUB_HEAD_SHA) return process.env.GITHUB_HEAD_SHA;
31
+ if (process.env.GITHUB_EVENT_PATH) {
32
+ try {
33
+ const sha = JSON.parse(readFileSync(process.env.GITHUB_EVENT_PATH, 'utf8'))?.pull_request?.head?.sha;
34
+ if (sha) return sha;
35
+ } catch { /* no event file that reads */ }
36
+ }
37
+ if (process.env.GITHUB_SHA) return process.env.GITHUB_SHA;
38
+ try {
39
+ const r = spawnSync('git', ['rev-parse', 'HEAD'], { cwd: root, encoding: 'utf8' });
40
+ return r.status === 0 ? r.stdout.trim() : null;
41
+ } catch { return null; }
42
+ }
43
+
44
+ // The results of one assertion for one way in (design section 6.11). `flaky` is never set by a
45
+ // test. `atlas tests verdict` and the proof table read it from two attempts (verdict.mjs).
46
+ export const RESULTS = Object.freeze(['pass', 'fail', 'not checked', 'not exercised', 'blocked', 'not here', 'flaky']);
28
47
 
29
48
  const MAX_BODY = 8000;
30
49
 
@@ -47,13 +66,17 @@ export class Evidence {
47
66
  scenario: scenario.id, title: scenario.title,
48
67
  source: relative(root, scenario.file).split('\\').join('/'), source_sha256: scenario.sha256,
49
68
  fingerprint_method: scenario.method,
50
- started: new Date().toISOString(), ended: null, verdict: 'not finished',
69
+ commit: commitOf(root), started: new Date().toISOString(), ended: null, verdict: 'not finished',
51
70
  ways: {}, assertions: [], tables: {}, fresh: [], calls: [], notes: [], files: [],
52
71
  provided_secrets: providedSecrets, fault_run: process.env.ATLAS_FAULT_RUN || null,
53
72
  };
54
- for (const way of scenario.through) {
73
+ // One entry for each assertion and each way in that it belongs to (scenario.mjs, waysOf). An
74
+ // assertion that reads a result of a step with its own way in (the page) has that way only.
75
+ for (const way of scenario.ways ?? scenario.through) {
55
76
  this.record.ways[way] = { verdict: 'not finished', reason: null, started: null, ended: null };
56
- for (const a of scenario.assertions) this.record.assertions.push({ id: a.fullId, way, result: 'not checked', proof: null });
77
+ }
78
+ for (const a of scenario.assertions) {
79
+ for (const way of a.ways ?? scenario.through) this.record.assertions.push({ id: a.fullId, way, result: 'not checked', proof: null });
57
80
  }
58
81
  mkdirSync(this.dir, { recursive: true });
59
82
  this.path = this.claim(scenario.id);
package/tests/index.mjs CHANGED
@@ -2,10 +2,11 @@
2
2
  export { scenario, defineAdapter, waitUntil, RUN_ID } from './contract.mjs';
3
3
  export { startEnvironment, parseDotenv, provideSecrets } from './environment.mjs';
4
4
  export { createStandIn, serveStandIn, CALLS_PATH, LIBRARY_HEADER } from './stand-in.mjs';
5
- export { createHttpDriver, createMcpStdioDriver, createFunctionDriver, makeDriver } from './drivers/index.mjs';
5
+ export { createHttpDriver, createMcpStdioDriver, createFunctionDriver, createBrowserDriver, makeDriver } from './drivers/index.mjs';
6
6
  export { parseTestsYaml, readTestsYaml, checkTestsYaml, guardParts, compareGuardParts, GUARD_PARTS, looksLikeSecret } from './tests-yaml.mjs';
7
7
  export { parseScenario, readScenario, addFingerprints, FINGERPRINT_METHOD } from './scenario.mjs';
8
8
  export { Blocked, Unavailable, WaitFailed, WaitTimeout, GateFailed } from './errors.mjs';
9
9
  export { Redactor } from './redact.mjs';
10
10
  export { Evidence, EVIDENCE_SCHEMA, RESULTS } from './evidence.mjs';
11
11
  export { checkArea } from './link-check.mjs';
12
+ export { listNamed, decide, settleRecords, readAttempts, loadAreaScenarios, keyOf } from './verdict.mjs';
@@ -466,6 +466,25 @@ export function newestRunPerScenario(evidenceDir) {
466
466
  return new Map([...best].map(([scenario, b]) => [scenario, b.name]));
467
467
  }
468
468
 
469
+ // The newest run of the whole folder, by the start time of its evidence (a name breaks a tie).
470
+ export function newestRunId(evidenceDir) {
471
+ let best = null;
472
+ if (!existsSync(evidenceDir)) return null;
473
+ for (const name of readdirSync(evidenceDir)) {
474
+ const dir = join(evidenceDir, name);
475
+ if (!statSync(dir).isDirectory()) continue;
476
+ for (const file of readdirSync(dir)) {
477
+ if (!file.endsWith('.json')) continue;
478
+ let data;
479
+ try { data = JSON.parse(readFileSync(join(dir, file), 'utf8')); } catch { continue; }
480
+ if (data?.evidence !== 1 || data.fault_run || !data.scenario) continue;
481
+ const started = String(data.started);
482
+ if (!best || started > best.started || (started === best.started && name > best.name)) best = { started, name };
483
+ }
484
+ }
485
+ return best ? best.name : null;
486
+ }
487
+
469
488
  // The run half reads the files of one run only, and only evidence of the
470
489
  // scenario file as it is now (source_sha256).
471
490
  export function checkRun({ evidenceDir, runId = null, environment = null, scenarios, rel, findings }) {
@@ -488,8 +507,8 @@ export function checkRun({ evidenceDir, runId = null, environment = null, scenar
488
507
  runOf.set(data.scenario, id);
489
508
  }
490
509
  }
491
- const reached = new Set(['pass', 'fail', 'not exercised', 'not here']);
492
- const ran = new Set(['pass', 'fail', 'not exercised']);
510
+ const reached = new Set(['pass', 'fail', 'not exercised', 'not here', 'flaky']);
511
+ const ran = new Set(['pass', 'fail', 'not exercised', 'flaky']);
493
512
  for (const s of scenarios) {
494
513
  if (!s.id) continue;
495
514
  const all = records.filter((r) => r.data.scenario === s.id);
@@ -500,7 +519,7 @@ export function checkRun({ evidenceDir, runId = null, environment = null, scenar
500
519
  continue;
501
520
  }
502
521
  for (const a of s.assertions) {
503
- for (const way of s.through) {
522
+ for (const way of a.ways ?? s.through) {
504
523
  const seen = mine.some((r) => r.data.assertions?.some((e) => e.id === a.fullId && e.way === way && reached.has(e.result)));
505
524
  if (!seen) findings.push({ file: rel(s.file), line: a.line, code: 'not-checked', message: `no run checked ${s.id}/${a.id}#${a.fp} through ${way}` });
506
525
  }
@@ -271,6 +271,23 @@ export function readScenario(path) {
271
271
 
272
272
  const runsIn = (scenario) => [].concat(scenario.fields['runs-in'] ?? []).map(String);
273
273
 
274
+ // The ways in that an assertion is checked through. An assertion reads a kept result. When the
275
+ // step that kept it names its own way in ("As `owner`, through `page`, ..."), the assertion belongs
276
+ // to that way: the proof table shows it under that column (design section 6.3). An assertion that
277
+ // names no kept result of such a step belongs to every way of `through:`. The ways of `through:`
278
+ // stay first. The words and the fingerprint do not change.
279
+ function waysOf(assertion, scenario, kept) {
280
+ const named = new Set();
281
+ let plain = false;
282
+ for (const name of assertion.names) {
283
+ const step = kept.get(name);
284
+ if (!step) continue;
285
+ if (step.way) named.add(step.way); else plain = true;
286
+ }
287
+ if (!named.size || plain) return [...new Set([...scenario.through, ...named])];
288
+ return [...named];
289
+ }
290
+
274
291
  // Sets `fp` and `fullId` on every assertion. `tests` is the parsed value of
275
292
  // tests.yaml. Returns the scenario.
276
293
  export function addFingerprints(scenario, tests) {
@@ -315,7 +332,9 @@ export function addFingerprints(scenario, tests) {
315
332
  lines.push(`through=${[...scenario.through].sort().join(',')}`, `runs-in=${runsIn(scenario).sort().join(',')}`);
316
333
  a.fp = fingerprintOf([a.words, ...lines].join('\n'));
317
334
  a.fullId = `${scenario.id}/${a.id}#${a.fp}`;
335
+ a.ways = waysOf(a, scenario, kept);
318
336
  }
337
+ scenario.ways = [...new Set([...scenario.through, ...scenario.assertions.flatMap((a) => a.ways)])];
319
338
  return scenario;
320
339
  }
321
340