@arjunkhera/atlas 0.3.7 → 0.3.9
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/agents/artifact-renderer.md +22 -22
- package/agents/verifier.md +29 -0
- package/door/cli.mjs +62 -12
- package/door/kit-releases.json +4 -0
- package/door/lib/design-build.mjs +409 -0
- package/door/lib/design.mjs +199 -118
- package/door/lib/markdown.mjs +160 -0
- package/door/lib/proof.mjs +127 -0
- package/door/lib/tests.mjs +190 -0
- package/package.json +2 -1
- package/skills/lead/SKILL.md +4 -1
- package/skills/sdlc-task/SKILL.md +33 -24
- package/skills/sdlc-task/design/README.md +187 -0
- package/skills/sdlc-task/design/parts/actors.md +26 -0
- package/skills/sdlc-task/design/parts/alternatives.md +24 -0
- package/skills/sdlc-task/design/parts/build.md +23 -0
- package/skills/sdlc-task/design/parts/calls.md +25 -0
- package/skills/sdlc-task/design/parts/change.md +27 -0
- package/skills/sdlc-task/design/parts/data.md +22 -0
- package/skills/sdlc-task/design/parts/done.md +23 -0
- package/skills/sdlc-task/design/parts/edges.md +24 -0
- package/skills/sdlc-task/design/parts/goals.md +27 -0
- package/skills/sdlc-task/design/parts/key.md +25 -0
- package/skills/sdlc-task/design/parts/migration.md +22 -0
- package/skills/sdlc-task/design/parts/order.md +24 -0
- package/skills/sdlc-task/design/parts/problem.md +22 -0
- package/skills/sdlc-task/design/parts/proof.md +24 -0
- package/skills/sdlc-task/design/parts/proposal.md +24 -0
- package/skills/sdlc-task/design/parts/records.md +24 -0
- package/skills/sdlc-task/design/parts/repos.md +25 -0
- package/skills/sdlc-task/design/parts/risks.md +24 -0
- package/skills/sdlc-task/design/parts/rollout.md +24 -0
- package/skills/sdlc-task/design/parts/routes.md +24 -0
- package/skills/sdlc-task/design/parts/scorecard.md +25 -0
- package/skills/sdlc-task/design/parts/security.md +22 -0
- package/skills/sdlc-task/design/parts/shared-decisions.md +24 -0
- package/skills/sdlc-task/design/parts/states.md +25 -0
- package/skills/sdlc-task/design/parts/stories.md +26 -0
- package/skills/sdlc-task/design/parts/summary.md +33 -0
- package/skills/sdlc-task/design/parts/why.md +22 -0
- package/skills/sdlc-task/design/parts/words.md +29 -0
- package/skills/sdlc-task/design/parts/yardstick.md +25 -0
- package/skills/sdlc-task/design/parts.yaml +306 -0
- package/skills/sdlc-task/lifecycle.yaml +2 -2
- package/skills/tests/SKILL.md +234 -0
- package/tests/contract.mjs +398 -0
- package/tests/drivers/function.mjs +30 -0
- package/tests/drivers/http.mjs +68 -0
- package/tests/drivers/index.mjs +65 -0
- package/tests/drivers/mcp-stdio.mjs +174 -0
- package/tests/environment.mjs +227 -0
- package/tests/errors.mjs +27 -0
- package/tests/evidence.mjs +113 -0
- package/tests/fresh.mjs +42 -0
- package/tests/guards.mjs +159 -0
- package/tests/index.mjs +11 -0
- package/tests/link-check.mjs +576 -0
- package/tests/procs.mjs +43 -0
- package/tests/redact.mjs +58 -0
- package/tests/scenario.mjs +325 -0
- package/tests/stand-in.mjs +74 -0
- package/tests/tests-yaml.mjs +258 -0
- package/tests/wait.mjs +44 -0
- package/tests/yaml.mjs +327 -0
- package/agents/artifact-format/walkthrough.html +0 -706
- package/skills/sdlc-task/templates/design-doc.md +0 -126
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
// The contract library (design section 8.1). It sits between the words of a
|
|
2
|
+
// scenario and the test code, on `node:test`.
|
|
3
|
+
//
|
|
4
|
+
// // in test/scenarios/week-conflict.test.mjs, with the kit copy in test/atlas/
|
|
5
|
+
// scenario('scenarios/week-conflict.md', async (run, way) => {
|
|
6
|
+
// const first = await run.persona('planner-1', way);
|
|
7
|
+
// // ... steps, through the actions of the area ...
|
|
8
|
+
// run.gate('week-conflict/e1#be9cf0', () => assert.equal(firstAnswer.revision, 1));
|
|
9
|
+
// run.check('week-conflict/e2#7f1d74', () => assert.equal(secondAnswer.revision, 2));
|
|
10
|
+
// }, { adapter });
|
|
11
|
+
//
|
|
12
|
+
// What `scenario(path, body, options)` does
|
|
13
|
+
// 1. Reads the scenario and atlas/tests.yaml, and refuses to run when either
|
|
14
|
+
// has a fault.
|
|
15
|
+
// 2. Registers one node:test test for each way in of `through:`. It runs the
|
|
16
|
+
// body with (run, way).
|
|
17
|
+
// 3. Starts the environment once for the scenario (environment.mjs): the
|
|
18
|
+
// stand-ins, then the product. It stops them when the last way is done.
|
|
19
|
+
// 4. Writes the evidence file (evidence.mjs): at the start, and after each
|
|
20
|
+
// assertion.
|
|
21
|
+
// 5. A test fails (node:test red) when any assertion is not `pass`. That
|
|
22
|
+
// holds for `fail`, `not checked`, `not exercised` and `blocked`.
|
|
23
|
+
// When the scenario's `runs-in` excludes the environment, the result is
|
|
24
|
+
// `not here` and the test passes.
|
|
25
|
+
//
|
|
26
|
+
// Guard parts at run time: with ATLAS_GUARDS_FROM=<tests.yaml of the main
|
|
27
|
+
// branch>, a branch whose guard parts differ ends `blocked` and starts nothing.
|
|
28
|
+
// After the body and the results are settled, the run is closed: a late
|
|
29
|
+
// run.check throws and changes nothing. After the product is ready, a lost
|
|
30
|
+
// connection, a server exit or a time-out is `fail`, not `blocked`.
|
|
31
|
+
//
|
|
32
|
+
// The adapter binds the library to one product. Every part is optional:
|
|
33
|
+
// adapter.standIns { name: async ({ name, runId, hosts }) => ({ url, stop, calls }) }
|
|
34
|
+
// a stand-in made in this process, instead of a start command
|
|
35
|
+
// adapter.warmUp async (env) => {} make the product call its stand-ins once
|
|
36
|
+
// adapter.drivers { wayOrDriver: async (ctx) => driver } a driver the kit does not ship
|
|
37
|
+
// adapter.functions the module (or { way: module }) for the function driver
|
|
38
|
+
// adapter.loadModule async (file) => module loads the `module` of a function way in; the
|
|
39
|
+
// kit gives the path under the product root, so a fault run loads the copy
|
|
40
|
+
// adapter.makers { name: async (ctx) => ({ credential, headers, cleanup }) } for `make:`
|
|
41
|
+
// adapter.signIns { name: async (ctx) => ({ credential, headers, cleanup }) } for `sign-in:`
|
|
42
|
+
// adapter.data { rule: ({ runId, count }) => value } a data rule of the repo
|
|
43
|
+
// adapter.actions an object, or (run) => object: the verbs of the area, on run.actions
|
|
44
|
+
import { describe, it, after } from 'node:test';
|
|
45
|
+
import { existsSync, mkdirSync, readFileSync } from 'node:fs';
|
|
46
|
+
import { dirname, join, resolve } from 'node:path';
|
|
47
|
+
import { fileURLToPath } from 'node:url';
|
|
48
|
+
import { Blocked, GateFailed } from './errors.mjs';
|
|
49
|
+
import { Redactor } from './redact.mjs';
|
|
50
|
+
import { readTestsYaml, checkTestsYaml, compareGuardParts } from './tests-yaml.mjs';
|
|
51
|
+
import { readScenario, addFingerprints, combinations, pairs } from './scenario.mjs';
|
|
52
|
+
import { Evidence, clip } from './evidence.mjs';
|
|
53
|
+
import { makeFresh, makeRunId } from './fresh.mjs';
|
|
54
|
+
import { startEnvironment, provideSecrets } from './environment.mjs';
|
|
55
|
+
import { makeDriver } from './drivers/index.mjs';
|
|
56
|
+
import { parseLimit, waitUntil } from './wait.mjs';
|
|
57
|
+
|
|
58
|
+
export { waitUntil };
|
|
59
|
+
export const defineAdapter = (adapter) => adapter;
|
|
60
|
+
|
|
61
|
+
const FULL_ID = /^(.+)\/(e\d+)#([0-9a-f]{6})$/;
|
|
62
|
+
const isThenable = (x) => x && typeof x.then === 'function';
|
|
63
|
+
|
|
64
|
+
export const RUN_ID = makeRunId();
|
|
65
|
+
|
|
66
|
+
function locate(path) {
|
|
67
|
+
if (path instanceof URL) return fileURLToPath(path);
|
|
68
|
+
if (typeof path === 'string' && path.startsWith('file:')) return fileURLToPath(path);
|
|
69
|
+
return resolve(String(path));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function findRoot(file) {
|
|
73
|
+
let dir = dirname(file);
|
|
74
|
+
for (;;) {
|
|
75
|
+
if (existsSync(join(dir, 'atlas', 'tests.yaml'))) return dir;
|
|
76
|
+
const up = dirname(dir);
|
|
77
|
+
if (up === dir) throw new Error(`no atlas/tests.yaml above ${file}`);
|
|
78
|
+
dir = up;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function load(path, options) {
|
|
83
|
+
const file = locate(path);
|
|
84
|
+
const root = findRoot(file);
|
|
85
|
+
const yaml = readTestsYaml(join(root, 'atlas', 'tests.yaml'));
|
|
86
|
+
const findings = checkTestsYaml(yaml);
|
|
87
|
+
if (findings.length) throw new Error(`atlas/tests.yaml: ${findings.map((f) => `line ${f.line}: ${f.message}`).join('; ')}`);
|
|
88
|
+
const sc = readScenario(file);
|
|
89
|
+
if (sc.problems.length) throw new Error(`${path}: ${sc.problems.map((p) => `line ${p.line}: ${p.message}`).join('; ')}`);
|
|
90
|
+
const tests = yaml.value;
|
|
91
|
+
addFingerprints(sc, tests);
|
|
92
|
+
// ATLAS_GUARDS_FROM names tests.yaml of the main branch. The guard parts of this
|
|
93
|
+
// run come from that file, and a branch whose guard parts differ is refused
|
|
94
|
+
// (`blocked`). CI sets it. A local run without it uses the branch file.
|
|
95
|
+
let guardRefusal = null;
|
|
96
|
+
const guardsFrom = options.guardsFrom ?? process.env.ATLAS_GUARDS_FROM;
|
|
97
|
+
if (guardsFrom) {
|
|
98
|
+
try {
|
|
99
|
+
const changes = compareGuardParts(readFileSync(guardsFrom, 'utf8'), yaml.value);
|
|
100
|
+
if (changes.length) guardRefusal = `the guard parts of atlas/tests.yaml differ from the main branch copy (${changes.map((c) => c.part).join(', ')}); the run is refused. A change to a guard part comes to the owner`;
|
|
101
|
+
} catch (error) {
|
|
102
|
+
guardRefusal = `ATLAS_GUARDS_FROM names a file that cannot be read as tests.yaml (${error.code ?? error.message}); the run is refused`;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
const environment = options.environment ?? process.env.ATLAS_ENV ?? (process.env.CI ? 'ci' : 'local');
|
|
106
|
+
const evidenceDir = options.evidenceDir ?? process.env.ATLAS_EVIDENCE_DIR ?? join(root, 'test', 'evidence');
|
|
107
|
+
return { file, root, tests, sc, environment, evidenceDir, guardRefusal };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const listOf = (x) => (Array.isArray(x) ? x.map(String) : x === undefined ? [] : [String(x)]);
|
|
111
|
+
|
|
112
|
+
export function scenario(path, body, options = {}) {
|
|
113
|
+
const ctx = load(path, options);
|
|
114
|
+
const { sc, tests, root, environment } = ctx;
|
|
115
|
+
const adapter = options.adapter ?? {};
|
|
116
|
+
const runsHere = listOf(sc.fields['runs-in']).includes(environment);
|
|
117
|
+
const limitMs = parseLimit(sc.fields.limit);
|
|
118
|
+
|
|
119
|
+
describe(`${sc.id}: ${sc.title}`, () => {
|
|
120
|
+
const state = {
|
|
121
|
+
redactor: new Redactor(), evidence: null, envPromise: null, env: null,
|
|
122
|
+
drivers: new Map(), personas: new Map(), cleanups: [], fresh: null,
|
|
123
|
+
};
|
|
124
|
+
const evidence = () => {
|
|
125
|
+
state.evidence ??= new Evidence({
|
|
126
|
+
evidenceDir: ctx.evidenceDir, runId: RUN_ID, environment, scenario: sc, root,
|
|
127
|
+
redactor: state.redactor, providedSecrets: [],
|
|
128
|
+
});
|
|
129
|
+
return state.evidence;
|
|
130
|
+
};
|
|
131
|
+
const getEnv = () => {
|
|
132
|
+
state.envPromise ??= (async () => {
|
|
133
|
+
if (ctx.guardRefusal) throw new Blocked(ctx.guardRefusal);
|
|
134
|
+
const named = [...new Set([...sc.start, ...sc.steps, ...sc.assertions].flatMap((x) => x.names))]
|
|
135
|
+
.filter((n) => Object.hasOwn(tests.secrets ?? {}, n));
|
|
136
|
+
const provided = provideSecrets(named, { root, tests });
|
|
137
|
+
const env = await startEnvironment({ root, tests, name: environment, runId: RUN_ID, redactor: state.redactor, adapter, provided });
|
|
138
|
+
evidence().record.provided_secrets = named;
|
|
139
|
+
state.env = env;
|
|
140
|
+
return env;
|
|
141
|
+
})();
|
|
142
|
+
return state.envPromise;
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
if (!runsHere) {
|
|
146
|
+
it(`${sc.id} [not here: runs-in excludes ${environment}]`, () => {
|
|
147
|
+
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() });
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
for (const way of sc.through) {
|
|
157
|
+
it(`${sc.id} [${way}]`, async () => {
|
|
158
|
+
const ev = evidence();
|
|
159
|
+
ev.setWay(way, { started: new Date().toISOString() });
|
|
160
|
+
let run = null;
|
|
161
|
+
let error = null;
|
|
162
|
+
try {
|
|
163
|
+
const env = await getEnv();
|
|
164
|
+
run = makeRun({ ctx, state, way, env, ev, adapter });
|
|
165
|
+
await withLimit(Promise.resolve().then(() => body(run, way)), limitMs, sc.fields.limit);
|
|
166
|
+
} catch (thrown) {
|
|
167
|
+
error = thrown;
|
|
168
|
+
}
|
|
169
|
+
const outcome = settle({ sc, way, ev, error });
|
|
170
|
+
// The run is closed: a late call (after a time limit, say) changes nothing.
|
|
171
|
+
run?.close();
|
|
172
|
+
await cleanUp(state, run, ev);
|
|
173
|
+
// The problem list goes to node:test and then to the CI log: redact it first.
|
|
174
|
+
if (outcome.problems.length) throw new Error(state.redactor.text(outcome.problems.join('\n')));
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
after(async () => {
|
|
179
|
+
for (const driver of state.drivers.values()) { try { await (await driver).close(); } catch { /* the stop goes on */ } }
|
|
180
|
+
try { await (await state.envPromise)?.stop(); } catch { /* it did not start */ }
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function withLimit(promise, ms, text) {
|
|
186
|
+
let timer;
|
|
187
|
+
const limit = new Promise((_, reject) => { timer = setTimeout(() => reject(new Error(`the scenario ran out of time: its limit is ${text}`)), ms); timer.unref?.(); });
|
|
188
|
+
return Promise.race([promise, limit]).finally(() => clearTimeout(timer));
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async function cleanUp(state, run, ev) {
|
|
192
|
+
const list = run ? run.cleanups.splice(0).reverse() : [];
|
|
193
|
+
for (const fn of list) { try { await fn(); } catch (error) { ev.note(`clean-up failed: ${error.message}`); } }
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Turns the end of a body into results, a verdict and a list of problems.
|
|
197
|
+
function settle({ sc, way, ev, error }) {
|
|
198
|
+
const mine = ev.record.assertions.filter((a) => a.way === way);
|
|
199
|
+
const problems = [];
|
|
200
|
+
let verdict = 'pass';
|
|
201
|
+
let reason = null;
|
|
202
|
+
// `blocked` is for a guard refusal, a start-up fault and the first-call check. After
|
|
203
|
+
// the product is ready, a lost connection, a server exit or a timeout is a `fail`.
|
|
204
|
+
if (error instanceof Blocked) {
|
|
205
|
+
verdict = 'blocked';
|
|
206
|
+
reason = error.message;
|
|
207
|
+
for (const a of mine) if (a.result === 'not checked') { a.result = 'blocked'; a.proof = { why: reason }; }
|
|
208
|
+
} else if (error && !(error instanceof GateFailed)) {
|
|
209
|
+
verdict = 'fail';
|
|
210
|
+
reason = error.message;
|
|
211
|
+
} else if (error instanceof GateFailed) {
|
|
212
|
+
verdict = 'fail';
|
|
213
|
+
reason = 'a gate assertion failed, so the run stopped';
|
|
214
|
+
}
|
|
215
|
+
for (const a of mine) {
|
|
216
|
+
if (a.result === 'pass' || a.result === 'not here') continue;
|
|
217
|
+
if (verdict === 'pass') verdict = 'fail';
|
|
218
|
+
problems.push(`[${a.id}] ${a.result}${a.proof?.error ? `: ${a.proof.error}` : a.proof?.why ? `: ${a.proof.why}` : ''}`);
|
|
219
|
+
}
|
|
220
|
+
if (verdict === 'blocked') problems.unshift(`BLOCKED: ${reason}`);
|
|
221
|
+
else if (reason && !(error instanceof GateFailed)) problems.push(`the run stopped: ${reason}`);
|
|
222
|
+
ev.setWay(way, { verdict, reason, ended: new Date().toISOString() });
|
|
223
|
+
return { verdict, problems };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// ---------------------------------------------------------------- the run object
|
|
227
|
+
|
|
228
|
+
function makeRun({ ctx, state, way, env, ev, adapter }) {
|
|
229
|
+
const { sc, tests, root } = ctx;
|
|
230
|
+
state.fresh ??= makeFresh(RUN_ID, tests.data, adapter.data);
|
|
231
|
+
const cleanups = [];
|
|
232
|
+
let mark = ev.record.calls.length;
|
|
233
|
+
const callsSince = () => { const from = mark; mark = ev.record.calls.length; return ev.record.calls.slice(from).map((c) => c.n); };
|
|
234
|
+
|
|
235
|
+
let closed = false;
|
|
236
|
+
const open = (what) => { if (closed) throw new Error(`the run is closed: ${what} came after the scenario ended, so it changed nothing`); };
|
|
237
|
+
const record = (call) => (closed ? undefined : ev.addCall(call));
|
|
238
|
+
|
|
239
|
+
const driver = (wayName = way) => {
|
|
240
|
+
if (!state.drivers.has(wayName)) {
|
|
241
|
+
const spec = tests['ways-in'][wayName];
|
|
242
|
+
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 }));
|
|
244
|
+
}
|
|
245
|
+
return state.drivers.get(wayName);
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
const find = (full) => {
|
|
249
|
+
const m = FULL_ID.exec(String(full));
|
|
250
|
+
if (!m) throw new Error(`"${full}" is not a full assertion id like ${sc.id}/e1#abc123`);
|
|
251
|
+
if (m[1] !== sc.id) throw new Error(`"${full}" names the scenario "${m[1]}"; this is "${sc.id}"`);
|
|
252
|
+
const a = sc.assertions.find((x) => x.id === m[2]);
|
|
253
|
+
if (!a) throw new Error(`the scenario has no assertion ${m[2]}`);
|
|
254
|
+
return { a, fp: m[3] };
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
const settleOne = (full, fn, opts = {}, mode) => {
|
|
258
|
+
open(`run.${mode}`);
|
|
259
|
+
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`);
|
|
262
|
+
const entry = ev.entry(a.fullId, target);
|
|
263
|
+
const calls = callsSince();
|
|
264
|
+
let failure = null;
|
|
265
|
+
let value;
|
|
266
|
+
if (fp !== a.fp) failure = `the fingerprint ${fp} is old: the words of ${a.id} now print ${a.fp}; convert the test again`;
|
|
267
|
+
else if (mode === 'exercise') {
|
|
268
|
+
if (entry.result === 'pass' || entry.result === 'fail') return a;
|
|
269
|
+
ev.setResult(a.fullId, target, 'not exercised', { why: String(fn), calls });
|
|
270
|
+
return a;
|
|
271
|
+
} else {
|
|
272
|
+
try {
|
|
273
|
+
value = fn();
|
|
274
|
+
if (isThenable(value)) { failure = 'a check must not be async: wait first, then check'; value = undefined; }
|
|
275
|
+
} catch (e) { failure = e?.message ?? String(e); }
|
|
276
|
+
}
|
|
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 });
|
|
279
|
+
if (failure && mode === 'gate') throw new GateFailed(`[${a.id}] ${failure}`);
|
|
280
|
+
return a;
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
const tableOf = (name) => {
|
|
284
|
+
const t = sc.tables.get(name);
|
|
285
|
+
if (!t) throw new Error(`the scenario has no case table ${name}`);
|
|
286
|
+
return t;
|
|
287
|
+
};
|
|
288
|
+
const checkTable = (t, expected) => {
|
|
289
|
+
ev.record.tables[t.name] = t.fingerprint;
|
|
290
|
+
if (expected && expected !== t.fingerprint) throw new Error(`the table ${t.name} now prints ${t.fingerprint}, not ${expected}; its rows changed`);
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
const run = {
|
|
294
|
+
id: sc.id, title: sc.title, runId: RUN_ID, way, env, scenario: sc, tests, root, cleanups,
|
|
295
|
+
redact: (text) => state.redactor.text(text),
|
|
296
|
+
driver,
|
|
297
|
+
|
|
298
|
+
// One assertion. `fn` runs at once, must not be async, and must hold an
|
|
299
|
+
// assert statement. A failure is recorded and the run goes on.
|
|
300
|
+
check: (full, fn, opts) => { settleOne(full, fn, opts, 'check'); },
|
|
301
|
+
// The same, and a failure stops the run. Later assertions read `not checked`.
|
|
302
|
+
gate: (full, fn, opts) => { settleOne(full, fn, opts, 'gate'); },
|
|
303
|
+
// A "when" assertion whose situation never happened in this run.
|
|
304
|
+
notExercised: (full, why) => { settleOne(full, why, {}, 'exercise'); },
|
|
305
|
+
// The test could not run. Never use it for a product result.
|
|
306
|
+
blocked: (why) => { throw new Blocked(why); },
|
|
307
|
+
|
|
308
|
+
// The rows of a rule table, as objects keyed by the header. A test that
|
|
309
|
+
// reads a table gets every row of the file at run time.
|
|
310
|
+
table(name, { fingerprint } = {}) {
|
|
311
|
+
const t = tableOf(name);
|
|
312
|
+
checkTable(t, fingerprint);
|
|
313
|
+
return t.rows.map((row) => ({ ...row }));
|
|
314
|
+
},
|
|
315
|
+
// The cells of a rule table: one case each, with a label that names the cell.
|
|
316
|
+
cells(name, { fingerprint } = {}) {
|
|
317
|
+
const t = tableOf(name);
|
|
318
|
+
if (t.kind !== 'rule') throw new Error(`${name} is a combination table; use run.cases`);
|
|
319
|
+
checkTable(t, fingerprint);
|
|
320
|
+
const [first, ...rest] = t.header;
|
|
321
|
+
return t.rows.flatMap((row, rowIndex) => rest.map((column) => ({ row: row[first], column, value: row[column], rowIndex, label: `${name}: ${row[first]} / ${column}` })));
|
|
322
|
+
},
|
|
323
|
+
// Every case of a combination table: every combination, or a set that holds every pair.
|
|
324
|
+
cases(name, { fingerprint } = {}) {
|
|
325
|
+
const t = tableOf(name);
|
|
326
|
+
if (t.kind !== 'combination') throw new Error(`${name} is a rule table; use run.table or run.cells`);
|
|
327
|
+
checkTable(t, fingerprint);
|
|
328
|
+
return (t.cover === 'pair' ? pairs(t.dimensions) : combinations(t.dimensions)).map((one, i) => ({ ...one, label: `${name} case ${i + 1}` }));
|
|
329
|
+
},
|
|
330
|
+
|
|
331
|
+
// A test user from tests.yaml, made or loaded for this run.
|
|
332
|
+
async persona(name, wayName = way) {
|
|
333
|
+
const key = `${name}\u0000${wayName}`;
|
|
334
|
+
if (state.personas.has(key)) return state.personas.get(key);
|
|
335
|
+
const def = tests.personas?.[name];
|
|
336
|
+
if (!def) throw new Error(`there is no persona "${name}" in tests.yaml`);
|
|
337
|
+
const made = (async () => {
|
|
338
|
+
const drv = await driver(wayName);
|
|
339
|
+
let extra = {};
|
|
340
|
+
const maker = def.make ? adapter.makers?.[def.make] : def['sign-in'] ? adapter.signIns?.[def['sign-in']] : null;
|
|
341
|
+
if ((def.make || def['sign-in']) && !maker) throw new Blocked(`persona "${name}" needs the ${def.make ? 'maker' : 'sign-in'} "${def.make ?? def['sign-in']}" and the adapter does not give it`);
|
|
342
|
+
if (maker) {
|
|
343
|
+
extra = (await maker({ run, name, def, way: wayName, env, driver: drv, fresh: run.fresh, persona: (n) => run.persona(n, wayName) })) ?? {};
|
|
344
|
+
if (extra.cleanup) cleanups.push(extra.cleanup);
|
|
345
|
+
}
|
|
346
|
+
const credential = extra.credential ?? (def.secret ? (env.vars[def.secret] ?? null) : null);
|
|
347
|
+
state.redactor.add(credential);
|
|
348
|
+
for (const value of Object.values(extra.headers ?? {})) state.redactor.add(String(value));
|
|
349
|
+
const persona = {
|
|
350
|
+
...extra, name, role: def.role ?? null, way: wayName, credential,
|
|
351
|
+
call: (request, opts = {}) => drv.call(request, { credential, headers: extra.headers ?? {}, persona: name, ...opts }),
|
|
352
|
+
};
|
|
353
|
+
return persona;
|
|
354
|
+
})();
|
|
355
|
+
state.personas.set(key, made);
|
|
356
|
+
return made;
|
|
357
|
+
},
|
|
358
|
+
|
|
359
|
+
// A fresh value from a data rule. It carries the run id; a date is picked from it.
|
|
360
|
+
fresh: (rule) => {
|
|
361
|
+
open('run.fresh');
|
|
362
|
+
const value = state.fresh(rule);
|
|
363
|
+
ev.record.fresh = state.fresh.made.slice();
|
|
364
|
+
ev.write();
|
|
365
|
+
return value;
|
|
366
|
+
},
|
|
367
|
+
|
|
368
|
+
// The wait of a scenario step: wait for a state, never for a fixed time.
|
|
369
|
+
wait: waitUntil,
|
|
370
|
+
// The same, and a wait that fails or runs out marks the assertion it
|
|
371
|
+
// serves as failed, with the last state as proof. The run stops.
|
|
372
|
+
async waitFor(full, read, opts) {
|
|
373
|
+
open('run.waitFor');
|
|
374
|
+
const { a } = find(full);
|
|
375
|
+
try { return await waitUntil(read, opts); } catch (e) {
|
|
376
|
+
if (e.name !== 'WaitFailed' && e.name !== 'WaitTimeout') throw e;
|
|
377
|
+
ev.setResult(a.fullId, way, 'fail', { error: e.message, last: clip(e.last?.body ?? e.last, state.redactor) });
|
|
378
|
+
throw new GateFailed(`[${a.id}] ${e.message}`);
|
|
379
|
+
}
|
|
380
|
+
},
|
|
381
|
+
|
|
382
|
+
note: (text) => { open('run.note'); ev.note(state.redactor.text(String(text))); },
|
|
383
|
+
// Keep a file, such as a screenshot, as evidence. Returns the path to write to.
|
|
384
|
+
attach(fileName) {
|
|
385
|
+
open('run.attach');
|
|
386
|
+
const dir = join(ev.dir, sc.id);
|
|
387
|
+
mkdirSync(dir, { recursive: true });
|
|
388
|
+
const path = join(dir, fileName);
|
|
389
|
+
ev.addFile(path);
|
|
390
|
+
return path;
|
|
391
|
+
},
|
|
392
|
+
onCleanup: (fn) => { cleanups.push(fn); },
|
|
393
|
+
// Called by the library after the body and the results are settled.
|
|
394
|
+
close: () => { closed = true; },
|
|
395
|
+
};
|
|
396
|
+
run.actions = typeof adapter.actions === 'function' ? adapter.actions(run) : (adapter.actions ?? {});
|
|
397
|
+
return run;
|
|
398
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// The function driver. It calls a function or a module directly, for a rule
|
|
2
|
+
// that holds below every way in. It has no address and no credential, so
|
|
3
|
+
// duties 1 and 2 of the driver contract do not apply. It records the inputs
|
|
4
|
+
// and the return or the error, and it opens nothing.
|
|
5
|
+
//
|
|
6
|
+
// The shipped kit cannot load a module by name. The repo loads the module
|
|
7
|
+
// itself and gives it to the driver through the adapter, as the value of
|
|
8
|
+
// adapter.functions (one module, or one module for each way in).
|
|
9
|
+
export function createFunctionDriver({ way, target, record: rawRecord, redactor }) {
|
|
10
|
+
const record = rawRecord && ((call) => rawRecord(redactor ? redactor.deep(call) : call));
|
|
11
|
+
if (!target) throw new Error(`the way in "${way}" has no target: give the module in adapter.functions`);
|
|
12
|
+
|
|
13
|
+
// request: { fn, args } - or { args } when the target is itself a function.
|
|
14
|
+
async function call(request = {}, { persona = null } = {}) {
|
|
15
|
+
const args = request.args ?? [];
|
|
16
|
+
const fn = request.fn === undefined ? target : target[request.fn];
|
|
17
|
+
if (typeof fn !== 'function') throw new Error(`the target of "${way}" has no function "${request.fn}"`);
|
|
18
|
+
let answer;
|
|
19
|
+
try {
|
|
20
|
+
const value = await fn.apply(request.fn === undefined ? undefined : target, args);
|
|
21
|
+
answer = { status: 'ok', body: value, isError: false, raw: value };
|
|
22
|
+
} catch (error) {
|
|
23
|
+
answer = { status: 'threw', body: { name: error?.name ?? 'Error', message: String(error?.message ?? error) }, isError: true, raw: error };
|
|
24
|
+
}
|
|
25
|
+
record?.({ way, persona, request: { fn: request.fn ?? '(target)', args }, response: { status: answer.status, body: answer.body } });
|
|
26
|
+
return answer;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return { name: 'function', way, call, async close() {} };
|
|
30
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// The http driver. It calls a REST or plain HTTP route with `fetch`.
|
|
2
|
+
//
|
|
3
|
+
// The seven duties of a driver (design section 7.2):
|
|
4
|
+
// 1. It refuses an address that guards.hosts does not allow (`blocked`).
|
|
5
|
+
// 2. It sends a credential only to the origin it belongs to.
|
|
6
|
+
// 3. It records each call and each answer.
|
|
7
|
+
// 4. It removes run secrets and named secrets from what it records, by value.
|
|
8
|
+
// 5. It returns the raw answer: { status, body, isError, headers, raw }.
|
|
9
|
+
// 6. It signs in as a persona, never as the person who runs the test.
|
|
10
|
+
// 7. It cleans up what it opened. This driver opens nothing.
|
|
11
|
+
import { Unavailable } from '../errors.mjs';
|
|
12
|
+
import { requireAllowed, sameOrigin } from '../guards.mjs';
|
|
13
|
+
import { clip } from '../evidence.mjs';
|
|
14
|
+
import { LIBRARY_HEADER } from '../stand-in.mjs';
|
|
15
|
+
|
|
16
|
+
export function createHttpDriver({ way, base, hosts, record: rawRecord, redactor, timeoutMs = 30000 }) {
|
|
17
|
+
const record = rawRecord && ((call) => rawRecord(redactor ? redactor.deep(call) : call));
|
|
18
|
+
if (!base) throw new Error(`the way in "${way}" has no base address`);
|
|
19
|
+
const origin = new URL(base).origin;
|
|
20
|
+
requireAllowed(base, hosts, `the way in "${way}"`);
|
|
21
|
+
|
|
22
|
+
async function call(request = {}, { credential = null, headers: personaHeaders = {}, persona = null } = {}) {
|
|
23
|
+
const method = (request.method ?? 'GET').toUpperCase();
|
|
24
|
+
let target;
|
|
25
|
+
if (request.url) target = new URL(request.url);
|
|
26
|
+
else {
|
|
27
|
+
const path = String(request.path ?? '');
|
|
28
|
+
target = new URL(`${base.replace(/\/+$/, '')}${path.startsWith('/') || path === '' ? '' : '/'}${path}`);
|
|
29
|
+
}
|
|
30
|
+
for (const [k, v] of Object.entries(request.query ?? {})) target.searchParams.set(k, String(v));
|
|
31
|
+
requireAllowed(target.href, hosts, `${method} ${target.pathname}`);
|
|
32
|
+
const own = sameOrigin(target.href, origin);
|
|
33
|
+
// A call of the kit carries this header, so a stand-in does not count it as a call of the product.
|
|
34
|
+
const headers = { ...(request.headers ?? {}), [LIBRARY_HEADER]: '1' };
|
|
35
|
+
const sent = [];
|
|
36
|
+
// A credential goes only to the origin of this way in.
|
|
37
|
+
if (own) {
|
|
38
|
+
for (const [k, v] of Object.entries(personaHeaders)) { headers[k] = v; sent.push(k); }
|
|
39
|
+
if (credential) { headers['authorization'] = `Bearer ${credential}`; sent.push('authorization'); }
|
|
40
|
+
}
|
|
41
|
+
const init = { method, headers, redirect: 'manual', signal: AbortSignal.timeout(timeoutMs) };
|
|
42
|
+
if (request.body !== undefined) {
|
|
43
|
+
if (request.raw) init.body = request.body;
|
|
44
|
+
else { init.body = JSON.stringify(request.body); headers['content-type'] ??= 'application/json'; }
|
|
45
|
+
}
|
|
46
|
+
let response;
|
|
47
|
+
let text;
|
|
48
|
+
try {
|
|
49
|
+
response = await fetch(target, init);
|
|
50
|
+
text = await response.text();
|
|
51
|
+
} catch (error) {
|
|
52
|
+
throw new Unavailable(`${method} ${target.pathname}: ${error.cause?.code ?? error.name}`);
|
|
53
|
+
}
|
|
54
|
+
let body = text;
|
|
55
|
+
if (/json/.test(response.headers.get('content-type') ?? '') && text) {
|
|
56
|
+
try { body = JSON.parse(text); } catch { /* keep the text */ }
|
|
57
|
+
}
|
|
58
|
+
const answer = { status: response.status, body, isError: response.status >= 400, headers: Object.fromEntries(response.headers), raw: text };
|
|
59
|
+
record?.({
|
|
60
|
+
way, persona,
|
|
61
|
+
request: { method, url: target.href, credential_headers: sent, body: request.body === undefined ? undefined : clip(request.body, redactor) },
|
|
62
|
+
response: { status: answer.status, body: clip(body, redactor) },
|
|
63
|
+
});
|
|
64
|
+
return answer;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return { name: 'http', way, base, call, async close() {} };
|
|
68
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
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 }
|
|
4
|
+
// A driver has the shape { name, way, call(request, opts), close() }.
|
|
5
|
+
import { isAbsolute, resolve } from 'node:path';
|
|
6
|
+
import { Blocked } from '../errors.mjs';
|
|
7
|
+
import { requireAddressesAllowed, substitute } from '../guards.mjs';
|
|
8
|
+
import { createHttpDriver } from './http.mjs';
|
|
9
|
+
import { createMcpStdioDriver } from './mcp-stdio.mjs';
|
|
10
|
+
import { createFunctionDriver } from './function.mjs';
|
|
11
|
+
|
|
12
|
+
export { createHttpDriver, createMcpStdioDriver, createFunctionDriver };
|
|
13
|
+
|
|
14
|
+
// ctx: { way, spec, env, root, hosts, redactor, record, adapter, runId }
|
|
15
|
+
// In a fault run, ATLAS_PRODUCT_ROOT names a throwaway copy. A process that a
|
|
16
|
+
// way in starts (an MCP server) and the module of the function driver load from
|
|
17
|
+
// that copy, so a planted fault is what runs. tests.yaml and the kit stay in the
|
|
18
|
+
// real worktree. A `cwd` in the way in is a folder under that root.
|
|
19
|
+
export function productRootOf(root) {
|
|
20
|
+
return process.env.ATLAS_PRODUCT_ROOT ? resolve(process.env.ATLAS_PRODUCT_ROOT) : root;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Moves a path in a command from the worktree to the product root. The text
|
|
24
|
+
// is changed only where it names the worktree root as a whole path part.
|
|
25
|
+
export function commandFor(command, root, productRoot) {
|
|
26
|
+
if (!command || productRoot === root) return command;
|
|
27
|
+
return command.split(root).join(productRoot);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function makeDriver(ctx) {
|
|
31
|
+
const { way, spec, env, root, hosts, redactor, record, adapter = {}, runId } = ctx;
|
|
32
|
+
const productRoot = productRootOf(root);
|
|
33
|
+
const own = adapter.drivers?.[way] ?? adapter.drivers?.[spec.driver];
|
|
34
|
+
if (own) return own(ctx);
|
|
35
|
+
const template = { id: runId, varsFile: env.varsFile, url: env.url, vars: (n) => env.vars[n], standIn: (n) => env.standIns[n]?.url };
|
|
36
|
+
const fill = (text) => (text === undefined ? text : substitute(text, template));
|
|
37
|
+
// Every address in the env of a way in must be on the list, of any scheme and whatever its name.
|
|
38
|
+
if (spec.env && typeof spec.env === 'object') {
|
|
39
|
+
requireAddressesAllowed(Object.fromEntries(Object.entries(spec.env).map(([k, v]) => [k, fill(String(v))])), hosts, `the env of the way in "${way}"`);
|
|
40
|
+
}
|
|
41
|
+
switch (spec.driver) {
|
|
42
|
+
case 'http':
|
|
43
|
+
return createHttpDriver({ way, base: fill(spec.base), hosts, record, redactor });
|
|
44
|
+
case 'mcp-stdio':
|
|
45
|
+
return createMcpStdioDriver({
|
|
46
|
+
way, command: commandFor(fill(spec.command), root, productRoot), cwd: spec.cwd ? (isAbsolute(spec.cwd) ? spec.cwd : resolve(productRoot, spec.cwd)) : productRoot, hosts, redactor, record,
|
|
47
|
+
env: Object.fromEntries(Object.entries(spec.env ?? {}).map(([k, v]) => [k, fill(String(v))])),
|
|
48
|
+
});
|
|
49
|
+
case 'function': {
|
|
50
|
+
let target = adapter.functions?.[way] ?? adapter.functions?.function ?? adapter.functions;
|
|
51
|
+
// No module from the adapter: the `module` of the way in is a path under the
|
|
52
|
+
// product root. The adapter loads it (adapter.loadModule(file)), because the
|
|
53
|
+
// kit loads no module by a name it builds at run time.
|
|
54
|
+
if (!target && spec.module) {
|
|
55
|
+
const file = resolve(productRoot, fill(String(spec.module)));
|
|
56
|
+
if (typeof adapter.loadModule !== 'function') throw new Blocked(`the way in "${way}" names the module ${spec.module}; the adapter gives no loadModule(file) to load it from ${productRoot}`);
|
|
57
|
+
try { target = await adapter.loadModule(file, { way, root, productRoot }); } catch (error) { throw new Blocked(`the module of "${way}" could not be loaded from ${file}: ${error.message}`); }
|
|
58
|
+
}
|
|
59
|
+
if (!target) throw new Blocked(`the way in "${way}" uses the function driver and the adapter gives no module: set adapter.functions`);
|
|
60
|
+
return createFunctionDriver({ way, target, record, redactor });
|
|
61
|
+
}
|
|
62
|
+
default:
|
|
63
|
+
throw new Blocked(`the way in "${way}" uses the driver "${spec.driver}", which the kit does not ship; add it to adapter.drivers`);
|
|
64
|
+
}
|
|
65
|
+
}
|