@arjunkhera/atlas 0.3.6 → 0.3.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,234 @@
1
+ ---
2
+ name: tests
3
+ description: >
4
+ Scenario tests for an area of a repo. Trigger when the owner asks to put tests on an area,
5
+ to review the words of a scenario, to write or convert a test from a scenario, when a test
6
+ is red, or to prove that tests catch faults. It holds five procedures: onboard an area,
7
+ review words, convert, sort a red test, and plant faults.
8
+ ---
9
+
10
+ # Tests
11
+
12
+ A scenario is a Markdown file. It says what must be true. A test is code
13
+ that checks it. Atlas ships the code that joins the two: the contract
14
+ library, the drivers, the stand-in helper and the link check. This skill
15
+ holds the steps to use them.
16
+
17
+ ## The files of an area
18
+
19
+ | File | What it holds | Who changes it |
20
+ |---|---|---|
21
+ | `atlas/tests.yaml` | How to start the product, the ways in, the stand-ins, the personas, the guards | You, at onboarding. The owner approves its guard parts |
22
+ | `scenarios/<id>.md` | The words: start, steps, waits, case tables, assertions | The design. The builder copies the approved words |
23
+ | `scenarios/map.md` | How to reach each thing: calls, page parts, error forms, gaps | The builder, as it learns facts |
24
+ | `test/atlas/` | The Atlas kit, copied byte for byte | Only `atlas tests write` |
25
+ | `test/scenarios/` | One test file for each scenario | The builder |
26
+ | `test/support/` | The stand-ins, the actions, the makers, the adapter | The builder |
27
+ | `test/evidence/` | The evidence of each run | The library. Git ignores it |
28
+
29
+ In this skill, "you" is the agent that runs the skill. It is never the owner.
30
+
31
+ Never edit a file in `test/atlas/`. A hand edit stops the next
32
+ `atlas tests write`, and `atlas tests check` reports it. A person merges
33
+ every file that command writes.
34
+
35
+ ## The three commands
36
+
37
+ 1. `atlas tests write --root <repo>` puts the kit in `test/atlas/`. It
38
+ refuses to replace a file that you edited, and it refuses to write
39
+ through a link. It writes `test/evidence/.gitignore`. It adds a `.env.test`
40
+ line to the `.gitignore` at the repo root. It says what it added.
41
+ 2. `atlas tests check --root <repo>` runs the link check. It prints one line
42
+ for each finding, as `file:line code words`. Run it before you ask for a merge.
43
+ 3. `atlas tests proof --root <repo>` prints the proof table of one run.
44
+
45
+ The link check has three halves. Run the first two before the tests and the
46
+ third after them:
47
+
48
+ 1. `--halves static,lint` reads the scenarios, `tests.yaml` and the code. It
49
+ also checks each file in `test/atlas/` against `manifest.json`. The manifest
50
+ must match a kit that a release of Atlas shipped.
51
+ 2. `--halves run --run <id>` reads the evidence of that one run. With no
52
+ `--run`, it reads the newest run of each scenario, and `atlas tests proof`
53
+ does the same. It fails on an assertion that the run did not check. It fails
54
+ on evidence of an older version of a scenario file.
55
+ 3. `--guards-from <file>` takes `tests.yaml` from the main branch. A guard
56
+ part that differs is a finding. CI must run the check from the main branch.
57
+
58
+ The environment variable `ATLAS_GUARDS_FROM` names that file for both the
59
+ check and the scenario run. A scenario run whose guard parts differ from
60
+ that file ends `blocked` and starts nothing. A local run without it uses the branch file.
61
+
62
+ ## Run the tests in CI
63
+
64
+ CI runs the tests from the main branch, so a pull request cannot change its own judge.
65
+
66
+ 1. Fetch the main branch.
67
+ 2. Run the copy of `<area>/test/atlas/` that main holds. If main has none
68
+ yet, run the copy of the branch, and print a warning that says so.
69
+ 3. Set `ATLAS_GUARDS_FROM` to `atlas/tests.yaml` of the main branch.
70
+ 4. Run the scenarios with the runner command of `tests.yaml`. Set `ATLAS_RUN_ID`
71
+ or let CI make the run id.
72
+ 5. Run the link check with `--run <that run id>`.
73
+ 6. Upload `<area>/test/evidence/` for 14 days.
74
+
75
+ ## Onboard an area
76
+
77
+ Path 1 is for an area that has integration tests which call its routes,
78
+ and which people keep using. Old unit tests that call functions do not make path 1.
79
+ Use path 2: the area has no integration test that calls its routes. If it has
80
+ route tests that people keep, stop. Tell the owner that path 1 comes later.
81
+
82
+ 1. Send the `atlas:code-explorer` crew to map the area. Ask for routes,
83
+ tools, pages, storage, calls to outside services and existing tests.
84
+ 2. Run `atlas tests write --root <repo>`.
85
+ 3. Draft `atlas/tests.yaml`. Give it `local` and `ci`, each way in, a persona
86
+ for each caller, and `guards.hosts` with local addresses only.
87
+ 4. Give the product no real secret. Write `{run.secret}` for each secret
88
+ of the product. The library makes it new for each run.
89
+ 5. Write a stand-in for each outside service. Use `serveStandIn` from
90
+ `test/atlas/stand-in.mjs`. Give the product a fake key for the real service.
91
+ 6. Write the contract scenario of each stand-in (kind `contract`). It proves
92
+ that the stand-in answers like the real service.
93
+ 7. Put two personas on one secret only with `same-identity` on both.
94
+ 8. Draft `scenarios/map.md` from the code digest and the docs of the repo.
95
+ 9. Write two or three first scenarios. Start with the smallest.
96
+ 10. Review the words (next section). Run `atlas ste` on each scenario.
97
+ 11. Convert each scenario into a test.
98
+ 12. Run `atlas tests check --root <repo> --halves static,lint`, then the tests,
99
+ then `atlas tests check --root <repo> --halves run`.
100
+ 13. Ask the `atlas:verifier` crew to start each environment once and run every scenario.
101
+ 14. Plant a fault for each assertion (last section).
102
+ 15. Open one pull request. Show the owner the first scenarios, and the guard
103
+ parts of `tests.yaml` as a short list in plain words.
104
+
105
+ Worked run, on a meal planner with a web page, an MCP server and storage in
106
+ a note service:
107
+
108
+ 1. Input: the code digest names the routes, the MCP server and the page. It
109
+ finds 11 tests that call functions and none that call a route.
110
+ 2. Process: path 2. Draft `tests.yaml` with two environments, three ways in,
111
+ a stand-in for the note service and four personas. Write the stand-in
112
+ and two scenarios.
113
+ 3. Output: one pull request. The owner reads two scenarios and three lines of guards.
114
+
115
+ ## Write assertions
116
+
117
+ Use these steps when you write or change an assertion. They come from
118
+ sections 9.2 and 6.10 of the design.
119
+
120
+ 1. Write the definition of done as assertions. A feature gets full scenarios.
121
+ A small fix gets a short card with its assertions.
122
+ 2. Name each assertion that you add, change or remove by its full id in the design.
123
+ 3. Write one outcome in each assertion. Then one planted fault points at one assertion.
124
+ 4. Mark it `exact` when code checks one right answer. Mark it `judged` only
125
+ when it is about meaning, and say why in the design.
126
+ 5. Never let a judged assertion decide an access rule alone.
127
+ 6. Mark `gate` on an assertion that later steps need.
128
+ 7. Start a "when" assertion with the step that makes it true: "step 3", or a kept result.
129
+ 8. Name what it reads in backticks.
130
+ 9. Write a "not" assertion so that a blank answer fails it.
131
+ 10. Add `repeat` only when the subject varies by design, such as a model. Say why.
132
+
133
+ ## Review the words
134
+
135
+ A separate agent reads each new or changed scenario before any code exists.
136
+ It proposes words. It never edits. It asks ten questions of each assertion:
137
+
138
+ | Problem | The question |
139
+ |---|---|
140
+ | two-readings | Can two careful agents write different tests from it? |
141
+ | not-exact | Does an "exact" assertion have one right answer? |
142
+ | many-claims | Does it hold more than one outcome? |
143
+ | duplicate | Does another assertion check the same thing? |
144
+ | weak | How could the product pass it while the claim is false? |
145
+ | hidden-precondition | Does it need a state that `Start with` does not name? |
146
+ | missing-data | Does it need a value that the scenario does not give? |
147
+ | shared-data | Can another run change the data it reads? |
148
+ | empty-when | Does a "when" assertion name a step that makes it true? |
149
+ | loose-not | Does a "not" assertion pass on a blank or broken answer? |
150
+
151
+ Then run `atlas ste` on the scenario. The owner reads these pages.
152
+
153
+ Rules for the words:
154
+
155
+ 1. One outcome for each assertion. Mark `exact` or `judged`.
156
+ 2. Mark `gate` on an assertion that later steps need.
157
+ 3. A wait ends with "Give up after <time>". The link check refuses it otherwise.
158
+ 4. A "when" assertion names its step: "step 3", or a result that a step keeps.
159
+ 5. Put each name in backticks. A name is a persona, way in, data rule, setup, fixture, table or kept result.
160
+ 6. An access rule is always exact. A judge never decides it alone.
161
+
162
+ ## Convert a scenario into a test
163
+
164
+ 1. Read the scenario, the map, the actions and one worked example. Do not
165
+ read the product source if the map covers the scenario.
166
+ 2. Write one test file for each scenario, with `scenario(path, body, { adapter })`
167
+ from `test/atlas/contract.mjs`. The library runs the body once for each way in.
168
+ 3. Name each assertion by its full id, as a plain string:
169
+ `run.check('<scenario>/e1#<fingerprint>', () => assert...)`.
170
+ Use `run.gate` for a `gate` assertion. Get the fingerprints from the failure message of `atlas tests check`.
171
+ 4. Use an action, or add one. An action for a new call also adds its row to the map.
172
+ 5. Make data with `run.fresh(rule)`. Read a case table with `run.table`, `run.cells` or `run.cases`.
173
+ 6. Wait with `run.wait` and a limit. Never sleep for a fixed time.
174
+ 7. Use `run.notExercised(id, why)` for a "when" assertion that the run never made true.
175
+ 8. Mark each guess in a comment. List the guesses in the pull request.
176
+ 9. Never change the words to make a test pass. Only the design changes the words.
177
+ 10. Run the test before the change. A test for new behaviour must fail.
178
+
179
+ The adapter binds the library to the product. All its parts are optional:
180
+ `standIns`, `warmUp`, `drivers`, `functions`, `loadModule`, `makers`, `signIns`,
181
+ `data` and `actions`. The header of `test/atlas/contract.mjs` describes each one.
182
+
183
+ A result is `pass`, `fail`, `not checked`, `not exercised`, `blocked` or
184
+ `not here`. A test is red when any assertion is not `pass`. The one
185
+ exception is `not here`: the scenario does not run in that environment, it is
186
+ not counted, and the test passes.
187
+
188
+ After the product is ready, a lost connection, a server exit or a time-out is
189
+ `fail`. `blocked` is for a guard refusal, a start-up fault and the first-call check.
190
+
191
+ ## Sort a red test
192
+
193
+ Do not edit a red test before you know why it is red. There are four causes:
194
+
195
+ | Cause | Sign | What happens | What may change |
196
+ |---|---|---|---|
197
+ | The product broke | The words still hold. The product does not | Fix the code | Product code only |
198
+ | The behaviour changed on purpose | The approved design names the assertion | Change the assertion and its test | The assertions that the design names, and their tests |
199
+ | The test is out of date | A page part moved, or the form of an answer changed. The verifier's own check passes | Repair the actions, the map or the finders | Actions, map and finders. No assertion and no assert statement |
200
+ | The environment failed | The result is `blocked`: the product did not start, or a stand-in is down | Fix the setup | `tests.yaml` without its guard parts, and the support code |
201
+
202
+ Two checks follow:
203
+
204
+ 1. A repair that changes an assertion or an assert statement is not a repair. Refuse it unless the design names the assertion.
205
+ 2. A repair must keep the verifier's own check green. The verifier does not use the actions.
206
+
207
+ A `blocked` result because a guard refused, or because the stand-in saw no
208
+ first call from the product, is a finding, not noise.
209
+ Find out why the product reached for something else.
210
+
211
+ ## Plant faults
212
+
213
+ A fault proves that an assertion can go red. Plant one for each new or
214
+ changed assertion, and for each assertion at onboarding.
215
+
216
+ 1. Commit first. A clone holds committed files only, so a change that is not
217
+ committed is not in the clone. Then clone the worktree to a throwaway
218
+ folder outside it, with `git clone --no-hardlinks`.
219
+ 2. Remove every remote from the clone. Run `git remote` there. It must print nothing.
220
+ 3. Plant faults in product code only. Never touch `tests.yaml`, the support code or the kit.
221
+ 4. Use the cheap model first: Haiku. After two failed tries on one assertion, use Sonnet.
222
+ 5. Give the planter one assertion and the product code. Ask for one fault that makes only that claim false.
223
+ 6. Run the scenario tests from the real worktree. Set `ATLAS_PRODUCT_ROOT` to
224
+ the area folder in the clone, and `ATLAS_FAULT_RUN` to a label. The product,
225
+ the MCP server and the module of the `function` driver start in the clone.
226
+ The guards, the kit and `tests.yaml` load from the worktree.
227
+ 7. The test for that assertion must go red.
228
+ 8. Ask the `atlas:verifier` crew for its own check of that assertion, on the same clone. It uses a raw driver, not the actions.
229
+ 9. A fault is valid only when the verifier's own check also fails. Discard any other fault and try again.
230
+ 10. Report each assertion as caught, missed, or "no fault of its own" after Sonnet also fails.
231
+ 11. Delete the clone. Run `git status` in the worktree. It must show no change from the faults.
232
+
233
+ Evidence from a fault run carries the label. The link check ignores it, so it never counts as proof.
234
+ Never push from the clone.
@@ -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
+ }