@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.
- package/.claude-plugin/plugin.json +1 -1
- package/agents/verifier.md +29 -0
- package/door/cli.mjs +27 -2
- package/door/kit-releases.json +4 -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 +3 -0
- 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/work/lib/child-env.mjs +14 -0
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// The http driver. It calls a REST or plain HTTP route with `fetch`.
|
|
2
|
+
//
|
|
3
|
+
// The seven duties of a driver (design section 7.2):
|
|
4
|
+
// 1. It refuses an address that guards.hosts does not allow (`blocked`).
|
|
5
|
+
// 2. It sends a credential only to the origin it belongs to.
|
|
6
|
+
// 3. It records each call and each answer.
|
|
7
|
+
// 4. It removes run secrets and named secrets from what it records, by value.
|
|
8
|
+
// 5. It returns the raw answer: { status, body, isError, headers, raw }.
|
|
9
|
+
// 6. It signs in as a persona, never as the person who runs the test.
|
|
10
|
+
// 7. It cleans up what it opened. This driver opens nothing.
|
|
11
|
+
import { Unavailable } from '../errors.mjs';
|
|
12
|
+
import { requireAllowed, sameOrigin } from '../guards.mjs';
|
|
13
|
+
import { clip } from '../evidence.mjs';
|
|
14
|
+
import { LIBRARY_HEADER } from '../stand-in.mjs';
|
|
15
|
+
|
|
16
|
+
export function createHttpDriver({ way, base, hosts, record: rawRecord, redactor, timeoutMs = 30000 }) {
|
|
17
|
+
const record = rawRecord && ((call) => rawRecord(redactor ? redactor.deep(call) : call));
|
|
18
|
+
if (!base) throw new Error(`the way in "${way}" has no base address`);
|
|
19
|
+
const origin = new URL(base).origin;
|
|
20
|
+
requireAllowed(base, hosts, `the way in "${way}"`);
|
|
21
|
+
|
|
22
|
+
async function call(request = {}, { credential = null, headers: personaHeaders = {}, persona = null } = {}) {
|
|
23
|
+
const method = (request.method ?? 'GET').toUpperCase();
|
|
24
|
+
let target;
|
|
25
|
+
if (request.url) target = new URL(request.url);
|
|
26
|
+
else {
|
|
27
|
+
const path = String(request.path ?? '');
|
|
28
|
+
target = new URL(`${base.replace(/\/+$/, '')}${path.startsWith('/') || path === '' ? '' : '/'}${path}`);
|
|
29
|
+
}
|
|
30
|
+
for (const [k, v] of Object.entries(request.query ?? {})) target.searchParams.set(k, String(v));
|
|
31
|
+
requireAllowed(target.href, hosts, `${method} ${target.pathname}`);
|
|
32
|
+
const own = sameOrigin(target.href, origin);
|
|
33
|
+
// A call of the kit carries this header, so a stand-in does not count it as a call of the product.
|
|
34
|
+
const headers = { ...(request.headers ?? {}), [LIBRARY_HEADER]: '1' };
|
|
35
|
+
const sent = [];
|
|
36
|
+
// A credential goes only to the origin of this way in.
|
|
37
|
+
if (own) {
|
|
38
|
+
for (const [k, v] of Object.entries(personaHeaders)) { headers[k] = v; sent.push(k); }
|
|
39
|
+
if (credential) { headers['authorization'] = `Bearer ${credential}`; sent.push('authorization'); }
|
|
40
|
+
}
|
|
41
|
+
const init = { method, headers, redirect: 'manual', signal: AbortSignal.timeout(timeoutMs) };
|
|
42
|
+
if (request.body !== undefined) {
|
|
43
|
+
if (request.raw) init.body = request.body;
|
|
44
|
+
else { init.body = JSON.stringify(request.body); headers['content-type'] ??= 'application/json'; }
|
|
45
|
+
}
|
|
46
|
+
let response;
|
|
47
|
+
let text;
|
|
48
|
+
try {
|
|
49
|
+
response = await fetch(target, init);
|
|
50
|
+
text = await response.text();
|
|
51
|
+
} catch (error) {
|
|
52
|
+
throw new Unavailable(`${method} ${target.pathname}: ${error.cause?.code ?? error.name}`);
|
|
53
|
+
}
|
|
54
|
+
let body = text;
|
|
55
|
+
if (/json/.test(response.headers.get('content-type') ?? '') && text) {
|
|
56
|
+
try { body = JSON.parse(text); } catch { /* keep the text */ }
|
|
57
|
+
}
|
|
58
|
+
const answer = { status: response.status, body, isError: response.status >= 400, headers: Object.fromEntries(response.headers), raw: text };
|
|
59
|
+
record?.({
|
|
60
|
+
way, persona,
|
|
61
|
+
request: { method, url: target.href, credential_headers: sent, body: request.body === undefined ? undefined : clip(request.body, redactor) },
|
|
62
|
+
response: { status: answer.status, body: clip(body, redactor) },
|
|
63
|
+
});
|
|
64
|
+
return answer;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return { name: 'http', way, base, call, async close() {} };
|
|
68
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// Builds the driver for a way in from its entry in tests.yaml. A driver that
|
|
2
|
+
// the kit does not ship (a browser, a command) comes from the adapter:
|
|
3
|
+
// adapter.drivers = { browser: async (ctx) => driver }
|
|
4
|
+
// A driver has the shape { name, way, call(request, opts), close() }.
|
|
5
|
+
import { isAbsolute, resolve } from 'node:path';
|
|
6
|
+
import { Blocked } from '../errors.mjs';
|
|
7
|
+
import { requireAddressesAllowed, substitute } from '../guards.mjs';
|
|
8
|
+
import { createHttpDriver } from './http.mjs';
|
|
9
|
+
import { createMcpStdioDriver } from './mcp-stdio.mjs';
|
|
10
|
+
import { createFunctionDriver } from './function.mjs';
|
|
11
|
+
|
|
12
|
+
export { createHttpDriver, createMcpStdioDriver, createFunctionDriver };
|
|
13
|
+
|
|
14
|
+
// ctx: { way, spec, env, root, hosts, redactor, record, adapter, runId }
|
|
15
|
+
// In a fault run, ATLAS_PRODUCT_ROOT names a throwaway copy. A process that a
|
|
16
|
+
// way in starts (an MCP server) and the module of the function driver load from
|
|
17
|
+
// that copy, so a planted fault is what runs. tests.yaml and the kit stay in the
|
|
18
|
+
// real worktree. A `cwd` in the way in is a folder under that root.
|
|
19
|
+
export function productRootOf(root) {
|
|
20
|
+
return process.env.ATLAS_PRODUCT_ROOT ? resolve(process.env.ATLAS_PRODUCT_ROOT) : root;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Moves a path in a command from the worktree to the product root. The text
|
|
24
|
+
// is changed only where it names the worktree root as a whole path part.
|
|
25
|
+
export function commandFor(command, root, productRoot) {
|
|
26
|
+
if (!command || productRoot === root) return command;
|
|
27
|
+
return command.split(root).join(productRoot);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function makeDriver(ctx) {
|
|
31
|
+
const { way, spec, env, root, hosts, redactor, record, adapter = {}, runId } = ctx;
|
|
32
|
+
const productRoot = productRootOf(root);
|
|
33
|
+
const own = adapter.drivers?.[way] ?? adapter.drivers?.[spec.driver];
|
|
34
|
+
if (own) return own(ctx);
|
|
35
|
+
const template = { id: runId, varsFile: env.varsFile, url: env.url, vars: (n) => env.vars[n], standIn: (n) => env.standIns[n]?.url };
|
|
36
|
+
const fill = (text) => (text === undefined ? text : substitute(text, template));
|
|
37
|
+
// Every address in the env of a way in must be on the list, of any scheme and whatever its name.
|
|
38
|
+
if (spec.env && typeof spec.env === 'object') {
|
|
39
|
+
requireAddressesAllowed(Object.fromEntries(Object.entries(spec.env).map(([k, v]) => [k, fill(String(v))])), hosts, `the env of the way in "${way}"`);
|
|
40
|
+
}
|
|
41
|
+
switch (spec.driver) {
|
|
42
|
+
case 'http':
|
|
43
|
+
return createHttpDriver({ way, base: fill(spec.base), hosts, record, redactor });
|
|
44
|
+
case 'mcp-stdio':
|
|
45
|
+
return createMcpStdioDriver({
|
|
46
|
+
way, command: commandFor(fill(spec.command), root, productRoot), cwd: spec.cwd ? (isAbsolute(spec.cwd) ? spec.cwd : resolve(productRoot, spec.cwd)) : productRoot, hosts, redactor, record,
|
|
47
|
+
env: Object.fromEntries(Object.entries(spec.env ?? {}).map(([k, v]) => [k, fill(String(v))])),
|
|
48
|
+
});
|
|
49
|
+
case 'function': {
|
|
50
|
+
let target = adapter.functions?.[way] ?? adapter.functions?.function ?? adapter.functions;
|
|
51
|
+
// No module from the adapter: the `module` of the way in is a path under the
|
|
52
|
+
// product root. The adapter loads it (adapter.loadModule(file)), because the
|
|
53
|
+
// kit loads no module by a name it builds at run time.
|
|
54
|
+
if (!target && spec.module) {
|
|
55
|
+
const file = resolve(productRoot, fill(String(spec.module)));
|
|
56
|
+
if (typeof adapter.loadModule !== 'function') throw new Blocked(`the way in "${way}" names the module ${spec.module}; the adapter gives no loadModule(file) to load it from ${productRoot}`);
|
|
57
|
+
try { target = await adapter.loadModule(file, { way, root, productRoot }); } catch (error) { throw new Blocked(`the module of "${way}" could not be loaded from ${file}: ${error.message}`); }
|
|
58
|
+
}
|
|
59
|
+
if (!target) throw new Blocked(`the way in "${way}" uses the function driver and the adapter gives no module: set adapter.functions`);
|
|
60
|
+
return createFunctionDriver({ way, target, record, redactor });
|
|
61
|
+
}
|
|
62
|
+
default:
|
|
63
|
+
throw new Blocked(`the way in "${way}" uses the driver "${spec.driver}", which the kit does not ship; add it to adapter.drivers`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
// The mcp-stdio driver: a small JSON-RPC 2.0 client for an MCP server that
|
|
2
|
+
// speaks over standard input and output, one JSON message on each line (the
|
|
3
|
+
// MCP stdio transport). Atlas has no dependency, so it does not use the
|
|
4
|
+
// official client library. It does what the tests need: initialize, the
|
|
5
|
+
// initialized notification, tools/list and tools/call.
|
|
6
|
+
//
|
|
7
|
+
// The seven duties of a driver (design section 7.2):
|
|
8
|
+
// 1. Before it starts the server, it checks every address in the env that it
|
|
9
|
+
// gives the server against guards.hosts. A server that gets no address is
|
|
10
|
+
// refused, because it could fall back to a real one. The result is `blocked`.
|
|
11
|
+
// 2. A credential goes only into the env of the server it belongs to. Each
|
|
12
|
+
// distinct env starts its own server process.
|
|
13
|
+
// 3. It records each tool call and each answer.
|
|
14
|
+
// 4. Recorded text has run secrets and named secrets removed, by value.
|
|
15
|
+
// 5. It returns the raw answer: { status, body, isError, raw }.
|
|
16
|
+
// `body` is the result of tools/call, or the JSON-RPC error. `raw` is the whole message.
|
|
17
|
+
// Reading the product's error form is the work of the actions.
|
|
18
|
+
// 6. It signs in as a persona: the env of the spec may hold {persona.credential}.
|
|
19
|
+
// 7. It stops each server it started, on close and when the process ends.
|
|
20
|
+
import { Blocked, Unavailable } from '../errors.mjs';
|
|
21
|
+
import { childEnv, requireLocalAddresses } from '../guards.mjs';
|
|
22
|
+
import { spawnManaged } from '../procs.mjs';
|
|
23
|
+
import { clip } from '../evidence.mjs';
|
|
24
|
+
|
|
25
|
+
const PROTOCOL = '2025-06-18';
|
|
26
|
+
// The most that one line of the server may hold. A longer line is refused.
|
|
27
|
+
export const MAX_LINE_BYTES = 4 * 1024 * 1024;
|
|
28
|
+
|
|
29
|
+
class Session {
|
|
30
|
+
constructor({ proc, timeoutMs }) {
|
|
31
|
+
this.proc = proc;
|
|
32
|
+
this.timeoutMs = timeoutMs;
|
|
33
|
+
this.nextId = 1;
|
|
34
|
+
this.pending = new Map();
|
|
35
|
+
this.buffer = '';
|
|
36
|
+
this.closed = false;
|
|
37
|
+
proc.child.stdout.on('data', (chunk) => this.onData(chunk.toString('utf8')));
|
|
38
|
+
proc.child.stdin.on('error', () => { /* the server stopped; the exit handler reports it */ });
|
|
39
|
+
proc.exited.then(({ code, signal }) => {
|
|
40
|
+
this.closed = true;
|
|
41
|
+
for (const { reject } of this.pending.values()) reject(new Unavailable(`the MCP server stopped (${code ?? signal}). Last output: ${proc.tail().slice(-400)}`));
|
|
42
|
+
this.pending.clear();
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
overflow() {
|
|
47
|
+
this.buffer = '';
|
|
48
|
+
this.closed = true;
|
|
49
|
+
const why = new Unavailable(`the MCP server sent a line over ${MAX_LINE_BYTES / (1024 * 1024)} MB; the line was refused and the server was stopped`);
|
|
50
|
+
for (const { reject, timer } of this.pending.values()) { clearTimeout(timer); reject(why); }
|
|
51
|
+
this.pending.clear();
|
|
52
|
+
this.proc.stop();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
onData(text) {
|
|
56
|
+
if (this.closed) return;
|
|
57
|
+
this.buffer += text;
|
|
58
|
+
let at;
|
|
59
|
+
while ((at = this.buffer.indexOf('\n')) >= 0) {
|
|
60
|
+
if (at > MAX_LINE_BYTES) return this.overflow();
|
|
61
|
+
const line = this.buffer.slice(0, at).trim();
|
|
62
|
+
this.buffer = this.buffer.slice(at + 1);
|
|
63
|
+
if (!line) continue;
|
|
64
|
+
let message;
|
|
65
|
+
try { message = JSON.parse(line); } catch { continue; }
|
|
66
|
+
this.onMessage(message);
|
|
67
|
+
}
|
|
68
|
+
if (this.buffer.length > MAX_LINE_BYTES) this.overflow();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
onMessage(message) {
|
|
72
|
+
if (message.id !== undefined && (message.result !== undefined || message.error !== undefined)) {
|
|
73
|
+
const waiting = this.pending.get(message.id);
|
|
74
|
+
if (waiting) { this.pending.delete(message.id); clearTimeout(waiting.timer); waiting.resolve(message); }
|
|
75
|
+
} else if (message.id !== undefined && message.method) {
|
|
76
|
+
// The client offers nothing to a server that asks.
|
|
77
|
+
this.send({ jsonrpc: '2.0', id: message.id, error: { code: -32601, message: 'method not found' } });
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
send(message) {
|
|
82
|
+
if (this.closed) throw new Unavailable('the MCP server is not running');
|
|
83
|
+
this.proc.child.stdin.write(`${JSON.stringify(message)}\n`);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
request(method, params) {
|
|
87
|
+
const id = this.nextId++;
|
|
88
|
+
return new Promise((resolve, reject) => {
|
|
89
|
+
const timer = setTimeout(() => {
|
|
90
|
+
this.pending.delete(id);
|
|
91
|
+
reject(new Unavailable(`the MCP server did not answer ${method} in ${this.timeoutMs} ms`));
|
|
92
|
+
}, this.timeoutMs);
|
|
93
|
+
this.pending.set(id, { resolve, reject, timer });
|
|
94
|
+
try { this.send({ jsonrpc: '2.0', id, method, params }); } catch (error) { clearTimeout(timer); this.pending.delete(id); reject(error); }
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
notify(method, params) { this.send({ jsonrpc: '2.0', method, params }); }
|
|
99
|
+
|
|
100
|
+
async open() {
|
|
101
|
+
// A server that stops or stays mute before it answers initialize never started: `blocked`.
|
|
102
|
+
let init;
|
|
103
|
+
try { init = await this.request('initialize', { protocolVersion: PROTOCOL, capabilities: {}, clientInfo: { name: 'atlas-tests', version: '1' } }); } catch (error) {
|
|
104
|
+
if (error instanceof Unavailable) throw new Blocked(`the MCP server did not start: ${error.message}`);
|
|
105
|
+
throw error;
|
|
106
|
+
}
|
|
107
|
+
if (init.error) throw new Blocked(`the MCP server refused initialize: ${init.error.message}`);
|
|
108
|
+
this.notify('notifications/initialized');
|
|
109
|
+
return init.result;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async close() {
|
|
113
|
+
this.closed = true;
|
|
114
|
+
try { this.proc.child.stdin.end(); } catch { /* it is closed */ }
|
|
115
|
+
await this.proc.stop();
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function createMcpStdioDriver({ way, command, env: specEnv = {}, cwd, hosts, redactor, record: rawRecord, timeoutMs = 30000 }) {
|
|
120
|
+
const record = rawRecord && ((call) => rawRecord(redactor ? redactor.deep(call) : call));
|
|
121
|
+
if (!command) throw new Error(`the way in "${way}" has no command`);
|
|
122
|
+
const sessions = new Map();
|
|
123
|
+
|
|
124
|
+
const fill = (credential) => Object.fromEntries(Object.entries(specEnv).map(([k, v]) => [k, String(v).replaceAll('{persona.credential}', credential ?? '')]));
|
|
125
|
+
|
|
126
|
+
async function sessionFor(credential) {
|
|
127
|
+
const given = fill(credential);
|
|
128
|
+
const key = JSON.stringify(given);
|
|
129
|
+
if (sessions.has(key)) return sessions.get(key);
|
|
130
|
+
// Duty 1: check the address before the server starts.
|
|
131
|
+
// The check runs on the whole env the server gets, not only on the part the way in sets.
|
|
132
|
+
const full = childEnv(process.env, given);
|
|
133
|
+
requireLocalAddresses(given, hosts, `the MCP server of "${way}"`, full);
|
|
134
|
+
const proc = spawnManaged({ command, cwd, redactor, stdin: true, env: full });
|
|
135
|
+
const session = new Session({ proc, timeoutMs });
|
|
136
|
+
sessions.set(key, session);
|
|
137
|
+
try { await session.open(); } catch (error) { sessions.delete(key); await session.close(); throw error; }
|
|
138
|
+
return session;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// request: { tool, arguments } for tools/call, or { method, params } for any other call.
|
|
142
|
+
async function call(request = {}, { credential = null, persona = null } = {}) {
|
|
143
|
+
const session = await sessionFor(credential);
|
|
144
|
+
const method = request.tool ? 'tools/call' : request.method;
|
|
145
|
+
const params = request.tool ? { name: request.tool, arguments: request.arguments ?? {} } : request.params;
|
|
146
|
+
if (!method) throw new Error('an MCP request names a tool, or a method');
|
|
147
|
+
const message = await session.request(method, params);
|
|
148
|
+
const failed = message.error !== undefined;
|
|
149
|
+
const body = failed ? message.error : message.result;
|
|
150
|
+
const answer = {
|
|
151
|
+
status: failed ? message.error.code : (body?.isError ? 'tool-error' : 'ok'),
|
|
152
|
+
body, isError: failed || body?.isError === true, raw: message,
|
|
153
|
+
};
|
|
154
|
+
record?.({
|
|
155
|
+
way, persona,
|
|
156
|
+
request: { method, params: clip(params, redactor) },
|
|
157
|
+
response: { status: answer.status, body: clip(body, redactor) },
|
|
158
|
+
});
|
|
159
|
+
return answer;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function listTools(opts) {
|
|
163
|
+
const answer = await call({ method: 'tools/list', params: {} }, opts);
|
|
164
|
+
return answer.body?.tools ?? [];
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function close() {
|
|
168
|
+
const all = [...sessions.values()];
|
|
169
|
+
sessions.clear();
|
|
170
|
+
await Promise.all(all.map((s) => s.close()));
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return { name: 'mcp-stdio', way, call, listTools, close };
|
|
174
|
+
}
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
// The run environment: start the stand-ins, then the product; make the run
|
|
2
|
+
// secrets and the vars file new for each run; wait until the product is
|
|
3
|
+
// ready; check that the product called each stand-in; stop everything.
|
|
4
|
+
//
|
|
5
|
+
// const env = await startEnvironment({ root, tests, name: 'local', runId, redactor, adapter });
|
|
6
|
+
// env.url, env.vars, env.standIns.engram.url
|
|
7
|
+
// await env.stop();
|
|
8
|
+
//
|
|
9
|
+
// Every child process starts from an allow-list environment (guards.mjs,
|
|
10
|
+
// childEnv), and each resolved var is set as a real env value on the product.
|
|
11
|
+
// The vars file lives in a new temporary folder and is removed on stop.
|
|
12
|
+
import { mkdtempSync, writeFileSync, rmSync, readFileSync, existsSync } from 'node:fs';
|
|
13
|
+
import { createServer } from 'node:net';
|
|
14
|
+
import { tmpdir } from 'node:os';
|
|
15
|
+
import { dirname, join, parse } from 'node:path';
|
|
16
|
+
import { setTimeout as sleep } from 'node:timers/promises';
|
|
17
|
+
import { spawnManaged } from './procs.mjs';
|
|
18
|
+
import { Blocked } from './errors.mjs';
|
|
19
|
+
import { resolveEnvironments } from './tests-yaml.mjs';
|
|
20
|
+
import { childEnv, hostsOf, newSecret, requireAddressesAllowed, requireAllowed, substitute } from './guards.mjs';
|
|
21
|
+
import { CALLS_PATH, countCalls, LIBRARY_HEADER } from './stand-in.mjs';
|
|
22
|
+
import { parseLimit } from './wait.mjs';
|
|
23
|
+
|
|
24
|
+
const LIBRARY_HEADERS = { [LIBRARY_HEADER]: '1' };
|
|
25
|
+
|
|
26
|
+
function freePort() {
|
|
27
|
+
return new Promise((resolve, reject) => {
|
|
28
|
+
const server = createServer();
|
|
29
|
+
server.once('error', reject);
|
|
30
|
+
server.listen(0, '127.0.0.1', () => { const { port } = server.address(); server.close(() => resolve(port)); });
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// ---------------------------------------------------------------- ready
|
|
35
|
+
|
|
36
|
+
async function pollReady(spec, ctx, { label, process: proc, hosts, redactor, standInOrigins = [] }) {
|
|
37
|
+
const url = substitute(spec.get ?? '', ctx);
|
|
38
|
+
if (!url) throw new Blocked(`${label}: "ready" has no "get" address`);
|
|
39
|
+
const parsed = requireAllowed(url, hosts, `${label} ready check`);
|
|
40
|
+
// A ready address must not point at a stand-in: a poll there could stand in
|
|
41
|
+
// for the first call of the product.
|
|
42
|
+
if (standInOrigins.includes(parsed.origin)) throw new Blocked(`${label}: the "ready" address ${parsed.origin} is a stand-in; the ready check must ask the product itself`);
|
|
43
|
+
const want = spec.status ?? 200;
|
|
44
|
+
const limit = parseLimit(spec.limit ?? '30s');
|
|
45
|
+
const deadline = Date.now() + limit;
|
|
46
|
+
let last = 'no answer';
|
|
47
|
+
for (;;) {
|
|
48
|
+
if (proc && (proc.child.exitCode !== null || proc.child.signalCode !== null)) {
|
|
49
|
+
throw new Blocked(`${label} stopped before it was ready (${proc.child.exitCode ?? proc.child.signalCode}). Last output: ${proc.tail().slice(-600)}`);
|
|
50
|
+
}
|
|
51
|
+
try {
|
|
52
|
+
const res = await fetch(url, { redirect: 'manual', headers: LIBRARY_HEADERS, signal: AbortSignal.timeout(2000) });
|
|
53
|
+
await res.arrayBuffer();
|
|
54
|
+
if (res.status === want) return;
|
|
55
|
+
last = `status ${res.status}`;
|
|
56
|
+
} catch (error) {
|
|
57
|
+
last = error.cause?.code ?? error.name;
|
|
58
|
+
}
|
|
59
|
+
if (Date.now() >= deadline) {
|
|
60
|
+
throw new Blocked(`${label} was not ready after ${spec.limit ?? '30s'} (${last}). Last output: ${redactor?.text(proc?.tail() ?? '').slice(-600)}`);
|
|
61
|
+
}
|
|
62
|
+
await sleep(150);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ---------------------------------------------------------------- secrets
|
|
67
|
+
|
|
68
|
+
// KEY=value lines of a dotenv file. No expansion.
|
|
69
|
+
export function parseDotenv(text) {
|
|
70
|
+
const out = {};
|
|
71
|
+
for (const line of String(text).split('\n')) {
|
|
72
|
+
const m = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/.exec(line);
|
|
73
|
+
if (!m || line.trim().startsWith('#')) continue;
|
|
74
|
+
let value = m[2];
|
|
75
|
+
if (/^(["']).*\1$/.test(value)) value = value.slice(1, -1);
|
|
76
|
+
out[m[1]] = value;
|
|
77
|
+
}
|
|
78
|
+
return out;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function findDotenv(from) {
|
|
82
|
+
let dir = from;
|
|
83
|
+
for (;;) {
|
|
84
|
+
const file = join(dir, '.env.test');
|
|
85
|
+
if (existsSync(file)) return file;
|
|
86
|
+
if (existsSync(join(dir, '.git')) || dir === parse(dir).root) return null;
|
|
87
|
+
dir = dirname(dir);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// The value of each provided secret that the scenario names. On the Mac it is
|
|
92
|
+
// in .env.test at the repo root. In CI it is a CI secret of the same name.
|
|
93
|
+
// With no value, the result is `blocked` and names the key.
|
|
94
|
+
export function provideSecrets(names, { root, tests, env = process.env }) {
|
|
95
|
+
const out = {};
|
|
96
|
+
if (!names.length) return out;
|
|
97
|
+
const file = findDotenv(root);
|
|
98
|
+
const fromFile = file ? parseDotenv(readFileSync(file, 'utf8')) : {};
|
|
99
|
+
for (const name of names) {
|
|
100
|
+
if (!Object.hasOwn(tests.secrets ?? {}, name)) continue;
|
|
101
|
+
const value = fromFile[name] ?? env[name];
|
|
102
|
+
if (!value) throw new Blocked(`the scenario names the key ${name} and no value exists: add it to .env.test at the repo root, or set the CI secret of the same name`);
|
|
103
|
+
out[name] = value;
|
|
104
|
+
}
|
|
105
|
+
return out;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const dotenvLine = (name, value) => `${name}=${/^[A-Za-z0-9_./:@%+=,-]*$/.test(value) ? value : JSON.stringify(value)}\n`;
|
|
109
|
+
|
|
110
|
+
// ---------------------------------------------------------------- start
|
|
111
|
+
|
|
112
|
+
export async function startEnvironment({ root, tests, name, runId, redactor, adapter = {}, provided = {} }) {
|
|
113
|
+
const environments = resolveEnvironments(tests.environments ?? {});
|
|
114
|
+
const spec = environments[name];
|
|
115
|
+
if (!spec) throw new Blocked(`tests.yaml has no environment "${name}"`);
|
|
116
|
+
if (spec.allowed === false) throw new Blocked(`the environment "${name}" is not allowed: "allowed" is false until a test environment and a scoped key exist`);
|
|
117
|
+
// A deployed environment is closed until the owner opens it with `allowed: true`.
|
|
118
|
+
if (name === 'deployed' && spec.allowed !== true) throw new Blocked(`the environment "deployed" is closed: it has no "allowed: true". Only the owner opens it, with a test environment and a scoped key`);
|
|
119
|
+
const hosts = hostsOf(tests);
|
|
120
|
+
const workDir = mkdtempSync(join(tmpdir(), 'atlas-run-'));
|
|
121
|
+
const varsFile = join(workDir, 'vars.env');
|
|
122
|
+
const managed = [];
|
|
123
|
+
const stoppers = [];
|
|
124
|
+
const standIns = {};
|
|
125
|
+
const resolvedVars = {};
|
|
126
|
+
const secretsMade = [];
|
|
127
|
+
for (const value of Object.values(provided)) redactor.add(value);
|
|
128
|
+
|
|
129
|
+
const stop = async () => {
|
|
130
|
+
for (const fn of stoppers.reverse()) { try { await fn(); } catch { /* the stop goes on */ } }
|
|
131
|
+
for (const proc of managed.reverse()) await proc.stop(proc.stopCommand);
|
|
132
|
+
rmSync(workDir, { recursive: true, force: true });
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
try {
|
|
136
|
+
// 1. The stand-ins.
|
|
137
|
+
for (const [standInName, def] of Object.entries(tests['stand-ins'] ?? {})) {
|
|
138
|
+
const label = `stand-in "${standInName}"`;
|
|
139
|
+
if (adapter.standIns?.[standInName]) {
|
|
140
|
+
const made = await adapter.standIns[standInName]({ name: standInName, runId, hosts });
|
|
141
|
+
if (made.stop) stoppers.push(made.stop);
|
|
142
|
+
requireAllowed(made.url, hosts, label);
|
|
143
|
+
standIns[standInName] = { url: made.url, calls: made.calls ?? (async () => countCalls(await (await fetch(`${made.url}${CALLS_PATH}`, { headers: LIBRARY_HEADERS, signal: AbortSignal.timeout(3000) })).json())) };
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
if (!def?.start) throw new Blocked(`${label} has no "start" command and the adapter does not make it`);
|
|
147
|
+
const port = await freePort();
|
|
148
|
+
const url = `http://127.0.0.1:${port}`;
|
|
149
|
+
const ctx = { id: runId, port, url };
|
|
150
|
+
const proc = spawnManaged({
|
|
151
|
+
command: substitute(def.start, ctx), cwd: root, redactor,
|
|
152
|
+
env: childEnv(process.env, { PORT: port, ATLAS_STAND_IN_PORT: port, ATLAS_RUN_ID: runId }),
|
|
153
|
+
});
|
|
154
|
+
managed.push(proc);
|
|
155
|
+
await pollReady(def.ready ?? { get: `${url}${CALLS_PATH}`, status: 200, limit: '30s' }, ctx, { label, process: proc, hosts, redactor, standInOrigins: Object.values(standIns).map((one) => new URL(one.url).origin) });
|
|
156
|
+
standIns[standInName] = {
|
|
157
|
+
url,
|
|
158
|
+
calls: async () => countCalls(await (await fetch(`${url}${CALLS_PATH}`, { headers: LIBRARY_HEADERS, signal: AbortSignal.timeout(3000) })).json()),
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// 2. The vars: run secrets are new for each placeholder; references resolve in order.
|
|
163
|
+
const raw = spec.vars ?? {};
|
|
164
|
+
// The product gets a free port too: `{port}` in start, ready, url and vars.
|
|
165
|
+
const productPort = await freePort();
|
|
166
|
+
const ctx = {
|
|
167
|
+
id: runId, varsFile, port: productPort, url: spec.url ? substitute(spec.url, { id: runId, port: productPort }) : undefined,
|
|
168
|
+
secret: () => { const s = newSecret(); secretsMade.push(s); redactor.add(s); return s; },
|
|
169
|
+
standIn: (n) => standIns[n]?.url,
|
|
170
|
+
vars: (n) => resolveVar(n, []),
|
|
171
|
+
};
|
|
172
|
+
function resolveVar(varName, stack) {
|
|
173
|
+
if (Object.hasOwn(resolvedVars, varName)) return resolvedVars[varName];
|
|
174
|
+
if (!Object.hasOwn(raw, varName)) throw new Blocked(`a placeholder names the variable ${varName}, which "vars" does not set`);
|
|
175
|
+
if (stack.includes(varName)) throw new Blocked(`the variables ${[...stack, varName].join(' -> ')} refer to each other in a circle`);
|
|
176
|
+
const value = substitute(String(raw[varName]), { ...ctx, vars: (n) => resolveVar(n, [...stack, varName]) });
|
|
177
|
+
resolvedVars[varName] = value;
|
|
178
|
+
return value;
|
|
179
|
+
}
|
|
180
|
+
for (const varName of Object.keys(raw)) resolveVar(varName, []);
|
|
181
|
+
for (const [varName, value] of Object.entries(resolvedVars)) {
|
|
182
|
+
if (/(TOKEN|SECRET|KEY|PASSWORD|CREDENTIAL)/i.test(varName)) redactor.add(value);
|
|
183
|
+
}
|
|
184
|
+
// Every value that is an address, of any scheme or as host:port, must be on
|
|
185
|
+
// the list, whatever the name of its variable.
|
|
186
|
+
requireAddressesAllowed(resolvedVars, hosts, 'the vars of the environment');
|
|
187
|
+
writeFileSync(varsFile, Object.entries(resolvedVars).map(([n, v]) => dotenvLine(n, v)).join(''), { mode: 0o600 });
|
|
188
|
+
|
|
189
|
+
// 3. The product. In a fault run, ATLAS_PRODUCT_ROOT names the folder of a
|
|
190
|
+
// throwaway copy: the product starts there, while tests.yaml, the support
|
|
191
|
+
// code and the stand-ins stay in the real worktree.
|
|
192
|
+
const productRoot = process.env.ATLAS_PRODUCT_ROOT || root;
|
|
193
|
+
const given = { ...provided, ATLAS_RUN_ID: runId };
|
|
194
|
+
let product = null;
|
|
195
|
+
if (spec.start) {
|
|
196
|
+
// An allow-list environment. Each resolved var is a real value that
|
|
197
|
+
// replaces any parent value of the same name; the vars file stays too.
|
|
198
|
+
product = spawnManaged({
|
|
199
|
+
command: substitute(spec.start, ctx), cwd: productRoot, redactor,
|
|
200
|
+
env: childEnv(process.env, { ...resolvedVars, ...given }),
|
|
201
|
+
});
|
|
202
|
+
product.stopCommand = spec.stop;
|
|
203
|
+
managed.push(product);
|
|
204
|
+
}
|
|
205
|
+
if (spec.ready) await pollReady(spec.ready, ctx, { label: `the product (${name})`, process: product, hosts, redactor, standInOrigins: Object.values(standIns).map((one) => new URL(one.url).origin) });
|
|
206
|
+
if (spec.url) requireAllowed(substitute(spec.url, ctx), hosts, 'the environment url');
|
|
207
|
+
|
|
208
|
+
const env = {
|
|
209
|
+
name, url: spec.url ? substitute(spec.url, ctx) : null, port: productPort, vars: { ...resolvedVars }, varsFile, hosts, standIns, root, productRoot,
|
|
210
|
+
provided: Object.keys(provided), output: () => product?.tail() ?? '', stop,
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
// 4. The first-call rule: the product must have called each stand-in.
|
|
214
|
+
await adapter.warmUp?.(env);
|
|
215
|
+
for (const [standInName, one] of Object.entries(standIns)) {
|
|
216
|
+
let count = 0;
|
|
217
|
+
try { count = await one.calls(); } catch (error) {
|
|
218
|
+
throw new Blocked(`stand-in "${standInName}" does not answer ${CALLS_PATH}: ${error.cause?.code ?? error.message}`);
|
|
219
|
+
}
|
|
220
|
+
if (count < 1) throw new Blocked(`stand-in "${standInName}" saw no first call from the product before step 1: the product may talk to something else`);
|
|
221
|
+
}
|
|
222
|
+
return env;
|
|
223
|
+
} catch (error) {
|
|
224
|
+
await stop();
|
|
225
|
+
throw error;
|
|
226
|
+
}
|
|
227
|
+
}
|
package/tests/errors.mjs
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// The kinds of "could not run" and the other stops. Each one has one result.
|
|
2
|
+
|
|
3
|
+
// A setup failed, a guard refused, or a precondition does not hold. The
|
|
4
|
+
// result is `blocked`, never `fail`.
|
|
5
|
+
export class Blocked extends Error {
|
|
6
|
+
constructor(reason) { super(reason); this.name = 'Blocked'; }
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
// A service that the test needs does not answer. The result is `blocked`.
|
|
10
|
+
export class Unavailable extends Error {
|
|
11
|
+
constructor(reason) { super(reason); this.name = 'Unavailable'; }
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// A wait met its fail state. This is a product result, so it is a `fail`.
|
|
15
|
+
export class WaitFailed extends Error {
|
|
16
|
+
constructor(reason, last) { super(reason); this.name = 'WaitFailed'; this.last = last; }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// A wait ran out of time. This is a product result, so it is a `fail`.
|
|
20
|
+
export class WaitTimeout extends Error {
|
|
21
|
+
constructor(reason, last) { super(reason); this.name = 'WaitTimeout'; this.last = last; }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Thrown by a failed gate. It stops the body of the test.
|
|
25
|
+
export class GateFailed extends Error {
|
|
26
|
+
constructor(reason) { super(reason); this.name = 'GateFailed'; }
|
|
27
|
+
}
|