@arjunkhera/atlas 0.3.11 → 0.3.13

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "atlas",
3
- "version": "0.3.11",
3
+ "version": "0.3.13",
4
4
  "description": "Atlas: the delivery lifecycle, its crews and the Atlas tools, for any repository.",
5
5
  "author": {
6
6
  "name": "Arjun Khera"
@@ -1,4 +1,9 @@
1
1
  {
2
2
  "note": "Every kit of the folder tests/ that a release of Atlas shipped, as the kit hash of its manifest (the sha256 of the sorted lines of path and file hash). The kit of this package is always accepted and need not be listed. When a release changes anything in tests/, add the kit hash of the release before it here. atlas tests check reads this list; git history is never read.",
3
- "releases": []
3
+ "releases": [
4
+ {
5
+ "kit_sha256": "ae0f32b8c6694fbffb8bdacd8bd919d27c1c1da6281a46779e521dfea2341565",
6
+ "first_package": "0.3.8"
7
+ }
8
+ ]
4
9
  }
@@ -8,6 +8,9 @@
8
8
  // { "<scenario>/e1": { "result": "pass", "proof": "revision 1" } }; a full id
9
9
  // (with its #fingerprint) works as a key too. A way that is blocked or failed
10
10
  // shows its reason in the proof column. Evidence of a fault run is left out.
11
+ // A scenario that kept files (screenshots of the browser driver) gets a list under the table:
12
+ // each file with its run folder, and the count of console errors and failed requests of its page.
13
+ // The pictures stay in the CI run (design section 8.3); the table holds their names only.
11
14
  import { existsSync, readFileSync, readdirSync } from 'node:fs';
12
15
  import { join, resolve } from 'node:path';
13
16
  import { newestRunPerScenario } from '../../tests/link-check.mjs';
@@ -64,10 +67,21 @@ export function buildProof({ evidenceDir, runId = null, own = {} }) {
64
67
  const ways = [...new Set(scenarios.flatMap((s) => Object.keys(s.ways ?? {})))];
65
68
  const rows = [];
66
69
  const blocked = [];
70
+ const screenshots = [];
71
+ const pageEvents = [];
67
72
  for (const s of scenarios) {
68
73
  for (const [way, w] of Object.entries(s.ways ?? {})) {
69
74
  if (w.verdict === 'blocked' || w.verdict === 'fail') blocked.push(`${s.scenario} through ${way} is ${w.verdict}${w.reason ? `: ${clip(w.reason, 200)}` : ''}`);
70
75
  }
76
+ for (const file of s.files ?? []) {
77
+ if (/\.(png|jpe?g|webp)$/i.test(file)) screenshots.push({ scenario: s.scenario, file, run: s.run_id });
78
+ }
79
+ const events = (s.calls ?? []).map((c) => c.request?.event).filter(Boolean);
80
+ // A scenario that kept a picture used a page, so a count of 0 is news too.
81
+ if (events.length || (s.files ?? []).some((f) => /\.(png|jpe?g|webp)$/i.test(f))) {
82
+ const count = (kind) => events.filter((e) => e === kind).length;
83
+ pageEvents.push({ scenario: s.scenario, consoleErrors: count('console-error') + count('page-error'), failedRequests: count('request-failed'), httpErrors: count('http-error'), stopped: count('request-stopped') });
84
+ }
71
85
  const ids = [...new Set((s.assertions ?? []).map((a) => a.id))];
72
86
  for (const full of ids) {
73
87
  const short = full.replace(/#[0-9a-f]+$/, '');
@@ -92,7 +106,7 @@ export function buildProof({ evidenceDir, runId = null, own = {} }) {
92
106
  });
93
107
  }
94
108
  }
95
- return { runId: id, environment: scenarios[0].environment, ways, rows, blocked, faultRuns, scenarios: scenarios.map((s) => s.scenario) };
109
+ return { runId: id, environment: scenarios[0].environment, ways, rows, blocked, faultRuns, screenshots, pageEvents, scenarios: scenarios.map((s) => s.scenario) };
96
110
  }
97
111
 
98
112
  export function renderProof(proof) {
@@ -107,6 +121,16 @@ export function renderProof(proof) {
107
121
  out.push('Blocked or failed ways in:');
108
122
  for (const b of proof.blocked) out.push(`- ${clean(b)}`);
109
123
  }
124
+ if (proof.screenshots.length) {
125
+ out.push('');
126
+ out.push('Screenshots (kept in test/evidence of the run; the CI run holds them for 14 days):');
127
+ for (const shot of proof.screenshots) out.push(`- ${clean(shot.scenario)}: test/evidence/${clean(shot.run)}/${clean(shot.file)}`);
128
+ }
129
+ if (proof.pageEvents.length) {
130
+ out.push('');
131
+ out.push('Page events:');
132
+ for (const e of proof.pageEvents) out.push(`- ${clean(e.scenario)}: ${e.consoleErrors} console error(s), ${e.failedRequests} failed request(s), ${e.httpErrors} HTTP error answer(s), ${e.stopped} request(s) stopped by the guards`);
133
+ }
110
134
  if (proof.faultRuns) {
111
135
  out.push('');
112
136
  out.push(`${proof.faultRuns} evidence file(s) of fault runs were left out.`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arjunkhera/atlas",
3
- "version": "0.3.11",
3
+ "version": "0.3.13",
4
4
  "description": "Atlas: the delivery lifecycle, its crews and the Atlas tools, as a Claude Code plugin for any repository.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -25,5 +25,13 @@
25
25
  "agents/",
26
26
  ".claude-plugin/",
27
27
  ".mcp.json"
28
- ]
28
+ ],
29
+ "peerDependencies": {
30
+ "playwright": ">=1.40.0"
31
+ },
32
+ "peerDependenciesMeta": {
33
+ "playwright": {
34
+ "optional": true
35
+ }
36
+ }
29
37
  }
@@ -34,6 +34,7 @@ checks and the transition log; each verb's own description says what it needs.
34
34
  | Keep its next step, its wait or its title true | `item_edit` |
35
35
  | Split it, or link it to other work | `item_split`, `item_link` |
36
36
  | Mark that it waits on another item, or clear that | `item_block`, `item_unblock` |
37
+ | Keep a note on it, or read what happened on it | `item_comment`, `item_activity` |
37
38
  | Ask the owner something, or record the answer | `question_ask`, `question_answer` |
38
39
  | Record a decision in the owner's words | `decision_record` |
39
40
  | Mark it delivered, or reopen it | `item_done`, `item_reopen` |
@@ -34,6 +34,7 @@
34
34
  // a stand-in made in this process, instead of a start command
35
35
  // adapter.warmUp async (env) => {} make the product call its stand-ins once
36
36
  // adapter.drivers { wayOrDriver: async (ctx) => driver } a driver the kit does not ship
37
+ // adapter.playwright the Playwright module, for a test of the browser driver that has none installed
37
38
  // adapter.functions the module (or { way: module }) for the function driver
38
39
  // adapter.loadModule async (file) => module loads the `module` of a function way in; the
39
40
  // kit gives the path under the product root, so a fault run loads the copy
@@ -145,10 +146,10 @@ export function scenario(path, body, options = {}) {
145
146
  if (!runsHere) {
146
147
  it(`${sc.id} [not here: runs-in excludes ${environment}]`, () => {
147
148
  const ev = evidence();
148
- for (const way of sc.through) {
149
- for (const a of sc.assertions) ev.setResult(a.fullId, way, 'not here', { why: `runs-in does not list ${environment}` });
150
- ev.setWay(way, { verdict: 'not here', started: new Date().toISOString(), ended: new Date().toISOString() });
149
+ for (const a of sc.assertions) {
150
+ for (const way of a.ways) ev.setResult(a.fullId, way, 'not here', { why: `runs-in does not list ${environment}` });
151
151
  }
152
+ for (const way of sc.ways) ev.setWay(way, { verdict: 'not here', started: new Date().toISOString(), ended: new Date().toISOString() });
152
153
  });
153
154
  return;
154
155
  }
@@ -167,6 +168,8 @@ export function scenario(path, body, options = {}) {
167
168
  error = thrown;
168
169
  }
169
170
  const outcome = settle({ sc, way, ev, error });
171
+ // A failed run keeps a picture of each open page (a driver that has one), before the run closes.
172
+ if (run && outcome.verdict === 'fail') await keepOnFail(state, sc.id);
170
173
  // The run is closed: a late call (after a time limit, say) changes nothing.
171
174
  run?.close();
172
175
  await cleanUp(state, run, ev);
@@ -182,6 +185,12 @@ export function scenario(path, body, options = {}) {
182
185
  });
183
186
  }
184
187
 
188
+ async function keepOnFail(state, label) {
189
+ for (const pending of state.drivers.values()) {
190
+ try { await (await pending).keepOnFail?.(`fail-${label}`); } catch { /* a missing picture is not the news */ }
191
+ }
192
+ }
193
+
185
194
  function withLimit(promise, ms, text) {
186
195
  let timer;
187
196
  const limit = new Promise((_, reject) => { timer = setTimeout(() => reject(new Error(`the scenario ran out of time: its limit is ${text}`)), ms); timer.unref?.(); });
@@ -195,7 +204,9 @@ async function cleanUp(state, run, ev) {
195
204
 
196
205
  // Turns the end of a body into results, a verdict and a list of problems.
197
206
  function settle({ sc, way, ev, error }) {
198
- const mine = ev.record.assertions.filter((a) => a.way === way);
207
+ // The entries of this way in, and the entries of a way in that only a step names (the page).
208
+ // A run through `mcp` checks the page assertions too, so it settles them.
209
+ const mine = ev.record.assertions.filter((a) => a.way === way || !sc.through.includes(a.way));
199
210
  const problems = [];
200
211
  let verdict = 'pass';
201
212
  let reason = null;
@@ -220,6 +231,21 @@ function settle({ sc, way, ev, error }) {
220
231
  if (verdict === 'blocked') problems.unshift(`BLOCKED: ${reason}`);
221
232
  else if (reason && !(error instanceof GateFailed)) problems.push(`the run stopped: ${reason}`);
222
233
  ev.setWay(way, { verdict, reason, ended: new Date().toISOString() });
234
+ // A way in that only a step names has no run of its own. Its verdict is the verdict of its assertions.
235
+ for (const extra of sc.ways.filter((w) => !sc.through.includes(w))) {
236
+ const own = ev.record.assertions.filter((a) => a.way === extra).map((a) => a.result);
237
+ // A gate that failed, or a block, leaves the later assertions `not checked`. That is no `fail` of the page.
238
+ const stopped = own.every((r) => r === 'pass' || r === 'not here' || r === 'not checked' || r === 'blocked') && own.some((r) => r === 'not checked' || r === 'blocked');
239
+ let extraVerdict;
240
+ let extraReason = null;
241
+ if (own.includes('fail')) { extraVerdict = 'fail'; extraReason = `an assertion through ${extra} failed`; }
242
+ else if (stopped) {
243
+ extraVerdict = 'blocked';
244
+ extraReason = error instanceof GateFailed ? 'the gate stopped the run' : verdict === 'blocked' ? reason : 'the run stopped before every assertion through this way in was checked';
245
+ } else if (own.every((r) => r === 'pass' || r === 'not here')) extraVerdict = own.every((r) => r === 'not here') ? 'not here' : 'pass';
246
+ else { extraVerdict = 'fail'; extraReason = `an assertion through ${extra} is not a pass`; }
247
+ ev.setWay(extra, { verdict: extraVerdict, reason: extraReason, started: ev.record.ways[way].started, ended: new Date().toISOString() });
248
+ }
223
249
  return { verdict, problems };
224
250
  }
225
251
 
@@ -240,7 +266,12 @@ function makeRun({ ctx, state, way, env, ev, adapter }) {
240
266
  if (!state.drivers.has(wayName)) {
241
267
  const spec = tests['ways-in'][wayName];
242
268
  if (!spec) throw new Error(`there is no way in "${wayName}" in tests.yaml`);
243
- state.drivers.set(wayName, makeDriver({ way: wayName, spec, env, root, hosts: env.hosts, redactor: state.redactor, record, adapter, runId: RUN_ID }));
269
+ // A driver is shared by the runs of the scenario (the page way in has no run of its own), so it reaches
270
+ // the record and the attach of the run that is live now, and not those of the run that made it.
271
+ // A driver is shared by the runs of a scenario (the page way in has no run of its own), so it reaches the
272
+ // record and the attach of the run that is live now (state.live), not those of the run that made it.
273
+ // attach: where a driver keeps a file (a screenshot) as evidence, in the folder of this scenario.
274
+ state.drivers.set(wayName, makeDriver({ way: wayName, spec, env, root, hosts: env.hosts, redactor: state.redactor, record: (call) => state.live?.record(call), adapter, runId: RUN_ID, attach: (name) => state.live.attach(name) }));
244
275
  }
245
276
  return state.drivers.get(wayName);
246
277
  };
@@ -257,8 +288,9 @@ function makeRun({ ctx, state, way, env, ev, adapter }) {
257
288
  const settleOne = (full, fn, opts = {}, mode) => {
258
289
  open(`run.${mode}`);
259
290
  const { a, fp } = find(full);
260
- const target = opts.way ?? way;
261
- if (!sc.through.includes(target)) throw new Error(`"${target}" is not a way in of this scenario`);
291
+ // An assertion that reads a result of a step with its own way in (the page) belongs to that way.
292
+ const target = opts.way ?? (a.ways.includes(way) ? way : a.ways[0]);
293
+ if (!a.ways.includes(target)) throw new Error(`"${target}" is not a way in of ${a.id}: its ways are ${a.ways.join(', ')}`);
262
294
  const entry = ev.entry(a.fullId, target);
263
295
  const calls = callsSince();
264
296
  let failure = null;
@@ -274,8 +306,14 @@ function makeRun({ ctx, state, way, env, ev, adapter }) {
274
306
  if (isThenable(value)) { failure = 'a check must not be async: wait first, then check'; value = undefined; }
275
307
  } catch (e) { failure = e?.message ?? String(e); }
276
308
  }
277
- if (failure) ev.setResult(a.fullId, target, 'fail', { error: failure, calls });
278
- else if (entry.result !== 'fail') ev.setResult(a.fullId, target, 'pass', { value: value === undefined ? undefined : clip(value, state.redactor), calls });
309
+ // `settled_by` names the run (the way in of `through:`) that settled the assertion. A page assertion of a
310
+ // scenario with two `through` ways is settled twice. A fail stands over a later pass. The result of the other
311
+ // run is listed under `also`, so no result is overwritten without a trace.
312
+ const earlier = entry.proof?.settled_by && entry.proof.settled_by !== way ? entry : null;
313
+ const also = earlier ? [...(earlier.proof.also ?? []), { run: earlier.proof.settled_by, result: earlier.result }] : (entry.proof?.also ?? undefined);
314
+ if (failure) ev.setResult(a.fullId, target, 'fail', { error: failure, calls, settled_by: way, also });
315
+ else if (entry.result !== 'fail') ev.setResult(a.fullId, target, 'pass', { value: value === undefined ? undefined : clip(value, state.redactor), calls, settled_by: way, also });
316
+ else if (earlier || entry.proof) { entry.proof = { ...entry.proof, also: [...(entry.proof.also ?? []), { run: way, result: 'pass' }] }; ev.write(); }
279
317
  if (failure && mode === 'gate') throw new GateFailed(`[${a.id}] ${failure}`);
280
318
  return a;
281
319
  };
@@ -393,6 +431,7 @@ function makeRun({ ctx, state, way, env, ev, adapter }) {
393
431
  // Called by the library after the body and the results are settled.
394
432
  close: () => { closed = true; },
395
433
  };
434
+ state.live = { record, attach: (name) => run.attach(name) };
396
435
  run.actions = typeof adapter.actions === 'function' ? adapter.actions(run) : (adapter.actions ?? {});
397
436
  return run;
398
437
  }
@@ -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,