@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,258 @@
|
|
|
1
|
+
// atlas/tests.yaml: the reader, the check and the guard parts.
|
|
2
|
+
//
|
|
3
|
+
// The file is YAML with `spec: 1`. An unknown field is an error, so a typo
|
|
4
|
+
// cannot hide. A field that holds what looks like a secret value is an error,
|
|
5
|
+
// and the message names the field, never the value. Two personas that share
|
|
6
|
+
// one secret must each say `same-identity`.
|
|
7
|
+
//
|
|
8
|
+
// The guard parts decide what a test may reach. A pull request may not change
|
|
9
|
+
// them for itself: CI reads them from the main branch and compares.
|
|
10
|
+
import { readFileSync } from 'node:fs';
|
|
11
|
+
import { parseYaml, YamlError } from './yaml.mjs';
|
|
12
|
+
|
|
13
|
+
export const SPEC = 1;
|
|
14
|
+
|
|
15
|
+
// The parts of the file that only the owner may change. The `vars` part holds
|
|
16
|
+
// the names of the variables, not their values.
|
|
17
|
+
export const GUARD_PARTS = Object.freeze([
|
|
18
|
+
'guards',
|
|
19
|
+
'environments.*.allowed',
|
|
20
|
+
'environments.*.vars',
|
|
21
|
+
'personas.*.secret',
|
|
22
|
+
'secrets',
|
|
23
|
+
'judges',
|
|
24
|
+
'outside-text',
|
|
25
|
+
]);
|
|
26
|
+
|
|
27
|
+
const ENV_KEYS = ['same-as', 'start', 'ready', 'stop', 'url', 'vars', 'allowed', 'why'];
|
|
28
|
+
const SCHEMA = {
|
|
29
|
+
top: ['spec', 'area', 'runner', 'environments', 'ways-in', 'stand-ins', 'personas', 'setups', 'data', 'fixtures', 'judges', 'guards', 'secrets', 'limits', 'budget', 'outside-text'],
|
|
30
|
+
runner: ['language', 'command'],
|
|
31
|
+
environment: ENV_KEYS,
|
|
32
|
+
ready: ['get', 'status', 'limit'],
|
|
33
|
+
wayIn: ['driver', 'base', 'command', 'module', 'env', 'cwd', 'why'],
|
|
34
|
+
standIn: ['start', 'why', 'ready'],
|
|
35
|
+
persona: ['role', 'secret', 'same-identity', 'sign-in', 'make', 'of', 'why'],
|
|
36
|
+
setup: ['with', 'steps', 'gives', 'why'],
|
|
37
|
+
data: ['rule', 'why'],
|
|
38
|
+
fixture: ['path', 'facts', 'bytes', 'text', 'why', 'made-up'],
|
|
39
|
+
judge: ['model', 'asks', 'runs', 'agree', 'cost-cap'],
|
|
40
|
+
guards: ['hosts', 'secrets'],
|
|
41
|
+
secret: ['why', 'local', 'ci'],
|
|
42
|
+
budget: ['cases', 'calls', 'minutes', 'money'],
|
|
43
|
+
};
|
|
44
|
+
// `guards` is optional: the smallest file of the design has none, and then a
|
|
45
|
+
// test may reach this machine only.
|
|
46
|
+
const REQUIRED = ['spec', 'runner', 'environments', 'ways-in'];
|
|
47
|
+
const NAME = /^[A-Z][A-Z0-9_]*$/;
|
|
48
|
+
|
|
49
|
+
// ---------------------------------------------------------------- secret test
|
|
50
|
+
|
|
51
|
+
function entropy(text) {
|
|
52
|
+
const counts = new Map();
|
|
53
|
+
for (const c of text) counts.set(c, (counts.get(c) ?? 0) + 1);
|
|
54
|
+
let bits = 0;
|
|
55
|
+
for (const n of counts.values()) bits -= (n / text.length) * Math.log2(n / text.length);
|
|
56
|
+
return bits;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// The shapes of a leaked credential. The sources are built from parts, so this
|
|
60
|
+
// file never holds the text of a key block.
|
|
61
|
+
const PEM = new RegExp(`-----BEGIN [A-Z ]*${'PRIVATE'} KEY-----`);
|
|
62
|
+
const SHAPES = [
|
|
63
|
+
PEM,
|
|
64
|
+
/\b(?:gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,})\b/,
|
|
65
|
+
/\b(?:sk-[A-Za-z0-9_-]{20,}|AKIA[0-9A-Z]{16})\b/,
|
|
66
|
+
/\bxox[abprs]-[A-Za-z0-9-]{10,}/,
|
|
67
|
+
/\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/,
|
|
68
|
+
/\bBearer\s+[A-Za-z0-9._~+/=-]{20,}/i,
|
|
69
|
+
/\b[a-z][a-z0-9+.-]*:\/\/[^\s:@/]+:[^\s:@/]+@[^\s/]+/i,
|
|
70
|
+
];
|
|
71
|
+
|
|
72
|
+
// True when a text looks like a secret value. A placeholder such as
|
|
73
|
+
// "{run.secret}" never does. A word such as "not-a-real-key" does not either:
|
|
74
|
+
// it has no digit and little entropy.
|
|
75
|
+
export function looksLikeSecret(text) {
|
|
76
|
+
if (typeof text !== 'string') return false;
|
|
77
|
+
if (/^\{[^{}]+\}$/.test(text.trim())) return false;
|
|
78
|
+
if (SHAPES.some((shape) => shape.test(text))) return true;
|
|
79
|
+
const word = text.trim();
|
|
80
|
+
if (word.length < 24 || /\s/.test(word)) return false;
|
|
81
|
+
if (/^https?:\/\//i.test(word) || /^[./~]/.test(word) || word.includes('{')) return false;
|
|
82
|
+
if (/^[0-9a-f]{32,}$/i.test(word)) return true;
|
|
83
|
+
if (!/\d/.test(word) || !/[A-Za-z]/.test(word)) return false;
|
|
84
|
+
return entropy(word) >= 3.6;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ---------------------------------------------------------------- the reader
|
|
88
|
+
|
|
89
|
+
export function parseTestsYaml(source) {
|
|
90
|
+
return parseYaml(source);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function readTestsYaml(path) {
|
|
94
|
+
return parseYaml(readFileSync(path, 'utf8'));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Merges a `same-as` environment over the one it names.
|
|
98
|
+
export function resolveEnvironments(environments = {}) {
|
|
99
|
+
const resolved = {};
|
|
100
|
+
const walk = (name, seen = []) => {
|
|
101
|
+
const env = environments[name];
|
|
102
|
+
if (!env || typeof env !== 'object') return {};
|
|
103
|
+
if (!env['same-as']) return { ...env };
|
|
104
|
+
if (seen.includes(name)) return { ...env };
|
|
105
|
+
const { 'same-as': from, ...rest } = env;
|
|
106
|
+
return { ...walk(from, [...seen, name]), ...rest, 'same-as': from };
|
|
107
|
+
};
|
|
108
|
+
for (const name of Object.keys(environments)) resolved[name] = walk(name);
|
|
109
|
+
return resolved;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ---------------------------------------------------------------- the check
|
|
113
|
+
|
|
114
|
+
// Returns a list of { code, path, line, message }. `path` is the field as
|
|
115
|
+
// dotted text. A finding never holds the value of a field.
|
|
116
|
+
export function checkTestsYaml(parsed) {
|
|
117
|
+
const { value, lineOf } = parsed;
|
|
118
|
+
const findings = [];
|
|
119
|
+
const say = (code, path, message) => findings.push({ code, path: path.join('.'), line: lineOf(path) ?? lineOf(path.slice(0, -1)) ?? 1, message });
|
|
120
|
+
const isMap = (x) => x && typeof x === 'object' && !Array.isArray(x);
|
|
121
|
+
|
|
122
|
+
if (!isMap(value)) { findings.push({ code: 'tests-yaml', path: '', line: 1, message: 'the file holds no map' }); return findings; }
|
|
123
|
+
const known = (obj, allowed, path) => {
|
|
124
|
+
for (const key of Object.keys(obj)) {
|
|
125
|
+
if (!allowed.includes(key)) say('tests-yaml-unknown-field', [...path, key], `unknown field "${[...path, key].join('.')}"`);
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
const each = (obj, allowed, path, extra) => {
|
|
129
|
+
if (!isMap(obj)) return;
|
|
130
|
+
for (const [name, entry] of Object.entries(obj)) {
|
|
131
|
+
if (!isMap(entry)) { say('tests-yaml', [...path, name], `"${[...path, name].join('.')}" must be a map`); continue; }
|
|
132
|
+
known(entry, allowed, [...path, name]);
|
|
133
|
+
extra?.(entry, [...path, name], name);
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
known(value, SCHEMA.top, []);
|
|
138
|
+
for (const key of REQUIRED) if (!(key in value)) say('tests-yaml-missing-field', [key], `the field "${key}" is needed`);
|
|
139
|
+
if ('spec' in value && value.spec !== SPEC) say('tests-yaml-spec', ['spec'], `spec must be ${SPEC}; this check reads spec ${SPEC} only`);
|
|
140
|
+
if (isMap(value.runner)) {
|
|
141
|
+
known(value.runner, SCHEMA.runner, ['runner']);
|
|
142
|
+
for (const key of SCHEMA.runner) if (!(key in value.runner)) say('tests-yaml-missing-field', ['runner', key], `the field "runner.${key}" is needed`);
|
|
143
|
+
} else if ('runner' in value) say('tests-yaml', ['runner'], '"runner" must be a map');
|
|
144
|
+
|
|
145
|
+
each(value.environments, SCHEMA.environment, ['environments'], (env, path) => {
|
|
146
|
+
if (isMap(env.ready)) known(env.ready, SCHEMA.ready, [...path, 'ready']);
|
|
147
|
+
if (env.vars !== undefined && !isMap(env.vars)) say('tests-yaml', [...path, 'vars'], `"${path.join('.')}.vars" must be a map`);
|
|
148
|
+
if (env['same-as'] && !isMap(value.environments?.[env['same-as']])) say('unknown-name', [...path, 'same-as'], `"same-as" names an environment that does not exist`);
|
|
149
|
+
});
|
|
150
|
+
if (!isMap(value.environments) && 'environments' in value) say('tests-yaml', ['environments'], '"environments" must be a map');
|
|
151
|
+
|
|
152
|
+
each(value['ways-in'], SCHEMA.wayIn, ['ways-in'], (way, path) => {
|
|
153
|
+
if (!way.driver) say('tests-yaml-missing-field', [...path, 'driver'], `the field "${[...path, 'driver'].join('.')}" is needed`);
|
|
154
|
+
});
|
|
155
|
+
each(value['stand-ins'], SCHEMA.standIn, ['stand-ins'], (entry, path) => { if (isMap(entry.ready)) known(entry.ready, SCHEMA.ready, [...path, 'ready']); });
|
|
156
|
+
each(value.personas, SCHEMA.persona, ['personas']);
|
|
157
|
+
each(value.setups, SCHEMA.setup, ['setups']);
|
|
158
|
+
each(value.fixtures, SCHEMA.fixture, ['fixtures']);
|
|
159
|
+
each(value.judges, SCHEMA.judge, ['judges']);
|
|
160
|
+
each(value.secrets, SCHEMA.secret, ['secrets']);
|
|
161
|
+
if (isMap(value.data)) {
|
|
162
|
+
for (const [name, rule] of Object.entries(value.data)) if (isMap(rule)) known(rule, SCHEMA.data, ['data', name]);
|
|
163
|
+
}
|
|
164
|
+
if (isMap(value.guards)) {
|
|
165
|
+
known(value.guards, SCHEMA.guards, ['guards']);
|
|
166
|
+
if (!Array.isArray(value.guards.hosts) || !value.guards.hosts.length) say('tests-yaml-missing-field', ['guards', 'hosts'], 'the field "guards.hosts" is needed: the hosts that a test may reach');
|
|
167
|
+
} else if ('guards' in value) say('tests-yaml', ['guards'], '"guards" must be a map');
|
|
168
|
+
if (isMap(value.budget)) known(value.budget, SCHEMA.budget, ['budget']);
|
|
169
|
+
if ('outside-text' in value && typeof value['outside-text'] !== 'boolean') say('tests-yaml', ['outside-text'], '"outside-text" must be true or false');
|
|
170
|
+
|
|
171
|
+
// A secret appears by name only. Every text in the file is tested.
|
|
172
|
+
const walk = (node, path) => {
|
|
173
|
+
if (typeof node === 'string') {
|
|
174
|
+
if (looksLikeSecret(node)) say('tests-yaml-secret', path, `"${path.join('.')}" holds what looks like a secret value; this file carries names only`);
|
|
175
|
+
} else if (Array.isArray(node)) node.forEach((one, i) => walk(one, [...path, i]));
|
|
176
|
+
else if (isMap(node)) for (const [k, one] of Object.entries(node)) walk(one, [...path, k]);
|
|
177
|
+
};
|
|
178
|
+
walk(value, []);
|
|
179
|
+
|
|
180
|
+
// A persona names its secret; it never holds one.
|
|
181
|
+
const varNames = new Set();
|
|
182
|
+
for (const env of Object.values(value.environments ?? {})) for (const name of Object.keys(env?.vars ?? {})) varNames.add(name);
|
|
183
|
+
const secretNames = new Set(Object.keys(value.secrets ?? {}));
|
|
184
|
+
const bySecret = new Map();
|
|
185
|
+
if (isMap(value.personas)) {
|
|
186
|
+
for (const [name, persona] of Object.entries(value.personas)) {
|
|
187
|
+
if (!isMap(persona)) continue;
|
|
188
|
+
if (persona.secret !== undefined) {
|
|
189
|
+
if (typeof persona.secret !== 'string' || !NAME.test(persona.secret)) {
|
|
190
|
+
say('tests-yaml-secret', ['personas', name, 'secret'], `"personas.${name}.secret" must be the NAME of a variable, in capitals; it must not hold a value`);
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
if (!varNames.has(persona.secret) && !secretNames.has(persona.secret)) {
|
|
194
|
+
say('unknown-name', ['personas', name, 'secret'], `"personas.${name}.secret" names ${persona.secret}, which no environment sets in "vars" and "secrets" does not list`);
|
|
195
|
+
}
|
|
196
|
+
if (!bySecret.has(persona.secret)) bySecret.set(persona.secret, []);
|
|
197
|
+
bySecret.get(persona.secret).push(name);
|
|
198
|
+
}
|
|
199
|
+
if (persona['same-identity'] !== undefined && !isMap(value.personas[persona['same-identity']])) {
|
|
200
|
+
say('unknown-name', ['personas', name, 'same-identity'], `"personas.${name}.same-identity" names a persona that does not exist`);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
for (const [secret, names] of bySecret) {
|
|
204
|
+
if (names.length < 2) continue;
|
|
205
|
+
for (const name of names) {
|
|
206
|
+
const partner = value.personas[name]['same-identity'];
|
|
207
|
+
if (!partner || !names.includes(partner) || partner === name) {
|
|
208
|
+
say('persona-shared-secret', ['personas', name], `persona "${name}" shares the secret ${secret} with ${names.filter((n) => n !== name).join(', ')} and does not say "same-identity" for one of them`);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
return findings;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// ---------------------------------------------------------------- guard parts
|
|
217
|
+
|
|
218
|
+
const sortKeys = (x) => {
|
|
219
|
+
if (Array.isArray(x)) return x.map(sortKeys);
|
|
220
|
+
if (x && typeof x === 'object') return Object.fromEntries(Object.keys(x).sort().map((k) => [k, sortKeys(x[k])]));
|
|
221
|
+
return x;
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
// The guard parts of a parsed file, flat: part path to value. An environment
|
|
225
|
+
// is read after its `same-as` is applied. A `vars` part holds the names only.
|
|
226
|
+
export function guardParts(value) {
|
|
227
|
+
const out = {};
|
|
228
|
+
const v = value ?? {};
|
|
229
|
+
if ('guards' in v) out.guards = sortKeys(v.guards);
|
|
230
|
+
const envs = resolveEnvironments(v.environments ?? {});
|
|
231
|
+
for (const [name, env] of Object.entries(envs)) {
|
|
232
|
+
if ('allowed' in env) out[`environments.${name}.allowed`] = env.allowed;
|
|
233
|
+
if (env.vars && typeof env.vars === 'object') out[`environments.${name}.vars`] = Object.keys(env.vars).sort();
|
|
234
|
+
}
|
|
235
|
+
for (const [name, persona] of Object.entries(v.personas ?? {})) {
|
|
236
|
+
if (persona && typeof persona === 'object' && 'secret' in persona) out[`personas.${name}.secret`] = persona.secret;
|
|
237
|
+
}
|
|
238
|
+
if ('secrets' in v) out.secrets = sortKeys(v.secrets);
|
|
239
|
+
if ('judges' in v) out.judges = sortKeys(v.judges);
|
|
240
|
+
if ('outside-text' in v) out['outside-text'] = v['outside-text'];
|
|
241
|
+
return out;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Compares the guard parts of a file from the main branch with the same file
|
|
245
|
+
// in the pull request. Both arguments are the parsed `value` or the text.
|
|
246
|
+
// Returns one change for each part that differs: { part, base, head }.
|
|
247
|
+
export function compareGuardParts(base, head) {
|
|
248
|
+
const read = (x) => (typeof x === 'string' ? parseYaml(x).value : x);
|
|
249
|
+
const a = guardParts(read(base));
|
|
250
|
+
const b = guardParts(read(head));
|
|
251
|
+
const changes = [];
|
|
252
|
+
for (const part of [...new Set([...Object.keys(a), ...Object.keys(b)])].sort()) {
|
|
253
|
+
if (JSON.stringify(a[part]) !== JSON.stringify(b[part])) changes.push({ part, base: a[part] ?? null, head: b[part] ?? null });
|
|
254
|
+
}
|
|
255
|
+
return changes;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export { YamlError };
|
package/tests/wait.mjs
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// The wait of a scenario: wait for a signal, never for a fixed time.
|
|
2
|
+
//
|
|
3
|
+
// const last = await waitUntil(() => api.get('/status'), {
|
|
4
|
+
// until: (r) => r.body.state === 'done',
|
|
5
|
+
// fail: (r) => r.body.state === 'error',
|
|
6
|
+
// limit: '2m',
|
|
7
|
+
// });
|
|
8
|
+
//
|
|
9
|
+
// It reads again and again. It returns the last value when `until` holds. It
|
|
10
|
+
// throws WaitFailed at once when `fail` holds. It throws WaitTimeout, with
|
|
11
|
+
// the last value, when the limit runs out. A wait with no limit is refused.
|
|
12
|
+
import { setTimeout as sleep } from 'node:timers/promises';
|
|
13
|
+
import { WaitFailed, WaitTimeout } from './errors.mjs';
|
|
14
|
+
|
|
15
|
+
const UNITS = { ms: 1, millisecond: 1, milliseconds: 1, s: 1000, second: 1000, seconds: 1000, m: 60000, min: 60000, minute: 60000, minutes: 60000, h: 3600000, hour: 3600000, hours: 3600000 };
|
|
16
|
+
|
|
17
|
+
// "30s", "2m", "2 minutes" or a number of milliseconds.
|
|
18
|
+
export function parseLimit(limit) {
|
|
19
|
+
if (typeof limit === 'number' && Number.isFinite(limit) && limit > 0) return limit;
|
|
20
|
+
const match = /^(\d+(?:\.\d+)?)\s*([a-z]+)$/i.exec(String(limit ?? '').trim());
|
|
21
|
+
if (!match || !Object.hasOwn(UNITS, match[2].toLowerCase())) throw new Error(`a limit is a number of milliseconds, or like "30s" or "2m"; found "${limit}"`);
|
|
22
|
+
return Number(match[1]) * UNITS[match[2].toLowerCase()];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const show = (last) => JSON.stringify(last?.body ?? last)?.slice(0, 600);
|
|
26
|
+
|
|
27
|
+
export async function waitUntil(read, options = {}) {
|
|
28
|
+
const { until, fail = () => false, absent = () => false, limit, every = '250ms', absentLimit = 10 } = options;
|
|
29
|
+
if (typeof read !== 'function') throw new Error('waitUntil needs a function that reads the state');
|
|
30
|
+
if (typeof until !== 'function') throw new Error('waitUntil needs an `until` state');
|
|
31
|
+
if (limit === undefined || limit === null) throw new Error('waitUntil needs a `limit`: a wait with no time limit is refused');
|
|
32
|
+
const deadline = Date.now() + parseLimit(limit);
|
|
33
|
+
let last;
|
|
34
|
+
let absentReads = 0;
|
|
35
|
+
for (;;) {
|
|
36
|
+
last = await read();
|
|
37
|
+
if (fail(last)) throw new WaitFailed(`the wait met its fail state: ${show(last)}`, last);
|
|
38
|
+
if (until(last)) return last;
|
|
39
|
+
absentReads = absent(last) ? absentReads + 1 : 0;
|
|
40
|
+
if (absentReads >= absentLimit) throw new WaitFailed(`the field that the wait reads was absent for ${absentReads} reads in a row`, last);
|
|
41
|
+
if (Date.now() >= deadline) throw new WaitTimeout(`the wait ran out after ${limit}; last state: ${show(last)}`, last);
|
|
42
|
+
await sleep(parseLimit(every));
|
|
43
|
+
}
|
|
44
|
+
}
|
package/tests/yaml.mjs
ADDED
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
// A small YAML reader for tests.yaml and the front matter of a scenario.
|
|
2
|
+
//
|
|
3
|
+
// Atlas ships no dependency, so this reads the subset that these files use:
|
|
4
|
+
// comments; nested maps by indentation; block lists of scalars and of maps;
|
|
5
|
+
// flow lists and flow maps, nested to any depth, over several lines;
|
|
6
|
+
// quoted and plain scalars; null, booleans and numbers; and the block scalars
|
|
7
|
+
// | and >. Anything else raises a YamlError, so a file is never half read.
|
|
8
|
+
//
|
|
9
|
+
// `parseYaml(source)` returns `{ value, lineOf }`. `lineOf(path)` gives the
|
|
10
|
+
// 1-based line where the key at that path (an array of keys and indexes)
|
|
11
|
+
// starts, or null. An error names its line. It never carries the text of the
|
|
12
|
+
// line, because a line can hold a secret value.
|
|
13
|
+
|
|
14
|
+
export class YamlError extends Error {
|
|
15
|
+
constructor(message, line) {
|
|
16
|
+
super(line === undefined ? message : `${message} (line ${line})`);
|
|
17
|
+
this.name = 'YamlError';
|
|
18
|
+
this.line = line;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const pathKey = (path) => JSON.stringify(path);
|
|
23
|
+
|
|
24
|
+
// Cuts a trailing comment: a # that starts the text or follows a space, and
|
|
25
|
+
// sits outside quotes.
|
|
26
|
+
function stripComment(text) {
|
|
27
|
+
let quote = null;
|
|
28
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
29
|
+
const c = text[i];
|
|
30
|
+
if (quote) {
|
|
31
|
+
if (c === '\\' && quote === '"') i += 1;
|
|
32
|
+
else if (c === quote) quote = null;
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
if ((c === '"' || c === "'") && (i === 0 || /[\s[{,:]/.test(text[i - 1]))) quote = c;
|
|
36
|
+
else if (c === '#' && (i === 0 || /\s/.test(text[i - 1]))) return text.slice(0, i).trimEnd();
|
|
37
|
+
}
|
|
38
|
+
return text.trimEnd();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Counts open brackets outside quotes: positive when a flow value is still open.
|
|
42
|
+
function depthOf(text) {
|
|
43
|
+
let depth = 0;
|
|
44
|
+
let quote = null;
|
|
45
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
46
|
+
const c = text[i];
|
|
47
|
+
if (quote) {
|
|
48
|
+
if (c === '\\' && quote === '"') i += 1;
|
|
49
|
+
else if (c === quote) quote = null;
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
if (c === '"' || c === "'") quote = c;
|
|
53
|
+
else if (c === '[' || c === '{') depth += 1;
|
|
54
|
+
else if (c === ']' || c === '}') depth -= 1;
|
|
55
|
+
}
|
|
56
|
+
return depth;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function plainScalar(text) {
|
|
60
|
+
if (text === '' || text === '~' || /^null$/i.test(text)) return null;
|
|
61
|
+
if (/^true$/i.test(text)) return true;
|
|
62
|
+
if (/^false$/i.test(text)) return false;
|
|
63
|
+
if (/^-?\d+$/.test(text)) return Number(text);
|
|
64
|
+
if (/^-?\d+\.\d+$/.test(text)) return Number(text);
|
|
65
|
+
return text;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function quoted(text, line) {
|
|
69
|
+
const q = text[0];
|
|
70
|
+
if (text.length < 2 || text[text.length - 1] !== q) throw new YamlError('a quoted value does not close', line);
|
|
71
|
+
const inner = text.slice(1, -1);
|
|
72
|
+
if (q === "'") return inner.replaceAll("''", "'");
|
|
73
|
+
return inner.replace(/\\(.)/g, (_, c) => ({ n: '\n', t: '\t', r: '\r' })[c] ?? c);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Reads one value from inline text: a flow list, a flow map or a scalar.
|
|
77
|
+
function inlineValue(text, line, lines, path) {
|
|
78
|
+
const t = text.trim();
|
|
79
|
+
if (t.startsWith('[') || t.startsWith('{')) {
|
|
80
|
+
const flow = new Flow(t, line, lines, path);
|
|
81
|
+
const value = flow.value(path);
|
|
82
|
+
flow.skip();
|
|
83
|
+
if (flow.i < t.length) throw new YamlError('text follows a flow value', line);
|
|
84
|
+
return value;
|
|
85
|
+
}
|
|
86
|
+
if (t.startsWith('"') || t.startsWith("'")) return quoted(t, line);
|
|
87
|
+
return plainScalar(t);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
class Flow {
|
|
91
|
+
constructor(text, line, lines, path) {
|
|
92
|
+
this.t = text;
|
|
93
|
+
this.i = 0;
|
|
94
|
+
this.line = line;
|
|
95
|
+
this.lines = lines;
|
|
96
|
+
this.base = path;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
skip() { while (this.i < this.t.length && /\s/.test(this.t[this.i])) this.i += 1; }
|
|
100
|
+
|
|
101
|
+
value(path) {
|
|
102
|
+
this.skip();
|
|
103
|
+
const c = this.t[this.i];
|
|
104
|
+
if (c === '[') return this.list(path);
|
|
105
|
+
if (c === '{') return this.map(path);
|
|
106
|
+
if (c === '"' || c === "'") return this.quotedString();
|
|
107
|
+
return plainScalar(this.plain(',]}').trim());
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
quotedString() {
|
|
111
|
+
const q = this.t[this.i];
|
|
112
|
+
const start = this.i;
|
|
113
|
+
this.i += 1;
|
|
114
|
+
while (this.i < this.t.length && this.t[this.i] !== q) {
|
|
115
|
+
if (q === '"' && this.t[this.i] === '\\') this.i += 1;
|
|
116
|
+
this.i += 1;
|
|
117
|
+
}
|
|
118
|
+
if (this.i >= this.t.length) throw new YamlError('a quoted value does not close', this.line);
|
|
119
|
+
this.i += 1;
|
|
120
|
+
return quoted(this.t.slice(start, this.i), this.line);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// A plain scalar ends at one of `stops`; a colon only ends it when a space or a stop follows.
|
|
124
|
+
plain(stops) {
|
|
125
|
+
const start = this.i;
|
|
126
|
+
while (this.i < this.t.length && !stops.includes(this.t[this.i])) this.i += 1;
|
|
127
|
+
return this.t.slice(start, this.i);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
list(path) {
|
|
131
|
+
this.i += 1;
|
|
132
|
+
const out = [];
|
|
133
|
+
for (;;) {
|
|
134
|
+
this.skip();
|
|
135
|
+
if (this.i >= this.t.length) throw new YamlError('a flow list does not close', this.line);
|
|
136
|
+
if (this.t[this.i] === ']') { this.i += 1; return out; }
|
|
137
|
+
this.lines.set(pathKey([...path, out.length]), this.line);
|
|
138
|
+
out.push(this.value([...path, out.length]));
|
|
139
|
+
this.skip();
|
|
140
|
+
if (this.t[this.i] === ',') this.i += 1;
|
|
141
|
+
else if (this.t[this.i] !== ']') throw new YamlError('a flow list has text where a comma should be', this.line);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
map(path) {
|
|
146
|
+
this.i += 1;
|
|
147
|
+
const out = {};
|
|
148
|
+
for (;;) {
|
|
149
|
+
this.skip();
|
|
150
|
+
if (this.i >= this.t.length) throw new YamlError('a flow map does not close', this.line);
|
|
151
|
+
if (this.t[this.i] === '}') { this.i += 1; return out; }
|
|
152
|
+
let key;
|
|
153
|
+
if (this.t[this.i] === '"' || this.t[this.i] === "'") key = this.quotedString();
|
|
154
|
+
else {
|
|
155
|
+
const start = this.i;
|
|
156
|
+
while (this.i < this.t.length) {
|
|
157
|
+
const c = this.t[this.i];
|
|
158
|
+
if (c === ':' && (this.i + 1 >= this.t.length || /[\s,}\]]/.test(this.t[this.i + 1]))) break;
|
|
159
|
+
if (c === ',' || c === '}') break;
|
|
160
|
+
this.i += 1;
|
|
161
|
+
}
|
|
162
|
+
key = this.t.slice(start, this.i).trim();
|
|
163
|
+
}
|
|
164
|
+
this.skip();
|
|
165
|
+
if (this.t[this.i] !== ':') throw new YamlError('a flow map entry has no colon', this.line);
|
|
166
|
+
this.i += 1;
|
|
167
|
+
this.lines.set(pathKey([...path, key]), this.line);
|
|
168
|
+
this.skip();
|
|
169
|
+
out[key] = (this.t[this.i] === ',' || this.t[this.i] === '}') ? null : this.value([...path, key]);
|
|
170
|
+
this.skip();
|
|
171
|
+
if (this.t[this.i] === ',') this.i += 1;
|
|
172
|
+
else if (this.t[this.i] !== '}') throw new YamlError('a flow map has text where a comma should be', this.line);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Splits "key: rest" at the first colon that ends a key. Returns null when the text is no entry.
|
|
178
|
+
function splitKey(text) {
|
|
179
|
+
let key;
|
|
180
|
+
let end;
|
|
181
|
+
if (text[0] === '"' || text[0] === "'") {
|
|
182
|
+
const q = text[0];
|
|
183
|
+
let i = 1;
|
|
184
|
+
while (i < text.length && text[i] !== q) i += (q === '"' && text[i] === '\\') ? 2 : 1;
|
|
185
|
+
if (i >= text.length) return null;
|
|
186
|
+
key = quoted(text.slice(0, i + 1));
|
|
187
|
+
end = i + 1;
|
|
188
|
+
while (text[end] === ' ') end += 1;
|
|
189
|
+
if (text[end] !== ':') return null;
|
|
190
|
+
} else {
|
|
191
|
+
let i = 0;
|
|
192
|
+
for (; i < text.length; i += 1) {
|
|
193
|
+
if (text[i] === ':' && (i + 1 === text.length || text[i + 1] === ' ')) break;
|
|
194
|
+
}
|
|
195
|
+
if (i >= text.length) return null;
|
|
196
|
+
key = text.slice(0, i).trim();
|
|
197
|
+
end = i;
|
|
198
|
+
if (!key || /^[[{]/.test(key)) return null;
|
|
199
|
+
}
|
|
200
|
+
return { key, rest: text.slice(end + 1).trim() };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export function parseYaml(source) {
|
|
204
|
+
const raw = String(source ?? '').replace(/\r\n?/g, '\n').split('\n');
|
|
205
|
+
const rows = raw.map((text, index) => ({ text, number: index + 1 }));
|
|
206
|
+
const lines = new Map();
|
|
207
|
+
let at = 0;
|
|
208
|
+
|
|
209
|
+
const indentOf = (row) => {
|
|
210
|
+
if (/^\t/.test(row.text)) throw new YamlError('a tab starts a line; YAML indents with spaces', row.number);
|
|
211
|
+
return row.text.length - row.text.trimStart().length;
|
|
212
|
+
};
|
|
213
|
+
const blank = (row) => !row.text.trim() || /^\s*#/.test(row.text);
|
|
214
|
+
const skipBlank = () => { while (at < rows.length && blank(rows[at])) at += 1; };
|
|
215
|
+
const peek = () => { skipBlank(); return at < rows.length ? rows[at] : null; };
|
|
216
|
+
|
|
217
|
+
// Document markers are allowed only before the first content.
|
|
218
|
+
skipBlank();
|
|
219
|
+
while (at < rows.length && /^(---|\.\.\.)\s*$/.test(rows[at].text)) { at += 1; skipBlank(); }
|
|
220
|
+
|
|
221
|
+
function blockScalar(header, parentIndent) {
|
|
222
|
+
const fold = header[0] === '>';
|
|
223
|
+
const parts = [];
|
|
224
|
+
let inner = null;
|
|
225
|
+
while (at < rows.length) {
|
|
226
|
+
const row = rows[at];
|
|
227
|
+
if (row.text.trim() === '') { parts.push(''); at += 1; continue; }
|
|
228
|
+
const indent = indentOf(row);
|
|
229
|
+
if (indent <= parentIndent) break;
|
|
230
|
+
if (inner === null) inner = indent;
|
|
231
|
+
parts.push(row.text.slice(Math.min(inner, indent)));
|
|
232
|
+
at += 1;
|
|
233
|
+
}
|
|
234
|
+
while (parts.length && parts[parts.length - 1] === '') parts.pop();
|
|
235
|
+
const text = fold ? parts.join(' ') : parts.join('\n');
|
|
236
|
+
return header.includes('-') ? text : `${text}\n`;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// The inline text of an entry, joined over lines while a flow value is open.
|
|
240
|
+
function inlineText(first, row) {
|
|
241
|
+
let text = stripComment(first);
|
|
242
|
+
let depth = depthOf(text);
|
|
243
|
+
while (depth > 0) {
|
|
244
|
+
if (at >= rows.length) throw new YamlError('a flow value does not close', row.number);
|
|
245
|
+
const next = rows[at];
|
|
246
|
+
at += 1;
|
|
247
|
+
if (next.text.trim() === '' || /^\s*#/.test(next.text)) continue;
|
|
248
|
+
text += ` ${stripComment(next.text.trim())}`;
|
|
249
|
+
depth = depthOf(text);
|
|
250
|
+
}
|
|
251
|
+
return text;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function valueAfterKey(rest, row, indent, path, allowSameIndentList) {
|
|
255
|
+
if (rest === '' || rest.startsWith('#')) {
|
|
256
|
+
const next = peek();
|
|
257
|
+
if (next && indentOf(next) > indent) return block(indentOf(next), path);
|
|
258
|
+
if (allowSameIndentList && next && indentOf(next) === indent && /^-( |$)/.test(next.text.trimStart())) return block(indent, path);
|
|
259
|
+
return null;
|
|
260
|
+
}
|
|
261
|
+
if (/^[|>][+-]?\s*(#.*)?$/.test(rest)) return blockScalar(rest, indent);
|
|
262
|
+
return inlineValue(inlineText(rest, row), row.number, lines, path);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function block(indent, path) {
|
|
266
|
+
const row = peek();
|
|
267
|
+
if (!row) return null;
|
|
268
|
+
return /^-( |$)/.test(row.text.trimStart()) ? list(indent, path) : map(indent, path);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function map(indent, path) {
|
|
272
|
+
const out = {};
|
|
273
|
+
for (;;) {
|
|
274
|
+
const row = peek();
|
|
275
|
+
if (!row || indentOf(row) !== indent) {
|
|
276
|
+
if (row && indentOf(row) > indent) throw new YamlError('the indentation is deeper than the map above it', row.number);
|
|
277
|
+
return out;
|
|
278
|
+
}
|
|
279
|
+
const body = row.text.trimStart();
|
|
280
|
+
if (/^-( |$)/.test(body)) return out;
|
|
281
|
+
if (/^(---|\.\.\.)\s*$/.test(body)) throw new YamlError('a second document starts here; this reader reads one document per file', row.number);
|
|
282
|
+
const entry = splitKey(body);
|
|
283
|
+
if (!entry) throw new YamlError('a line is not a key and a value', row.number);
|
|
284
|
+
if (Object.hasOwn(out, entry.key)) throw new YamlError(`the key "${entry.key}" is written twice`, row.number);
|
|
285
|
+
at += 1;
|
|
286
|
+
const here = [...path, entry.key];
|
|
287
|
+
lines.set(pathKey(here), row.number);
|
|
288
|
+
out[entry.key] = valueAfterKey(entry.rest, row, indent, here, true);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function list(indent, path) {
|
|
293
|
+
const out = [];
|
|
294
|
+
for (;;) {
|
|
295
|
+
const row = peek();
|
|
296
|
+
if (!row || indentOf(row) !== indent || !/^-( |$)/.test(row.text.trimStart())) return out;
|
|
297
|
+
const rest = row.text.trimStart().slice(1).trim();
|
|
298
|
+
const here = [...path, out.length];
|
|
299
|
+
lines.set(pathKey(here), row.number);
|
|
300
|
+
if (rest === '' || rest.startsWith('#')) {
|
|
301
|
+
at += 1;
|
|
302
|
+
const next = peek();
|
|
303
|
+
out.push(next && indentOf(next) > indent ? block(indentOf(next), here) : null);
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
306
|
+
const entry = (!rest.startsWith('[') && !rest.startsWith('{')) ? splitKey(rest) : null;
|
|
307
|
+
if (entry) {
|
|
308
|
+
// A map that starts on the dash line: read it at the column of its first key.
|
|
309
|
+
const column = indent + (row.text.trimStart().length - rest.length);
|
|
310
|
+
rows[at] = { text: `${' '.repeat(column)}${rest}`, number: row.number };
|
|
311
|
+
out.push(map(column, here));
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
at += 1;
|
|
315
|
+
out.push(inlineValue(inlineText(rest, row), row.number, lines, here));
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const first = peek();
|
|
320
|
+
let value = null;
|
|
321
|
+
if (first) {
|
|
322
|
+
value = block(indentOf(first), []);
|
|
323
|
+
const extra = peek();
|
|
324
|
+
if (extra) throw new YamlError(/^(---|\.\.\.)\s*$/.test(extra.text) ? 'a second document starts here; this reader reads one document per file' : 'the indentation does not match the lines above', extra.number);
|
|
325
|
+
}
|
|
326
|
+
return { value, lineOf: (path) => lines.get(pathKey(path)) ?? null };
|
|
327
|
+
}
|