@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.
- package/.claude-plugin/plugin.json +1 -1
- package/door/kit-releases.json +6 -1
- package/door/lib/proof.mjs +25 -1
- package/package.json +10 -2
- package/skills/sdlc-task/SKILL.md +1 -0
- package/tests/contract.mjs +48 -9
- package/tests/drivers/browser.mjs +364 -0
- package/tests/drivers/index.mjs +11 -4
- package/tests/evidence.mjs +6 -2
- package/tests/index.mjs +1 -1
- package/tests/link-check.mjs +1 -1
- package/tests/scenario.mjs +19 -0
- package/work/lib/activity.mjs +246 -0
- package/work/lib/questions.mjs +4 -1
- package/work/lib/verbs.mjs +75 -31
- package/work/{manifest-0.6.0.json → manifest-0.7.0.json} +74 -2
- package/work/mcp.mjs +5 -3
package/door/kit-releases.json
CHANGED
|
@@ -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
|
}
|
package/door/lib/proof.mjs
CHANGED
|
@@ -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.
|
|
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` |
|
package/tests/contract.mjs
CHANGED
|
@@ -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
|
|
149
|
-
for (const
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
261
|
-
|
|
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
|
-
|
|
278
|
-
|
|
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
|
+
}
|
package/tests/drivers/index.mjs
CHANGED
|
@@ -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
|
|
3
|
-
// adapter.drivers = {
|
|
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,
|