@arjunkhera/atlas 0.3.31 → 0.3.32
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/cli.mjs +8 -2
- package/door/kit-releases.json +4 -0
- package/package.json +1 -1
- package/skills/lead/SKILL.md +1 -1
- package/skills/tests/SKILL.md +56 -4
- package/tests/drivers/index.mjs +2 -0
- package/tests/junit.mjs +64 -0
- package/tests/kept.mjs +185 -0
- package/tests/link-check.mjs +64 -0
- package/tests/scenario.mjs +28 -0
- package/tests/tests-yaml.mjs +7 -1
package/door/cli.mjs
CHANGED
|
@@ -30,6 +30,7 @@ import { loadPrivateTerms } from './lib/privacy.mjs';
|
|
|
30
30
|
import { checkCommand as testsCheck, writeCommand as testsWrite } from './lib/tests.mjs';
|
|
31
31
|
import { proofCommand as testsProof } from './lib/proof.mjs';
|
|
32
32
|
import { verdictCommand as testsVerdict, namedCommand as testsNamed, rerunCommand as testsRerun } from '../tests/verdict.mjs';
|
|
33
|
+
import { runKept } from '../tests/kept.mjs';
|
|
33
34
|
import { sweepCommand as testsSweep, coversCommand as testsCovers } from '../tests/sweep.mjs';
|
|
34
35
|
import { issuesCommand as testsIssues } from './lib/issues.mjs';
|
|
35
36
|
import { upkeepCommand } from './lib/upkeep.mjs';
|
|
@@ -97,6 +98,10 @@ const HELP = `atlas — the door into a repo's Atlas files
|
|
|
97
98
|
--dry-run print what it would open; change nothing
|
|
98
99
|
The token is only GITHUB_TOKEN. The base URL is GITHUB_API_URL (default https://api.github.com)
|
|
99
100
|
|
|
101
|
+
atlas tests kept --root <area> path 1: run the kept tests that a scenario names in kept-tests, and write their evidence
|
|
102
|
+
--evidence <dir> where the evidence files go; default <area>/test/evidence
|
|
103
|
+
--scenario <id> run one scenario only
|
|
104
|
+
|
|
100
105
|
atlas tests rerun --root <area> --run <id> list the scenario test files whose first attempt did not end well
|
|
101
106
|
--tests <dir> the test folder of the area; default test. The test files are in <dir>/scenarios
|
|
102
107
|
--evidence <dir> where the evidence files are; default <area>/test/evidence
|
|
@@ -131,7 +136,7 @@ export const FLAGS = Object.freeze({
|
|
|
131
136
|
ste: { share: 'switch', terms: 'value' },
|
|
132
137
|
design: { tracker: 'value', out: 'value', draft: 'switch', root: 'optional' },
|
|
133
138
|
view: { product: 'value', name: 'value', out: 'value' },
|
|
134
|
-
tests: { root: 'optional', halves: 'value', evidence: 'value', 'guards-from': 'value', tests: 'value', 'dry-run': 'switch', run: 'value', own: 'value', base: 'value', covers: 'switch', findings: 'value', repo: 'value', max: 'value', bot: 'value', since: 'value', out: 'value', text: 'switch', json: 'switch' },
|
|
139
|
+
tests: { root: 'optional', halves: 'value', evidence: 'value', 'guards-from': 'value', tests: 'value', 'dry-run': 'switch', run: 'value', own: 'value', base: 'value', covers: 'switch', findings: 'value', repo: 'value', max: 'value', bot: 'value', since: 'value', out: 'value', text: 'switch', json: 'switch', scenario: 'value' },
|
|
135
140
|
upkeep: { root: 'optional', event: 'value', json: 'switch' },
|
|
136
141
|
install: { local: 'switch', from: 'value', 'skip-global': 'switch', yes: 'switch' },
|
|
137
142
|
upgrade: { version: 'optional', yes: 'switch' },
|
|
@@ -330,9 +335,10 @@ async function testsCommand(chosen) {
|
|
|
330
335
|
if (what === 'named') return testsNamed({ base: chosen.base, covers: Boolean(chosen.covers) }, root, line);
|
|
331
336
|
if (what === 'sweep') return testsSweep({ evidence: chosen.evidence, since: chosen.since, base: chosen.base, out: chosen.out, text: Boolean(chosen.text) }, root, line);
|
|
332
337
|
if (what === 'covers') return testsCovers({ itemId: chosen._[2], evidence: chosen.evidence, json: Boolean(chosen.json) }, root, line);
|
|
338
|
+
if (what === 'kept') return (await runKept({ root, evidenceDir: chosen.evidence, only: chosen.scenario ?? null, write: (text) => process.stdout.write(text) })).exit;
|
|
333
339
|
if (what === 'rerun') return testsRerun({ run: chosen.run, evidence: chosen.evidence, tests: chosen.tests }, root, line);
|
|
334
340
|
if (what === 'issues') return testsIssues({ findings: chosen.findings, repo: chosen.repo, max: chosen.max, bot: chosen.bot, dryRun: Boolean(chosen['dry-run']), args: [...chosen._, ...Object.values(chosen).filter((v) => typeof v === 'string')] }, line);
|
|
335
|
-
throw new Error(`there is no tests verb "${what ?? ''}". Use check, write, proof, verdict, named, sweep, covers, issues or rerun.`);
|
|
341
|
+
throw new Error(`there is no tests verb "${what ?? ''}". Use check, write, proof, verdict, named, sweep, covers, kept, issues or rerun.`);
|
|
336
342
|
}
|
|
337
343
|
|
|
338
344
|
function upkeepVerb(chosen) {
|
package/door/kit-releases.json
CHANGED
|
@@ -24,6 +24,10 @@
|
|
|
24
24
|
{
|
|
25
25
|
"kit_sha256": "1e62be9a3fb632161c593d44ac7ad404ef04186fe2fbcd108645fbe1559bf860",
|
|
26
26
|
"first_package": "0.3.28"
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
"kit_sha256": "08c868aa39e6c9f471da2571e9a1d5939932a59856cea9c4176e3173f93454db",
|
|
30
|
+
"first_package": "0.3.30"
|
|
27
31
|
}
|
|
28
32
|
]
|
|
29
33
|
}
|
package/package.json
CHANGED
package/skills/lead/SKILL.md
CHANGED
|
@@ -100,7 +100,7 @@ set. A test keeps the two lists equal.
|
|
|
100
100
|
| `atlas kind-drift` | When the repo record and `atlas.yaml` may disagree on the kind; this command retires in stage 2, the onboarding stage |
|
|
101
101
|
| `atlas code-read` | To resolve the citations of a code digest |
|
|
102
102
|
| `atlas ste` | Before each publish; `--share` before a page goes to anyone else |
|
|
103
|
-
| `atlas tests` | `write` puts the test kit in an area. `check` runs before a merge. `proof`, `verdict`, `named`, `covers` and `rerun` read the results of a run. `sweep` and `issues` run the daily sweep |
|
|
103
|
+
| `atlas tests` | `write` puts the test kit in an area. `check` runs before a merge. `kept` runs the kept tests of a path 1 area and writes their evidence. `proof`, `verdict`, `named`, `covers` and `rerun` read the results of a run. `sweep` and `issues` run the daily sweep |
|
|
104
104
|
| `atlas design` | `folder` to learn where designs live; `check` before you show a design; `build` to make its page |
|
|
105
105
|
| `atlas view` | `build --product <id>` makes the project view page of one product, when the owner asks for it or after an Atlas release changes it. Publish it with only the grant the command prints |
|
|
106
106
|
| `atlas upkeep` | `check` before you ask for a merge: the docs index, dead doc links, and the tracker item a pull request names. The daily upkeep run calls it on the main branch |
|
package/skills/tests/SKILL.md
CHANGED
|
@@ -192,10 +192,12 @@ CI runs the tests from the main branch, so a pull request cannot change its own
|
|
|
192
192
|
|
|
193
193
|
## Onboard an area
|
|
194
194
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
195
|
+
Pick the path at step 1 of the map.
|
|
196
|
+
|
|
197
|
+
- Path 1: the area has integration tests that people keep using. They call
|
|
198
|
+
its routes, or start its tool or its server. Follow "Path 1: adopt the
|
|
199
|
+
kept tests" below. Old unit tests that only call functions do not make path 1.
|
|
200
|
+
- Path 2: the area has no such tests. Follow the steps here.
|
|
199
201
|
|
|
200
202
|
1. Send the `atlas:code-explorer` crew to map the area. Ask for routes,
|
|
201
203
|
tools, pages, storage, calls to outside services and existing tests.
|
|
@@ -230,6 +232,56 @@ a note service:
|
|
|
230
232
|
and two scenarios.
|
|
231
233
|
3. Output: one pull request. The owner reads two scenarios and three lines of guards.
|
|
232
234
|
|
|
235
|
+
## Path 1: adopt the kept tests
|
|
236
|
+
|
|
237
|
+
The kept tests stay as they are. Each kept test file gets a scenario: the
|
|
238
|
+
scenario holds the words, and the kept test stays the code that checks them.
|
|
239
|
+
`atlas tests kept` runs the kept runner, reads its JUnit report and writes
|
|
240
|
+
the evidence. The design: `docs/design-docs/kept-tests/` in the Atlas repo.
|
|
241
|
+
|
|
242
|
+
1. Send the `atlas:code-explorer` crew to map the area. Ask for each kept
|
|
243
|
+
test file and its test case names. Ask for the runner command and the env
|
|
244
|
+
variables it reads. Ask for each outside address that a kept test uses.
|
|
245
|
+
2. Run the kept tests once as they are. Record the count. Each one must be
|
|
246
|
+
green before you start, and stays green to the end.
|
|
247
|
+
3. Run `atlas tests write --root <repo>`.
|
|
248
|
+
4. Find the JUnit form of the runner. Examples: `node --test
|
|
249
|
+
--test-reporter=junit --test-reporter-destination={report} {file}`,
|
|
250
|
+
`pytest --junitxml={report} {file}`, and a TestNG suite file with the
|
|
251
|
+
JUnit reporter.
|
|
252
|
+
5. Draft `atlas/tests.yaml` with one way in of driver `kept`. Give it the
|
|
253
|
+
`command` with `{file}` and `{report}`, the `files` patterns, and the
|
|
254
|
+
`env` the kept tests need. Give it a local address in the env, or
|
|
255
|
+
`offline: true` when the tests reach no network.
|
|
256
|
+
6. For each kept file, write one scenario with `through: [kept]` and
|
|
257
|
+
`kept-tests: <file>`. Write the assertions: what the file proves, in
|
|
258
|
+
plain words. Never claim more than its test cases check.
|
|
259
|
+
7. Write the `## Kept checks` section. Map each assertion to the test cases
|
|
260
|
+
that check it: `- e1: "case name"`. Name each case of the file once.
|
|
261
|
+
List a case that proves no promise as `- not mapped: "case name" (why)`.
|
|
262
|
+
8. Review the words (the section below). Give the crew the kept test file
|
|
263
|
+
too, so it can check each assertion against the code of its cases.
|
|
264
|
+
9. Run `atlas tests kept --root <repo>`, then `atlas tests check --root <repo>`.
|
|
265
|
+
10. Plant a fault in the product for each assertion, in a throwaway copy.
|
|
266
|
+
Run `atlas tests kept` there. Each assertion must go red once.
|
|
267
|
+
11. Run the kept tests as they are once more. The count and the result
|
|
268
|
+
must match step 2.
|
|
269
|
+
12. Add a CI step that runs `node test/atlas/kept.mjs --root .` from the
|
|
270
|
+
kit copy of the main branch, with `ATLAS_GUARDS_FROM` set.
|
|
271
|
+
13. Open one pull request. List the outside addresses that step 1 found.
|
|
272
|
+
Give the guard parts in plain words, and one scenario with its proof
|
|
273
|
+
table. A person merges it.
|
|
274
|
+
|
|
275
|
+
Worked run, on a CLI area with 14 kept test files:
|
|
276
|
+
|
|
277
|
+
1. Input: the map finds `test/status.test.mjs` with 4 test cases. They start
|
|
278
|
+
the tool as a child process. No test reaches a network.
|
|
279
|
+
2. Process: one kept way in with `offline: true`. The scenario
|
|
280
|
+
`thread-status` has two assertions. e1 maps 2 cases and e2 maps 1 case.
|
|
281
|
+
The help-text case is not mapped.
|
|
282
|
+
3. Output: `atlas tests kept` prints `thread-status pass`. A planted fault
|
|
283
|
+
in the state code turns e1 red, and e2 stays green.
|
|
284
|
+
|
|
233
285
|
## Write assertions
|
|
234
286
|
|
|
235
287
|
Use these steps when you write or change an assertion.
|
package/tests/drivers/index.mjs
CHANGED
|
@@ -64,6 +64,8 @@ export async function makeDriver(ctx) {
|
|
|
64
64
|
env: Object.fromEntries(Object.entries(spec.env ?? {}).map(([k, v]) => [k, fill(String(v))])),
|
|
65
65
|
offline: spec.offline === true, hosts, root, productRoot, runId, redactor, record,
|
|
66
66
|
});
|
|
67
|
+
case 'kept':
|
|
68
|
+
throw new Blocked(`the way in "${way}" is a kept runner; its scenarios run with atlas tests kept, not in a scenario test`);
|
|
67
69
|
case 'function': {
|
|
68
70
|
let target = adapter.functions?.[way] ?? adapter.functions?.function ?? adapter.functions;
|
|
69
71
|
// No module from the adapter: the `module` of the way in is a path under the
|
package/tests/junit.mjs
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// The JUnit report reader, for path 1 of onboarding: an area whose kept tests
|
|
2
|
+
// run on their own runner (design docs/design-docs/kept-tests). Most runners
|
|
3
|
+
// write this report: node --test (--test-reporter=junit), pytest
|
|
4
|
+
// (--junitxml), TestNG, JUnit, Vitest and Jest.
|
|
5
|
+
//
|
|
6
|
+
// readJunit(xml) -> [{ name, classname, file, suite, result, message }]
|
|
7
|
+
// result: "pass", "fail" or "skipped". An <error> is a fail too.
|
|
8
|
+
//
|
|
9
|
+
// The reader has no dependency. It reads only the parts it needs: the
|
|
10
|
+
// <testsuite> nesting, each <testcase> and its <failure>, <error> and
|
|
11
|
+
// <skipped> children. It refuses text that holds no <testsuites> or
|
|
12
|
+
// <testsuite> element, so a broken report is not read as "no tests".
|
|
13
|
+
export class ReportError extends Error {}
|
|
14
|
+
|
|
15
|
+
const ENTITIES = { lt: '<', gt: '>', amp: '&', quot: '"', apos: "'" };
|
|
16
|
+
export const unescapeXml = (text) => String(text).replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (whole, code) => {
|
|
17
|
+
if (code[0] === '#') return String.fromCodePoint(code[1].toLowerCase() === 'x' ? parseInt(code.slice(2), 16) : Number(code.slice(1)));
|
|
18
|
+
return ENTITIES[code.toLowerCase()] ?? whole;
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
function attributes(text) {
|
|
22
|
+
const out = {};
|
|
23
|
+
for (const m of String(text).matchAll(/([A-Za-z_:][\w:.-]*)\s*=\s*("([^"]*)"|'([^']*)')/g)) out[m[1]] = unescapeXml(m[3] ?? m[4]);
|
|
24
|
+
return out;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// One tag at a time: comments, the XML head and CDATA are skipped.
|
|
28
|
+
const TAG = /<!--[\s\S]*?-->|<\?[\s\S]*?\?>|<!\[CDATA\[[\s\S]*?\]\]>|<(\/?)([A-Za-z_][\w:.-]*)((?:\s+[^\s=/>]+\s*=\s*(?:"[^"]*"|'[^']*'))*)\s*(\/?)>/g;
|
|
29
|
+
|
|
30
|
+
export function readJunit(xml) {
|
|
31
|
+
const text = String(xml ?? '');
|
|
32
|
+
if (!/<testsuites?[\s>/]/.test(text)) throw new ReportError('the report holds no <testsuites> or <testsuite> element');
|
|
33
|
+
const cases = [];
|
|
34
|
+
const suites = [];
|
|
35
|
+
let current = null;
|
|
36
|
+
for (const m of text.matchAll(TAG)) {
|
|
37
|
+
const [, closing, tag, attrs, selfClosing] = m;
|
|
38
|
+
if (!tag) continue;
|
|
39
|
+
if (tag === 'testsuite') {
|
|
40
|
+
if (closing) suites.pop();
|
|
41
|
+
else if (!selfClosing) suites.push(attributes(attrs));
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
if (tag === 'testcase') {
|
|
45
|
+
if (closing) { current = null; continue; }
|
|
46
|
+
const a = attributes(attrs);
|
|
47
|
+
const suite = suites.map((s) => s.name).filter(Boolean).join(' > ');
|
|
48
|
+
const file = a.file ?? suites.map((s) => s.file).filter(Boolean).at(-1) ?? null;
|
|
49
|
+
const one = { name: a.name ?? '', classname: a.classname ?? null, file, suite: suite || null, result: 'pass', message: null };
|
|
50
|
+
cases.push(one);
|
|
51
|
+
current = selfClosing ? null : one;
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
if (!current || closing) continue;
|
|
55
|
+
if (tag === 'failure' || tag === 'error') {
|
|
56
|
+
current.result = 'fail';
|
|
57
|
+
current.message ??= attributes(attrs).message ?? tag;
|
|
58
|
+
} else if (tag === 'skipped' && current.result !== 'fail') {
|
|
59
|
+
current.result = 'skipped';
|
|
60
|
+
current.message ??= attributes(attrs).message ?? null;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return cases;
|
|
64
|
+
}
|
package/tests/kept.mjs
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Path 1 of onboarding: the tests an area already keeps (design
|
|
3
|
+
// docs/design-docs/kept-tests). The kept tests are never edited. For each
|
|
4
|
+
// scenario with `kept-tests: <file>` in its front matter, this:
|
|
5
|
+
// 1. runs the kept runner of the scenario's `kept` way in, once, for that
|
|
6
|
+
// file. The way in's command holds {file} and {report}. The run goes
|
|
7
|
+
// through the command driver, so the runner gets the allow-list env, the
|
|
8
|
+
// env of the way in, a throwaway HOME, and the address guards;
|
|
9
|
+
// 2. reads the JUnit report that the runner wrote (junit.mjs);
|
|
10
|
+
// 3. sets the result of each assertion from the test cases that its
|
|
11
|
+
// "Kept checks" lines name: every case passed is `pass`; a failed case is
|
|
12
|
+
// `fail`; a skipped case, a case that is not in the report, or an
|
|
13
|
+
// assertion that names no case is `not checked`;
|
|
14
|
+
// 4. writes one evidence file for the scenario, in the same schema and run
|
|
15
|
+
// folder as a scenario test. `atlas tests check`, `proof`, `verdict`,
|
|
16
|
+
// `covers` and `sweep` read it as they read any other.
|
|
17
|
+
// A case of the report that no line maps, and that no "not mapped" line lists,
|
|
18
|
+
// is a finding: every kept test case is named once.
|
|
19
|
+
//
|
|
20
|
+
// Usage:
|
|
21
|
+
// node kept.mjs --root <area> [--evidence <dir>] [--scenario <id>]
|
|
22
|
+
// ATLAS_ENV, ATLAS_RUN_ID, ATLAS_EVIDENCE_DIR and ATLAS_GUARDS_FROM work as for a scenario test.
|
|
23
|
+
// Exit 0 when every assertion passed and there is no finding, 1 otherwise, 2 for a bad call.
|
|
24
|
+
import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync } from 'node:fs';
|
|
25
|
+
import { tmpdir } from 'node:os';
|
|
26
|
+
import { join, relative, resolve } from 'node:path';
|
|
27
|
+
import { fileURLToPath } from 'node:url';
|
|
28
|
+
import { Blocked } from './errors.mjs';
|
|
29
|
+
import { Redactor } from './redact.mjs';
|
|
30
|
+
import { readTestsYaml, checkTestsYaml, compareGuardParts } from './tests-yaml.mjs';
|
|
31
|
+
import { readScenario, addFingerprints, normalize } from './scenario.mjs';
|
|
32
|
+
import { Evidence } from './evidence.mjs';
|
|
33
|
+
import { makeRunId } from './fresh.mjs';
|
|
34
|
+
import { hostsOf } from './guards.mjs';
|
|
35
|
+
import { parseLimit } from './wait.mjs';
|
|
36
|
+
import { makeDriver } from './drivers/index.mjs';
|
|
37
|
+
import { shellWord } from './drivers/command.mjs';
|
|
38
|
+
import { readJunit } from './junit.mjs';
|
|
39
|
+
|
|
40
|
+
function scenarioFiles(dir) {
|
|
41
|
+
if (!existsSync(dir)) return [];
|
|
42
|
+
const out = [];
|
|
43
|
+
for (const name of readdirSync(dir).sort()) {
|
|
44
|
+
const full = join(dir, name);
|
|
45
|
+
if (statSync(full).isDirectory()) { if (name !== 'node_modules') out.push(...scenarioFiles(full)); continue; }
|
|
46
|
+
if (name.endsWith('.md') && name !== 'map.md' && name !== 'README.md') out.push(full);
|
|
47
|
+
}
|
|
48
|
+
return out;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// The result of one assertion from the cases it names.
|
|
52
|
+
export function resultOf(named, byName) {
|
|
53
|
+
if (!named.length) return { result: 'not checked', cases: [], why: 'the assertion names no kept test case' };
|
|
54
|
+
const cases = named.map(({ name }) => {
|
|
55
|
+
const found = byName.get(normalize(name)) ?? [];
|
|
56
|
+
if (!found.length) return { name, result: 'missing', message: 'not in the report' };
|
|
57
|
+
const failed = found.find((c) => c.result === 'fail');
|
|
58
|
+
const skipped = found.find((c) => c.result === 'skipped');
|
|
59
|
+
return { name, result: failed ? 'fail' : skipped ? 'skipped' : 'pass', message: (failed ?? skipped)?.message ?? null };
|
|
60
|
+
});
|
|
61
|
+
if (cases.some((c) => c.result === 'fail')) return { result: 'fail', cases };
|
|
62
|
+
if (cases.some((c) => c.result !== 'pass')) return { result: 'not checked', cases, why: 'a case was skipped or is not in the report' };
|
|
63
|
+
return { result: 'pass', cases };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function runKept({ root, evidenceDir, environment, runId = makeRunId(), guardsFrom = process.env.ATLAS_GUARDS_FROM, only = null, adapter = {}, write = () => {} } = {}) {
|
|
67
|
+
root = resolve(root);
|
|
68
|
+
evidenceDir = resolve(evidenceDir ?? process.env.ATLAS_EVIDENCE_DIR ?? join(root, 'test', 'evidence'));
|
|
69
|
+
environment ??= process.env.ATLAS_ENV ?? (process.env.CI ? 'ci' : 'local');
|
|
70
|
+
if (!existsSync(join(root, 'atlas', 'tests.yaml'))) {
|
|
71
|
+
write(`there is no atlas/tests.yaml under ${root}; path 1 needs a kept way in there\n`);
|
|
72
|
+
return { runId, results: [], findings: ['no atlas/tests.yaml'], exit: 1 };
|
|
73
|
+
}
|
|
74
|
+
const yaml = readTestsYaml(join(root, 'atlas', 'tests.yaml'));
|
|
75
|
+
const bad = checkTestsYaml(yaml);
|
|
76
|
+
if (bad.length) throw new Error(`atlas/tests.yaml: ${bad.map((f) => `line ${f.line}: ${f.message}`).join('; ')}`);
|
|
77
|
+
const tests = yaml.value;
|
|
78
|
+
let refusal = null;
|
|
79
|
+
if (guardsFrom) {
|
|
80
|
+
try {
|
|
81
|
+
const changes = compareGuardParts(readFileSync(guardsFrom, 'utf8'), tests);
|
|
82
|
+
if (changes.length) refusal = `the guard parts of atlas/tests.yaml differ from the main branch copy (${changes.map((c) => c.part).join(', ')}); the run is refused`;
|
|
83
|
+
} catch (error) { refusal = `ATLAS_GUARDS_FROM names a file that cannot be read as tests.yaml (${error.code ?? error.message}); the run is refused`; }
|
|
84
|
+
}
|
|
85
|
+
const findings = [];
|
|
86
|
+
const results = [];
|
|
87
|
+
for (const file of scenarioFiles(join(root, 'scenarios'))) {
|
|
88
|
+
const sc = readScenario(file);
|
|
89
|
+
if (!sc.keptFile || (only && sc.id !== only)) continue;
|
|
90
|
+
const rel = relative(root, file);
|
|
91
|
+
if (sc.problems.length) { findings.push(`${rel}: ${sc.problems.map((p) => `line ${p.line}: ${p.message}`).join('; ')}`); continue; }
|
|
92
|
+
addFingerprints(sc, tests);
|
|
93
|
+
const way = sc.through.find((w) => tests['ways-in']?.[w]?.driver === 'kept');
|
|
94
|
+
if (!way) { findings.push(`${rel}: through names no way in with driver kept`); continue; }
|
|
95
|
+
const spec = tests['ways-in'][way];
|
|
96
|
+
const redactor = new Redactor();
|
|
97
|
+
const evidence = new Evidence({ evidenceDir, runId, environment, scenario: sc, root, redactor });
|
|
98
|
+
const one = { scenario: sc.id, way, verdict: null, assertions: [] };
|
|
99
|
+
results.push(one);
|
|
100
|
+
const started = new Date().toISOString();
|
|
101
|
+
const block = (reason) => {
|
|
102
|
+
evidence.setWay(way, { verdict: 'blocked', reason, started, ended: new Date().toISOString() });
|
|
103
|
+
for (const a of sc.assertions) one.assertions.push({ id: a.fullId, result: 'blocked' });
|
|
104
|
+
one.verdict = 'blocked';
|
|
105
|
+
findings.push(`${rel}: blocked: ${reason}`);
|
|
106
|
+
};
|
|
107
|
+
if (refusal) { block(refusal); continue; }
|
|
108
|
+
if ([].concat(sc.fields['runs-in'] ?? []).map(String).includes(environment) === false) {
|
|
109
|
+
for (const a of sc.assertions) for (const w of a.ways ?? sc.through) evidence.setResult(a.fullId, w, 'not here', { why: `runs-in does not list ${environment}` });
|
|
110
|
+
evidence.setWay(way, { verdict: 'not here', reason: null, started, ended: new Date().toISOString() });
|
|
111
|
+
one.verdict = 'not here';
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
const reportDir = mkdtempSync(join(tmpdir(), `atlas-${runId}-kept-`));
|
|
115
|
+
const report = join(reportDir, 'report.xml');
|
|
116
|
+
const command = String(spec.command).replaceAll('{file}', shellWord(sc.keptFile)).replaceAll('{report}', shellWord(report));
|
|
117
|
+
let driver = null;
|
|
118
|
+
try {
|
|
119
|
+
driver = await makeDriver({
|
|
120
|
+
way, spec: { driver: 'command', command, env: spec.env, offline: spec.offline, cwd: spec.cwd },
|
|
121
|
+
env: { vars: {}, standIns: {} }, root, hosts: hostsOf(tests), redactor, runId, adapter,
|
|
122
|
+
record: (call) => evidence.addCall(call),
|
|
123
|
+
});
|
|
124
|
+
const answer = await driver.call({ timeoutMs: Math.max(1, Math.round(parseLimit(sc.fields.limit ?? '5m'))) });
|
|
125
|
+
if (answer.body.timedOut) { block(`the kept runner did not end within ${sc.fields.limit ?? '5m'}`); continue; }
|
|
126
|
+
if (!existsSync(report)) { block(`the kept runner wrote no report (exit ${answer.body.code}). Last output: ${redactor.text(String(answer.body.stderr || answer.body.stdout).trim().slice(-300))}`); continue; }
|
|
127
|
+
let cases;
|
|
128
|
+
try { cases = readJunit(readFileSync(report, 'utf8')); } catch (error) { block(`the report of the kept runner cannot be read: ${error.message}`); continue; }
|
|
129
|
+
const byName = new Map();
|
|
130
|
+
for (const c of cases) { const k = normalize(c.name); if (!byName.has(k)) byName.set(k, []); byName.get(k).push(c); }
|
|
131
|
+
for (const a of sc.assertions) {
|
|
132
|
+
const r = resultOf(sc.keptChecks.get(a.id) ?? [], byName);
|
|
133
|
+
for (const w of a.ways ?? sc.through) evidence.setResult(a.fullId, w, r.result, { kept: sc.keptFile, cases: r.cases, ...(r.why ? { why: r.why } : {}) });
|
|
134
|
+
one.assertions.push({ id: a.fullId, result: r.result });
|
|
135
|
+
for (const c of r.cases) if (c.result === 'missing') findings.push(`${rel}: ${a.id} names "${c.name}", which the report of ${sc.keptFile} does not hold`);
|
|
136
|
+
}
|
|
137
|
+
const named = new Set([...[...sc.keptChecks.values()].flat(), ...sc.notMapped].map((k) => normalize(k.name)));
|
|
138
|
+
for (const k of [...byName.keys()].filter((k) => !named.has(k))) {
|
|
139
|
+
findings.push(`${rel}: the case "${byName.get(k)[0].name}" of ${sc.keptFile} is not mapped; map it to an assertion, or list it under not mapped with a reason`);
|
|
140
|
+
evidence.note(`case not mapped: ${byName.get(k)[0].name}`);
|
|
141
|
+
}
|
|
142
|
+
const all = one.assertions.map((x) => x.result);
|
|
143
|
+
one.verdict = all.every((x) => x === 'pass') ? 'pass' : 'fail';
|
|
144
|
+
evidence.setWay(way, { verdict: one.verdict, reason: one.verdict === 'pass' ? null : 'an assertion did not pass', started, ended: new Date().toISOString() });
|
|
145
|
+
} catch (error) {
|
|
146
|
+
if (!(error instanceof Blocked)) throw error;
|
|
147
|
+
block(error.message);
|
|
148
|
+
} finally {
|
|
149
|
+
await driver?.close();
|
|
150
|
+
rmSync(reportDir, { recursive: true, force: true });
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
for (const one of results) {
|
|
154
|
+
write(`${one.scenario} ${one.verdict}\n`);
|
|
155
|
+
for (const a of one.assertions) write(` ${a.id} ${a.result}\n`);
|
|
156
|
+
}
|
|
157
|
+
for (const f of findings) write(`finding ${f}\n`);
|
|
158
|
+
if (!results.length) write('no scenario names kept-tests\n');
|
|
159
|
+
const green = !findings.length && results.every((r) => r.verdict === 'pass' || r.verdict === 'not here');
|
|
160
|
+
return { runId, results, findings, exit: green ? 0 : 1 };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function parseArgs(argv) {
|
|
164
|
+
const out = {};
|
|
165
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
166
|
+
const m = /^--(root|evidence|scenario)(?:=(.*))?$/.exec(argv[i]);
|
|
167
|
+
if (!m) throw new Error(`unknown argument ${argv[i]}`);
|
|
168
|
+
out[m[1]] = m[2] ?? argv[++i];
|
|
169
|
+
if (out[m[1]] === undefined) throw new Error(`--${m[1]} needs a value`);
|
|
170
|
+
}
|
|
171
|
+
if (!out.root) throw new Error('--root <area> is needed');
|
|
172
|
+
return out;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export async function main(argv, write = (text) => process.stdout.write(text)) {
|
|
176
|
+
let args;
|
|
177
|
+
try { args = parseArgs(argv); } catch (error) { write(`${error.message}\nUse: node kept.mjs --root <area> [--evidence <dir>] [--scenario <id>]\n`); return 2; }
|
|
178
|
+
const { exit, runId } = await runKept({ root: args.root, evidenceDir: args.evidence, only: args.scenario ?? null, write });
|
|
179
|
+
write(`run ${runId}\n`);
|
|
180
|
+
return exit;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
184
|
+
main(process.argv.slice(2)).then((code) => { process.exitCode = code; }, (error) => { process.stderr.write(`${error.message}\n`); process.exitCode = 2; });
|
|
185
|
+
}
|
package/tests/link-check.mjs
CHANGED
|
@@ -289,11 +289,73 @@ function checkLinks(scenarios, root, testsDir, findings, rel) {
|
|
|
289
289
|
for (const s of scenarios) {
|
|
290
290
|
for (const a of s.assertions) {
|
|
291
291
|
const named = (by.get(`${s.id}/${a.id}`) ?? []).filter((r) => r.kind !== 'waitFor');
|
|
292
|
+
// Path 1: the kept test cases of the "Kept checks" section are the test of the assertion.
|
|
293
|
+
if (!named.length && s.keptFile) continue;
|
|
292
294
|
if (!named.length) findings.push({ file: rel(s.file), line: a.line, code: 'no-test', message: `no test names ${s.id}/${a.id}#${a.fp ?? '??????'}` });
|
|
293
295
|
}
|
|
294
296
|
}
|
|
295
297
|
}
|
|
296
298
|
|
|
299
|
+
// Path 1 (docs/design-docs/kept-tests). A scenario with kept-tests names a kept file that
|
|
300
|
+
// exists, goes through a kept way in, maps each assertion to at least one test case, and
|
|
301
|
+
// names each case once. A kept way in with `files` has a scenario for each file it matches.
|
|
302
|
+
const keptCodes = { file: 'kept-file-missing', way: 'kept-no-way', unmapped: 'kept-not-mapped', twice: 'kept-mapped-twice' };
|
|
303
|
+
function checkKept(s, tests, root, findings, rel) {
|
|
304
|
+
if (!s.keptFile) return;
|
|
305
|
+
const at = s.fieldLine('kept-tests');
|
|
306
|
+
if (!existsSync(join(root, s.keptFile))) findings.push({ file: rel(s.file), line: at, code: keptCodes.file, message: `kept-tests names ${s.keptFile}, which does not exist under the area` });
|
|
307
|
+
if (tests && !s.through.some((w) => tests['ways-in']?.[w]?.driver === 'kept')) findings.push({ file: rel(s.file), line: s.fieldLine('through'), code: keptCodes.way, message: 'a scenario with kept-tests goes through a way in with driver kept' });
|
|
308
|
+
for (const a of s.assertions) {
|
|
309
|
+
if (!(s.keptChecks.get(a.id) ?? []).length) findings.push({ file: rel(s.file), line: a.line, code: keptCodes.unmapped, message: `${s.id}/${a.id} names no kept test case in "Kept checks"` });
|
|
310
|
+
}
|
|
311
|
+
const seen = new Map();
|
|
312
|
+
for (const [id, list] of s.keptChecks) {
|
|
313
|
+
if (!s.assertions.some((a) => a.id === id)) findings.push({ file: rel(s.file), line: list[0].line, code: 'unknown-assertion', message: `a kept check names ${id}, which ${s.id} does not have` });
|
|
314
|
+
for (const k of list) {
|
|
315
|
+
const key = k.name.normalize('NFC').replace(/\s+/g, ' ').trim();
|
|
316
|
+
if (seen.has(key)) findings.push({ file: rel(s.file), line: k.line, code: keptCodes.twice, message: `the case "${k.name}" is named twice; name each kept case once` });
|
|
317
|
+
seen.set(key, k.line);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
for (const k of s.notMapped) {
|
|
321
|
+
const key = k.name.normalize('NFC').replace(/\s+/g, ' ').trim();
|
|
322
|
+
if (seen.has(key)) findings.push({ file: rel(s.file), line: k.line, code: keptCodes.twice, message: `the case "${k.name}" is mapped and also listed as not mapped` });
|
|
323
|
+
seen.set(key, k.line);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// The files that a pattern of a kept way in matches: "*" stays inside one folder, "**" crosses folders.
|
|
328
|
+
export function globToRegExp(pattern) {
|
|
329
|
+
let out = '';
|
|
330
|
+
const text = String(pattern).replace(/^\.\//, '');
|
|
331
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
332
|
+
const c = text[i];
|
|
333
|
+
if (c === '*' && text[i + 1] === '*') { out += text[i + 2] === '/' ? '(?:.+/)?' : '.*'; i += text[i + 2] === '/' ? 2 : 1; }
|
|
334
|
+
else if (c === '*') out += '[^/]*';
|
|
335
|
+
else if (c === '?') out += '[^/]';
|
|
336
|
+
else out += c.replace(/[.+^${}()|[\]\\]/g, '\\$&');
|
|
337
|
+
}
|
|
338
|
+
return new RegExp(`^${out}$`);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
export function keptFilesOf(root, pattern) {
|
|
342
|
+
const re = globToRegExp(pattern);
|
|
343
|
+
const relOf = (f) => relative(root, f).split('\\').join('/');
|
|
344
|
+
return walk(root, { accept: (f) => re.test(relOf(f)) }).map(relOf);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function checkKeptFiles(scenarios, tests, root, findings, rel, yamlPath) {
|
|
348
|
+
const mapped = new Set(scenarios.filter((s) => s.keptFile).map((s) => s.keptFile.replace(/^\.\//, '')));
|
|
349
|
+
for (const [name, way] of Object.entries(tests?.['ways-in'] ?? {})) {
|
|
350
|
+
if (way?.driver !== 'kept' || !Array.isArray(way.files)) continue;
|
|
351
|
+
for (const pattern of way.files) {
|
|
352
|
+
for (const file of keptFilesOf(root, pattern)) {
|
|
353
|
+
if (!mapped.has(file)) findings.push({ file: rel(yamlPath), line: 1, code: 'kept-file-no-scenario', message: `${file} matches files of the kept way in "${name}", and no scenario names it in kept-tests` });
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
297
359
|
export function checkStatic({ root, testsDir, guardsFrom, rel, findings }) {
|
|
298
360
|
const yamlPath = join(root, 'atlas', 'tests.yaml');
|
|
299
361
|
let tests = null;
|
|
@@ -329,6 +391,8 @@ export function checkStatic({ root, testsDir, guardsFrom, rel, findings }) {
|
|
|
329
391
|
}
|
|
330
392
|
}
|
|
331
393
|
for (const s of scenarios) checkWaitsAndWhens(s, findings, rel);
|
|
394
|
+
for (const s of scenarios) checkKept(s, tests, root, findings, rel);
|
|
395
|
+
checkKeptFiles(scenarios, tests, root, findings, rel, yamlPath);
|
|
332
396
|
checkLinks(scenarios, root, testsDir, findings, rel);
|
|
333
397
|
return { tests, scenarios };
|
|
334
398
|
}
|
package/tests/scenario.mjs
CHANGED
|
@@ -271,6 +271,31 @@ export function parseScenario(source, file = 'scenario.md') {
|
|
|
271
271
|
seen.add(m[1]);
|
|
272
272
|
out.assertions.push({ id: m[1], kind: m[2], gate: m[3] === 'gate', words: m[4].trim(), line: item.line, names: names(m[4]), fp: null });
|
|
273
273
|
}
|
|
274
|
+
// Path 1 (docs/design-docs/kept-tests): `kept-tests` names one kept test file, and the
|
|
275
|
+
// "Kept checks" section maps each assertion to the test cases of that file that check it.
|
|
276
|
+
// - e1: "status prints one JSON line"
|
|
277
|
+
// - not mapped: "the help text lists status" (why it maps to no assertion)
|
|
278
|
+
out.keptFile = typeof f['kept-tests'] === 'string' && f['kept-tests'].trim() ? f['kept-tests'].trim() : null;
|
|
279
|
+
if (f['kept-tests'] !== undefined && !out.keptFile) problem(out.fieldLine('kept-tests'), 'kept-tests names one kept test file, as a path under the area');
|
|
280
|
+
out.keptChecks = new Map();
|
|
281
|
+
out.notMapped = [];
|
|
282
|
+
const keptLines = sectionLines.get('kept checks') ?? [];
|
|
283
|
+
if (keptLines.length && !out.keptFile) problem(out.sections.get('kept checks'), 'a "Kept checks" section needs kept-tests in the front matter');
|
|
284
|
+
if (out.keptFile && !out.sections.has('kept checks')) problem(out.fieldLine('kept-tests'), 'a scenario with kept-tests needs a "## Kept checks" section');
|
|
285
|
+
for (const { text: raw, number } of keptLines) {
|
|
286
|
+
if (!raw.trim()) continue;
|
|
287
|
+
const m = /^-\s+(e\d+|not mapped):\s*"((?:[^"\\]|\\.)+)"\s*(.*)$/.exec(raw);
|
|
288
|
+
if (!m) { problem(number, `a kept check reads - eN: "test case name", or - not mapped: "test case name" (why); found "${raw.trim().slice(0, 60)}"`); continue; }
|
|
289
|
+
const name = m[2].replace(/\\(.)/g, '$1');
|
|
290
|
+
if (m[1] === 'not mapped') {
|
|
291
|
+
if (!m[3].trim()) problem(number, `"${name}" is not mapped and says no reason; add it in brackets`);
|
|
292
|
+
out.notMapped.push({ name, why: m[3].trim().replace(/^\(|\)$/g, ''), line: number });
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
if (!out.keptChecks.has(m[1])) out.keptChecks.set(m[1], []);
|
|
296
|
+
out.keptChecks.get(m[1]).push({ name, line: number });
|
|
297
|
+
}
|
|
298
|
+
|
|
274
299
|
if (!out.assertions.length) problem(out.sections.get('assertions') ?? 1, 'the scenario has no assertions');
|
|
275
300
|
else if (!out.sections.has('assertions')) problem(1, 'the scenario has no "## Assertions" section');
|
|
276
301
|
return out;
|
|
@@ -345,6 +370,9 @@ export function addFingerprints(scenario, tests) {
|
|
|
345
370
|
lines.push(`through=${[...scenario.through].sort().join(',')}`, `runs-in=${runsIn(scenario).sort().join(',')}`);
|
|
346
371
|
// A weaker pass rule is a weaker test, so `repeat` is in the print. It adds a line only when set.
|
|
347
372
|
if (scenario.repeat) lines.push(`repeat=${scenario.repeat.pass}/${scenario.repeat.runs}`);
|
|
373
|
+
// The kept test cases that check the assertion are part of what it means (path 1).
|
|
374
|
+
const keptNames = scenario.keptChecks?.get(a.id);
|
|
375
|
+
if (scenario.keptFile && keptNames?.length) lines.push(`kept=${scenario.keptFile}:${keptNames.map((k) => normalize(k.name)).sort().join('|')}`);
|
|
348
376
|
a.fp = fingerprintOf([a.words, ...lines].join('\n'));
|
|
349
377
|
a.fullId = `${scenario.id}/${a.id}#${a.fp}`;
|
|
350
378
|
a.ways = waysOf(a, scenario, kept);
|
package/tests/tests-yaml.mjs
CHANGED
|
@@ -38,7 +38,7 @@ const SCHEMA = {
|
|
|
38
38
|
runner: ['language', 'command'],
|
|
39
39
|
environment: ENV_KEYS,
|
|
40
40
|
ready: ['get', 'status', 'limit'],
|
|
41
|
-
wayIn: ['driver', 'base', 'command', 'module', 'env', 'cwd', 'model', 'key', 'tools-from', 'tools', 'system-prompt', 'offline', 'why'],
|
|
41
|
+
wayIn: ['driver', 'base', 'command', 'module', 'env', 'cwd', 'model', 'key', 'tools-from', 'tools', 'system-prompt', 'offline', 'files', 'why'],
|
|
42
42
|
standIn: ['start', 'why', 'ready'],
|
|
43
43
|
persona: ['role', 'secret', 'same-identity', 'sign-in', 'make', 'of', 'why'],
|
|
44
44
|
setup: ['with', 'steps', 'gives', 'why'],
|
|
@@ -170,6 +170,12 @@ export function checkTestsYaml(parsed) {
|
|
|
170
170
|
if (!way.driver) say('tests-yaml-missing-field', [...path, 'driver'], `the field "${[...path, 'driver'].join('.')}" is needed`);
|
|
171
171
|
if (way.key !== undefined && !secretNamed(way.key)) say('unknown-name', [...path, 'key'], `"key" names ${way.key}, which "secrets" does not list`);
|
|
172
172
|
if (way.driver === 'command' && way.command === undefined) say('tests-yaml-missing-field', [...path, 'command'], 'a command way in needs "command"');
|
|
173
|
+
// Path 1: the kept runner. Its command holds {file} and {report} (docs/design-docs/kept-tests).
|
|
174
|
+
if (way.driver === 'kept') {
|
|
175
|
+
if (typeof way.command !== 'string') say('tests-yaml-missing-field', [...path, 'command'], 'a kept way in needs "command": the kept runner, with {file} and {report}');
|
|
176
|
+
else for (const hole of ['{file}', '{report}']) if (!way.command.includes(hole)) say('tests-yaml', [...path, 'command'], `the command of a kept way in needs ${hole}`);
|
|
177
|
+
if (way.files !== undefined && (!Array.isArray(way.files) || !way.files.length || way.files.some((f) => typeof f !== 'string'))) say('tests-yaml', [...path, 'files'], '"files" is a list of paths or patterns, such as "test/*.test.mjs"');
|
|
178
|
+
} else if (way.files !== undefined) say('tests-yaml', [...path, 'files'], '"files" belongs to a kept way in');
|
|
173
179
|
if (way.offline !== undefined && way.offline !== true) say('tests-yaml', [...path, 'offline'], '"offline" is true, or it is left out');
|
|
174
180
|
if (way.driver === 'agent') {
|
|
175
181
|
for (const key of ['model', 'tools-from', 'tools']) if (way[key] === undefined) say('tests-yaml-missing-field', [...path, key], `an agent way in needs "${key}"`);
|