unitbob 0.2.8 → 0.3.0
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/README.md +19 -10
- package/dist/cli.js +84 -16
- package/dist/config.js +41 -3
- package/dist/files/behavioral.js +9 -1
- package/dist/files/mapBuild.js +2 -1
- package/dist/files/suiteBuild.js +37 -2
- package/dist/link.js +30 -17
- package/dist/links.js +24 -0
- package/dist/proc.js +15 -1
- package/dist/runner/bdd.js +21 -14
- package/dist/runner/bootcheck.js +389 -0
- package/dist/runner/manifest.js +87 -0
- package/dist/runner/precheck.js +31 -3
- package/dist/runner/provision.js +6 -1
- package/dist/runner/rspec.js +1 -10
- package/dist/surfaces/routeInventory.js +448 -0
- package/dist/verbs/extractSurfaces.js +17 -0
- package/dist/verbs/mapPrepare.js +15 -1
- package/dist/verbs/putMapBuild.js +69 -2
- package/dist/verbs/putSuiteBuild.js +98 -30
- package/dist/verbs/run.js +4 -1
- package/dist/verbs/show.js +2 -1
- package/dist/verbs/suitePrepare.js +191 -19
- package/dist/verbs/validateBuild.js +240 -0
- package/dist/wire.js +25 -3
- package/package.json +1 -1
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import { readHostSuiteOutputsPerBranch, readSuiteBuildRequest, } from "../files/suiteBuild.js";
|
|
2
|
+
// The assignment, reduced to what a local check can compare against. Ids are
|
|
3
|
+
// recovered from `contract_key`, which the server derives as `contract:<id>` and
|
|
4
|
+
// both sides copy verbatim — so nothing here has to know whether this branch's
|
|
5
|
+
// ids are called `interface_id` or `capability_id`.
|
|
6
|
+
const CONTRACT_PREFIX = 'contract:';
|
|
7
|
+
export function collectBuildProblems(request, outputs) {
|
|
8
|
+
const problems = [];
|
|
9
|
+
const branchFor = new Map(request.branches.map((branch) => [branch.suite_kind, branch]));
|
|
10
|
+
for (const output of outputs) {
|
|
11
|
+
// The host said plainly that it could not build this one. That is an answer,
|
|
12
|
+
// not a malformed answer, and the server records it as such.
|
|
13
|
+
if (output.build_error)
|
|
14
|
+
continue;
|
|
15
|
+
const branch = branchFor.get(output.suite_kind);
|
|
16
|
+
if (!branch)
|
|
17
|
+
continue; // reading the answer already refused this one
|
|
18
|
+
const add = (message) => { problems.push({ branch: output.suite_kind, message }); };
|
|
19
|
+
checkRunnerManifest(branch, output, add);
|
|
20
|
+
checkAssignment(branch, output, add);
|
|
21
|
+
}
|
|
22
|
+
return problems;
|
|
23
|
+
}
|
|
24
|
+
// After spec 32-5 the envelope comes down from the server inside the request, so
|
|
25
|
+
// there is nothing here to derive — only to confirm the host copied it. This is
|
|
26
|
+
// the field most likely to be rejected after all the work is done, which is
|
|
27
|
+
// exactly why it is worth a second of checking beforehand.
|
|
28
|
+
function checkRunnerManifest(branch, output, add) {
|
|
29
|
+
if (branch.runner_manifest === undefined)
|
|
30
|
+
return;
|
|
31
|
+
if (!sameJson(branch.runner_manifest, output.runner_manifest)) {
|
|
32
|
+
add('runner_manifest does not match the one the request issued. Copy it verbatim — the server ' +
|
|
33
|
+
'accepts only the exact combinations it named.\n' +
|
|
34
|
+
` issued: ${stableJson(branch.runner_manifest)}\n` +
|
|
35
|
+
` answered: ${stableJson(output.runner_manifest)}`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
// Every assigned id accounted for exactly once, and every marker the one the
|
|
39
|
+
// server minted. A marker the host invented or edited severs the only join
|
|
40
|
+
// between a runner's output and the map, so it cannot be allowed to travel.
|
|
41
|
+
function checkAssignment(branch, output, add) {
|
|
42
|
+
const assigned = assignedCases(branch.assignment);
|
|
43
|
+
if (assigned.length === 0) {
|
|
44
|
+
// An assignment with no cases in it is normal — a map with nothing to guard
|
|
45
|
+
// yet. An assignment that has content this walker could not read is not: the
|
|
46
|
+
// check would pass everything from then on and never say why. Fail open, but
|
|
47
|
+
// never fail open quietly.
|
|
48
|
+
if (hasContent(branch.assignment)) {
|
|
49
|
+
add('this branch\'s assignment could not be read, so its coverage was not checked here. ' +
|
|
50
|
+
'The server still checks it; if this persists the connector is older than the assignment format.');
|
|
51
|
+
}
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
const metadata = output.test_metadata;
|
|
55
|
+
const entries = Array.isArray(metadata?.capabilities) ? metadata.capabilities : null;
|
|
56
|
+
if (!entries) {
|
|
57
|
+
add('test_metadata must carry a capabilities array, one entry per assigned id.');
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
const byId = new Map(assigned.map((entry) => [entry.id, entry]));
|
|
61
|
+
const idKey = idKeyOf(branch.assignment, assigned);
|
|
62
|
+
const seen = new Map();
|
|
63
|
+
const suiteText = suiteBytes(output);
|
|
64
|
+
for (const entry of entries) {
|
|
65
|
+
const row = (entry ?? {});
|
|
66
|
+
const id = String(idKey ? row[idKey] ?? '' : '');
|
|
67
|
+
const expected = byId.get(id);
|
|
68
|
+
if (!expected) {
|
|
69
|
+
add(`test_metadata names "${id || '(no id)'}", which is not in this branch's assignment.`);
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
seen.set(id, (seen.get(id) ?? 0) + 1);
|
|
73
|
+
checkOneCase(row, id, expected, suiteText, add);
|
|
74
|
+
}
|
|
75
|
+
for (const [id, count] of seen) {
|
|
76
|
+
if (count > 1)
|
|
77
|
+
add(`${id} is answered ${count} times — every assigned id is answered exactly once.`);
|
|
78
|
+
}
|
|
79
|
+
const missing = assigned.filter((entry) => !seen.has(entry.id)).map((entry) => entry.id).sort();
|
|
80
|
+
if (missing.length > 0) {
|
|
81
|
+
add(`no answer for ${missing.length} assigned id(s): ${missing.join(', ')}.`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
function checkOneCase(row, id, expected, suiteText, add) {
|
|
85
|
+
const status = String(row.status ?? '');
|
|
86
|
+
if (status === 'unguarded') {
|
|
87
|
+
if (!String(row.reason ?? '').trim()) {
|
|
88
|
+
add(`${id} is unguarded but gives no business reason for it.`);
|
|
89
|
+
}
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
if (status !== 'covered') {
|
|
93
|
+
add(`${id} must be answered "covered" or "unguarded" (got ${JSON.stringify(status)}).`);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
if (String(row.contract_key ?? '') !== expected.contract_key) {
|
|
97
|
+
add(`${id} carries contract_key ${JSON.stringify(row.contract_key)} — it must be copied verbatim as "${expected.contract_key}".`);
|
|
98
|
+
}
|
|
99
|
+
if (String(row.case_marker ?? '') !== expected.case_marker) {
|
|
100
|
+
add(`${id} carries case_marker ${JSON.stringify(row.case_marker)} — it must be copied verbatim as "${expected.case_marker}". Markers are never minted or edited locally.`);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
// Declared covered, but the marker never made it into a test name or a
|
|
104
|
+
// Gherkin tag. The server refuses this, and rightly: without the marker in the
|
|
105
|
+
// suite there is nothing to join a result to, so the capability would report
|
|
106
|
+
// as a mismatch rather than as the green it claims.
|
|
107
|
+
if (suiteText && !suiteText.includes(expected.case_marker)) {
|
|
108
|
+
add(`${id} is answered "covered", but its marker ${expected.case_marker} appears nowhere in the suite files.`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
// Which field names the id. Read off the *assignment*, where the answer is
|
|
112
|
+
// exact: the id is already known (it is `contract_key` minus its prefix), so the
|
|
113
|
+
// field holding it can be identified rather than guessed.
|
|
114
|
+
//
|
|
115
|
+
// An earlier version searched the host's answer for any string field whose value
|
|
116
|
+
// happened to be an assigned id. That usually landed on the right key and could
|
|
117
|
+
// just as well have landed on a `headline` that echoed the id. Neither branch's
|
|
118
|
+
// key name is written down here either way — `interface_id` and `capability_id`
|
|
119
|
+
// stay the server's business.
|
|
120
|
+
function idKeyOf(assignment, cases) {
|
|
121
|
+
const ids = new Set(cases.map((entry) => entry.id));
|
|
122
|
+
let found = null;
|
|
123
|
+
const walk = (value) => {
|
|
124
|
+
if (found)
|
|
125
|
+
return;
|
|
126
|
+
if (Array.isArray(value)) {
|
|
127
|
+
value.forEach(walk);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
if (!value || typeof value !== 'object')
|
|
131
|
+
return;
|
|
132
|
+
const row = value;
|
|
133
|
+
if (typeof row.contract_key === 'string') {
|
|
134
|
+
const id = row.contract_key.slice(CONTRACT_PREFIX.length);
|
|
135
|
+
for (const [key, candidate] of Object.entries(row)) {
|
|
136
|
+
if (key !== 'contract_key' && candidate === id && ids.has(id)) {
|
|
137
|
+
found = key;
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
Object.values(row).forEach(walk);
|
|
143
|
+
};
|
|
144
|
+
walk(assignment);
|
|
145
|
+
return found;
|
|
146
|
+
}
|
|
147
|
+
// Does the assignment carry anything at all? Distinguishes "nothing to guard"
|
|
148
|
+
// from "we could not read what was there".
|
|
149
|
+
function hasContent(assignment) {
|
|
150
|
+
if (Array.isArray(assignment))
|
|
151
|
+
return assignment.length > 0;
|
|
152
|
+
if (!assignment || typeof assignment !== 'object')
|
|
153
|
+
return false;
|
|
154
|
+
return Object.values(assignment).some(hasContent);
|
|
155
|
+
}
|
|
156
|
+
// The assignment is an opaque body the server composed, so it is walked rather
|
|
157
|
+
// than destructured: every object carrying a `contract_key` is one assigned
|
|
158
|
+
// case, wherever the shape happens to nest it.
|
|
159
|
+
function assignedCases(assignment) {
|
|
160
|
+
const found = [];
|
|
161
|
+
const walk = (value) => {
|
|
162
|
+
if (Array.isArray(value)) {
|
|
163
|
+
value.forEach(walk);
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
if (!value || typeof value !== 'object')
|
|
167
|
+
return;
|
|
168
|
+
const row = value;
|
|
169
|
+
const key = row.contract_key;
|
|
170
|
+
const marker = row.case_marker;
|
|
171
|
+
if (typeof key === 'string' && key.startsWith(CONTRACT_PREFIX) && typeof marker === 'string') {
|
|
172
|
+
found.push({ id: key.slice(CONTRACT_PREFIX.length), contract_key: key, case_marker: marker });
|
|
173
|
+
}
|
|
174
|
+
Object.values(row).forEach(walk);
|
|
175
|
+
};
|
|
176
|
+
walk(assignment);
|
|
177
|
+
return found;
|
|
178
|
+
}
|
|
179
|
+
// Every byte of the branch's suite, main file and support files together, for
|
|
180
|
+
// the "is the marker actually in there" check.
|
|
181
|
+
function suiteBytes(output) {
|
|
182
|
+
const file = output.suite_file;
|
|
183
|
+
if (!file)
|
|
184
|
+
return '';
|
|
185
|
+
return [file.content, ...(Array.isArray(file.support_files) ? file.support_files.map((f) => f.content) : [])]
|
|
186
|
+
.filter((content) => typeof content === 'string')
|
|
187
|
+
.join('\n');
|
|
188
|
+
}
|
|
189
|
+
function sameJson(a, b) {
|
|
190
|
+
return stableJson(a) === stableJson(b);
|
|
191
|
+
}
|
|
192
|
+
function stableJson(value) {
|
|
193
|
+
if (Array.isArray(value))
|
|
194
|
+
return `[${value.map(stableJson).join(',')}]`;
|
|
195
|
+
if (value && typeof value === 'object') {
|
|
196
|
+
const object = value;
|
|
197
|
+
return `{${Object.keys(object).sort().map((key) => `${JSON.stringify(key)}:${stableJson(object[key])}`).join(',')}}`;
|
|
198
|
+
}
|
|
199
|
+
return JSON.stringify(value) ?? 'null';
|
|
200
|
+
}
|
|
201
|
+
// Reads the task and the answer and reports every problem it can see. Reading
|
|
202
|
+
// the answer is itself a check — safe paths, files that exist, a parseable
|
|
203
|
+
// envelope — and it is done branch by branch, so a bad entry in one contributes
|
|
204
|
+
// its problem and the other is still examined. Only the answer file as a whole
|
|
205
|
+
// can stop the pass, because then there is no document left to read.
|
|
206
|
+
export function validateBuildProblems(config) {
|
|
207
|
+
const request = readSuiteBuildRequest(config.projectRoot);
|
|
208
|
+
const { outputs, unreadable } = readHostSuiteOutputsPerBranch(request.output_path, request);
|
|
209
|
+
return [
|
|
210
|
+
...unreadable.map((entry) => ({ branch: entry.suite_kind, message: entry.message })),
|
|
211
|
+
...collectBuildProblems(request, outputs),
|
|
212
|
+
];
|
|
213
|
+
}
|
|
214
|
+
// One branch's problems, for the line that reports it unpublished alongside its
|
|
215
|
+
// peer. `put-suite-build` blocks per branch, so its message is per branch too.
|
|
216
|
+
export function formatBranchProblems(messages) {
|
|
217
|
+
if (messages.length === 1)
|
|
218
|
+
return messages[0];
|
|
219
|
+
return `${messages.length} problems in this branch's answer:\n${messages.map((m) => ` - ${m}`).join('\n')}`;
|
|
220
|
+
}
|
|
221
|
+
// One report, not a queue of one-at-a-time discoveries. Fixing one thing to be
|
|
222
|
+
// told the next costs a full round trip each time, and the round trip is the
|
|
223
|
+
// expensive part.
|
|
224
|
+
export function formatProblems(problems) {
|
|
225
|
+
const lines = problems.map((problem) => ` ${problem.branch}: ${problem.message}`);
|
|
226
|
+
return (`Your suite answer has ${problems.length} problem${problems.length === 1 ? '' : 's'}:\n` +
|
|
227
|
+
`${lines.join('\n')}\n` +
|
|
228
|
+
'Fix all of them, then answer again. The Unitbob server has the last word on ' +
|
|
229
|
+
'what it accepts; this check just finds the common problems in seconds instead ' +
|
|
230
|
+
'of after the whole build.\n');
|
|
231
|
+
}
|
|
232
|
+
export async function validateBuild(config, _args = [], deps) {
|
|
233
|
+
const stdout = deps?.stdout ?? process.stdout;
|
|
234
|
+
const problems = validateBuildProblems(config);
|
|
235
|
+
if (problems.length === 0) {
|
|
236
|
+
stdout.write('Your suite answer looks well-formed. Run `unitbob put-suite-build` to publish it.\n');
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
throw new Error(formatProblems(problems));
|
|
240
|
+
}
|
package/dist/wire.js
CHANGED
|
@@ -4,7 +4,12 @@ export class WireError extends Error {
|
|
|
4
4
|
}
|
|
5
5
|
// POST /repos/register — the linking bootstrap (spec 28). A standalone function
|
|
6
6
|
// rather than a Wire method because at link time there is no Config yet: only a
|
|
7
|
-
// server URL and the project's folder name.
|
|
7
|
+
// server URL and the project's folder name.
|
|
8
|
+
//
|
|
9
|
+
// It is no longer idempotent, and must not be called on an already-linked
|
|
10
|
+
// project: since spec 33 every call mints a brand-new project. Looking one up by
|
|
11
|
+
// folder name is exactly what used to let two people with a `myapp` write into
|
|
12
|
+
// the same repository.
|
|
8
13
|
export async function registerRepo(server, name) {
|
|
9
14
|
const url = `${server}/repos/register`;
|
|
10
15
|
let res;
|
|
@@ -33,7 +38,11 @@ export async function registerRepo(server, name) {
|
|
|
33
38
|
if (typeof payload.id !== 'number' || !Number.isInteger(payload.id)) {
|
|
34
39
|
throw new WireError(`POST ${url} returned a malformed payload: expected an integer id.`);
|
|
35
40
|
}
|
|
36
|
-
|
|
41
|
+
if (typeof payload.token !== 'string' || payload.token.length === 0) {
|
|
42
|
+
throw new WireError(`POST ${url} returned no project token — this Unitbob server is older than this connector. ` +
|
|
43
|
+
'Upgrade the server, or pin an older unitbob version.');
|
|
44
|
+
}
|
|
45
|
+
return { id: payload.id, token: payload.token };
|
|
37
46
|
}
|
|
38
47
|
export class Wire {
|
|
39
48
|
config;
|
|
@@ -188,11 +197,16 @@ export class Wire {
|
|
|
188
197
|
}
|
|
189
198
|
return suite;
|
|
190
199
|
}
|
|
200
|
+
// Every wire call carries the project's token (spec 33). Without it the brain
|
|
201
|
+
// answers 404 — never 403, which would confirm the project exists.
|
|
191
202
|
async send(method, url, body) {
|
|
203
|
+
const headers = { authorization: `Bearer ${this.config.token}` };
|
|
204
|
+
if (body !== undefined)
|
|
205
|
+
headers['content-type'] = 'application/json';
|
|
192
206
|
try {
|
|
193
207
|
return await fetch(url, {
|
|
194
208
|
method,
|
|
195
|
-
headers
|
|
209
|
+
headers,
|
|
196
210
|
body: body === undefined ? undefined : JSON.stringify(body),
|
|
197
211
|
});
|
|
198
212
|
}
|
|
@@ -205,6 +219,14 @@ export class Wire {
|
|
|
205
219
|
async ensureOk(res, what) {
|
|
206
220
|
if (res.ok)
|
|
207
221
|
return;
|
|
222
|
+
// A 404 on the wire means one of two things and there is no way to tell them
|
|
223
|
+
// apart from here — by design, since telling them apart is what would let a
|
|
224
|
+
// stranger discover which projects exist. Both have the same cure.
|
|
225
|
+
if (res.status === 404) {
|
|
226
|
+
throw new WireError(`This project is linked to a repository the server at ${this.config.server} does not have, ` +
|
|
227
|
+
'or the token in .unitbob.json does not open it. Delete .unitbob.json to link again ' +
|
|
228
|
+
'(the old project, along with its map and checks, stays where it is).');
|
|
229
|
+
}
|
|
208
230
|
let detail = '';
|
|
209
231
|
try {
|
|
210
232
|
detail = (await res.text()).slice(0, 500);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "unitbob",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Unitbob connector — thin local hands for the Unitbob Rails brain. Owns no domain logic: it runs tools, relays bytes over the wire, and prints what the server returns.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|